计算机网络 之 【TCP协议】(面向字节流、TCP异常情况、保活机制、文件与Socket的关系、网络协议栈的本质)

张开发
2026/4/4 20:17:58 15 分钟阅读
计算机网络 之 【TCP协议】(面向字节流、TCP异常情况、保活机制、文件与Socket的关系、网络协议栈的本质)
目录1.面向字节流2对比UDP问题粘包与拆包解决方案应用层协议设计5与之前的联系2.TCP异常情况4保活机制5与之前的联系3.文件与Socket的关系1Socket是特殊的文件2struct file与Socket的关联3继承体系struct sock作为基类4sk_buff报文描述与指针操作5生产者-消费者模型4.网络协议栈的本质1.面向字节流1什么是面向字节流TCP不保留应用层消息的边界只把它当作一连串无结构的、连续的字节序列换句话说发送方调用 write/send 4次每次100字节TCP协议栈可能把这400字节合并成1个大TCP报文段发送也可能拆分成10个小报文段发送接收方TCP协议栈收到后再按字节顺序重组接收方调用 read/recv 1次就能读完400字节也可能读20次每次20字节读写次数和大小完全无关2对比UDP特性TCP面向字节流UDP面向数据报/报文数据边界无边界有边界应用层write次数10次写100字节 → 可能合并/拆分10次写100字节 → 发送10个独立UDP包接收方recv次数1次可收全部也可分多次必须按包收每次收一个完整包是否保证有序是按序号重组否可能乱序接收方是否知道报文长度不知道只是字节流知道UDP头有长度字段接收方需自行解析消息边界是必须自己处理否一个包就是一个完整消息UDP头有 Length 字段 → 接收方知道这是一个独立、完整的报文TCP没有报文长度字段 → 接收方不知道应用层消息从哪里开始、到哪里结束3为什么TCP要设计成面向字节流1. 为了“合并”和“拆分”的灵活性网络MTU最大传输单元通常是1500字节应用层可能一次只写1字节如游戏按键TCP可以延迟合并减少小包浪费应用层可能一次写10MBTCP可以拆分成多个MSS大小的段避免IP分片2. 为了可靠有序的抽象TCP只保证你写入的字节序列在对方读出来时顺序完全一致中间怎么拆分、合并、重传、重组对应用层透明应用层看到的是一个可靠的、有序的、无边界的字节管道4面向字节流带来的实际问题问题粘包与拆包假设客户端发送两个请求 write(fd, HELLO, 5); write(fd, WORLD, 5);服务端可能一次读到HELLO正好5字节→ 正常HELLOWORLD10字节粘包→ 两个请求粘在一起HELL4字节拆包→ 一个请求被拆开因为TCP不保留应用层的消息边界解决方案应用层协议设计必须在应用层定义消息边界常见三种方式方案优点缺点典型协议固定长度实现极简单性能高浪费空间短消息填充浪费老式银行系统、内部RPC特殊分隔符直观、可读性好需要转义内容里不能出现分隔符扫描效率稍低HTTP、Redis、FTP长度前缀高效、无转义问题、灵活实现稍复杂需要处理半包WebSocket、gRPC、Dubbo长度前缀最推荐-------------------------- | 4字节长度 | 变长数据 | --------------------------接收方先读4字节得到长度L再读L字节得到完整消息处理半包可能只读到2字节长度字段或只读到部分数据需要缓存拼包情况说明半包只收到4字节长度字段中的2字节 → 需要等剩余2字节粘包一次收到长度数据A长度数据B → 需要拆分成两条消息5与之前的联系知识点与面向字节流的关系发送/接收缓冲区字节流就存放在这两个缓冲区中滑动窗口控制字节流能从发送缓冲区流向接收缓冲区的速率序号与确认给每个字节编号保证字节流的顺序和可靠MSS每次最多从字节流中取MSS字节封装成一个TCP段Nagle算法延迟发送小字节块合并成更大的段减少小包2.TCP异常情况异常情况TCP连接行为对方能否感知进程终止正常四次挥手能收到FIN机器重启先杀进程→正常挥手→重启大概率能除非重启太快机器断电/断网无法发送任何包不能需依赖保活机制探测本质差异进程终止是“软件层面”的受控终止操作系统有机会发FIN断电是“硬件层面”的瞬间失效什么都来不及发1进程终止软件层面可控进程终止时进程被杀死kill、崩溃、正常退出操作系统内核自动关闭该进程持有的所有文件描述符关闭socket描述符 → TCP协议栈立即发送FIN报文给对方正常执行四次挥手连接优雅关闭即使进程崩溃了操作系统内核还在内核会代为完成清理工作关闭文件发送FIN回收资源对方感知能正常收到FIN知道连接关闭不会一直傻等2机器重启1. 系统收到重启命令 2. 向所有进程发送SIGTERM信号优雅终止 3. 进程终止 → 自动关闭socket → 发送FIN 4. 等待一小段时间让挥手完成 5. 若还有未完成连接发送RST强制关闭 6. 断电重启对方感知正常情况下收到FIN正常关闭异常情况如果步骤4等待时间太短对方可能还没收到FIN就断电了 → 等同于断网大多数现代操作系统会尽力完成挥手但不保证100%3机器断电/断网硬件层面不可控发生了什么瞬间失去供电网卡停止工作没有任何报文能够发出FIN、ACK、任何数据都来不及发送对方感知什么都收不到连接看起来还“活着”发送方发数据 → 无ACK返回 → 超时重传 → 多次重传失败后主动关闭4保活机制网络保活机制的核心是在长连接空闲时主动发送极小探测包来确认对端和中间设备防火墙、NAT等的连接状态仍有效防止因超时而误判连接已死为什么需要凡是在长连接中需要对抗中间设备超时回收、快速检测对端存活性的场景都需要保活机制工作原理步骤行为1设置一个保活定时器通常7200秒即2小时2定时器到期发送一个探测报文一个空ACK或一个包含1字节旧数据的包3对方正常 → 回复ACK重置定时器4对方没响应 → 继续发探测通常间隔75秒共9~10次5全部无响应 → 判定连接死亡关闭socket保活机制的争议优点清理半开连接缺点2小时才检测太慢了额外流量TCP保活默认是全局关闭的需要应用主动用setsockopt(SO_KEEPALIVE)开启应用层心跳更常见业务层自己发心跳包如每30秒更快检测对比维度TCP保活Keep-Alive应用层心跳触发者操作系统内核应用程序自己默认周期2小时太慢业务自定如30秒检测对象连接是否“活着”服务是否“可用”能检测什么对方主机断电、断网对方进程挂死、业务卡顿、过载额外流量极少几小时才发一次较多几十秒一次可控性差系统级参数改需权限强业务自己决定5与之前的联系概念关联四次挥手进程终止时由操作系统正常执行文件描述符socket也是文件进程终止时内核自动关闭超时重传断电后对方无响应发送方靠超时重传逐步发现异常RST报文机器重启时如果还有处于ESTABLISHED状态且未关闭的TCP连接操作系统会发送RST强制关闭它们3.文件与Socket的关系Linux中Socket通过struct file关联网络专用操作方法集实现“一切皆文件”内核使用struct sock作为基类、tcp_sock/udp_sock继承它来管理协议状态报文通过sk_buff描述并以链表等数据结构组织封装解包仅通过移动指针实现零拷贝接收和发送过程本质是生产者消费者模型由sk_receive_queue和sk_write_queue作为缓冲区1Socket是特殊的文件在Linux“一切皆文件”哲学下Socket也是一种文件。它通过struct file关联到一套独立的、针对网络通信的操作方法集可以用read()/write()读写Socket就像读写普通文件一样但底层实际执行的是网络收发操作不是磁盘I/O特殊点struct file中的f_path指向的不是磁盘的inode而是网络协议栈2struct file与Socket的关联1.struct file的作用每个打开的文件在内核中对应一个struct file对象它包含f_op文件操作方法集指针如read、write、releaseprivate_data指向特定类型文件的私有数据2. Socket的关联方式// 简化的内核逻辑 struct file { const struct file_operations *f_op; // 操作方法集 void *private_data; // 指向socket结构 }; // Socket的文件操作方法集网络专用 const struct file_operations socket_file_ops { .read sock_read, .write sock_write, .release sock_release, // ... }; // 创建socket时 socket() → 创建struct socket → 创建struct file → file-f_op socket_file_ops; file-private_data socket;当你调用read(sockfd, buf, size)时read() → 内核找到struct file → 调用file-f_op-read() → sock_read() → 找到关联的struct socket → 执行网络接收操作代码逻辑通常是file-private_data 指向 struct socket而 struct socket 内部有一个指针 sk 指向 struct sock3继承体系struct sock作为基类内核用C语言实现了面向对象的继承// 基类通用socket struct sock { // 发送/接收缓冲区 struct sk_buff_head sk_receive_queue; // 接收队列 struct sk_buff_head sk_write_queue; // 发送队列 // 等待队列用于阻塞/唤醒进程 wait_queue_head_t sk_wq; // 协议相关操作 struct proto *sk_prot; // ... }; // TCP继承struct sock struct tcp_sock { struct sock inet_conn; // 基类或直接内嵌 // TCP特有拥塞窗口、RTT、慢启动阈值... u32 snd_cwnd; u32 rtt; // ... }; // UDP继承struct sock struct udp_sock { struct sock inet_conn; // 基类 // UDP特有字段... };多态的效果发送数据时struct sock中的sk_prot-sendmsg指针在TCP/UDP中指向不同的实现函数内核只需调用sock-sk_prot-sendmsg()不需要关心是TCP还是UDP基于此我写了博客 【C语言实现多态】4sk_buff报文描述与指针操作1. sk_buff的结构struct sk_buff { // 指针操作的核心字段 unsigned char *head; // 缓冲区起始 unsigned char *data; // 当前协议层数据起始 unsigned char *tail; // 当前协议层数据结束 unsigned char *end; // 缓冲区结束 // 链表指针组织成队列 struct sk_buff *next; struct sk_buff *prev; // 协议信息 unsigned int len; // 数据长度 // ... };2. 封装与解包只移动指针发送时封装// TCP层已准备好数据data指向TCP负载 skb-data skb-tail - payload_len; // 添加TCP头data指针向前移动 skb_push(skb, tcp_header_len); // data - len // 此时data指向TCP头TCP头后面是负载 // 添加IP头 skb_push(skb, ip_header_len); // data - len // 此时data指向IP头后面是TCP头负载接收时解包// IP层data指向IP头 skb_pull(skb, ip_header_len); // data len // 此时data指向TCP头 // TCP层data指向TCP头 skb_pull(skb, tcp_header_len); // data len // 此时data指向TCP负载关键整个过程没有拷贝数据只是移动指针现代网络协议栈在接收数据时通过DMA将数据直接写入内存缓冲区各层数据链路层、网络层、传输层的解包操作仅依赖移动指针如sk_buff中的data指针和偏移量计算来逐层剥离协议头从而避免对数据本身的多次拷贝实现了内核层面的高效处理但最终通过 read/recvfrom 将数据从内核缓冲区拷贝到用户空间仍需要一次不可避免的 CPU 拷贝5生产者-消费者模型接收方向角色动作生产者报文放入sk_receive_queue软中断缓冲区sk_receive_queuesk_buff链表消费者用户进程调用read()/recv()从队列取出sk_buff拷贝数据到用户态发送方向角色动作生产者用户进程调用write()/send()将数据拷贝到内核放入sk_write_queue缓冲区sk_write_queue消费者TCP协议栈软中断上下文从队列取出sk_buff封装后交给网卡需要同步生产者-消费者需要同步机制自旋锁、信号量队列为空时消费者阻塞阻塞I/O队列满时生产者阻塞流量控制接收端背压如果用户程序来不及读数据sk_receive_queue会满。网卡驱动收到包后如果发现队列满会直接丢包不会把生产者网卡堵死中断上下文不能被阻塞而用户进程可以被阻塞丢包 → TCP协议栈检测到丢包 → 降低发送窗口 → 对端发送方减速发送端背压当sk_write_queue满了write系统调用就会休眠直到有空间阻塞用户进程 → 生产者被直接“踩刹车”方向队列满时队列空时锁类型接收生产者软中断丢包消费者用户进程阻塞自旋锁发送生产者用户进程阻塞消费者软中断忙轮询/无操作自旋锁背压指的是当数据接收方的处理速度跟不上发送方时接收方向发送方传递的一种“减速”或“停止”信号特征说明方向从慢速消费者向快速生产者传递目的防止缓冲区溢出导致数据丢失或系统崩溃形式阻塞、丢包、降速、拒绝请求、返回错误码本质一种反馈控制机制4.网络协议栈的本质网络协议栈的本质是分层的数据结构如struct sk_buff、struct tcphdr等协议规范与对应的方法集如proto_ops、net_protocol等虚函数表的组合每一层都通过函数指针实现多态向上逐层解析传递接收路径或向下逐层封装传递发送路径形成从网卡到用户态的双向调用链这正是C语言面向对象设计在Linux内核中的经典实践

更多文章