• 忘掉天地
  • 仿佛也想不起自己
bingliaolongBingliaolong  2026-07-16 23:55 Aet 隐藏边栏 |   抢沙发  1 
文章评分 1 次,平均分 5.0

谁会去读发送缓冲区的数据

概述

  1. 是内核,但不是"被动等着读",而是主动触发的

触发时机1

  1. send()调用本身,如果条件允许,会立即尝试发送
    1. 很多人以为send()只是"把数据扔进缓冲区,然后就不管了,等内核有空再处理"
  2. 实际上,在很多情况下,内核会在这次send()系统调用的执行过程中,就直接尝试把数据封装成TCP段发出去,前提是:
    1. 对方的接收窗口(rwnd)还有剩余空间(对方还能收)
    2. 本机的拥塞窗口(cwnd)还没跑满(网络还没拥塞)
    3. 没有被Nagle算法卡住
    4. 如果这些条件都满足,send()调用内部就会直接触发TCP层把这次的数据打包发出去

触发时机2

  1. 收到对方的ACK,释放了发送窗口空间
    1. 如果之前发送窗口已经被打满(比如批量发了很多数据,还没收到确认,不能再发新的了),这时候数据会滞留在发送缓冲区里等待
    2. 一旦收到对方的ACK(确认收到了之前的数据、并且可能顺带告知新的可用窗口大小),内核会在处理这个ACK的过程中,顺便检查发送缓冲区里是否还有排队等待发送的数据,如果有,就继续把这些数据打包发出去

触发时机3

  1. 重传定时器超时
    1. 发送缓冲区里的数据,在发出去之后并不会被立刻清除,而是继续保留(标记为"已发送但未确认"),因为TCP需要保证可靠传输
    2. 如果超过一定时间没收到对方的ACK,内核判定这个包可能丢了,重传定时器超时会触发从发送缓冲区里重新取出这段数据,再发一次
    3. 这也是为什么"发送缓冲区"不只是"待发送队列",更准确说,它同时承担着"待发送数据"和"已发送但待确认数据(以备重传)"这两个角色

Nagle算法

  1. 如果你连续调用几次send()发送很小的数据块(比如每次几个字节),默认情况下TCP会启用Nagle算法
    1. 如果当前还有未被确认的数据在途中,新的小数据会先攒在发送缓冲区里,不立刻发出,等凑够一个MSS大小、或者收到之前数据的ACK,才一起打包发送
    2. 这是为了避免网络上充斥大量只有几字节数据的小包(每个包还要背负TCP+IP+以太网头部的开销,效率低)
  2. 显式设置TCP_NODELAY选项关闭Nagle算法:

IP包的发送相关

路由表(Routing Table)

  1. 路由表是内核里维护的一张"该往哪个方向发"的查表
  2. 每一条记录(路由条目)大致包含这几个字段:

  1. 查看方式
    1. Windows: route print
    2. Linux: ip route show 或者老一点的 route -n

最长前缀匹配(Longest Prefix Match)

  1. 如果一个目标IP同时匹配多条路由规则
    1. 比如既匹配192.168.1.0/24也匹配0.0.0.0/0默认路由
    2. 内核会选择匹配的子网掩码最长(最精确)的那一条
  2. 这也是为什么表里最后一行0.0.0.0/0(俗称"默认路由")永远是兜底选项
    1. 它匹配任何IP,但优先级最低,只有前面所有更精确的规则都不匹配时才会用它

直连网络 vs 需要网关转发

  1. 如果目标IP和本机某个网卡在同一网段
    1. 比如你的IP192.168.1.10/24,目标是192.168.1.50
    2. 路由表里对应条目的网关字段通常是0.0.0.0或者显示"on-link"(在链路上)
    3. 意思是不需要经过任何网关,直接在这个局域网内广播ARP、拿到对方MAC地址就能发包
  2. 如果目标IP不在任何一个直连网段里
    1. 比如你要访问8.8.8.8
    2. 就会匹配到默认路由这一条,内核知道"这个包我处理不了,得交给网关帮我转发"

网卡对应的路由条目

  1. 每张网卡对应的路由条目是系统启动或者网卡配置时自动生成的
  2. 你给网卡配置IP和子网掩码时(比如DHCP自动获取,或者手动配置),操作系统会自动帮你生成对应的"直连网络"路由条目,不需要手动加
  3. 只有默认路由(网关地址)通常需要手动配置或者DHCP自动下发

网关

  1. 网关本质上就是一台路由器(或者一台开启了IP转发的普通机器)
  2. 它的作用是:
    1. 当你的包要发往一个不在本地网络的目标时,先把包发给这个网关,由网关根据它自己的路由表,决定下一步往哪转发
  3. 下一跳
    1. 网关不一定是"最终目的地所在网络的守门人",很可能只是"离目标近一点点的下一站"
    2. 这个包可能要经过好几个路由器接力转发,才能真正到达目标网络
    3. 你的机器只需要知道"发给我的默认网关"就行,不需要知道整条转发路径,每一跳的路由器各自决定下一跳是谁
    4. 这是IP路由"逐跳转发"的核心设计思想

网卡

  1. 路由表查完之后,知道了"用哪个网卡发"、"下一跳是谁",接下来才是真正的封包发送过程:
  2. 第一步:确定下一跳的MAC地址(ARP解析)
    1. IP层只知道下一跳的IP地址(可能是网关的IP,也可能是同网段目标本身的IP),但链路层(以太网)发包需要MAC地址
    2. 内核先查本机的ARP缓存(arp -a可以查看),如果没有对应记录,就发一个ARP广播"谁是这个IP,把MAC地址告诉我"
    3. 对方回复后,内核把这个IP-MAC映射缓存起来(有过期时间,通常几分钟)
  3. 第二步:封装以太网帧
    1. 拿到MAC地址后,内核把IP数据包包装成一个完整的以太网帧:
    2. 注意这里目的MAC填的是"下一跳"的MAC地址,不是最终目标机器的MAC地址(除非下一跳恰好就是最终目标)

  1. 第三步:交给网卡驱动,通过DMA放进发送队列
    1. 封装好的帧,由内核网络协议栈交给网卡驱动程序,驱动把这块内存通过DMA(直接内存访问)方式,搬进网卡自己的发送环形缓冲区(TX ring buffer)
  2. 第四步:网卡硬件把数字信号转换成物理信号发送出去
    1. 网卡硬件从发送队列里取出帧,转换成电信号(以太网线缆)或者光信号(光纤)或者无线电波(WiFi),通过物理链路发送出去
    2. 发送完成后,网卡通常会触发一个中断通知驱动"这一帧发完了,可以处理下一个了
    3. 或者高性能场景下用轮询模式减少中断开销

DHCP

详细发送流程

路由查表

  1. 系统内核根据tcp包的目标IP地址,查本机路由表,匹配得到接口和下一跳IP
    1. 同局域网内,通过目标主机自己的IPARP缓存拿目标主机的MAC,没有则广播ARP获取并缓存MAC,填作目的MAC
    2. 如果目标不在本地网段,则用ARP解析默认网关的IP,拿到网关的MAC地址,作为这一跳以太网帧的目的MAC

组装以太网帧

  1. 目的MAC + 源MAC + 帧类型(标识内部是IPv4/IPv6) + 已经封装好的完整IP包(IP包内部包含了TCP头,TCP头里有端口号)
  2. 组装一个以太网帧

以太网帧发送到网卡驱动

  1. 然后把这个以太网帧发送到网卡驱动,网卡驱动通过DMA把帧数据放进网卡自己的环形缓冲区

电信号发送到网关

  1. 网卡从队列里拿出这个帧,转换成电信号,再把这个电信号发送到网关(我的路由器A
  2. 路由器A收到,还原成帧,拆出IP包,发现目的IP不是自己
  3. 查路由表决定下一跳
  4. 如果需要NAT(比如要出公网了):改写源IP/源端口,重新计算IP头和TCP头校验和
  5. 用(可能已被NAT改写过的)IP包,重新封装新帧:目的MAC=下一跳MAC,源MAC=路由器AMAC
  6. 转成对应介质的信号(电/光/无线),发出

其他

  1. WiFi(无线电波)
  2. 网线(电信号)
  3. 光信号(光纤入户)

IP包的接收相关

  1. 网卡

    1. 网卡硬件收到物理信号(电/光/无线电波),还原成比特流,组装成完整的以太网帧
    2. 网卡通过DMA,把这个帧数据直接写入环形接收缓冲区
    3. 数据已经在内存里了之后,网卡才触发一个硬件中断,告诉CPU"有新数据到了,内存地址在这里,你可以处理了"
    4. CPU响应中断,调用网卡驱动的中断处理函数
  2. 网卡驱动

    1. 网卡驱动从内存的环形接收缓冲区里取出数据,剥离以太网头,交给IP
  3. IP

    1. 校验和检查、剥离IP头、判断目的IP是否本机
    2. 是本机则往上交
    3. 不是本机则查路由表决定是否转发
  4. TCP

    1. 五元组查找定位socket,序号校验,必要时回复ACK
    2. 数据放入该socket的接收缓冲区
  5. 唤醒等待中的应用层

    1. 比如阻塞recv被唤醒,epoll标记fd就绪,IOCP投递完成通知
  6. 网卡驱动怎么区分IPv4/IPv6/ARP

    1. 靠以太网帧头里的EtherType字段
    2. 驱动剥离以太网头之后,读取这2字节的EtherType值,就知道该把剩下的数据交给哪个上层协议处理函数
    3. 具体机制上(以Linux为例):内核维护了一张协议处理函数注册表(ptype_base哈希表),IP协议栈、ARP协议栈初始化时,会各自用自己的EtherType值(0x08000x0806)向这张表注册自己的处理函数(dev_add_pack这个接口)
    4. Windows下的NDIS(网络驱动接口规范)也是类似的设计思路,协议驱动向NDIS注册自己关心的EtherType,底层驱动收到帧后按类型分发

谁会把数据写到接收缓冲区

  1. 是内核TCP层,但准确说不是"写",而是"挂载"
    1. 具体在Linux下是tcp_v4_rcv这个函数(处理IPv4TCP包)在软中断上下文里执行,做完五元组查找、序号校验之后,负责把这段数据"放进"对应socket的接收缓冲区
  2. 有个容易被误解的细节:
    1. 数据通常不是被"重新拷贝"一份放进缓冲区,而是把已经在内存里的这块数据(网卡DMA写进来的那块内存,包装成一个叫sk_buff的结构体)直接"挂载"到这个socket的接收队列(sk_receive_queue)上
  3. 真正发生"内存拷贝"的时机,是应用层调用recv()的那一刻
    1. 这时候内核才把sk_receive_queue里排队的数据,从内核态内存拷贝到你在用户态传入的buf

状态机图

图示

Wireshark三次握手观察

  1. 客户端syn
    1. Wireshark显示的是相对序号,方便阅读,真实的ISN是一个随机大数
    2. 客户端的序号是0

  1. 服务端syn+ack
    1. ack1,对应客户端的序号0+1
    2. 服务端的序号是0

  1. 客户端ack
    1. ack1,对应服务端的序号0+1
    2. 客户端的序号是1,对应客户端序号0+1

  1. 握手完成,双方进入ESTABLISHED
    1. 状态的改变是内核管理的,wireshark这里看不到
  2. 客户端发送了3个字节
    1. 第一次发数据,seqack延续自握手时的值

  1. 服务端回复了3个字节
    1. 服务端的seq从延续自握手时的值
    2. 服务端收到了客户端3个字节,所以,服务端的确认号ack的值要往前推进3
    3. 表示:你发的这3个字节我收到了,我下次期待收到你发来的第4个字节开始的数据

  1. 客户端收到服务端的数据后,发送了ack,表示我已经收到你(服务端)发来的那3字节
    1. 所以,ack变成4了,客户端在告诉服务端"我收到了你从序号1开始发的3字节数据,我下次期待你从第4字节开始发"
    2. 而此次这个ack包不携带数据,所以seq还是4

总结

  1. 服务端的Seq字段
    1. 反映的是"服务端自己已经发送了多少字节"
  2. 服务端的Ack字段
    1. 反映的是"服务端从客户端那里收到了多少字节"
  3. 客户端的seq字段
    1. 反映的是"客户端自己已经发送了多少字节"
  4. 客户端的ack字段
    1. 反映的是"客户端从服务端那里收到了多少字节"

过滤条件

Wireshark四次挥手观察

  1. 客户端发送了fin
    1. 为什么是fin+ack,因为TCP连接一旦进入ESTABLISHED状态之后,几乎所有报文都会同时带上ACK标志
    2. 这是TCP协议的一个通用规则

三次握手

第一次握手:客户端发SYN

  1. 客户端调用connect(),内核:
    1. 生成一个随机初始序号(ISN, Initial Sequence Number),记为x,写进这个sockettcp_sock.snd_nxt字段(下一个要发的序号)
    2. 组装一个SYN包(TCP头里SYN标志位置1,序号字段=x)
    3. 通过前面讲过的完整发送链路(TCPIP→以太网帧→网卡)发出去
  2. 客户端sk_stateCLOSED变成SYN_SENT

第二次握手:服务端回SYN+ACK

  1. 服务端的监听socket收到这个SYN包后:
    1. 内核检查这是不是发给已经LISTEN的端口,如果是,在SYN队列里创建一个"半连接"记录
    2. 服务端也生成自己的随机初始序号,记为y,写进这个(即将建立的)连接的snd_nxt
    3. 同时,服务端把客户端的序号x记下来,计算rcv_nxt = x + 1
      表示"我已经收到了你的SYN,下一个我期望收到的字节序号是x+1"
    4. 组装一个SYN+ACK包(SYNACK标志位都置1,序号=y,确认号=x+1)发回去
  2. 服务端这个半连接的状态变成SYN_RCVD

第三次握手:客户端回ACK

  1. 客户端收到SYN+ACK后:
    1. 检查确认号x+1是否等于自己期望的(说明服务端确实收到了第一次握手),校验通过
    2. 客户端把服务端的序号y记下来,计算rcv_nxt = y + 1
    3. 组装一个ACK包(确认号=y+1)发回去
    4. 客户端sk_state变成ESTABLISHED
  2. 服务端收到这个最后的ACK后:
    1. 校验确认号y+1无误
    2. 这个连接的状态从SYN_RCVD变成ESTABLISHED
    3. 这个连接从SYN队列移动到Accept队列,等待应用层调用accept()取走
    4. accept()取走后,内核创建一个全新的socket对象(五元组、状态ESTABLISHEDrcv_nxt/snd_nxt这些字段全部继承自这次握手协商的结果),返回给应用层

握手完成后

  1. 双方各自的rcv_nxtsnd_nxt就是后续所有数据收发时,序号校验、滑动窗口计算的基准起点

为什么是三次握手,不是二次?

  1. TCP最初设计时要解决的一个真实网络环境问题是:
    1. 网络是不可靠的,一个包可能会延迟很久才到达,也可能会被重复发送
  2. 设想这个场景:
    1. 客户端发了一个SYN(称为SYN₁)给服务端,但这个包在网络中意外延迟了(比如经过了一条绕远路的链路,滞留在某个路由器的队列里很久)
    2. 客户端等了一段时间没收到回复,判定超时,放弃了这次连接尝试(比如用户取消了操作、或者上层应用逻辑判定失败重试),这个连接请求在客户端这边已经作废
    3. 过了一段时间,那个滞留的SYN₁,突然又送到了服务端(纯粹是网络延迟导致的迟到,不是客户端故意重发的)

  1. 如果只有两次握手(客户端发SYN,服务端回SYN+ACK就直接判定连接建立,不需要客户端再确认),会出什么问题?
    1. 服务端收到那个迟到的、早已失效的SYN₁,误以为是一次新的连接请求
    2. 服务端立刻分配资源、创建这个连接对象,状态直接进入ESTABLISHED(因为两次握手不需要等第三次确认)
    3. 但客户端根本不知道有这么一回事——它早就放弃了这次连接尝试,自然不会理睬服务端回复的这个SYN+ACK,更不会发送任何数据
    4. 服务端这边,会白白维护着一个"已建立"但永远不会有数据到来的连接,持续占用内核资源,直到某个超时机制(比如长时间没有数据往来触发的keepalive超时)才会被动清理
  2. 如果是三次握手(必须等客户端确认了才算真正建立):
    1. 服务端收到迟到的SYN₁,依然会回复SYN+ACK,但这次连接停留在SYN_RCVD状态,待在SYN队列里,而不是直接进入ESTABLISHED
    2. 因为客户端根本不会理会这个意外收到的SYN+ACK,服务端永远等不到第三次握手的ACK
    3. 服务端这个半连接会因为握手超时机制(SYN队列里的连接如果长时间没有收到最终ACK,会被自动清理,并且会有限次重传SYN+ACK后放弃)而被正确回收,不会被误判成一个真实建立的连接

四次挥手

第一次挥手:客户端发FIN

  1. 客户端应用层调用close()(或者Windows下closesocket())
    1. 组装一个FIN包(FIN标志位置1,序号=m,是当前snd_nxt的值)
  2. 客户端sk_stateESTABLISHED变成FIN_WAIT_1

第二次挥手:服务端回ACK(不是FIN)

  1. 服务端收到FIN后:

    1. 内核立刻回复一个ACK(确认号=m+1),告诉客户端"你的关闭请求我收到了"
    2. 服务端状态从ESTABLISHED变成CLOSE_WAIT
      注意这一步服务端只是确认收到了,它自己还没有关闭,因为服务端这个方向可能还有数据没发完
  2. 客户端收到这个ACK后:

    1. 客户端状态从FIN_WAIT_1变成FIN_WAIT_2
    2. 这个状态代表"我已经发完我这边的数据了,对方也确认收到了我的关闭请求,现在我在等对方也关闭它那边"
  3. 这里就是CLOSE_WAIT状态可能"卡住不动"的关键点

    1. 如果服务端应用层代码,收到对方关闭请求之后(也就是recv()返回0的那一刻),没有及时调用close(),服务端会一直停留在CLOSE_WAIT状态,这个连接不会往下推进,长期积累会导致文件描述符/句柄耗尽
    2. 所以,CLOSE_WAIT大量堆积通常意味着代码里收到对方关闭后没有及时调用close

第三次挥手:服务端发FIN

  1. 服务端应用层调用close()之后(这一步的触发完全由服务端应用层代码的逻辑决定,不是自动的):
    1. 组装FIN包(序号=n,是服务端此时的snd_nxt)
  2. 服务端状态从CLOSE_WAIT变成LAST_ACK

第四次挥手:客户端回ACK

  1. 客户端收到服务端的FIN后:
    1. 回复ACK(确认号=n+1)
    2. 客户端状态从FIN_WAIT_2变成TIME_WAIT
  2. 服务端收到这个最后的ACK后:
    1. 状态从LAST_ACK直接变成CLOSED,这个连接的内核资源被释放

为什么是四次挥手,不是三次?

  1. 握手时
    1. 服务端可以把"确认收到SYN"和"我也要发SYN"这两件事合并成一个SYN+ACK包一起发
    2. 因为这两个动作是同时发生的意愿
  2. 但关闭连接不一样
    1. TCP连接是全双工的(两个方向的数据流是独立的),当一方发起关闭(比如客户端调用close()),这只代表"我这个方向不再发数据了"
    2. 但服务端那个方向可能还有数据没发完,所以不能强行把两个方向的关闭动作绑在一起
  3. 所以必须拆成两组独立的"挥手":
    1. 客户端方向的关闭:FIN(客户端发起) + ACK(服务端确认)
    2. 服务端方向的关闭:FIN(服务端发起,在它确认没有数据要发了之后) + ACK(客户端确认)
  4. 这两组动作分开发生,中间可能间隔任意长时间(服务端可以在收到客户端FIN之后,继续处理完自己没发完的数据,再发起自己的FIN)
    1. 这就是半关闭(half-close)的概念

TIME_WAIT

  1. 为什么客户端不能收到ACK就立刻关闭,非要等一段时间
    1. 客户端进入TIME_WAIT之后,不会立刻释放连接资源,而是要等待一段时间
    2. 通常是2MSL, Maximum Segment Lifetime
    3. MSL是一个报文段在网络上能存活的最长时间,常见配置下2MSL大约是1-4分钟),才真正变成CLOSED
  2. 原因1:防止最后一个ACK丢失导致对方无法正常关闭
    1. 如果客户端发出最后这个ACK之后就立刻关闭,万一这个ACK在网络上丢了,服务端会因为一直等不到ACK而触发重传FIN
    2. 但客户端此时已经把这个连接的资源释放掉了,收到这个重传的FIN会回复一个RST(连接重置),而不是正确的ACK
    3. 这会导致服务端的LAST_ACK状态无法正常结束,只能通过超时机制强制关闭,这不是一个干净的关闭方式
    4. 保持TIME_WAIT一段时间,是为了如果真的收到重传的FIN,客户端还能正确地再发一次ACK,保证服务端能正常关闭
  3. 原因2:防止这条连接的"旧数据包"污染后续使用相同五元组的新连接
    1. 网络上可能存在滞留、延迟到达的旧数据包
      比如经过某些绕远路的路由路径,晚了很久才到
    2. 如果客户端在TIME_WAIT结束前(旧数据包还有可能在路上飘着的这段时间内)就用相同的五元组(相同的IP+端口组合)建立了一个新连接,这些迟到的旧包如果被新连接误收,会造成数据错乱
    3. 等待2MSL,足够保证网络上属于这条旧连接的所有滞留包都已经消失或者被丢弃,这样才能安全地用同样的五元组开启新连接

CLOSE_WAIT

CLOSE_WAIT出现的场景回顾

  1. 当对方主动发起关闭(发了FIN过来),你的内核会自动帮你回复一个ACK,并把你这一端的状态从ESTABLISHED切换成CLOSE_WAIT
    1. 这个状态转换是内核自动完成的,不需要你的应用层代码做任何事情
  2. 这个状态怎么变成bug——最常见的代码模式

  1. 危害:句柄耗尽
    1. 每一个处于CLOSE_WAIT状态的连接,依然占用着:
    2. 一个文件描述符/socket句柄(操作系统对单进程可打开的句柄数量是有上限的,Linuxulimit -n可查,Windows下也有类似限制)
    3. 内核里这个socket对应的完整数据结构,持续占用内存
  2. 如果这类连接持续累积而不清理,最终会导致:
    1. 句柄耗尽:新连接的accept()/AcceptEx会失败,因为进程能打开的句柄数已经达到上限
    2. 内存持续增长:每个残留连接占用的内核内存不会被释放,表现为服务进程内存缓慢泄漏

高并发下连接建连失败

大量TIME_WAIT堆积

  1. 是客户端(主动发起连接的一方)常见的问题
    1. 如果客户端频繁地"建立连接→发几条消息→立刻关闭"这种短连接模式,每次关闭后客户端本地都会占用一个TIME_WAIT状态的端口一段时间,如果短时间内建立了大量这样的短连接,可能会导致本地临时端口耗尽(因为TIME_WAIT状态的端口在等待期内不能立刻被复用)
  2. 生产环境常见的优化手段是连接复用(长连接/连接池),减少频繁建立/关闭连接的次数

大量CLOSE_WAIT堆积

  1. 是服务端(被动关闭方)常见的bug
  2. 常意味着服务端代码在recv()返回0(对方已关闭)之后,没有及时调用closesocket(),导致这个连接一直卡在CLOSE_WAIT,长期积累会耗尽服务端的socket句柄资源

同时关闭(Simultaneous Close)

  1. 如果双方几乎同时调用close()(而不是一方先关、另一方后关这种典型场景)
    1. 会各自发出FIN,各自收到对方的FIN后回ACK,状态会经过一个叫CLOSING的中间状态,最终双方都能正常到达TIME_WAIT再到CLOSED
    2. 这种场景在实际应用里比较少见(大部分是一方明确先发起关闭)

短连接

核心定义

  1. 每次通信都重新建立一次TCP连接,发完数据、收到回复后立刻关闭这个连接,下一次通信再重新走一遍"三次握手→收发数据→四次挥手"的完整流程

图示

短连接的开销具体体现在哪

  1. 每次都要走一遍三次握手,至少消耗1个RTT(往返时延)
  2. 每次关闭都要走四次挥手,并且客户端会产生TIME_WAIT
  3. 每次都要重新创建/销毁socket内核对象
  4. TCP拥塞控制要重新"热身"
    1. TCP连接建立初期,拥塞窗口(cwnd)是从一个很小的值开始,通过慢启动逐步增大,直到探测到网络的实际承载能力
    2. 短连接每次都要重新经历这个慢启动过程,还没充分利用带宽连接就关闭了;而长连接在多轮数据交换后,拥塞窗口已经"热身"完毕,能更快地达到较高的吞吐量

长连接

核心定义

  1. 连接建立一次之后,长时间保持打开状态,期间可能进行多轮数据收发,直到业务层面明确决定断开(比如用户退出游戏、客户端主动关闭),才走一次四次挥手关闭

图示

长连接的代价

  1. 长连接不是"只有好处没有坏处",它引入了新的问题,需要额外的机制来解决:
  2. 怎么知道对方是不是还"活着"——心跳机制
    1. TCP连接一旦建立,如果双方都不发数据,协议层面没有任何机制主动告诉你"对方还在不在"
    2. 如果客户端的网络突然断掉(比如拔网线、手机进电梯信号丢失),而没有经过正常的四次挥手,服务端这边的连接会一直停留在ESTABLISHED状态,直到应用层某个动作触发失败(比如尝试发送数据超时)才会发现问题
    3. 解决方法是应用层心跳机制
  3. 大量并发长连接对服务端资源管理提出更高要求
    1. 短连接因为生命周期短,同一时刻服务端需要维护的连接数相对可控
    2. 长连接则意味着大量连接会同时长期占用服务端的内存和句柄资源
    3. 这也是所谓"C10K问题"(如何用一台服务器同时支撑上万并发连接)的历史背景
      epollIOCP这些高性能IO模型,正是为了应对长连接场景下海量并发这个挑战而设计出来的
  4. 空闲连接的超时清理
    1. 如果客户端异常退出但连接没有正常关闭(比如没有心跳机制、又没有正常挥手),服务端需要有一套空闲连接超时踢出的机制,定期检查"多久没有收发数据的连接就主动断开",否则这类"僵尸连接"会一直占用资源不释放

心跳机制

  1. 约定客户端每隔一段时间(比如10-30秒)主动发一个很小的"心跳包"给服务端,服务端如果连续几个心跳周期都没收到,判定这个连接已经失效,主动关闭并清理资源
    1. 这就是SO_KEEPALIVE选项的应用场景
    2. 不过实际生产系统更常见的做法是在应用层自己实现心跳,而不是依赖TCP自带的SO_KEEPALIVE,原因是TCP自带的keepalive检测周期通常是以小时为单位的
    3. 默认2小时才探测一次,虽然可以调整,但没有应用层心跳灵活

怎么发送心跳包

  1. 客户端

  1. 服务端

HTTP协议的演进

  1. HTTP/1.0默认是短连接,每个请求都新建TCP连接
  2. HTTP/1.1引入Keep-Alive,默认变成长连接,一条TCP连接上可以连续发送多个HTTP请求/响应
  3. HTTP/2更进一步,用多路复用在一条长连接上并行处理多个请求,进一步减少连接数

粘包

粘包问题的本质

  1. TCP传输的是一串连续的字节流,没有"消息边界"这个概念
  2. 应用层调用一次send(),发送的是一段有明确边界的数据(比如"这一条消息是3个字节"),但TCP协议本身根本不关心、也不记录你调用了几次send()、每次发了多长
    1. 它只关心"总共有多少字节需要可靠地送到对方",至于这些字节在对方那边,会被一次recv()整体读到,还是被拆成好几次recv()分批读到,TCP完全不做任何保证

粘包 和 半包(拆包)

粘包的成因(多条消息被合并读到)

  1. Nagle算法
    1. 如果你连续调用几次send()发送小数据,内核可能会把它们攒在一起,合并成一个TCP段发出去,减少小包数量
  2. 接收方处理不及时
    1. 即使发送方是分开发的,如果接收方没有及时调用recv(),数据会在内核接收缓冲区里持续累积,等你终于调用recv()时,可能一次性就读到了好几条消息的内容,因为它们都已经躺在缓冲区里了

半包/拆包的成因(一条消息被拆成多次读到)

  1. 数据量超过MSS
    1. 一次send()如果超过MSS(约1460字节),会被TCP层拆成多个TCP段分别发送,接收方完全有可能"先收到第一段,过一会儿才收到第二段"
    2. 如果你的应用层代码不做处理,直接把第一次recv()读到的内容当成完整消息处理,就会出错
  2. 接收方的应用层缓冲区不够大、或者读取时机凑巧卡在数据传输过程中
    1. 导致一次recv()调用只读到了这条消息的一部分

容易被误解的点

  1. 很多初学者会以为"粘包"是发送方"发错了",但实际上,发送方发出去的字节流本身是完全正确、连续、没有丢失的
    1. 问题出在接收方不知道"这一串连续的字节,应该在哪里切开,才能还原出发送方原本想表达的一条条独立消息"
    2. 这也是为什么我们说"TCP不保证消息边界"

解决方案——三种经典做法

  1. 方案1:定长消息
    1. 约定每条消息都是固定长度(比如每条消息恰好64字节),接收方每次固定读64字节就是一条完整消息
    2. 缺点很明显:实际业务数据长度千变万化,很少有场景适合用定长方案,会造成大量浪费(短消息也要填充到64字节)或者不够用(超过64字节的消息没法处理)
  2. 方案2:分隔符法
    1. 用一个特殊字符(或字符序列)作为消息结束标志,比如HTTP协议早期用\r\n作为行结束标志
    2. 缺点:如果消息内容本身可能包含这个分隔符,需要额外做"转义"处理,增加复杂度
      而且接收方需要逐字节扫描寻找分隔符,效率不如直接读一个长度值
  3. 方案3:长度前缀法
    1. 在每条消息前面,加一个固定大小的包头,里面写明"接下来这条消息的长度是多少"
    2. 接收方的处理逻辑变成:
    3. 先确保攒够了包头这么多字节(我们代码里是5字节:1字节类型+4字节长度)
    4. 从包头里解析出这条消息的实际长度
    5. 检查目前攒的数据够不够这个长度,不够就继续等,够了就把这一整条消息切出来处理
    6. 处理完这条消息后,继续检查剩下的数据里是不是还粘着下一条消息的开头

拆包

  1. 粘包和拆包(半包),本质上是同一个根源问题的两种不同表现形式
  2. 根本原因只有一个
    1. TCP只保证字节流按顺序、可靠地到达,不保证"发送方调用几次send,接收方就对应能调用几次recv收到边界清晰的消息"
  3. 粘包
    1. 多条消息被"挤"在一起,一次recv()读到了不止一条消息的内容
  4. 拆包(半包)
    1. 一条消息被"拆散"了,一次recv()只读到了这条消息的一部分,剩下的要等下一次(或者好几次)recv()才能凑齐
  5. 详细参考上文发送心跳包

声明:本文为原创文章,版权归所有,欢迎分享本文,转载请保留出处!

bingliaolong
Bingliaolong 关注:0    粉丝:0
Everything will be better.

发表评论

表情 格式 链接 私密 签到
扫一扫二维码分享