2月26日,我把压箱底的网络编程笔记重新翻了出来。起因是近期要接手一个跨平台通信模块,服务端、客户端和网关之间的链路需要整体梳理一遍。网络编程这门课有个特点:理论听一遍觉得自己全懂了,真到写代码跑线上,粘包、半包、TIME_WAIT、缓冲区上限、连接超时,随便哪个点没处理好,程序都会死得很难看。
这篇文章就是我这一天的复习记录和实操日志,不是教材式的知识罗列,而是我实际敲代码、抓包、踩坑的全过程。如果你正在学网络编程,或者已经写过一些Socket代码但总觉得底层原理隔着一层膜,完全可以按我这一天的节奏走一遍:先从回环地址把基础链路跑通,再逐步验证握手、挥手、分帧、调优,最后落到真实网络的性能差距上。整个过程用一个Python脚本就能复现,不需要额外装什么重型环境。
1. 今天先从回环地址跑通一个最基础的TCP回显服务
1.1 为什么拿回环实验当开胃菜
很多人学网络编程的第一课就是看三次握手、四次挥手的图解,看完了还是写不出一个能正常通信的程序。我今天的做法反过来:先让代码跑起来,再回头验证理论。
回环地址(127.0.0.1)最大的好处是纯粹。数据包不会经过物理网卡、交换机、路由器,没有跨网段的延迟和丢包干扰,RTT基本在亚毫秒级。在这个环境里调通代码逻辑,等于给整个程序打了一个稳定的地基。等代码确认无误后,再挪到局域网、公网环境去测试,遇到问题时才能把"代码bug"和"网络环境"这两种因素分开定位。
1.2 最小可用的回显服务端与客户端
我用Python的内置socket模块写了一个最简回显服务:客户端发什么,服务端原样返回什么。服务端代码长这样:
import socket HOST = "127.0.0.1" PORT = 9001 srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((HOST, PORT)) srv.listen(5) print(f"[server] listening on {HOST}:{PORT}") while True: conn, addr = srv.accept() print(f"[server] accepted {addr}") with conn: while True: data = conn.recv(1024) if not data: break conn.sendall(data) print(f"[server] closed {addr}")客户端代码更短:
import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(3) sock.connect(("127.0.0.1", 9001)) sock.sendall(b"hello network programming") resp = sock.recv(1024) print(f"[client] received: {resp}") sock.close()运行结果很直观:服务端打印出accepted ('127.0.0.1', 53210),客户端打印出[client] received: b'hello network programming'。链路通了。
1.3 四个API背后容易被忽略的细节
第一是bind。服务端必须绑定固定端口,客户端通常不需要显式bind,由内核自动分配一个临时端口,所以客户端连接建立后你会看到对端端口是一个五位数的随机值。第二是listen(5)里的backlog参数。它表示内核为这个监听socket维护的已完成握手、但还没被accept取走的连接队列长度。如果队列满了,新的连接请求会被内核直接拒绝或丢弃,这在线上就是"客户端连不上,服务端却没崩"的经典现象。第三是accept的返回值:它返回的是一个全新的socket,专门用于和这个客户端通信,原来的监听socket继续负责接收新连接。第四是recv返回空字节串(b"")的含义——这表示对端已经正常关闭连接(发出了FIN),此时服务端必须也关闭这个socket,否则连接状态就会卡在CLOSE_WAIT,这个问题后面专门讲。
2. 三次握手、四次挥手与那些越积越多的CLOSE_WAIT
2.1 握手不是面试题专利,它决定了你的连接能否建立
三次握手的本质是让通信双方各自确认"对方能收到我的数据"和"我能收到对方的数据"。SYN报文里携带的初始序列号(ISN)是随机的,目的是防止旧连接中的延迟报文被误认为是新连接的合法数据。更关键的是,握手成功后才会有数据发送,所以握手包的传输质量直接决定用户体验。
用抓包视角看整个过程:客户端发SYN(seq=x),服务端回SYN, ACK(seq=y, ack=x+1),客户端再回ACK(seq=x+1, ack=y+1),连接进入ESTABLISHED。如果服务端的SYN队列和accept队列都满了,连接请求会被丢弃或者返回RST,客户端表现就是connect超时或者拒绝连接。排查这类问题时,先去ss -lnt看队列长度,再检查服务端代码处理连接的速度,别一上来就怀疑网络。
2.2 四次挥手的两个关键点:主动关闭方和2MSL
挥手过程比握手更容易被忽视,因为它的状态机细节多。主动关闭方先发FIN,被动关闭方回ACK;被动关闭方把剩余数据发完后,再发自己的FIN,主动关闭方回最后一个ACK,然后进入TIME_WAIT,等待2个MSL(报文最大生存时间)后才彻底关闭。
很多人不理解为什么TIME_WAIT要等那么久。原因有两个:一是万一最后一个ACK丢了,对端会重发FIN,主动关闭方必须留出时间重新回ACK;二是防止同一个四元组(源IP、源端口、目标IP、目标端口)的新连接收到旧连接延迟到达的报文。理解这一点,比死背"主动关闭方进TIME_WAIT"要有用得多。
2.3 用ss命令观察CLOSE_WAIT是怎么堆出来的
我在实验里故意写过一段有问题的代码——处理完一次请求后忘记调用conn.close():
while True: conn, addr = srv.accept() data = conn.recv(1024) # 处理数据... # 这里少写了 conn.close()客户端正常发完数据后主动关闭连接,内核收到FIN后把连接置为CLOSE_WAIT,但这个状态一直要等应用层调用close()才会继续走向LAST_ACK和关闭。应用不关,这个连接就永远卡在这里,fd和内存一直被占着。线上服务如果每秒有几百个短连接请求,CLOSE_WAIT会像雪球一样越滚越大,最后把进程的fd耗尽,新连接全部失败。
用ss -ant可以看到这样的输出:
State Local Address:Port Peer Address:Port LISTEN 0.0.0.0:9001 0.0.0.0:* ESTAB 127.0.0.1:9001 127.0.0.1:53210 CLOSE_WAIT 127.0.0.1:9001 127.0.0.1:53211TCP状态表大概是这么个对应关系:
| 状态 | 含义 | 常见出现原因 |
|---|---|---|
| LISTEN | 服务端等待连接 | 正常 |
| ESTABLISHED | 连接已建立 | 正常 |
| FIN_WAIT_1 | 主动关闭方发出FIN,等ACK | 短暂出现 |
| FIN_WAIT_2 | 主动关闭方收到ACK,等对方FIN | 对方迟迟不关闭就会出现 |
| CLOSE_WAIT | 被动关闭方收到FIN,等应用调close | 应用代码没关socket |
| LAST_ACK | 被动关闭方发出FIN,等最后一个ACK | 短暂出现 |
| TIME_WAIT | 主动关闭方等待2MSL | 正常,连接多时会明显 |
排查CDOE_WAIT的方法很直接:写一个定时任务检查ss -ant,一旦CLOSE_WAIT数量持续增长,直接翻业务日志看哪个逻辑分支没有关闭连接。修的时候优先用with conn:上下文管理器或者try/finally保证每条路径都会close,别指望内核帮你回收。
3. 粘包与半包:给字节流加上消息边界
3.1 TCP是水管,不是快递箱
把TCP想成一根水管:你往一头灌水,对端从另一头接水,但水流会被分成什么形状,完全取决于中间的过程。应用层调两次sendall(b"A")和sendall(b"B"),对端的recv完全可能一次性读到b"AB",也可能一次只读到b"A"——这中间没有任何"消息边界"。所谓粘包,本质是多个消息被合并到一次读取里;半包则是一个消息被拆成了多次读取。两者是同一个问题的两面:TCP不保证应用层消息的完整性。
3.2 三种常见的分帧方案对比
解决这个问题,必须在应用层定义"消息的边界"。主流做法有三种:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 定长帧 | 每条消息固定N字节 | 实现最简单 | 浪费空间,无法传输任意长度数据 |
| 分隔符 | 消息末尾加特殊字符(如\n、0x00) | 简单,适合文本协议 | 二进制数据可能天然包含分隔符 |
| 长度前缀 | 固定长度的头部记录负载长度 | 通用、可靠、适合二进制协议 | 需要处理粘包半包,代码稍复杂 |
我实际做协议设计,绝大多数场景都会用长度前缀。头部固定4个字节,用大端序存一个无符号整数,表示后面负载的字节数。HTTP/2这类的二进制协议,本质思路也是类似的。
3.3 手写一个带四字节长度头的收发函数
import socket import struct def send_msg(sock: socket.socket, payload: bytes) -> None: sock.sendall(struct.pack(">I", len(payload)) + payload) def recv_exact(sock: socket.socket, n: int) -> bytes: chunks = [] remaining = n while remaining > 0: chunk = sock.recv(remaining) if not chunk: raise ConnectionError("connection closed while reading") chunks.append(chunk) remaining -= len(chunk) return b"".join(chunks) def recv_msg(sock: socket.socket) -> bytes: header = recv_exact(sock, 4) length = struct.unpack(">I", header)[0] return recv_exact(sock, length)这里最容易被新手忽略的就是recv_exact。很多人写完header = sock.recv(4)就直接往下走,以为一次能读满4个字节。实际上recv(4)的意思是"最多读4个字节",很可能只返回1个或者2个字节。同理,读完了4字节头,后续读负载也必须循环直到凑够length。我用一个1KB大小的消息做了测试,客户端把数据拆成几十个sendall发送,服务端用recv_msg依然能完整无误地还原,粘包半包问题彻底消失。这也是生产环境里最容易被验证的可靠性设计。
4. 今天实测踩中的三个坑:Nagle、缓冲区与连接超时
4.1 Nagle算法和那个莫名其妙的40毫秒延迟
Nagle算法的作用是把多个小数据包攒在一起发送,减少网络中微小报文的数量。它本身没问题,但它和TCP延迟确认(delayed ACK)机制叠加时,就会出现经典的低速请求响应场景:客户端发一个小请求,因为Nagle算法,要等收到上一个包的ACK才发下一个;服务端则故意延迟ACK,期待能合并响应。两个机制互相等待,就产生了接近40毫秒的停顿。
在交互性强、对延迟敏感的场景,比如聊天消息、交易指令、游戏操作,我会在socket上直接关掉Nagle:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)做批量文件传输时反而可以开着Nagle,用带宽换取报文数量减少。一句话结论:默认不放NODELAY,但你要知道它存在,遇到诡异的小请求延迟先想到它。
4.2 recv可能读到一半,send也可能发不完
recv(n)的行为是"最多读取n字节",返回值可能小于n。你以为读到了完整消息,实际上只是消息的一部分,这就会造成半包。同样,send(data)也不会保证一次性把data发完,尤其是在发送缓冲区快满、对端接收速度跟不上的时候,它只返回实际写入缓冲区的字节数。所以收消息要写循环拆数据,发消息要用sendall或者自己封一个发送循环。
我在调试中遇到过一个很隐蔽的问题:客户端发了一个2KB的包,服务端用recv(1024)只读了前1KB就继续往下处理,结果数据错位、协议解析失败。这个bug在回环环境下偶发,放到跨主机环境就变成了高频,因为真实网络的数据包到达时序更不规则。
4.3 收发缓冲区与背压:别让生产者的热情压垮消费者
每个TCP socket在内核里都有发送缓冲区和接收缓冲区,大小可以用SO_SNDBUF和SO_RCVBUF调整。如果服务端不读数据,接收缓冲区会逐渐填满,内核会把TCP接收窗口减小,进而限制发送方的发送速率,最终发送方被阻塞在send调用上。这其实是网络协议栈自带的一种"背压"机制,在系统设计上非常有用:它让生产者的速度自动匹配消费者的处理能力,避免了内存无限膨胀。
所以不要一看到客户端发送阻塞就认为是bug,先检查对端是不是没及时读数据。反之,如果业务上确实需要大吞吐又不希望被慢消费拖住,就该考虑改异步IO、增大缓冲区、或者干脆做应用层的丢弃策略,而不是堵在send上死等。
4.4 超时与心跳:别让连接死得不明不白
默认情况下,TCP socket在没有数据交互时是"永远不超时"的,连接可以静默悬挂很久。TCP自带的keepalive探测在Linux上默认要2小时才开始第一次探测,对应用层来说太迟钝了。我一般的做法是两层配合:TCP层设置settimeout兜底,应用层自己设计心跳。
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) # connect和recv都受这个超时保护 sock.connect(("127.0.0.1", 9001))应用层心跳的做法是:客户端每隔N秒发一个PING,服务端回PONG,如果客户端连续几次没收到PONG就判定连接失效,主动重连。这样既能探测链路断掉的情况,也能在服务端假死时快速反应。心跳间隔要按实际网络环境调,局域网可以2秒一次,公网建议10秒以上,太频繁反而浪费带宽。
5. 用tcpdump和Wireshark验证前面讲的状态机
5.1 先把抓包环境准备好
理论说得再多,都不如亲眼看一眼报文。这一节我用tcpdump在回环网卡上抓包,把之前回显服务的握手和挥手过程完整记录下来:
sudo tcpdump -i lo -nn -S port 9001 -w /tmp/date0226.pcap参数说明:-i lo表示抓回环网卡,-nn不做域名和端口名解析,-S显示绝对序列号(方便人工比对),-w把原始报文写入文件。抓包期间重新跑一遍客户端,然后按Ctrl+C停止抓包,用Wireshark打开pcap文件查看。
5.2 从报文里读出手握手的完整证据
打开抓包文件,按时间顺序看最前面的三个报文:
- 客户端发
SYN,序列号是某个随机大数,例如seq=3829452135 - 服务端回
SYN, ACK,seq=2213450411, ack=3829452136 - 客户端回
ACK,seq=3829452136, ack=2213450412
注意到没有:ack的值等于对方的seq加1,这就是"消耗掉一个序列号来确认SYN标志位"的含义。再看最后四个报文,客户端先发FIN, ACK,服务端回ACK,然后服务端发自己的FIN, ACK,客户端最后回ACK——和教科书完全一致。自己抓一次包,这些状态就不是死记硬背了,而是亲眼见过的东西。
5.3 在回环上加人为丢包,观察重传过程
回环环境默认零丢包,想亲眼看TCP重传,可以用tc工具往回环网卡上加人为干扰:
sudo tc qdisc add dev lo root netem loss 10% delay 20ms再跑客户端发几次数据,tcpdump里就能看到"TCP Retransmission"提示以及重复的序列号。实验结束后一定要删掉规则:
sudo tc qdisc del dev lo root netem这个结论非常重要:TCP所谓的"可靠传输",靠的是数据重传和确认机制,而不是"网络不会丢数据"。在跨主机场景里,丢包、乱序、延迟都是常态,只有理解了这一点,你才会在写代码时自觉地处理读超时、重试和幂等。
6. 回到真实网络:从回环到跨主机,差距比想象中更大
6.1 RTT、MTU与MSS的数字感受
回环实验里RTT几乎为零,而到了局域网是零点几毫秒,跨地域公网则是几十到几百毫秒。每次发送的数据还会被切分成TCP段,段的大小由MSS决定,MSS又由MTU决定。普通以太网MTU是1500字节,扣掉IP头(20字节)和TCP头(20字节),应用层每个TCP段最多携带1460字节。这意味着你调用一次sendall发送10KB数据,在内核里会被拆成7个左右的TCP段分别发送。
做网络编程的人必须养成这个直觉:一次应用层发送不等于一次网络传输。数据会被分段、合并、重组,这是分帧协议必须存在的根本原因。
6.2 丢包、重传与拥塞控制的门道
真实网络里丢包不可避免,TCP通过确认号和重传机制保证可靠。发送方发出一个段后启动重传定时器(RTO),如果定时器到期没收到ACK就重传。快速重传的逻辑是:收到3个重复的ACK就直接重发丢失的段,不用等RTO超时。
拥塞控制则决定了发送窗口的增长速度和收缩策略:网络通畅时窗口呈指数增长,出现丢包或网络延迟增大时窗口迅速收缩。理解这一层之后,你再看到"为什么公网大文件传输忽快忽慢"这类问题,就不会只怀疑硬件了。
6.3 一天的实验下来,我最想提醒新手的三件事
第一,调试顺序永远是"回环→局域网→公网",逐个环境暴露问题,不要一上来就在公网环境里裸奔。第二,任何阻塞IO都必须配超时,否则线上事故现场无法复现,你连从哪个进程开始排查都不知道。第三,不要看进程活着就认定系统健康,要看连接状态、fd数量、重传率这些网络层指标。
这一天下来最有价值的体会是:网络编程的很多所谓"深坑",根子上都是没把字节流模型、状态机转换和内核缓冲区机制吃透。代码出问题时,第一反应应该是我要抓包看证据,而不是猜。等你看过几次真实的SYN、FIN、重传报文,很多网上玄乎的说法自己就能判断真假了。