CSInternet10
本篇讲述TCP粘包拆包、TCP异常、TCP保活机制、TCP Socket的机制。
粘包拆包
为什么有拆包/粘包
TCP 传输协议是面向流的,没有数据包界限。客户端向服务端发送数据时,可能将一个完整的报文拆分成多个小报文进行发送,也可能将多个报文合并成一个大的报文进行发送。因此就有了拆包和粘包。
为什么会出现拆包/粘包现象呢?在网络通信的过程中,每次可以发送的数据包大小是受多种因素限制的,如 MTU 传输单元大小、MSS 最大分段大小、滑动窗口等。如果一次传输的网络包数据大小超过传输单元大小,那么我们的数据可能会拆分为多个数据包发送出去。如果每次请求的网络包数据都很小,一共请求了 10000 次,TCP 并不会分别发送 10000 次。因为 TCP 采用的 Nagle 算法对此作出了优化。如果你是一位网络新手,可能对这些概念并不非常清楚。那我们先了解下计算机网络中 MTU、MSS、Nagle 这些基础概念以及它们为什么会造成拆包/粘包问题。
MTU 最大传输单元和 MSS 最大分段大小
MTU(Maxitum Transmission Unit) 是链路层一次最大传输数据的大小。MTU 一般来说大小为 1500 byte。MSS(Maximum Segement Size) 是指 TCP 最大报文段长度,它是传输层一次发送最大数据的大小。如下图所示,MTU 和 MSS 一般的计算关系为:MSS = MTU - IP 首部 - TCP首部,如果 MSS + TCP 首部 + IP 首部 > MTU,那么数据包将会被拆分为多个发送。这就是拆包现象。
Nagle 算法
Nagle 算法于 1984 年被福特航空和通信公司定义为 TCP/IP 拥塞控制方法。它主要用于解决频繁发送小数据包而带来的网络拥塞问题。试想如果每次需要发送的数据只有 1 字节,加上 20 个字节 IP Header 和 20 个字节 TCP Header,每次发送的数据包大小为 41 字节,但是只有 1 字节是有效信息,这就造成了非常大的浪费。Nagle 算法可以理解为批量发送,也是我们平时编程中经常用到的优化思路,它是在数据未得到确认之前先写入缓冲区(即发送窗口),等待数据确认或者缓冲区积攒到一定大小再把数据包发送出去。
Linux 在默认情况下是开启 Nagle 算法的,在大量小数据包的场景下可以有效地降低网络开销。但如果你的业务场景每次发送的数据都需要获得及时响应,那么 Nagle 算法就不能满足你的需求了,因为 Nagle 算法会有一定的数据延迟。你可以通过 Linux 提供的 TCP_NODELAY 参数禁用 Nagle 算法。
正是因为Nagle算法会等待数据确认或缓冲区积攒到一定大小再发送,导致多个小的数据包被合并成一个大的数据包发送,接收方就需要面对如何从这个大数据包中正确识别出原始数据边界的问题。
拆包/粘包的解决方案
在客户端和服务端通信的过程中,服务端一次读到的数据大小是不确定的。如下图所示,拆包/粘包可能会出现以下五种情况:
- 服务端恰巧读到了两个完整的数据包 A 和 B,没有出现拆包/粘包问题;
- 服务端接收到 A 和 B 粘在一起的数据包,服务端需要解析出 A 和 B;
- 服务端收到完整的 A 和 B 的一部分数据包 B-1,服务端需要解析出完整的 A,并等待读取完整的 B 数据包;
- 服务端接收到 A 的一部分数据包 A-1,此时需要等待接收到完整的 A 数据包;
- 数据包 A 较大,服务端需要多次才可以接收完数据包 A。
由于拆包/粘包问题的存在,数据接收方很难界定数据包的边界在哪里,很难识别出一个完整的数据包。所以需要提供一种机制来识别数据包的界限,这也是解决拆包/粘包的唯一方法:定义应用层的通信协议。下面我们一起看下主流协议的解决方案。
消息长度固定
每个数据报文都需要一个固定的长度。当接收方累计读取到固定长度的报文后,就认为已经获得一个完整的消息。当发送方的数据小于固定长度时,则需要空位补齐。
1 | +----+------+------+---+----+ |
假设我们的固定长度为 4 字节,那么如上所示的 5 条数据一共需要发送 4 个报文:
1 | +------+------+------+------+ |
消息定长法使用非常简单,但是缺点也非常明显,无法很好设定固定长度的值,如果长度太大会造成字节浪费,长度太小又会影响消息传输,所以在一般情况下消息定长法不会被采用。
特定分隔符
既然接收方无法区分消息的边界,那么我们可以在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分。以下报文根据特定分隔符 \n 按行解析,即可得到 AB、CDEF、GHIJ、K、LM 五条原始报文。
1 | +-------------------------+ |
由于在发送报文时尾部需要添加特定分隔符,所以对于分隔符的选择一定要避免和消息体中字符相同,以免冲突。否则可能出现错误的消息拆分。比较推荐的做法是将消息进行编码,例如 base64 编码,然后可以选择 64 个编码字符之外的字符作为特定分隔符。特定分隔符法在消息协议足够简单的场景下比较高效,例如大名鼎鼎的 Redis 在通信过程中采用的就是换行分隔符。
消息长度 + 消息内容
1 | 消息头 消息体 |
消息长度 + 消息内容是项目开发中最常用的一种协议,如上展示了该协议的基本格式。消息头中存放消息的总长度,例如使用 4 字节的 int 值记录消息的长度,消息体实际的二进制的字节数据。接收方在解析数据时,首先读取消息头的长度字段 Len,然后紧接着读取长度为 Len 的字节数据,该数据即判定为一个完整的数据报文。依然以上述提到的原始字节数据为例,使用该协议进行编码后的结果如下所示:
1 | +-----+-------+-------+----+-----+ |
消息长度 + 消息内容的使用方式非常灵活,且不会存在消息定长法和特定分隔符法的明显缺陷。当然在消息头中不仅只限于存放消息的长度,而且可以自定义其他必要的扩展字段,例如消息版本、算法类型等。
总结
TCP的传输是面向字节流的,无法区分数据包的边界。此时需要定义应用层的协议帮助TCP区分数据包。主流协议的解决方案有:消息长度固定、特定分隔符处理和消息长度+消息内容三种方法。其中基于消息长度 + 消息内容的变长协议是项目开发中最常用的一种方法。
保活机制
什么是 TCP 保活机制
在网络通信中,我们经常会遇到需要维持长时间连接的场景,如数据库连接、聊天应用等。但在实际的网络环境中,由于网络故障、防火墙策略或其他原因,连接可能会意外断开,而连接的双方却浑然不知。TCP Keepalive(TCP 保活机制)正是为了解决这个问题而生的。
TCP Keepalive 是 TCP 协议的一种机制,它允许在 TCP 连接空闲一段时间后,由一方发送探测包来确认连接是否仍然有效。这种机制可以用来:
- 检测死亡的对端(Dead Peer Detection)
- 防止因长时间不活动而被网络设备(如 NAT、防火墙)终止连接
- 在服务端保持对客户端连接状态的感知
TCP Keepalive 的工作原理非常简单:当 TCP 连接在一段时间内没有数据传输时,保活机制会定期发送一个不包含数据的探测包。如果对方正常响应,那么连接继续保持;如果连续多次没有收到响应,则认为连接已经断开,系统会主动关闭 Socket。
TCP Keepalive 的内核参数
Linux 系统中,TCP Keepalive 机制由三个内核参数控制:
- tcp_keepalive_time:空闲多长时间后开始发送 keepalive 探测包,默认为 7200 秒(2小时)
- tcp_keepalive_intvl:探测包发送间隔,默认为 75 秒
- tcp_keepalive_probes:连续探测失败次数,默认为 9 次
这意味着,在默认配置下,一个 TCP 连接如果空闲 2 小时后,内核会开始发送探测包。如果连续 9 次(共 675 秒,约 11.25 分钟)都没有收到响应,内核会认为连接已经断开,并关闭相应的 Socket。
我们可以通过以下命令查看和修改这些参数:
1 | # 查看当前参数值 |
对于特定的应用场景,我们可能需要调整这些参数以提高连接检测的敏感度。例如,对于金融交易系统,我们可能希望更快地检测到连接断开,以便及时采取措施。
TCP Keepalive 与 HTTP Keep-Alive 有什么区别
这两玩意儿的区别堪比周杰和周杰伦的区别,简单来说除了名字相近以外基本没啥共同点了。它们工作在不同的协议层次,解决的问题也不同。
TCP Keepalive:
- 工作在传输层(TCP 协议),用于检测连接是否仍然有效,由操作系统内核实现。主要工作是在连接空闲一段时间后发送探测包
- 对应用层透明,应用程序只需要启用即可
HTTP Keep-Alive:
- 工作在应用层(HTTP 协议),用于复用 TCP 连接,避免频繁建立和关闭连接
- 由 HTTP 客户端和服务器实现,通过 HTTP 头部字段
Connection: keep-alive控制
举个例子,如果你使用浏览器访问一个网站,HTTP Keep-Alive 可以让浏览器在加载页面的 HTML、CSS、JavaScript 和图片时复用同一个 TCP 连接,而不是为每个资源建立新的连接。而 TCP Keepalive 则是在你打开一个聊天应用并最小化窗口后,即使长时间没有收发消息,连接也能保持活跃状态。
如何在网络抓包中识别 TCP Keepalive
使用 Wireshark 等抓包工具,我们可以清晰地看到 TCP Keepalive 探测包的特征:
- 它是一个没有数据负载的 TCP 包
- 序列号(Sequence Number)是连接最后一个出站数据包的序列号减一
- 标志位(Flags)中只有 ACK 被设置
- 如果对端仍然活跃,会回复一个同样没有数据负载的 ACK 包
下面是一个 TCP Keepalive 探测包在 Wireshark 中的示例。可以看到, [TCP Segment Len: 0] 和 [No TCP payload]都清晰地表明了它的身份:
1 | Frame 1234: 54 bytes on wire |
总结
TCP Keepalive 是一种重要的网络机制,它可以帮助我们检测并维护长连接的有效性。
需要特别注意的是,TCP Keepalive 和 HTTP Keep-Alive 是两个完全不同的概念,前者用于检测连接是否有效,后者用于复用 HTTP 连接。在实际应用中,我们可能会同时使用这两种机制,但要明确它们各自的作用和配置方式。
异常处理
TCP 连接异常概述
TCP 连接异常主要可以分为两大类:进程崩溃和主机宕机。这两种情况虽然表现形式相似——都可能导致连接中断,但在处理机制和表现形式上有着本质的区别。本文将深入探讨这两种异常情况下,TCP 协议是如何响应的,以及我们应该如何在应用层面正确处理这些异常。
进程崩溃时的 TCP 连接处理
进程崩溃是指客户端或服务端程序因为代码缺陷、资源不足等原因而意外终止的情况。当进程崩溃时,操作系统会自动回收该进程占用的资源,包括文件描述符、内存等,同时也会处理该进程所持有的 TCP 连接。
进程崩溃的连接终止过程
当进程崩溃时,操作系统会执行以下步骤来处理 TCP 连接:
- 关闭文件描述符:操作系统会自动关闭进程所打开的所有文件描述符,包括 Socket。
- 发送 FIN 报文:对于 TCP Socket,内核会发送 FIN 报文,启动标准的四次挥手过程。
- 状态转换:TCP 连接状态从 ESTABLISHED 转换为 FIN_WAIT_1,然后经过正常的状态转换。
以客户端进程崩溃为例,连接的状态变化如下:
1 | 客户端(崩溃方) 服务端 |
服务端如何感知客户端进程崩溃
当客户端进程崩溃时,服务端如何感知并处理呢?这主要取决于服务端代码的设计:
- 读操作:如果服务端尝试从 Socket 读取数据,会收到 EOF(结束符),表示连接已经关闭。
- 写操作:如果服务端尝试向已关闭的连接写入数据,在第一次写入时可能成功(数据进入对端的接收缓冲区),但由于客户端进程已崩溃,无法处理这些数据,当服务端继续写入时,将收到 SIGPIPE 信号或返回 EPIPE 错误。
需要注意的是,服务端不会立即感知到客户端进程的崩溃,只有在尝试进行 I/O 操作时才能发现连接已关闭。这也是为什么在网络编程中,我们通常需要设置读超时或者使用心跳机制(一个类似于tcp keepalive,但是工作在应用层的协议,负责发送心跳包)来主动检测连接状态。
主机宕机时的 TCP 连接处理
主机宕机是指整个系统因为断电、硬件故障或操作系统崩溃等原因而突然关闭的情况。与进程崩溃不同,主机宕机时,操作系统没有机会执行任何清理操作,包括发送 FIN 报文来正常关闭 TCP 连接。
主机宕机的连接终止特点
当主机宕机时,TCP 连接的处理有以下特点:
- 无 FIN 报文:由于系统突然关闭,没有机会发送 FIN 报文,因此不会触发标准的四次挥手过程。
- 连接状态保持:对端的 TCP 连接状态会一直保持在 ESTABLISHED 状态,直到超时或显式关闭。
- 依赖超时机制:对端必须依靠重传超时或 Keepalive 机制来检测连接是否中断。
另一端如何感知主机宕机
当主机宕机后,另一端如何感知到连接已断开?主要有以下几种情况:
- 发送数据但无响应:如果尝试向宕机的主机发送数据,由于对方无法回复 ACK,发送方会不断重传。当重传次数达到上限(由系统参数 tcp_retries2 决定)后,发送方会认为连接已断开,并关闭连接。
- TCP Keepalive 机制:如果启用了 TCP Keepalive 机制,系统会在连接空闲一段时间后发送探测包。如果连续多次探测都没有响应,系统会认为连接已断开,并关闭连接。
以下是重传机制和 Keepalive 机制的相关参数:
1 | # 重传机制相关参数 |
对比与总结
| 特性 | 进程崩溃 | 主机宕机 |
|---|---|---|
| FIN 报文 | 发送 | 不发送 |
| 连接终止方式 | 四次挥手 | 重传超时或 Keepalive 检测 |
| 对端感知方式 | 读取 EOF 或写入错误 | 重传超时或 Keepalive 超时 |
| 感知速度 | 较快(取决于网络延迟) | 较慢(取决于重传或 Keepalive 配置) |
| TCP 连接状态 | 正常状态转换 | 一直保持 ESTABLISHED 直到超时 |
如果「客户端进程崩溃」,客户端的进程在发生崩溃的时候,内核会发送 FIN 报文,与服务端进行四次挥手。
但是,「客户端主机宕机」,那么是不会发生四次挥手的,具体后续会发生什么?还要看服务端会不会发送数据。
- 如果服务端会发送数据,由于客户端已经不存在,收不到数据报文的响应报文,服务端的数据报文会超时重传,当重传总间隔时长达到一定阈值(内核会根据 tcp_retries2 设置的值计算出一个阈值)后,会断开 TCP 连接;
- 如果服务端一直不会发送数据,再看服务端有没有开启 TCP keepalive 机制?
- 如果有开启,服务端在一段时间没有进行数据交互时,会触发 TCP keepalive 机制,探测对方是否存在,如果探测到对方已经消失,则会断开自身的 TCP 连接;
- 如果没有开启,服务端的 TCP 连接会一直存在,并且一直保持在 ESTABLISHED 状态直到超时。
初识 TCP Socket
Socket 基础概念
Socket(套接字)是网络通信的基础设施,它为应用程序提供了一套标准的接口,使得应用层能够方便地进行网络通信。Socket 本质上是对网络通信过程的抽象,它由 IP 地址和端口号组成,唯一标识网络中的一个进程。
为什么需要 Socket 呢?在计算机网络中,仅有 IP 地址只能定位到具体的主机,而无法定位到主机中的特定进程。端口号的引入解决了这一问题,它可以唯一标识主机上的一个进程。因此,IP 地址 + 端口号的组合(即 Socket)可以在整个网络中唯一地标识一个进程。
1 | +-------------+----------------+ |
如上所示,这个 Socket 表示 IP 地址为 192.168.1.1,端口号为 8080 的进程。在网络编程中,我们通常使用 Socket API 来创建、绑定、监听、接受连接、发送和接收数据等操作。
从 Socket 到 tcp_sock
在 Linux 内核中,Socket 的实现比我们想象的要复杂得多。当我们在应用程序中调用 socket() 函数创建一个 Socket 时,内核会创建一个 struct socket 结构体,这是一个通用的 Socket 抽象。但对于 TCP 协议,内核会进一步创建一个 struct tcp_sock 结构体,它是 struct sock 的扩展,包含了 TCP 协议特有的信息。
struct tcp_sock 比 struct socket 要复杂得多,它包含了 TCP 协议状态机、拥塞控制、滑动窗口等机制所需的各种参数和状态信息。下面是这两个结构体之间的关系:
1 | +-----------------+ |
如上图所示,struct socket 通过 sk 指针关联到 struct sock,而 struct sock 则是 struct tcp_sock 的一部分(通过强制类型转换实现)。这种设计允许内核在不同协议族和不同传输协议之间共享通用的 Socket 接口,同时又能处理特定协议的细节。
Socket 在操作系统中的位置
Socket 在操作系统中处于网络协议栈的顶端,是应用程序和传输层之间的接口。从整个网络协议栈的角度看,Socket 的位置如下:
1 | +---------------------------+ |
当应用程序调用 Socket API 进行网络通信时,数据会经过这些层层传递。在 Linux 内核中,Socket 相关的代码主要位于 net/socket.c 和协议特定的实现文件中,如 net/ipv4/tcp.c。
文件描述符与 Socket 的关系
在 Linux 系统中,一切皆文件,Socket 也不例外。当我们创建一个 Socket 时,内核会为其分配一个文件描述符(File Descriptor,简称 FD)。应用程序通过这个文件描述符来操作 Socket,就像操作普通文件一样。
1 | 文件表 系统打开文件表 索引节点表 |
如上图所示,文件描述符 3 指向了一个 Socket 结构体。这种设计使得应用程序可以使用统一的文件 I/O 接口(如 read(), write(), close())来操作 Socket。
基于TCP协议的Socket程序函数调用过程
当两端都创建了Socket之后,接下来的过程中,TCP和UDP稍有不同,我们先来看TCP。
TCP的服务端要先监听一个端口,一般是先调用bind函数,给这个Socket赋予一个IP地址和端口。当服务端有了IP和端口号,就可以调用listen函数进行监听。在TCP的状态图里面,有一个listen状态,当调用这个函数之后,服务端就进入了这个状态,这个时候客户端就可以发起连接了。
在内核中,为每个Socket维护两个队列。一个是已经建立了连接的队列,这时候连接三次握手已经完毕,处于established状态;一个是还没有完全建立连接的队列,这个时候三次握手还没完成,处于syn_rcvd的状态。
这里“队列”和socket的关系,我们在之后的文章里还会详细说明。现在大致理解意思即可。
接下来,服务端调用accept函数,拿出一个已经完成的连接进行处理。如果还没有完成,就要等着。
在服务端等待的时候,客户端可以通过connect函数发起连接。先在参数中指明要连接的IP地址和端口号,然后开始发起三次握手。内核会给客户端分配一个临时的端口。一旦握手成功,服务端的accept就会返回另一个Socket。
这是一个经常考的知识点,就是监听的Socket和真正用来传数据的Socket是两个,一个叫作监听Socket,一个叫作已连接Socket。
连接建立成功之后,双方开始通过read和write函数来读写数据,就像往一个文件流里面写东西一样。
这个图就是基于TCP协议的Socket程序函数调用过程。
说TCP的Socket就是一个文件流,是非常准确的。因为,Socket在Linux中就是以文件的形式存在的。除此之外,还存在文件描述符。写入和读出,也是通过文件描述符。
在内核中,Socket是一个文件,那对应就有文件描述符。每一个进程都有一个数据结构task_struct,里面指向一个文件描述符数组,来列出这个进程打开的所有文件的文件描述符。文件描述符是一个整数,是这个数组的下标。
这个数组中的内容是一个指针,指向内核中所有打开的文件的列表。既然是一个文件,就会有一个inode,只不过Socket对应的inode不像真正的文件系统一样,保存在硬盘上的,而是在内存中的。在这个inode中,指向了Socket在内核中的Socket结构。
在这个结构里面,主要的是两个队列,一个是发送队列,一个是接收队列。在这两个队列里面保存的是一个缓存sk_buff。这个缓存里面能够看到完整的包的结构。
结合前面提到的内容,整个流程就变成了下面这张图(sk->tck_sock的过程被省略):

TCP 连接的终止是用户空间(应用程序)和内核空间(TCP/IP 协议栈)协作的结果。应用程序通过 Socket API 发出指令,内核则负责执行具体的协议操作。这种分工使得应用程序可以专注于业务逻辑,而将复杂的网络通信细节交给内核处理。
因此,当端进程突然崩溃时,仍能发送第一次挥手的FIN报文以正常结束连接。
基于UDP协议的Socket程序函数调用过程
对于UDP来讲,过程有些不一样。UDP是没有连接的,所以不需要三次握手,也就不需要调用listen和connect,但是,UDP的交互仍然需要IP和端口号,因而也需要bind。UDP是没有维护连接状态的,因而不需要每对连接建立一组Socket,而是只要有一个Socket,就能够和多个客户端通信。也正是因为没有连接状态,每次通信的时候,都调用sendto和recvfrom,都可以传入IP地址和端口。
这个图的内容就是基于UDP协议的Socket程序函数调用过程。
总结
TCP Socket 是操作系统网络编程的核心概念,它为应用程序提供了一套标准的接口,用于访问传输层协议(如 TCP)的功能。在 Linux 内核中,Socket 的实现涉及多层结构,从用户可见的文件描述符,到内核中的 struct socket 和特定协议的 struct tcp_sock。
当应用程序通过调用 close() 函数关闭一个 TCP Socket 时,会触发 TCP 的四次挥手过程。这个过程涉及用户空间和内核空间的协作,以及内核中 TCP 状态机的多次状态转换。理解这一过程对于编写健壮的网络应用程序至关重要,也有助于诊断和解决网络通信中的各种问题。





