谁会去读发送缓冲区的数据
概述
- 是内核,但不是"被动等着读",而是主动触发的
触发时机1
send()调用本身,如果条件允许,会立即尝试发送- 很多人以为
send()只是"把数据扔进缓冲区,然后就不管了,等内核有空再处理"
- 很多人以为
- 实际上,在很多情况下,内核会在这次
send()系统调用的执行过程中,就直接尝试把数据封装成TCP段发出去,前提是:- 对方的接收窗口(
rwnd)还有剩余空间(对方还能收) - 本机的拥塞窗口(
cwnd)还没跑满(网络还没拥塞) - 没有被
Nagle算法卡住 - 如果这些条件都满足,
send()调用内部就会直接触发TCP层把这次的数据打包发出去
- 对方的接收窗口(
触发时机2
- 收到对方的
ACK,释放了发送窗口空间- 如果之前发送窗口已经被打满(比如批量发了很多数据,还没收到确认,不能再发新的了),这时候数据会滞留在发送缓冲区里等待
- 一旦收到对方的
ACK(确认收到了之前的数据、并且可能顺带告知新的可用窗口大小),内核会在处理这个ACK的过程中,顺便检查发送缓冲区里是否还有排队等待发送的数据,如果有,就继续把这些数据打包发出去
触发时机3
- 重传定时器超时
- 发送缓冲区里的数据,在发出去之后并不会被立刻清除,而是继续保留(标记为"已发送但未确认"),因为
TCP需要保证可靠传输 - 如果超过一定时间没收到对方的
ACK,内核判定这个包可能丢了,重传定时器超时会触发从发送缓冲区里重新取出这段数据,再发一次 - 这也是为什么"发送缓冲区"不只是"待发送队列",更准确说,它同时承担着"待发送数据"和"已发送但待确认数据(以备重传)"这两个角色
- 发送缓冲区里的数据,在发出去之后并不会被立刻清除,而是继续保留(标记为"已发送但未确认"),因为
Nagle算法
- 如果你连续调用几次
send()发送很小的数据块(比如每次几个字节),默认情况下TCP会启用Nagle算法- 如果当前还有未被确认的数据在途中,新的小数据会先攒在发送缓冲区里,不立刻发出,等凑够一个
MSS大小、或者收到之前数据的ACK,才一起打包发送 - 这是为了避免网络上充斥大量只有几字节数据的小包(每个包还要背负
TCP+IP+以太网头部的开销,效率低)
- 如果当前还有未被确认的数据在途中,新的小数据会先攒在发送缓冲区里,不立刻发出,等凑够一个
- 显式设置
TCP_NODELAY选项关闭Nagle算法:
|
1 2 |
int flag = 1; setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (char*)&flag, sizeof(flag)); |
IP包的发送相关
路由表(Routing Table)
- 路由表是内核里维护的一张"该往哪个方向发"的查表
- 每一条记录(路由条目)大致包含这几个字段:
|
1 2 3 4 |
目标网络 子网掩码/前缀长度 网关 出接口 metric(优先级) 192.168.1.0 /24 0.0.0.0 eth0 0 10.0.0.0 /8 192.168.1.1 eth0 10 0.0.0.0 /0 192.168.1.1 eth0 100 |
- 查看方式
Windows:route printLinux:ip route show或者老一点的route -n
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
=========================================================================== 接口列表 21...00 ff c1 c9 25 8a ......TAP-Windows Adapter V9 12...00 ff 52 b6 e5 92 ......TAP-Windows Adapter V9 #2 3...2c f0 5d 96 58 e6 ......Realtek PCIe 2.5GbE Family Controller 7...e0 d4 e8 75 d9 7e ......Intel(R) Wi-Fi 6 AX200 160MHz 25...e0 d4 e8 75 d9 7f ......Microsoft Wi-Fi Direct Virtual Adapter 5...e2 d4 e8 75 d9 7e ......Microsoft Wi-Fi Direct Virtual Adapter #2 23...00 50 56 c0 00 01 ......VMware Virtual Ethernet Adapter for VMnet1 22...00 50 56 c0 00 08 ......VMware Virtual Ethernet Adapter for VMnet8 14...00 ff 66 e3 a9 a0 ......Netease UU TAP-Win32 Adapter V9.21 11...e0 d4 e8 75 d9 82 ......Bluetooth Device (Personal Area Network) 1...........................Software Loopback Interface 1 =========================================================================== IPv4 路由表 =========================================================================== 活动路由: 网络目标 网络掩码 网关 接口 跃点数 0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.7 20 10.3.0.20 255.255.255.255 在链路上 127.0.0.1 76 127.0.0.0 255.0.0.0 在链路上 127.0.0.1 331 127.0.0.1 255.255.255.255 在链路上 127.0.0.1 331 127.255.255.255 255.255.255.255 在链路上 127.0.0.1 331 192.168.1.0 255.255.255.0 在链路上 192.168.1.7 276 192.168.1.7 255.255.255.255 在链路上 192.168.1.7 276 192.168.1.255 255.255.255.255 在链路上 192.168.1.7 276 192.168.101.0 255.255.255.0 在链路上 192.168.101.1 291 192.168.101.1 255.255.255.255 在链路上 192.168.101.1 291 192.168.101.255 255.255.255.255 在链路上 192.168.101.1 291 192.168.119.0 255.255.255.0 在链路上 192.168.119.1 291 192.168.119.1 255.255.255.255 在链路上 192.168.119.1 291 192.168.119.255 255.255.255.255 在链路上 192.168.119.1 291 224.0.0.0 240.0.0.0 在链路上 127.0.0.1 331 224.0.0.0 240.0.0.0 在链路上 192.168.119.1 291 224.0.0.0 240.0.0.0 在链路上 192.168.101.1 291 224.0.0.0 240.0.0.0 在链路上 192.168.1.7 276 255.255.255.255 255.255.255.255 在链路上 127.0.0.1 331 255.255.255.255 255.255.255.255 在链路上 192.168.119.1 291 255.255.255.255 255.255.255.255 在链路上 192.168.101.1 291 255.255.255.255 255.255.255.255 在链路上 192.168.1.7 276 =========================================================================== 永久路由: 网络地址 网络掩码 网关地址 跃点数 10.3.0.20 255.255.255.255 在链路上 1 0.0.0.0 0.0.0.0 10.0.0.0 1 =========================================================================== IPv6 路由表 =========================================================================== 活动路由: 接口跃点数网络目标 网关 1 331 ::1/128 在链路上 1 331 ff00::/8 在链路上 =========================================================================== 永久路由: 无 |
最长前缀匹配(Longest Prefix Match)
- 如果一个目标
IP同时匹配多条路由规则- 比如既匹配
192.168.1.0/24也匹配0.0.0.0/0默认路由 - 内核会选择匹配的子网掩码最长(最精确)的那一条
- 比如既匹配
- 这也是为什么表里最后一行
0.0.0.0/0(俗称"默认路由")永远是兜底选项- 它匹配任何
IP,但优先级最低,只有前面所有更精确的规则都不匹配时才会用它
- 它匹配任何
直连网络 vs 需要网关转发
- 如果目标
IP和本机某个网卡在同一网段- 比如你的
IP是192.168.1.10/24,目标是192.168.1.50 - 路由表里对应条目的网关字段通常是
0.0.0.0或者显示"on-link"(在链路上) - 意思是不需要经过任何网关,直接在这个局域网内广播
ARP、拿到对方MAC地址就能发包
- 比如你的
- 如果目标
IP不在任何一个直连网段里- 比如你要访问
8.8.8.8 - 就会匹配到默认路由这一条,内核知道"这个包我处理不了,得交给网关帮我转发"
- 比如你要访问
网卡对应的路由条目
- 每张网卡对应的路由条目是系统启动或者网卡配置时自动生成的
- 你给网卡配置
IP和子网掩码时(比如DHCP自动获取,或者手动配置),操作系统会自动帮你生成对应的"直连网络"路由条目,不需要手动加 - 只有默认路由(网关地址)通常需要手动配置或者
DHCP自动下发
网关
- 网关本质上就是一台路由器(或者一台开启了
IP转发的普通机器) - 它的作用是:
- 当你的包要发往一个不在本地网络的目标时,先把包发给这个网关,由网关根据它自己的路由表,决定下一步往哪转发
- 下一跳
- 网关不一定是"最终目的地所在网络的守门人",很可能只是"离目标近一点点的下一站"
- 这个包可能要经过好几个路由器接力转发,才能真正到达目标网络
- 你的机器只需要知道"发给我的默认网关"就行,不需要知道整条转发路径,每一跳的路由器各自决定下一跳是谁
- 这是
IP路由"逐跳转发"的核心设计思想
网卡
- 路由表查完之后,知道了"用哪个网卡发"、"下一跳是谁",接下来才是真正的封包发送过程:
- 第一步:确定下一跳的
MAC地址(ARP解析)IP层只知道下一跳的IP地址(可能是网关的IP,也可能是同网段目标本身的IP),但链路层(以太网)发包需要MAC地址- 内核先查本机的
ARP缓存(arp -a可以查看),如果没有对应记录,就发一个ARP广播"谁是这个IP,把MAC地址告诉我" - 对方回复后,内核把这个
IP-MAC映射缓存起来(有过期时间,通常几分钟)
- 第二步:封装以太网帧
- 拿到
MAC地址后,内核把IP数据包包装成一个完整的以太网帧: - 注意这里目的
MAC填的是"下一跳"的MAC地址,不是最终目标机器的MAC地址(除非下一跳恰好就是最终目标)
- 拿到
|
1 |
| 目的MAC | 源MAC | 类型(IPv4/IPv6) | IP包(包含IP头+TCP/UDP头+数据) | 帧校验 | |
- 第三步:交给网卡驱动,通过
DMA放进发送队列- 封装好的帧,由内核网络协议栈交给网卡驱动程序,驱动把这块内存通过
DMA(直接内存访问)方式,搬进网卡自己的发送环形缓冲区(TX ring buffer)
- 封装好的帧,由内核网络协议栈交给网卡驱动程序,驱动把这块内存通过
- 第四步:网卡硬件把数字信号转换成物理信号发送出去
- 网卡硬件从发送队列里取出帧,转换成电信号(以太网线缆)或者光信号(光纤)或者无线电波(
WiFi),通过物理链路发送出去 - 发送完成后,网卡通常会触发一个中断通知驱动"这一帧发完了,可以处理下一个了
- 或者高性能场景下用轮询模式减少中断开销
- 网卡硬件从发送队列里取出帧,转换成电信号(以太网线缆)或者光信号(光纤)或者无线电波(
DHCP
详细发送流程
路由查表
- 系统内核根据
tcp包的目标IP地址,查本机路由表,匹配得到接口和下一跳IP- 同局域网内,通过目标主机自己的
IP查ARP缓存拿目标主机的MAC,没有则广播ARP获取并缓存MAC,填作目的MAC - 如果目标不在本地网段,则用
ARP解析默认网关的IP,拿到网关的MAC地址,作为这一跳以太网帧的目的MAC
- 同局域网内,通过目标主机自己的
组装以太网帧
- 目的
MAC+ 源MAC+ 帧类型(标识内部是IPv4/IPv6) + 已经封装好的完整IP包(IP包内部包含了TCP头,TCP头里有端口号) - 组装一个以太网帧
以太网帧发送到网卡驱动
- 然后把这个以太网帧发送到网卡驱动,网卡驱动通过
DMA把帧数据放进网卡自己的环形缓冲区
电信号发送到网关
- 网卡从队列里拿出这个帧,转换成电信号,再把这个电信号发送到网关(我的路由器
A) - 路由器
A收到,还原成帧,拆出IP包,发现目的IP不是自己 - 查路由表决定下一跳
- 如果需要
NAT(比如要出公网了):改写源IP/源端口,重新计算IP头和TCP头校验和 - 用(可能已被
NAT改写过的)IP包,重新封装新帧:目的MAC=下一跳MAC,源MAC=路由器A的MAC - 转成对应介质的信号(电/光/无线),发出
其他
WiFi(无线电波)- 网线(电信号)
- 光信号(光纤入户)
IP包的接收相关
-
网卡
- 网卡硬件收到物理信号(电/光/无线电波),还原成比特流,组装成完整的以太网帧
- 网卡通过
DMA,把这个帧数据直接写入环形接收缓冲区 - 数据已经在内存里了之后,网卡才触发一个硬件中断,告诉
CPU"有新数据到了,内存地址在这里,你可以处理了" CPU响应中断,调用网卡驱动的中断处理函数
-
网卡驱动
- 网卡驱动从内存的环形接收缓冲区里取出数据,剥离以太网头,交给
IP层
- 网卡驱动从内存的环形接收缓冲区里取出数据,剥离以太网头,交给
-
IP层- 校验和检查、剥离IP头、判断目的
IP是否本机 - 是本机则往上交
- 不是本机则查路由表决定是否转发
- 校验和检查、剥离IP头、判断目的
-
TCP层- 五元组查找定位
socket,序号校验,必要时回复ACK - 数据放入该
socket的接收缓冲区
- 五元组查找定位
-
唤醒等待中的应用层
- 比如阻塞
recv被唤醒,epoll标记fd就绪,IOCP投递完成通知
- 比如阻塞
-
网卡驱动怎么区分
IPv4/IPv6/ARP- 靠以太网帧头里的
EtherType字段 - 驱动剥离以太网头之后,读取这
2字节的EtherType值,就知道该把剩下的数据交给哪个上层协议处理函数 - 具体机制上(以
Linux为例):内核维护了一张协议处理函数注册表(ptype_base哈希表),IP协议栈、ARP协议栈初始化时,会各自用自己的EtherType值(0x0800、0x0806)向这张表注册自己的处理函数(dev_add_pack这个接口) Windows下的NDIS(网络驱动接口规范)也是类似的设计思路,协议驱动向NDIS注册自己关心的EtherType,底层驱动收到帧后按类型分发
- 靠以太网帧头里的
|
1 2 3 4 5 6 |
| 目的MAC(6字节) | 源MAC(6字节) | EtherType(2字节) | 数据部分(IP包/ARP包等) | 帧校验 | // IPV4 0X0800 // ARP 0X0806 // IPV6 0X86DD // 802.1Q VLAN 0X8100 |
谁会把数据写到接收缓冲区
- 是内核
TCP层,但准确说不是"写",而是"挂载"- 具体在
Linux下是tcp_v4_rcv这个函数(处理IPv4的TCP包)在软中断上下文里执行,做完五元组查找、序号校验之后,负责把这段数据"放进"对应socket的接收缓冲区
- 具体在
- 有个容易被误解的细节:
- 数据通常不是被"重新拷贝"一份放进缓冲区,而是把已经在内存里的这块数据(网卡
DMA写进来的那块内存,包装成一个叫sk_buff的结构体)直接"挂载"到这个socket的接收队列(sk_receive_queue)上
- 数据通常不是被"重新拷贝"一份放进缓冲区,而是把已经在内存里的这块数据(网卡
- 真正发生"内存拷贝"的时机,是应用层调用
recv()的那一刻- 这时候内核才把
sk_receive_queue里排队的数据,从内核态内存拷贝到你在用户态传入的buf里
- 这时候内核才把
状态机图
图示
Wireshark三次握手观察
- 客户端
syn包Wireshark显示的是相对序号,方便阅读,真实的ISN是一个随机大数- 客户端的序号是
0
- 服务端
syn+ack包ack是1,对应客户端的序号0+1- 服务端的序号是
0
- 客户端
ack包ack是1,对应服务端的序号0+1- 客户端的序号是
1,对应客户端序号0+1
- 握手完成,双方进入
ESTABLISHED- 状态的改变是内核管理的,
wireshark这里看不到
- 状态的改变是内核管理的,
- 客户端发送了
3个字节- 第一次发数据,
seq和ack延续自握手时的值
- 第一次发数据,
- 服务端回复了
3个字节- 服务端的
seq从延续自握手时的值 - 服务端收到了客户端
3个字节,所以,服务端的确认号ack的值要往前推进3 - 表示:你发的这
3个字节我收到了,我下次期待收到你发来的第4个字节开始的数据
- 服务端的
- 客户端收到服务端的数据后,发送了
ack,表示我已经收到你(服务端)发来的那3字节- 所以,
ack变成4了,客户端在告诉服务端"我收到了你从序号1开始发的3字节数据,我下次期待你从第4字节开始发" - 而此次这个
ack包不携带数据,所以seq还是4
- 所以,
总结
- 服务端的
Seq字段- 反映的是"服务端自己已经发送了多少字节"
- 服务端的
Ack字段- 反映的是"服务端从客户端那里收到了多少字节"
- 客户端的
seq字段- 反映的是"客户端自己已经发送了多少字节"
- 客户端的
ack字段- 反映的是"客户端从服务端那里收到了多少字节"
过滤条件
|
1 |
tcp.port == 9000 && ip.addr == 192.168.1.45 && !tcp.analysis.retransmission && !tcp.analysis.duplicate_ack |
Wireshark四次挥手观察
- 客户端发送了
fin- 为什么是
fin+ack,因为TCP连接一旦进入ESTABLISHED状态之后,几乎所有报文都会同时带上ACK标志 - 这是
TCP协议的一个通用规则
- 为什么是
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
1781: 客户端 [FIN, ACK] Seq=1 Ack=1 → 客户端这次发的是FIN,消耗1个序号,所以客户端计数器从1变成2(体现在下一次客户端发包时) 1783: 服务端 [ACK] Seq=1 Ack=2 → 服务端确认收到客户端消耗的这1个序号(FIN),所以Ack=1+1=2 → 服务端这次是纯ACK,不消耗序号,服务端计数器保持1(体现在1785) 1785: 服务端 [FIN, ACK] Seq=1 Ack=2 → 服务端上次(1783)没消耗序号,所以这次起始Seq依然是1 → 这次服务端发的是FIN,消耗1个序号,服务端计数器变成2(如果还有下次发包会体现出来) 1787: 客户端 [ACK] Seq=2 Ack=2 → 客户端上次(1781)发FIN消耗了1个序号,所以这次起始Seq是2 → 客户端确认收到服务端消耗的这1个序号(FIN),所以Ack=1+1=2 |
三次握手
第一次握手:客户端发SYN
- 客户端调用
connect(),内核:- 生成一个随机初始序号(
ISN, Initial Sequence Number),记为x,写进这个socket的tcp_sock.snd_nxt字段(下一个要发的序号) - 组装一个
SYN包(TCP头里SYN标志位置1,序号字段=x) - 通过前面讲过的完整发送链路(
TCP→IP→以太网帧→网卡)发出去
- 生成一个随机初始序号(
- 客户端
sk_state从CLOSED变成SYN_SENT
第二次握手:服务端回SYN+ACK
- 服务端的监听
socket收到这个SYN包后:- 内核检查这是不是发给已经
LISTEN的端口,如果是,在SYN队列里创建一个"半连接"记录 - 服务端也生成自己的随机初始序号,记为
y,写进这个(即将建立的)连接的snd_nxt - 同时,服务端把客户端的序号
x记下来,计算rcv_nxt = x + 1
表示"我已经收到了你的SYN,下一个我期望收到的字节序号是x+1" - 组装一个
SYN+ACK包(SYN和ACK标志位都置1,序号=y,确认号=x+1)发回去
- 内核检查这是不是发给已经
- 服务端这个半连接的状态变成
SYN_RCVD
第三次握手:客户端回ACK
- 客户端收到
SYN+ACK后:- 检查确认号
x+1是否等于自己期望的(说明服务端确实收到了第一次握手),校验通过 - 客户端把服务端的序号
y记下来,计算rcv_nxt = y + 1 - 组装一个
ACK包(确认号=y+1)发回去 - 客户端
sk_state变成ESTABLISHED
- 检查确认号
- 服务端收到这个最后的
ACK后:- 校验确认号
y+1无误 - 这个连接的状态从
SYN_RCVD变成ESTABLISHED - 这个连接从
SYN队列移动到Accept队列,等待应用层调用accept()取走 accept()取走后,内核创建一个全新的socket对象(五元组、状态ESTABLISHED、rcv_nxt/snd_nxt这些字段全部继承自这次握手协商的结果),返回给应用层
- 校验确认号
握手完成后
- 双方各自的
rcv_nxt和snd_nxt就是后续所有数据收发时,序号校验、滑动窗口计算的基准起点
为什么是三次握手,不是二次?
TCP最初设计时要解决的一个真实网络环境问题是:- 网络是不可靠的,一个包可能会延迟很久才到达,也可能会被重复发送
- 设想这个场景:
- 客户端发了一个
SYN(称为SYN₁)给服务端,但这个包在网络中意外延迟了(比如经过了一条绕远路的链路,滞留在某个路由器的队列里很久) - 客户端等了一段时间没收到回复,判定超时,放弃了这次连接尝试(比如用户取消了操作、或者上层应用逻辑判定失败重试),这个连接请求在客户端这边已经作废
- 过了一段时间,那个滞留的
SYN₁,突然又送到了服务端(纯粹是网络延迟导致的迟到,不是客户端故意重发的)
- 客户端发了一个
- 如果只有两次握手(客户端发
SYN,服务端回SYN+ACK就直接判定连接建立,不需要客户端再确认),会出什么问题?- 服务端收到那个迟到的、早已失效的
SYN₁,误以为是一次新的连接请求 - 服务端立刻分配资源、创建这个连接对象,状态直接进入
ESTABLISHED(因为两次握手不需要等第三次确认) - 但客户端根本不知道有这么一回事——它早就放弃了这次连接尝试,自然不会理睬服务端回复的这个
SYN+ACK,更不会发送任何数据 - 服务端这边,会白白维护着一个"已建立"但永远不会有数据到来的连接,持续占用内核资源,直到某个超时机制(比如长时间没有数据往来触发的
keepalive超时)才会被动清理
- 服务端收到那个迟到的、早已失效的
- 如果是三次握手(必须等客户端确认了才算真正建立):
- 服务端收到迟到的
SYN₁,依然会回复SYN+ACK,但这次连接停留在SYN_RCVD状态,待在SYN队列里,而不是直接进入ESTABLISHED - 因为客户端根本不会理会这个意外收到的
SYN+ACK,服务端永远等不到第三次握手的ACK - 服务端这个半连接会因为握手超时机制(
SYN队列里的连接如果长时间没有收到最终ACK,会被自动清理,并且会有限次重传SYN+ACK后放弃)而被正确回收,不会被误判成一个真实建立的连接
- 服务端收到迟到的
四次挥手
第一次挥手:客户端发FIN
- 客户端应用层调用
close()(或者Windows下closesocket())- 组装一个
FIN包(FIN标志位置1,序号=m,是当前snd_nxt的值)
- 组装一个
- 客户端
sk_state从ESTABLISHED变成FIN_WAIT_1
第二次挥手:服务端回ACK(不是FIN)
-
服务端收到
FIN后:- 内核立刻回复一个
ACK(确认号=m+1),告诉客户端"你的关闭请求我收到了" - 服务端状态从
ESTABLISHED变成CLOSE_WAIT
注意这一步服务端只是确认收到了,它自己还没有关闭,因为服务端这个方向可能还有数据没发完
- 内核立刻回复一个
-
客户端收到这个
ACK后:- 客户端状态从
FIN_WAIT_1变成FIN_WAIT_2 - 这个状态代表"我已经发完我这边的数据了,对方也确认收到了我的关闭请求,现在我在等对方也关闭它那边"
- 客户端状态从
-
这里就是
CLOSE_WAIT状态可能"卡住不动"的关键点- 如果服务端应用层代码,收到对方关闭请求之后(也就是
recv()返回0的那一刻),没有及时调用close(),服务端会一直停留在CLOSE_WAIT状态,这个连接不会往下推进,长期积累会导致文件描述符/句柄耗尽 - 所以,
CLOSE_WAIT大量堆积通常意味着代码里收到对方关闭后没有及时调用close
- 如果服务端应用层代码,收到对方关闭请求之后(也就是
第三次挥手:服务端发FIN
- 服务端应用层调用
close()之后(这一步的触发完全由服务端应用层代码的逻辑决定,不是自动的):- 组装
FIN包(序号=n,是服务端此时的snd_nxt)
- 组装
- 服务端状态从
CLOSE_WAIT变成LAST_ACK
第四次挥手:客户端回ACK
- 客户端收到服务端的
FIN后:- 回复
ACK(确认号=n+1) - 客户端状态从
FIN_WAIT_2变成TIME_WAIT
- 回复
- 服务端收到这个最后的
ACK后:- 状态从
LAST_ACK直接变成CLOSED,这个连接的内核资源被释放
- 状态从
为什么是四次挥手,不是三次?
- 握手时
- 服务端可以把"确认收到
SYN"和"我也要发SYN"这两件事合并成一个SYN+ACK包一起发 - 因为这两个动作是同时发生的意愿
- 服务端可以把"确认收到
- 但关闭连接不一样
TCP连接是全双工的(两个方向的数据流是独立的),当一方发起关闭(比如客户端调用close()),这只代表"我这个方向不再发数据了"- 但服务端那个方向可能还有数据没发完,所以不能强行把两个方向的关闭动作绑在一起
- 所以必须拆成两组独立的"挥手":
- 客户端方向的关闭:
FIN(客户端发起) +ACK(服务端确认) - 服务端方向的关闭:
FIN(服务端发起,在它确认没有数据要发了之后) +ACK(客户端确认)
- 客户端方向的关闭:
- 这两组动作分开发生,中间可能间隔任意长时间(服务端可以在收到客户端
FIN之后,继续处理完自己没发完的数据,再发起自己的FIN)- 这就是半关闭(
half-close)的概念
- 这就是半关闭(
TIME_WAIT
- 为什么客户端不能收到
ACK就立刻关闭,非要等一段时间- 客户端进入
TIME_WAIT之后,不会立刻释放连接资源,而是要等待一段时间 - 通常是
2倍MSL,Maximum Segment Lifetime MSL是一个报文段在网络上能存活的最长时间,常见配置下2MSL大约是1-4分钟),才真正变成CLOSED
- 客户端进入
- 原因
1:防止最后一个ACK丢失导致对方无法正常关闭- 如果客户端发出最后这个
ACK之后就立刻关闭,万一这个ACK在网络上丢了,服务端会因为一直等不到ACK而触发重传FIN包 - 但客户端此时已经把这个连接的资源释放掉了,收到这个重传的
FIN会回复一个RST(连接重置),而不是正确的ACK - 这会导致服务端的
LAST_ACK状态无法正常结束,只能通过超时机制强制关闭,这不是一个干净的关闭方式 - 保持
TIME_WAIT一段时间,是为了如果真的收到重传的FIN,客户端还能正确地再发一次ACK,保证服务端能正常关闭
- 如果客户端发出最后这个
- 原因
2:防止这条连接的"旧数据包"污染后续使用相同五元组的新连接- 网络上可能存在滞留、延迟到达的旧数据包
比如经过某些绕远路的路由路径,晚了很久才到 - 如果客户端在
TIME_WAIT结束前(旧数据包还有可能在路上飘着的这段时间内)就用相同的五元组(相同的IP+端口组合)建立了一个新连接,这些迟到的旧包如果被新连接误收,会造成数据错乱 - 等待
2MSL,足够保证网络上属于这条旧连接的所有滞留包都已经消失或者被丢弃,这样才能安全地用同样的五元组开启新连接
- 网络上可能存在滞留、延迟到达的旧数据包
CLOSE_WAIT
CLOSE_WAIT出现的场景回顾
- 当对方主动发起关闭(发了
FIN过来),你的内核会自动帮你回复一个ACK,并把你这一端的状态从ESTABLISHED切换成CLOSE_WAIT- 这个状态转换是内核自动完成的,不需要你的应用层代码做任何事情
- 这个状态怎么变成
bug——最常见的代码模式
|
1 2 3 4 5 6 7 8 9 10 |
int n = recv(sock, buf, sizeof(buf), 0); if (n > 0) { // 处理数据 } else if (n == 0) { // 对端已经关闭!!这里必须调用close/closesocket // 但如果代码忘记写这一行... continue; // ← 典型bug:什么都没做,直接进入下一次循环 } else { // 错误处理 } |
- 危害:句柄耗尽
- 每一个处于
CLOSE_WAIT状态的连接,依然占用着: - 一个文件描述符/
socket句柄(操作系统对单进程可打开的句柄数量是有上限的,Linux下ulimit -n可查,Windows下也有类似限制) - 内核里这个
socket对应的完整数据结构,持续占用内存
- 每一个处于
- 如果这类连接持续累积而不清理,最终会导致:
- 句柄耗尽:新连接的
accept()/AcceptEx会失败,因为进程能打开的句柄数已经达到上限 - 内存持续增长:每个残留连接占用的内核内存不会被释放,表现为服务进程内存缓慢泄漏
- 句柄耗尽:新连接的
高并发下连接建连失败
大量TIME_WAIT堆积
- 是客户端(主动发起连接的一方)常见的问题
- 如果客户端频繁地"建立连接→发几条消息→立刻关闭"这种短连接模式,每次关闭后客户端本地都会占用一个
TIME_WAIT状态的端口一段时间,如果短时间内建立了大量这样的短连接,可能会导致本地临时端口耗尽(因为TIME_WAIT状态的端口在等待期内不能立刻被复用)
- 如果客户端频繁地"建立连接→发几条消息→立刻关闭"这种短连接模式,每次关闭后客户端本地都会占用一个
- 生产环境常见的优化手段是连接复用(长连接/连接池),减少频繁建立/关闭连接的次数
大量CLOSE_WAIT堆积
- 是服务端(被动关闭方)常见的
bug - 常意味着服务端代码在
recv()返回0(对方已关闭)之后,没有及时调用closesocket(),导致这个连接一直卡在CLOSE_WAIT,长期积累会耗尽服务端的socket句柄资源
同时关闭(Simultaneous Close)
- 如果双方几乎同时调用
close()(而不是一方先关、另一方后关这种典型场景)- 会各自发出
FIN,各自收到对方的FIN后回ACK,状态会经过一个叫CLOSING的中间状态,最终双方都能正常到达TIME_WAIT再到CLOSED - 这种场景在实际应用里比较少见(大部分是一方明确先发起关闭)
- 会各自发出
短连接
核心定义
- 每次通信都重新建立一次
TCP连接,发完数据、收到回复后立刻关闭这个连接,下一次通信再重新走一遍"三次握手→收发数据→四次挥手"的完整流程
图示
短连接的开销具体体现在哪
- 每次都要走一遍三次握手,至少消耗1个
RTT(往返时延) - 每次关闭都要走四次挥手,并且客户端会产生
TIME_WAIT - 每次都要重新创建/销毁
socket内核对象 TCP拥塞控制要重新"热身"TCP连接建立初期,拥塞窗口(cwnd)是从一个很小的值开始,通过慢启动逐步增大,直到探测到网络的实际承载能力- 短连接每次都要重新经历这个慢启动过程,还没充分利用带宽连接就关闭了;而长连接在多轮数据交换后,拥塞窗口已经"热身"完毕,能更快地达到较高的吞吐量
长连接
核心定义
- 连接建立一次之后,长时间保持打开状态,期间可能进行多轮数据收发,直到业务层面明确决定断开(比如用户退出游戏、客户端主动关闭),才走一次四次挥手关闭
图示
长连接的代价
- 长连接不是"只有好处没有坏处",它引入了新的问题,需要额外的机制来解决:
- 怎么知道对方是不是还"活着"——心跳机制
TCP连接一旦建立,如果双方都不发数据,协议层面没有任何机制主动告诉你"对方还在不在"- 如果客户端的网络突然断掉(比如拔网线、手机进电梯信号丢失),而没有经过正常的四次挥手,服务端这边的连接会一直停留在
ESTABLISHED状态,直到应用层某个动作触发失败(比如尝试发送数据超时)才会发现问题 - 解决方法是应用层心跳机制
- 大量并发长连接对服务端资源管理提出更高要求
- 短连接因为生命周期短,同一时刻服务端需要维护的连接数相对可控
- 长连接则意味着大量连接会同时长期占用服务端的内存和句柄资源
- 这也是所谓"
C10K问题"(如何用一台服务器同时支撑上万并发连接)的历史背景
epoll和IOCP这些高性能IO模型,正是为了应对长连接场景下海量并发这个挑战而设计出来的
- 空闲连接的超时清理
- 如果客户端异常退出但连接没有正常关闭(比如没有心跳机制、又没有正常挥手),服务端需要有一套空闲连接超时踢出的机制,定期检查"多久没有收发数据的连接就主动断开",否则这类"僵尸连接"会一直占用资源不释放
心跳机制
- 约定客户端每隔一段时间(比如
10-30秒)主动发一个很小的"心跳包"给服务端,服务端如果连续几个心跳周期都没收到,判定这个连接已经失效,主动关闭并清理资源- 这就是
SO_KEEPALIVE选项的应用场景 - 不过实际生产系统更常见的做法是在应用层自己实现心跳,而不是依赖
TCP自带的SO_KEEPALIVE,原因是TCP自带的keepalive检测周期通常是以小时为单位的 - 默认
2小时才探测一次,虽然可以调整,但没有应用层心跳灵活
- 这就是
怎么发送心跳包
- 客户端
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
// heartbeat_client.cpp // 配套客户端:独立线程定期发送心跳,主线程负责发送用户输入的业务数据, // 另有一个接收线程持续读取服务端消息(区分是心跳响应还是业务echo) // // 编译方式: // cl heartbeat_client.cpp /EHsc /std:c++17 Ws2_32.lib #include <winsock2.h> #include <ws2tcpip.h> #include <iostream> #include <thread> #include <atomic> #include <chrono> #include <string> #include <vector> #include <cstring> #pragma comment(lib, "Ws2_32.lib") constexpr const char* SERVER_IP = "127.0.0.1"; constexpr int SERVER_PORT = 9000; constexpr int HEADER_SIZE = 5; constexpr int HEARTBEAT_INTERVAL_SECONDS = 5; // 每5秒发一次心跳 enum class MsgType : uint8_t { DATA = 0x01, HEARTBEAT = 0x02, HEARTBEAT_ACK = 0x03 }; std::atomic<bool> g_running{true}; SOCKET g_sock = INVALID_SOCKET; // 组装并发送一条 [类型+长度+payload] 消息 void SendFramedMessage(SOCKET sock, MsgType type, const char* payload, uint32_t payloadLen) { std::vector<char> frame(HEADER_SIZE + payloadLen); frame[0] = static_cast<char>(type); uint32_t netLen = htonl(payloadLen); memcpy(frame.data() + 1, &netLen, 4); if (payloadLen > 0) memcpy(frame.data() + HEADER_SIZE, payload, payloadLen); send(sock, frame.data(), (int)frame.size(), 0); } // 心跳线程:每隔固定时间,发一个不带payload的心跳包 void HeartbeatThread() { while (g_running) { std::this_thread::sleep_for(std::chrono::seconds(HEARTBEAT_INTERVAL_SECONDS)); if (!g_running) break; SendFramedMessage(g_sock, MsgType::HEARTBEAT, nullptr, 0); std::cout << "\n[心跳] 已发送心跳包" << std::endl; } } // 接收线程:持续读取服务端数据,按协议解析出完整消息(处理粘包/半包),并根据类型分别处理 void RecvThread() { std::vector<char> accum; char rawBuf[4096]; while (g_running) { int n = recv(g_sock, rawBuf, sizeof(rawBuf), 0); if (n <= 0) { std::cout << "\n[连接断开] 服务端关闭了连接" << std::endl; g_running = false; break; } accum.insert(accum.end(), rawBuf, rawBuf + n); while (true) { if (accum.size() < HEADER_SIZE) break; uint8_t msgType = static_cast<uint8_t>(accum[0]); uint32_t netLen; memcpy(&netLen, accum.data() + 1, 4); uint32_t payloadLen = ntohl(netLen); size_t totalLen = HEADER_SIZE + payloadLen; if (accum.size() < totalLen) break; if (static_cast<MsgType>(msgType) == MsgType::HEARTBEAT_ACK) { std::cout << "[心跳响应] 收到服务端心跳确认" << std::endl; } else if (static_cast<MsgType>(msgType) == MsgType::DATA) { std::string payload(accum.data() + HEADER_SIZE, payloadLen); std::cout << "[数据回显] " << payload << std::endl; } accum.erase(accum.begin(), accum.begin() + totalLen); } } } int main() { WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); g_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in serverAddr{}; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(SERVER_PORT); inet_pton(AF_INET, SERVER_IP, &serverAddr.sin_addr); if (connect(g_sock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { std::cerr << "连接失败: " << WSAGetLastError() << std::endl; return 1; } std::cout << "已连接服务端,每" << HEARTBEAT_INTERVAL_SECONDS << "秒自动发送一次心跳" << std::endl; std::cout << "输入内容回车发送业务数据,输入 exit 退出" << std::endl; std::thread heartbeatThread(HeartbeatThread); std::thread recvThread(RecvThread); std::string line; while (g_running) { std::getline(std::cin, line); if (!g_running) break; if (line == "exit") { g_running = false; break; } if (line.empty()) continue; SendFramedMessage(g_sock, MsgType::DATA, line.c_str(), (uint32_t)line.size()); } g_running = false; closesocket(g_sock); // 触发recv线程退出阻塞 heartbeatThread.join(); recvThread.join(); WSACleanup(); return 0; } |
- 服务端
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 |
// heartbeat_server.cpp // 在IOCP echo server基础上,加入应用层心跳机制 + 简单的定长包头协议(解决粘包问题) // // 协议格式: [1字节类型][4字节payload长度(网络字节序)][payload] // 类型: 0x01=DATA 0x02=HEARTBEAT 0x03=HEARTBEAT_ACK // // 编译方式: // cl heartbeat_server.cpp /EHsc /std:c++17 Ws2_32.lib Mswsock.lib #include <winsock2.h> #include <ws2tcpip.h> #include <mswsock.h> #include <windows.h> #include <iostream> #include <vector> #include <thread> #include <mutex> #include <chrono> #include <algorithm> #include <cstring> #pragma comment(lib, "Ws2_32.lib") #pragma comment(lib, "Mswsock.lib") constexpr int PORT = 9000; constexpr int BUFFER_SIZE = 4096; constexpr int MAX_ACCEPT_POST = 10; constexpr int HEADER_SIZE = 5; // 1字节类型 + 4字节长度 // 心跳超时时间:如果一个连接超过这么久没有收到任何消息(心跳或数据),就判定为掉线 constexpr int HEARTBEAT_TIMEOUT_SECONDS = 15; // 后台监控线程的检查间隔 constexpr int MONITOR_INTERVAL_SECONDS = 5; enum class MsgType : uint8_t { DATA = 0x01, HEARTBEAT = 0x02, HEARTBEAT_ACK = 0x03 }; enum class IoType { ACCEPT, RECV, SEND }; struct IoContext { OVERLAPPED overlapped; WSABUF wsaBuf; char buffer[BUFFER_SIZE]; IoType ioType; SOCKET socket; IoContext(IoType type) : ioType(type), socket(INVALID_SOCKET) { ZeroMemory(&overlapped, sizeof(overlapped)); ZeroMemory(buffer, BUFFER_SIZE); wsaBuf.buf = buffer; wsaBuf.len = BUFFER_SIZE; } }; // 每个连接的上下文:新增"接收累积缓冲区"(解决粘包/半包) 和 "最后活跃时间"(心跳超时判断) struct ConnectionContext { SOCKET clientSocket; std::vector<char> recvAccum; // 累积尚未处理完的字节(可能是半条消息) std::chrono::steady_clock::time_point lastActiveTime; std::mutex activeMutex; // 保护lastActiveTime的并发读写 explicit ConnectionContext(SOCKET s) : clientSocket(s), lastActiveTime(std::chrono::steady_clock::now()) {} ~ConnectionContext() { if (clientSocket != INVALID_SOCKET) closesocket(clientSocket); } void Touch() { std::lock_guard<std::mutex> lk(activeMutex); lastActiveTime = std::chrono::steady_clock::now(); } bool IsTimedOut(int timeoutSeconds) { std::lock_guard<std::mutex> lk(activeMutex); auto elapsed = std::chrono::duration_cast<std::chrono::seconds>( std::chrono::steady_clock::now() - lastActiveTime).count(); return elapsed > timeoutSeconds; } }; HANDLE g_hIOCP = nullptr; SOCKET g_listenSocket = INVALID_SOCKET; LPFN_ACCEPTEX g_lpfnAcceptEx = nullptr; constexpr int ADDR_LEN = sizeof(SOCKADDR_IN) + 16; // 全局连接表:用于心跳超时监控线程遍历检查所有连接 std::mutex g_connMutex; std::vector<ConnectionContext*> g_allConnections; void AddConnection(ConnectionContext* ctx) { std::lock_guard<std::mutex> lk(g_connMutex); g_allConnections.push_back(ctx); } void RemoveConnection(ConnectionContext* ctx) { std::lock_guard<std::mutex> lk(g_connMutex); g_allConnections.erase( std::remove(g_allConnections.begin(), g_allConnections.end(), ctx), g_allConnections.end()); } bool PostAccept() { SOCKET acceptSocket = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, nullptr, 0, WSA_FLAG_OVERLAPPED); if (acceptSocket == INVALID_SOCKET) return false; IoContext* ctx = new IoContext(IoType::ACCEPT); ctx->socket = acceptSocket; DWORD bytesReceived = 0; BOOL result = g_lpfnAcceptEx(g_listenSocket, acceptSocket, ctx->buffer, 0, ADDR_LEN, ADDR_LEN, &bytesReceived, &ctx->overlapped); if (!result && WSAGetLastError() != ERROR_IO_PENDING) { closesocket(acceptSocket); delete ctx; return false; } return true; } bool PostRecv(ConnectionContext* connCtx) { IoContext* ctx = new IoContext(IoType::RECV); DWORD flags = 0, bytesRecv = 0; int result = WSARecv(connCtx->clientSocket, &ctx->wsaBuf, 1, &bytesRecv, &flags, &ctx->overlapped, nullptr); if (result == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING) { delete ctx; return false; } return true; } bool PostSend(ConnectionContext* connCtx, const char* data, DWORD len) { IoContext* ctx = new IoContext(IoType::SEND); memcpy(ctx->buffer, data, len); ctx->wsaBuf.len = len; DWORD bytesSent = 0; int result = WSASend(connCtx->clientSocket, &ctx->wsaBuf, 1, &bytesSent, 0, &ctx->overlapped, nullptr); if (result == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING) { delete ctx; return false; } return true; } // 组装一条完整的[类型+长度+payload]消息,交给PostSend发出去 void SendFramedMessage(ConnectionContext* connCtx, MsgType type, const char* payload, uint32_t payloadLen) { std::vector<char> frame(HEADER_SIZE + payloadLen); frame[0] = static_cast<char>(type); uint32_t netLen = htonl(payloadLen); memcpy(frame.data() + 1, &netLen, 4); if (payloadLen > 0) { memcpy(frame.data() + HEADER_SIZE, payload, payloadLen); } PostSend(connCtx, frame.data(), static_cast<DWORD>(frame.size())); } // 处理这个连接新收到的原始字节:追加到累积缓冲区,然后尽可能多地解析出完整消息 void ProcessIncomingData(ConnectionContext* connCtx, const char* data, DWORD len) { connCtx->recvAccum.insert(connCtx->recvAccum.end(), data, data + len); // 循环解析:只要累积缓冲区里够解析出一条完整消息,就继续处理 while (true) { if (connCtx->recvAccum.size() < HEADER_SIZE) break; // 连包头都不够,等更多数据 uint8_t msgType = static_cast<uint8_t>(connCtx->recvAccum[0]); uint32_t netLen; memcpy(&netLen, connCtx->recvAccum.data() + 1, 4); uint32_t payloadLen = ntohl(netLen); size_t totalMsgLen = HEADER_SIZE + payloadLen; if (connCtx->recvAccum.size() < totalMsgLen) break; // 包体还没收全,等更多数据(半包情况) // 到这里,说明累积缓冲区里已经有至少一条完整消息 const char* payloadPtr = connCtx->recvAccum.data() + HEADER_SIZE; switch (static_cast<MsgType>(msgType)) { case MsgType::HEARTBEAT: { std::cout << "[心跳] 收到来自 socket=" << connCtx->clientSocket << " 的心跳" << std::endl; // 立刻回复心跳响应,不带payload SendFramedMessage(connCtx, MsgType::HEARTBEAT_ACK, nullptr, 0); break; } case MsgType::DATA: { std::cout << "[数据] 收到 " << payloadLen << " 字节,原样echo回去" << std::endl; SendFramedMessage(connCtx, MsgType::DATA, payloadPtr, payloadLen); break; } default: std::cerr << "[警告] 未知消息类型: " << (int)msgType << std::endl; break; } // 消费掉已处理的这条消息,剩下的留着继续循环解析(可能还粘着下一条消息) connCtx->recvAccum.erase(connCtx->recvAccum.begin(), connCtx->recvAccum.begin() + totalMsgLen); } } void WorkerThread() { while (true) { DWORD bytesTransferred = 0; ULONG_PTR completionKey = 0; LPOVERLAPPED pOverlapped = nullptr; BOOL success = GetQueuedCompletionStatus(g_hIOCP, &bytesTransferred, &completionKey, &pOverlapped, INFINITE); if (pOverlapped == nullptr) break; IoContext* ioCtx = reinterpret_cast<IoContext*>(pOverlapped); if (!success || (bytesTransferred == 0 && ioCtx->ioType != IoType::ACCEPT)) { // 对方关闭或出错:清理连接 if (completionKey != 0) { ConnectionContext* connCtx = reinterpret_cast<ConnectionContext*>(completionKey); std::cout << "[连接关闭] socket=" << connCtx->clientSocket << std::endl; RemoveConnection(connCtx); delete connCtx; } delete ioCtx; continue; } switch (ioCtx->ioType) { case IoType::ACCEPT: { SOCKET clientSocket = ioCtx->socket; setsockopt(clientSocket, SOL_SOCKET, SO_UPDATE_ACCEPT_CONTEXT, (char*)&g_listenSocket, sizeof(g_listenSocket)); ConnectionContext* connCtx = new ConnectionContext(clientSocket); CreateIoCompletionPort((HANDLE)clientSocket, g_hIOCP, (ULONG_PTR)connCtx, 0); AddConnection(connCtx); std::cout << "[新连接] socket=" << clientSocket << std::endl; PostRecv(connCtx); delete ioCtx; PostAccept(); break; } case IoType::RECV: { ConnectionContext* connCtx = reinterpret_cast<ConnectionContext*>(completionKey); connCtx->Touch(); // 收到任何数据(心跳或业务)都刷新活跃时间 ProcessIncomingData(connCtx, ioCtx->buffer, bytesTransferred); delete ioCtx; PostRecv(connCtx); // 继续投递下一次接收 break; } case IoType::SEND: delete ioCtx; break; } } } // 心跳超时监控线程:定期扫描所有连接,把超时没有心跳/数据的连接强制关闭 void HeartbeatMonitorThread() { while (true) { std::this_thread::sleep_for(std::chrono::seconds(MONITOR_INTERVAL_SECONDS)); std::vector<ConnectionContext*> timedOut; { std::lock_guard<std::mutex> lk(g_connMutex); for (auto* conn : g_allConnections) { if (conn->IsTimedOut(HEARTBEAT_TIMEOUT_SECONDS)) { timedOut.push_back(conn); } } } for (auto* conn : timedOut) { std::cout << "[心跳超时] socket=" << conn->clientSocket << " 超过" << HEARTBEAT_TIMEOUT_SECONDS << "秒无消息,强制断开" << std::endl; // 直接关闭socket,会触发IOCP完成通知(recv返回0/出错), // WorkerThread会走清理分支,负责真正delete这个ConnectionContext closesocket(conn->clientSocket); conn->clientSocket = INVALID_SOCKET; // 避免析构函数重复关闭 } } } int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) return 1; g_hIOCP = CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 0); g_listenSocket = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, nullptr, 0, WSA_FLAG_OVERLAPPED); sockaddr_in serverAddr{}; serverAddr.sin_family = AF_INET; serverAddr.sin_addr.s_addr = INADDR_ANY; serverAddr.sin_port = htons(PORT); bind(g_listenSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)); listen(g_listenSocket, SOMAXCONN); CreateIoCompletionPort((HANDLE)g_listenSocket, g_hIOCP, 0, 0); GUID guidAcceptEx = WSAID_ACCEPTEX; DWORD bytes = 0; WSAIoctl(g_listenSocket, SIO_GET_EXTENSION_FUNCTION_POINTER, &guidAcceptEx, sizeof(guidAcceptEx), &g_lpfnAcceptEx, sizeof(g_lpfnAcceptEx), &bytes, nullptr, nullptr); SYSTEM_INFO sysInfo; GetSystemInfo(&sysInfo); int threadCount = sysInfo.dwNumberOfProcessors * 2; std::vector<std::thread> workers; for (int i = 0; i < threadCount; ++i) workers.emplace_back(WorkerThread); std::thread monitorThread(HeartbeatMonitorThread); std::cout << "带心跳机制的IOCP echo server启动,端口 " << PORT << ",心跳超时 " << HEARTBEAT_TIMEOUT_SECONDS << " 秒" << std::endl; for (int i = 0; i < MAX_ACCEPT_POST; ++i) PostAccept(); for (auto& t : workers) t.join(); monitorThread.join(); closesocket(g_listenSocket); WSACleanup(); return 0; } |
HTTP协议的演进
HTTP/1.0默认是短连接,每个请求都新建TCP连接HTTP/1.1引入Keep-Alive,默认变成长连接,一条TCP连接上可以连续发送多个HTTP请求/响应HTTP/2更进一步,用多路复用在一条长连接上并行处理多个请求,进一步减少连接数
粘包
粘包问题的本质
TCP传输的是一串连续的字节流,没有"消息边界"这个概念- 应用层调用一次
send(),发送的是一段有明确边界的数据(比如"这一条消息是3个字节"),但TCP协议本身根本不关心、也不记录你调用了几次send()、每次发了多长- 它只关心"总共有多少字节需要可靠地送到对方",至于这些字节在对方那边,会被一次
recv()整体读到,还是被拆成好几次recv()分批读到,TCP完全不做任何保证
- 它只关心"总共有多少字节需要可靠地送到对方",至于这些字节在对方那边,会被一次
粘包 和 半包(拆包)
粘包的成因(多条消息被合并读到)
Nagle算法- 如果你连续调用几次
send()发送小数据,内核可能会把它们攒在一起,合并成一个TCP段发出去,减少小包数量
- 如果你连续调用几次
- 接收方处理不及时
- 即使发送方是分开发的,如果接收方没有及时调用
recv(),数据会在内核接收缓冲区里持续累积,等你终于调用recv()时,可能一次性就读到了好几条消息的内容,因为它们都已经躺在缓冲区里了
- 即使发送方是分开发的,如果接收方没有及时调用
半包/拆包的成因(一条消息被拆成多次读到)
- 数据量超过
MSS- 一次
send()如果超过MSS(约1460字节),会被TCP层拆成多个TCP段分别发送,接收方完全有可能"先收到第一段,过一会儿才收到第二段" - 如果你的应用层代码不做处理,直接把第一次
recv()读到的内容当成完整消息处理,就会出错
- 一次
- 接收方的应用层缓冲区不够大、或者读取时机凑巧卡在数据传输过程中
- 导致一次
recv()调用只读到了这条消息的一部分
- 导致一次
容易被误解的点
- 很多初学者会以为"粘包"是发送方"发错了",但实际上,发送方发出去的字节流本身是完全正确、连续、没有丢失的
- 问题出在接收方不知道"这一串连续的字节,应该在哪里切开,才能还原出发送方原本想表达的一条条独立消息"
- 这也是为什么我们说"
TCP不保证消息边界"
解决方案——三种经典做法
- 方案
1:定长消息- 约定每条消息都是固定长度(比如每条消息恰好
64字节),接收方每次固定读64字节就是一条完整消息 - 缺点很明显:实际业务数据长度千变万化,很少有场景适合用定长方案,会造成大量浪费(短消息也要填充到
64字节)或者不够用(超过64字节的消息没法处理)
- 约定每条消息都是固定长度(比如每条消息恰好
- 方案
2:分隔符法- 用一个特殊字符(或字符序列)作为消息结束标志,比如
HTTP协议早期用\r\n作为行结束标志 - 缺点:如果消息内容本身可能包含这个分隔符,需要额外做"转义"处理,增加复杂度
而且接收方需要逐字节扫描寻找分隔符,效率不如直接读一个长度值
- 用一个特殊字符(或字符序列)作为消息结束标志,比如
- 方案
3:长度前缀法- 在每条消息前面,加一个固定大小的包头,里面写明"接下来这条消息的长度是多少"
- 接收方的处理逻辑变成:
- 先确保攒够了包头这么多字节(我们代码里是
5字节:1字节类型+4字节长度) - 从包头里解析出这条消息的实际长度
- 检查目前攒的数据够不够这个长度,不够就继续等,够了就把这一整条消息切出来处理
- 处理完这条消息后,继续检查剩下的数据里是不是还粘着下一条消息的开头
|
1 2 3 4 5 6 7 |
while (true) { if (connCtx->recvAccum.size() < HEADER_SIZE) break; // 包头都不够,等更多数据 // ...解析出payloadLen... if (connCtx->recvAccum.size() < totalMsgLen) break; // 包体没收全,等更多数据(半包) // ...处理这条完整消息... connCtx->recvAccum.erase(...); // 消费掉,继续检查是否还粘着下一条(粘包) } |
拆包
- 粘包和拆包(半包),本质上是同一个根源问题的两种不同表现形式
- 根本原因只有一个
TCP只保证字节流按顺序、可靠地到达,不保证"发送方调用几次send,接收方就对应能调用几次recv收到边界清晰的消息"
- 粘包
- 多条消息被"挤"在一起,一次
recv()读到了不止一条消息的内容
- 多条消息被"挤"在一起,一次
- 拆包(半包)
- 一条消息被"拆散"了,一次
recv()只读到了这条消息的一部分,剩下的要等下一次(或者好几次)recv()才能凑齐
- 一条消息被"拆散"了,一次
- 详细参考上文发送心跳包
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ Linux 高性能服务器编程:网络基础编程一11/27
- ♥ WebSocket协议相关学习一03/24
- ♥ Chromium:学习,框架,一09/02
- ♥ wireshark06/30
- ♥ Socket基础:TCP篇10/16
- ♥ Socket:发送结构化消息-结构体10/19












