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

目录

1.面向字节流

(2)对比UDP

问题:粘包与拆包

解决方案(应用层协议设计)

(5)与之前的联系

2.TCP异常情况

(4)保活机制

(5)与之前的联系

3.文件与Socket的关系

(1)Socket是特殊的文件

[(2)struct file与Socket的关联](#(2)struct file与Socket的关联)

[(3)继承体系:struct sock作为基类](#(3)继承体系:struct sock作为基类)

(4)sk_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可以延迟合并,减少小包浪费
  • 应用层可能一次写10MB,TCP可以拆分成多个MSS大小的段,避免IP分片
  1. 为了可靠有序的抽象
  • TCP只保证:你写入的字节序列,在对方读出来时顺序完全一致
  • 中间怎么拆分、合并、重传、重组,对应用层透明
  • 应用层看到的是一个可靠的、有序的、无边界的字节管道

(4)面向字节流带来的实际问题

问题:粘包与拆包

复制代码
假设客户端发送两个请求
write(fd, "HELLO", 5);
write(fd, "WORLD", 5);

服务端可能一次读到:

  • "HELLO"(正好5字节)→ 正常
  • "HELLOWORLD"(10字节,粘包)→ 两个请求粘在一起
  • "HELL"(4字节,拆包)→ 一个请求被拆开

因为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)进程终止(软件层面,可控)

进程终止时

  1. 进程被杀死(kill、崩溃、正常退出)
  2. 操作系统内核自动关闭该进程持有的所有文件描述符
  3. 关闭socket描述符 → TCP协议栈立即发送FIN报文给对方
  4. 正常执行四次挥手,连接优雅关闭

即使进程崩溃了,操作系统内核还在,内核会代为完成清理工作:

  • 关闭文件
  • 发送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作为缓冲区

(1)Socket是特殊的文件

在Linux"一切皆文件"哲学下,Socket也是一种文件。它通过struct file关联到一套独立的、针对网络通信的操作方法集

  • 可以用read()/write()读写Socket,就像读写普通文件一样
  • 但底层实际执行的是网络收发操作,不是磁盘I/O
  • 特殊点:struct file中的f_path指向的不是磁盘的inode,而是网络协议栈

(2)struct file与Socket的关联

1.struct file的作用

每个打开的文件在内核中对应一个struct file对象,它包含:

  • f_op:文件操作方法集指针(如read、write、release)
  • private_data:指向特定类型文件的私有数据
  1. 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 sock

(3)继承体系: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语言实现多态】

(4)sk_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_buffstruct tcphdr等协议规范)与对应的方法集(如proto_opsnet_protocol等虚函数表)的组合,每一层都通过函数指针实现多态,向上逐层解析传递(接收路径)或向下逐层封装传递(发送路径),形成从网卡到用户态的双向调用链,这正是C语言面向对象设计在Linux内核中的经典实践

相关推荐
多年小白9 小时前
2026年AI智能体“三国杀“:腾讯龙虾矩阵、阿里千问生态与字节豆包的技术架构全解析
网络·人工智能·科技·矩阵·notepad++
wanhengidc9 小时前
云手机 性能不受限 数据安全
服务器·网络·安全·游戏·智能手机
深念Y9 小时前
中兴BAV系列机顶盒WiFi天线改造记:从合盖信号差到外壳开孔外置
网络·wifi·天线·信号·diy·数码·机顶盒
kongba0079 小时前
win系统环境检查工具,powershell 脚本,一次检查AI全面掌握系统运行环境 ,AI 它写代码更兼容,更少折腾,无需中间来回折腾环境配置
网络·安全
芯智工坊9 小时前
第5章 Mosquitto配置文件完全指南
网络·人工智能·mqtt·开源
@syh.9 小时前
网络基础概念
网络
NaclarbCSDN10 小时前
User ID controlled by request parameter, with unpredictable user IDs -Burp 复现
网络·安全·网络安全
code_pgf10 小时前
yolov8详细讲解,包括网络结构图、关键创新点、部署
网络·人工智能·yolo
zl_dfq10 小时前
计算机网络 之 【TCP协议】(TCP的核心定位与控制本质、TCP报文结构)
网络·计算机网络·tcp