深入解析TCP/IP:从端口号到可靠性机制

目录

回顾:

再详谈端口号:

端口号范围划分

再详谈UDP协议

再详谈TCP协议

TCP协议的各个字段

TCP可靠性(确认应答&&提高传送效率)重点

超时重传机制

连接管理机制

流量控制

滑动窗口

拥塞控制

延迟应答

捎带应答

字节流

粘包问题

总结可靠性

再详谈listen的第二个参数


回顾:

前面已经学过了整个网络框架,从应用层到到数据链路层

应用层我们主要讲解了http和https,传输层tcp、udp,网络层ip(未讲),数据链路层MAC(未讲)

再整个框架的基础上,我们详细从上到下讲解

学习所有的协议无非就两条:
1.如何解包(你的报头和有效载荷怎么分离)

2.如何分用(如何交给上层的哪个协议,比如你上层是udp还是tcp,不同的协议它的有效载荷不一样,你要交给哪个协议)

再详谈端口号:

传输层:负责数据端到端的传输

端口号是传输层协议的核心标识,主要工作在 TCP/IP 五层模型的传输层(对应 OSI 七层模型的传输层)。

传输层的核心目标是在两台主机的应用程序之间建立端到端的通信,而端口号就是用来区分同一台主机上不同应用程序的 “逻辑门牌号”。

一个进程可以bind多个端口号,一个端口号也可能被多个进程bind

核心是传输层的判断:ip+端口号+协议

比如进程1使用0.0.0.0+50+tcp,进程2使用0.0.0.0+50+udp,这个是可以的,只要有不同,os就知道将该报文交给哪个fd,tcp的交给进程1,udp的交给进程2即可(本质是三元组是否唯一,能够让os去区分是哪个进程)

在Linux内核当中,本质就是使用一个哈希桶去管理三元组和struct sock结构体的对应关系,来一个三元组我先计算出key,然后遍历桶中的链表,如果没有就允许bind,然后存储key-value,如果有就不允许

这个struct sock结构体是file里面的一个字段的private_data内核私有资源结构体,文件的话就会关联对应的inode,网络的话就关联struct sock(里面有相关的资源)

然后有一个字段f_op,这个是指向文件的操作函数表的,比如read,有些是去磁盘,有些是网络,那就会去private_data中读取数据

当你int fd = socket(AF_INET, SOCK_STREAM, 0)的时候

// 1. VFS 层:代表一个打开的文件
struct file {
    const struct file_operations *f_op;  // 【关键1:函数指针表!read/write/sendmsg等都在这里】
    void *private_data;                  // 【关键2:指向下层的 struct socket!】
    atomic_long_t f_count;               // fork时这个值会增加
    // ...
};

// 2. BSD Socket 层:网络套接字的通用抽象
struct socket {
    short type;                          // 类型:SOCK_STREAM, SOCK_DGRAM 等
    const struct proto_ops *ops;         // 【关键3:地址族相关的操作函数表!bind/connect等】
    struct sock *sk;                     // 【关键4:指向下层真正干活的 struct sock!】
    struct file *file;                   // 反向指回 struct file
    // ...
};

// 3. INET Socket 层:真正处理网络协议的结构体
struct sock {
    __be16 sk_num;        // 本地端口 (Port)
    __be32 sk_rcv_saddr;  // 本地 IP (IP)
    unsigned short sk_protocol; // 协议号 (Protocol, 比如 IPPROTO_TCP=6, IPPROTO_UDP=17)

    struct proto *sk_prot;               // 【关键5:具体的协议操作表!TCP/UDP的收发底层实现】
    unsigned short sk_family;            // 【关键6:地址族!比如 AF_INET (IPv4)】
    // ... 里面还有巨大的接收队列、发送队列、TCP状态机等
};

当你调用 send(fd, buf, len, 0) 时,内核的寻路过程是这样的:

  1. 通过 fd 找 struct file:内核从当前进程的文件描述符表里,找到 fd 对应的 struct file
  2. VFS 降级到 Socket:VFS 层调用 file->f_op->write()(或 sendmsg)。对于套接字,f_op 被内核配置成了 socket_file_ops,这个函数表会把调用直接转给下层的 socket 结构。
  3. Socket 找地址族:怎么转的?内核通过 file->private_data 拿到 struct socket,然后调用 socket->ops->sendmsg()
    • 重点来了:这个 socket->ops 是啥?当你创建套接字传入 AF_INET 时,内核会把 ops 指针指向 inet_stream_ops(如果是 TCP)。地址族决定了你用哪套操作函数! 如果是 AF_INET6,这里就是 inet6_stream_ops
  4. Socket 降级到 Sockinet_stream_ops 里的 sendmsg 函数(实际叫 inet_sendmsg),又会通过 socket->sk 找到 struct sock
  5. Sock 真正发数据:最后调用 sock->sk_prot->sendmsg()(对于 TCP,这就是 tcp_sendmsg),数据才真正进入 TCP 协议栈的发送队列。

当你调用 bind(fd, {IP=192.168.1.100, Port=80}) 时,内核实际上就是走进了 struct sock 的肚子里面,把对应的源 IP 和源端口字段填上:

// 极度简化
struct sock {
    // ... 之前说的那些字段
    
   __be16 sk_num;        // 本地端口 (Port)
    __be32 sk_rcv_saddr;  // 本地 IP (IP)
    unsigned short sk_protocol; // 协议号 (Protocol, 比如 IPPROTO_TCP=6, IPPROTO_UDP=17)
    
    // ...
};


// 遍历已经绑定的 sock 链表
for_each_sock_in_hash(sk) {
    if (sk->sk_num == wanted_port && 
        sk->sk_rcv_saddr == wanted_ip && 
        sk->sk_protocol == wanted_protocol) 
    {
        // 报错:EADDRINUSE (地址已被使用)
        return -EADDRINUSE; 
    }
}

同时,内核会把这个 struct sock 挂载到一个全局的哈希表中。当网卡收到目标端口是 80 的数据包时,内核通过哈希表一查,就能瞬间找到这个 struct sock,把数据放进它的接收队列里。

如果不 bind(客户端自动绑定),内核在 connect 或第一次 sendto 时,也会去填写这两个字段,并挂入哈希表。

实际上内核是有不同的哈希表的,比如tcp协议有一个,udp协议有一个

当网卡收到一个数据包时,IP 层解析包头,发现协议号是 6(TCP),它就直接把包丢给 TCP 模块;如果是 17(UDP),就丢给 UDP 模块。

以下是哈希桶中的一个节点

struct inet_bind_bucket {
    possible_net_t   ib_net;      // 网络命名空间
    unsigned short   port;        // 绑定的端口号
    signed char      fastreuse;   // 优化标志位
    signed char      fastreuseport; // 优化标志位
    kuid_t           fastuid;     // 优化标志位
    
    struct hlist_node node;       // 【关键修正!】用于把本结构体挂到 bhash 桶的链表上
    struct hlist_head owners;     // 【核心!】用于把 sock 结构体挂在本节点下面
};

通过散列函数把port转换成key,然后插入到哈希表当中,这个节点里面的owners是一个链表,这个端口的所有sock就挂在这里

在tcp/ip中使用五元组【协议,本地ip:本地端口,外地ip:外地端口】来标识一个通信

五元组和三元组的使用场景是完全不同的,它们代表了网络连接生命周期的两个不同阶段,也代表了两种完全不同的“身份”。

  • 三元组(协议 + 本地IP + 本地端口):代表“我是谁”(本地身份标识)。
  • 五元组(协议 + 本地IP + 本地端口 + 远端IP + 远端端口):代表“我和谁在通信”(全局唯一会话标识)。

TCP 的三次握手,就是内核视角从“三元组”切换到“五元组”的经典时刻:

  1. SYN 到达:客户端发来 SYN 包。服务端内核提取目标三元组,在 lhash 中找到监听 Socket(此时只看本地身份)。
  2. 握手完成:三次握手结束。内核从监听 Socket 的全连接队列里取下这个请求,克隆/创建一个全新的 Socket,并将客户端的 IP 和端口写入新 Socket 的远端属性中。
  3. 挂载入表:这个新 Socket 拥有了完整的五元组,被内核插入到 ehash(已建立连接哈希表)中。
  4. 后续通信accept() 将这个新 Socket 返回给用户态。此后,所有针对这个连接的数据包,内核都用五元组在 ehash 中查找。

流程:

通信的时候是自顶向下封装,这里访问http,那封装就是你的有效载荷+应用层报头(浏览器帮你封装),然后向下封装tcp报头,tcp报头当中要含有源端口号和目标端口号,在向下就是ip报头,包含源ip地址和目标ip地址+协议号(这个就是传输到对方之后,对方分用)

服务器接收到一个报文,解包+分用,解包发现交给tcp协议,分用就是后面的有效载荷,那就是交给传输层的tcp协议,然后进行解包分用,发现交给端口号80,后面应用层解析http协议获取各个字段然后得到body(有效载荷)

fd 是给你(用户态进程)用的,而五元组是给网卡(内核中断)用的。发的时候不用ehash,收的时候得用,用到五元组,才能放到对应的fd的接收缓冲区,你才能read去接收

解包分用的本质:就是来一个我就记录下五元组的信息,然后通过计算key,去hash表当中寻找,找到对应的value也就是sock结构体,内核把解包后的应用层有效载荷(比如 HTTP 请求数据),写入对应套接字的接收缓冲区;对应进程通过read()等系统调用,从 fd 对应的缓冲区中读取数据,完成分发。

端口号范围划分

端口号的范围是 0~65535(共 2¹⁶个),这里和前面所学知识对应,我们编程时写的类型uint16_t

1 知名端口(Well-Known Ports):

  • 范围:0~1023

  • 用途:分配给互联网的常用标准化服务,由 IANA(互联网号码分配局)统一管理。

  • 特点:普通用户进程(非 root 权限)默认无法绑定这些端口,需管理员权限

  • 常见示例

    • 80(HTTP)、443(HTTPS)

    • 22(SSH)、21(FTP)

    • 53(DNS)、25(SMTP)

2 注册端口(Registered Ports)

  • 范围:1024~49151

  • 用途:用于用户自定义服务、应用程序或特定组织注册的服务,需向 IANA 申请注册(非强制)。

  • 特点:普通用户进程可自由绑定(无需特殊权限)。

  • 常见示例

    • 3306(MySQL)、6379(Redis)

    • 8080(常用的 Web 服务测试端口)

3. 动态 / 私有端口(Dynamic/Private Ports)

  • 范围:49152~65535

  • 用途:由客户端程序临时随机使用(客户端发起连接时,会随机选择一个该范围的端口作为源端口)。

  • 特点:不会被长期绑定,仅在连接期间临时占用。

以下命令是常用的大家务必熟记

pidof:在查看进程id的时候非常方便

语法:pidof【进程名】

这样返回id

ps:process status,用来查看进程相关的状态的

netstat:是 Linux/Unix 系统中用于查看网络连接、路由表、接口状态等网络信息的经典命令

再详谈UDP协议

学习一个协议无非学习两条

解包+分用

报文=报头+有效载荷(数据)

端口号都是16位,2字节就可以表示完毕,无论是tcp还是udp都需要带上源端口+目的端口

udp长度2字节表示报文有多长,那就是2^16-1约=64KB,这个再当今互联网中非常小,所以要是很大就需要应用层划分多个包,然后再交给udp(换言之如果你应用层没有自己处理,那sendto的是时候内核直接报错了)

更深的陷阱:MTU 与 IP 分片(这里可以先往下学完在回看)

你可能会想:那我发个 60000 字节的数据(小于 65507),总该没问题了吧?

技术上可以发出去,但工程上极度不推荐!

这就要提到 MTU(最大传输单元)。以太网的 MTU 通常是 1500 字节。减去 IP 头和 UDP 头,一个以太网帧能装下的 UDP 数据通常只有 1472 字节

当你发送 60000 字节的 UDP 包时,内核在 IP 层会发生什么?
IP 分片:内核把这 60000 字节切成大约 40 多个小片(每片 1500 字节)发到网络上。

IP 分片的致命危险
这 40 多个分片,在接收端的内核里必须全部到齐,才能重组成一个完整的 60000 字节 UDP 包交给你。只要丢失其中任何一个分片,整个 60000 字节的 UDP 数据包就会被内核整体丢弃!

在网络拥塞丢包率 1% 的情况下,40 个分片全到的概率骤降。所以,实际工程中,UDP 的payload最好控制在 1472 字节以内(避免 IP 分片),最安全!

(主要是udp是不可靠的,无连接的,面向数据报的)(它的接收缓冲区是一个数据报队列,是一个一个的数据报,传输过程要么针对某一个要么丢包要么就完整传过来,不会出现半个,一个半的情况)

封装报头

这里udp采用的是定长的格式,前8字节固定是报头

解包

那数据(有效载荷)的大小就是udp长度里的大小-报头

分用

交给上层的哪个协议/哪个进程,有个目的端口,根据之前记录下来五元组即可通过哈希表查询fd

UDP 校验和

检测 UDP 报文在传输过程中是否发生比特错误(如比特翻转、数据丢失、篡改等),其工作机制遵循 RFC 768 标准,采用 16 位反码求和 + 校验和验证 的逻辑,且 UDP 校验和是可选的(但现代系统默认开启)。

增加报头的本质就是在有效载荷(”你好“)前面加一堆结构化的数据

在前面我们说过结构化数据需要序列化,这里不需要,直接传二进制数据就行

本质是因为全球的所有udp协议的前8字节都是这样规定的,一端传udp数据的时候,os直接传对应的二进制数据,然后接收方直接将相应的内存开始直接进行强转成对应的结构体指针,然后把数据填写对即可,os帮你自动完成

为什么我们的结构化数据需要我们自己序列化???

序列化的本质就是消除自定义传输的时候歧义问题,有些平台结构体会有内存对齐规则,每个平台都不一样,有些语言有结构化这一说法有些没有,但udp大家都是前8字节,大家都一样,都用同一套规则

注意每一层对于数据的叫法可能不同:

TCP:数据段

UDP:数据报

IP:IP数据报

数据链路层:以太网帧

UDP传输的特点:

无连接:
知道对端的IP和端口号就直接进行传输, 不需要建立连接;
不可靠:
没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方, UDP协议层也不会给应用层返回任何错误信息;
面向数据报: 不能够灵活的控制读写数据的次数和数量;

详谈数据报:

应用层交给UDP多长的报文, UDP原样发送, 既不会拆分, 也不会合并;用UDP传输100个字节的数据;

如果发送端调用一次sendto, 发送100个字节, 那么接收端也必须调用对应的一次recvfrom, 接收100个字节; 而不能循环调用10次recvfrom, 每次接收10个字节
面向数据报类似寄快递,你朋友寄快递寄一个你就收一个,两个三个就收两个三个,不会出现半个这种情况,就是你调recvfrom的时候要么读不到,要么读就读整个
在udp这里不用考虑数据是否完整的问题,它能够读上来一定是一个完整的数据报,只要考虑序列化和反序列化,转换成结构化数据交付给上层使用即可
udp没有真正意义上的发送缓冲区(意思是有,但作用没有tcp这么大)调用sendto会直接交给内核, 由内核将数据封装udp报头后直接传给网络层协议进行后续的传输动作;
UDP具有接收缓冲区. 但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致; 如果缓冲区满了, 再到达的UDP数据就会被丢弃;
基于UDP的应用层协议
NFS: 网络文件系统
TFTP: 简单文件传输协议
DHCP: 动态主机配置协议
BOOTP: 启动协议 ( 用于无盘设备启动 )
DNS: 域名解析协议
当然 , 也包括你自己写 UDP 程序时自定义的应用层协议 ;

再详谈TCP协议

TCP 全称为 " 传输控制协议 (Transmission Control Protocol"). 人如其名,要对数据的传输进行一个详细的控制

TCP协议的各个字段

字段解释

一行32bit,4字节,暂时不考虑保留项,那报头就是20字节

4位首部长度

这里存储了报头的长度,表示的范围是0~15,但是要x4,所以实际是0~60,但标准是20,所以范围是20~60

有效载荷的大小:

ip层协议解包分用时算出来的总体大小,然后减去tcp的报头,udp是协议里面自带总体大小

端口:

这个和udp一样

添加报头

本质就是在报文的前面增加结构化的数据,也是二进制(全球都这样),应用层又不是全球统一

通过协议控制外设,就诞生一种-------->嵌入式

外设公司有协议文档,你可以查,然后通过协议控制外设,不要觉得协议只是网络特有的
但这种协议我们很少谈可靠性?为什么?因为离得近
但网络是距离过远,所以网络传输为什么会存在不可靠,就是因为距离变长了

你的最新发过去的消息,无法保证对方收到了,除非对方应你了,不存在绝对的可靠性,但存在相对的可靠性,就是历史消息的报文,让对方发送最新消息的时候顺便应答了,那这个历史消息报文就是可靠的,这就是确认应答的机制保证可靠性

基础:问:你吃了吗?答:吃了
真实:问:你吃了吗?答:吃了,你吃了什么?(在答的基础上多加消息)
这种是你发,我答你才发,我不答你就不发了,串行式
还有一种是连续发好几条,发几条也答几条,就是批量化的
基础和真实都是有可能出现的

对于udp它是不可靠的,所以你想要保证可靠性你可以在上层添加报头还是报文的时候做一些处理,比如乱序情况,你在上层可以进行排序

接下来我们仅需要搞清楚一边的发送数据逻辑即可,只有应用层我们才谈client和server,有请求和回复,但是对于tcp来说,无论是client->server,还是server->client都是一样的逻辑,理清楚一边发送和接收的逻辑即可

如果批量化发一些数据段,数据收到的顺序可能就不一样,既然不一样我怎么保证顺序一样?还有应答回去我怎么确定哪个应答对应哪个,如果答了3个有一个没答,那没答的是哪个数据段丢失了?
所以tcp数据段需要有方式来标识,也就是每一个tcp协议都要有32位序号32位确认序号是应答的

32位序号:发送给对方告诉这个数据段是几号

32位确认序号:回复对方收到的数据段

如果数据段是一整个报文(报头+有效载荷),那应答数据段就是简简单单的报头

流程:

client:发送10,11,12,13

server:收到10的时候回复11而不是10,依次递增的

比如说收到10,11,13,12没有收到,那就会一致回复12(因为11的后面是12)

本质:发回去的确认序号是确认以前收到的报文,发回去12是因为10和11都已经收到了

16位窗口大小---->提高传送效率

16位窗口大小:
对于tcp接发两边都有缓冲区,但如果发送端发送太快,而上层收满或者上层处理别的业务,缓冲区可能就会被堆满,后面在发就会导致丢包,而如果发送太慢,可能会影响上层业务的正常处理,所以慢了和快了都不行,要合适
那作为发送方如何得知对方什么叫合适,也就是我得发多少数据量才是合适
所以我们得得到对方剩余缓冲区的大小
所以16位窗口大小就是告知对方自己接收缓冲区中剩余空间的大小,你发数据时要填写自己的告诉对方你还剩多少

标记位

标记位的核心作用就是别人一看就知道你这个数据段是干嘛的(快速定位业务),是 TCP 实现连接管理、可靠传输、紧急数据处理等核心功能的控制开关—— 每个标记位对应一个特定的控制行为,内核通过检测这些标记位的值,决定如何处理当前报文段。

ACK:标记报头中的确认号字段生效,告知发送端「我已正确收到某序号之前的所有数据」

SYN:用于发起或同步连接,报文中携带本端的初始序列号(ISN),是三次握手的核心标记

FIN:表示发送端已无数据要传输,请求优雅关闭连接,是四次挥手的核心标记

PSH:push,推送数据,强制接收端立即将缓冲区数据交给应用层,不等待缓冲区填满,降低交互延迟

作用:read函数不是一有数据就返回,缓冲区有个低水位,数据大于低水位才会返回,并且跟后面讲的epoll联动(后续讲解),epoll相当于有数据了再通知上层来拿,PSH就是告知上层有数据了,赶紧来处理

URG:标记报文段中存在紧急数据,通知接收端优先处理,无需等待缓冲区填满

1. 终端远程登录时,用户按下 Ctrl+C 中断程序,会触发 URG=1,将中断指令作为紧急数据发送;2. 接收端收到后,会跳过正常的缓冲区排队逻辑,直接将紧急数据交给应用层。

那这个紧急数据在哪???

16位紧急指针当中,这个标识了有效载荷的偏移量,比如8,就是偏移量为8,但仅仅是一个字节,因为无法标识结束位置,所以一次就标识一个字节的紧急数据

  1. 发送端
    • 调用 send(fd, buf, len, MSG_OOB) 时,内核会:✅ 给当前 TCP 报文置 URG=1(紧急指针有效);✅ 把 “紧急指针” 字段设为「当前报文序列号 + 紧急数据的偏移量」(通常指向最后 1 字节,标记这是紧急数据);✅ 把带外数据混入正常字节流中发送(并非独立报文)。
  2. 接收端
    • 内核收到 URG=1 的报文后,会:✅ 标记 “当前连接有带外数据待处理”;✅ 给应用层发送 SIGURG 信号(需提前绑定信号处理函数);✅ 记录 “带外标记”(紧急指针指向的字节位置)。

RST:强制关闭异常连接,无需经过四次挥手的优雅关闭流程,是 TCP 的「异常终止开关」

假设服务器在三次握手,建立连接后把电源拔了重启,但客户端不知道,所以它就会发报文,但服务器就不认了,你都没和我建立连接还发报文,所以就发一个RST回去,所以RST表示如果单方面认为连接有问题就给对方发RST重新建立连接

TCP可靠性(确认应答&&提高传送效率)重点

// 源码位置: include/linux/tcp.h (精简版)
struct tcp_sock {
    // ====== 基类继承 ======
    // struct inet_connection_sock 继承自 struct sock
    // struct sock 里面包含了最基础的 sk_receive_queue, sk_write_queue 等

    // ==========================================
    // 1. 序列号与确认应答机制
    // ==========================================
    u32 snd_nxt;      // Send Next: 我下一个要发送的字节序号
    u32 snd_una;      // Oldest Unacknowledged: 我发出的数据中,最老的还没被确认的序号
                      // (snd_una 到 snd_nxt 之间,就是已发但未确认的数据,即滑动窗口的“已发送”部分)
    u32 rcv_nxt;      // Receive Next: 我下一个期望收到的字节序号 (用于给对方回 ACK)

    // ==========================================
    // 2. 流量控制 - 滑动窗口机制
    // ==========================================
    u32 snd_wnd;      // 发送窗口: 对端允许我发送的最大数据量 (对端的接收能力)
    u32 rcv_wnd;      // 接收窗口: 我目前还能接收多少数据 (我的剩余缓冲区大小,会随应用读取动态变化)
    u32 window_clamp; // 窗口钳位:限制窗口大小的绝对上限

    // ==========================================
    // 3. 拥塞控制机制
    // ==========================================
    u32 snd_cwnd;     // 拥塞窗口: 根据网络拥塞程度,我计算出的当前网络能承受的数据量
    u32 snd_ssthresh; // 慢启动阈值: 拥塞窗口增长的分水岭 (小于它指数增长,大于它线性增长)
    u32 snd_cwnd_cnt; // 拥塞窗口计数器,用于精确控制 cwnd 的增长

    // 实际的发送上限 = min(snd_wnd, snd_cwnd) 既要看对端脸色,也要看网络脸色!

    // ==========================================
    // 4. 超时重传机制
    // ==========================================
    u32 srtt_us;      // Smoothed Round Trip Time: 平滑的往返时延 (微秒)
    u32 rttvar_us;    // Round Trip Time Variation: 往返时延的波动值
    u32 rto;          // Retransmission Timeout: 计算出的重传超时时间
                      // 公式: RTO = srtt + 4 * rttvar (经典的 Jacobson/Karels 算法)

    // ==========================================
    // 5. 快速重传与恢复机制
    // ==========================================
    u32 packets_out;  // 已发出但尚未确认的数据包个数
    u32 retrans_out;  // 重传且尚未确认的数据包个数
    u32 lost_out;     // 被判定为丢失的数据包个数
    u32 sacked_out;   // 被 SACK 选项确认的数据包个数 (收到重复 ACK 的次数,达到3次触发快速重传)
    u32 undo_marker;  // 用于撤销错误降级的拥塞窗口记录点

    // ==========================================
    // 6. 保活机制
    // ==========================================
    u32 keepalive_time;   // 保活探测的间隔时间
    u32 keepalive_intvl;  // 保活探测的重试间隔
    u8  keepalive_probes; // 保活探测的最大尝试次数

    // ==========================================
    // 7. 校验和与紧急指针
    // ==========================================
    u16 urg_data;     // 紧急数据指针 (带外数据 OOB)
    // (注:校验和通常在底层 sk_buff 或硬件计算,不在 tcp_sock 状态里长驻)

    // ==========================================
    // 8. 缓冲区与队列管理 (部分在基类 struct sock 中)
    // ==========================================
    // struct sock 中的关键队列:
    // sk_write_queue:   发送队列 (存放已发送/待发送的 sk_buff)
    // sk_receive_queue: 接收队列 (存放按序到达、等待应用 read 的 sk_buff)
    
    // TCP 特有的乱序队列:
    struct rb_root out_of_order_queue; // 红黑树!存放提前到达的乱序数据块
};

应用层调用write,本质就是一个拷贝函数,将应用层的数据拷贝到tcp的缓冲区,拷贝过来就天然有了序号,比如0字节对应缓冲区当中的0,每个字节对应一个序号

所以发送方中的序号就是这么来的

思考问题:

TCP是一个可靠的传输协议,那就证明了应用层交给 TCP 的字节流,能以 “无丢失、无重复、无乱序、按需到达” 的方式,完整且有序地交付到对端应用层

对于丢包:TCP的策略是重传,那重传的机制是啥???

对于发送方他根本不知道有没有丢包,策略就是超时重传,超时时间如何规定

那如果没有丢包又重传了,说明数据有冲突,那对于tcp我们还需要有能力对重复的数据去重

发送的数据都是乱序的,因为网络当中不确定性还有发送的时机,就证明了到达的数据的无序,那如何排序

那发送出去的数据肯定要维持一段时间才移除,因为你要重传,那如何维护数据,维持在哪

https://mp.csdn.net/mp_blog/creation/editor/155817021

这篇文章讲述的tcp流程已经很详细了,但是没有搭配协议中字段意思

接下来重新梳理并且搭配协议中各个字段的意思更加详细

两个都要看,互为补充的

超时重传机制

主机A发送数据给B之后, 可能因为网络拥堵等原因, 数据无法到达主机B;
如果主机A在一个特定时间间隔内没有收到B发来的确认应答, 就会进行重发;

还可能是ACK传回来的时候丢包

1.数据真的丢包

2.ACK丢包

那ACK丢包,可能会一直重传,那接收端就需要有处理重复报文的能力,这时候我们可以利用前面提到的序列号, 就可以很容易做到去重的效果.

那超时时间如何确定???

最理想的情况下, 找到一个最小的时间, 保证 "确认应答一定能在这个时间内返回".
但是这个时间的长短, 随着网络环境的不同, 是有差异的.
如果超时时间设的太长, 会影响整体的重传效率;

如果超时时间设的太短, 有可能会频繁发送重复的包;

TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间.

Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间都是500ms的整数倍.
如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.
累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接.
总的来说有两种重传策略:
  • 超时重传 (RTO):只要发了数据没收到 ACK,启动定时器,超时则重传。RTO 的时间根据网络状况(srtt 和 rttvar)动态计算。
  • 快速重传:不等超时,只要收到 3 个重复 ACK,立刻断定包丢了,马上重传。

连接管理机制

sock这个结构体是在FILE*中的某个字段,是网络当中内核直接维护,里面有个字段sk_state标识连接状态的核心载体

// 简化版 struct sock 中与状态相关的字段
struct sock {
    // ... 其他字段(如缓冲区、端口、序列号)
    enum tcp_state sk_state;  // TCP 连接状态字段
};

// 常见的 TCP 状态枚举(对应 RFC 标准)
enum tcp_state {
    TCP_CLOSE = 0,        // 初始关闭状态
    TCP_SYN_SENT,         // 客户端发送 SYN 后,等待 SYN-ACK
    TCP_SYN_RECV,         // 服务端收到 SYN 后,等待 ACK
    TCP_ESTABLISHED,      // 连接已建立,可传输数据
    TCP_FIN_WAIT1,        // 主动关闭方发送 FIN 后
    TCP_FIN_WAIT2,        // 主动关闭方收到 FIN 的 ACK 后
    // ... 其他状态(如 TIME_WAIT、CLOSE_WAIT 等)
};

流程回顾:

左边客户端,右边服务端

客户端发送connect连接请求,内核发送报文SYN(SYN用于发起或同步连接),状态自动变为SYN_SENT,服务端收到之后接收并解析客户端 SYN 报文,服务端的监听套接字(LISTEN 状态)不会改变状态,内核会为该连接创建一个新的 struct sock,状态置为 TCP_SYN_RECV,构造并发送 SYN+ACK 报文,此时客户端收到SYN+ACK报文之后(证明对方的接收和发送没问题),之后发送ACK报文确认收到(同时表明自己的接收缓冲区没问题),从此双方三次握手建立好了连接

注意此时客户端收到SYN+ACK后建立成功是站在双方各自的视角下建立成功的,也就是后面服务端可能没收到ACK,此时只有客户端建立成功服务端不认为建立成功(三次握手是可能失败的,四次挥手也是如此)

对于第一步的SYN:我们不怕丢包,因为丢包了第二步就不会有了

对于第二步的SYN+ACK:我们不怕丢包,因为丢包了第三步就不会有了

对于第三步ACK:也不怕,因为还有RST状态标识,服务端认为没有建立连接,你一直发包干嘛,直接RST重新连接

为什么需要三次???

TCP协议是可靠传输,需要确认对方能否接收数据+发送数据的能力

三次就是最少的确认双方的能力

如果一次和两次,客户端只要发送SYN就能建立的话,那客户端就可以发无数次给服务器,那服务器为了维护这些连接也是要耗成本的,直接就把服务器的资源吃完,这就叫SYN洪水

对方只要通过单主机,一直发SYN即可

还有服务器建立连接成功必须收到客户端发的ACK,这样如果对方发洪水攻击就注定不是单主机,因为他也要建立成功多次,如果你要攻击我,就要建立同等的连接,这样单主机一般配置低于服务器,很难让服务器崩掉

tcp是为了解决通信问题而不是为了防止攻击,所以你一台攻击不了你可以多台攻击,意思就是防攻击不是tcp的任务,小白可以植入木马病毒给大家的主机,然后大家同一时间发SYN给服务器这就是DDos攻击,服务器会维护这些非法连接,而导致其他人无法连接
也就是三次握手根本就不是解决攻击的问题,如果你有明显漏洞那就是你的问题了一次两次这种就是明显漏洞,只发SYN而不去建立连接也会导致服务器的资源消耗,因为服务器还有一种半连接要维持

客户端与服务端进行通信

内核并不知道你什么时候不通信,这个由我们自己控制,当我们调用close时就是关闭通信

客户端调用close关闭文件描述符,客户端内核发送FIN报文之后进入状态FIN_WAIT_1(已关闭发送缓冲区),服务端收到后发送ACK进入状态CLOSE_WAIT(已关闭接收缓冲区)然后调用close关闭文件描述符,发送FIN后进入状态LAST_ACK(关闭发送缓冲区),客户端收到ACK后进入状态FIN_WAIT_2,收到FIN后发送ACK进入状态TIME_WAIT(关闭接收缓冲区)

主动关闭的一方最终会进入TIME_WAIT,被动关闭收到FIN后进入CLOST_WAIT

如果服务器存在大量的CLOST_WAIT,说明服务器有bug,没有做close关闭文件描述符的操作,还有可能是服务器有压力,一直再推送消息给客户端,导致来不及close

为什么主动断开连接的一方还需要维持TIME_WAIT状态,这个状态为等待 2MSL 时长后关闭连接”,其中 MSL(Maximum Segment Lifetime)是 TCP 报文的最大生存时间(Linux 默认为 30s,因此 2MSL 默认为 60s)。

作用一:主动关闭方发送的 ACK 包可能丢失,被动关闭方若没收到,会重传 FIN 包。主动关闭方在 TIME_WAIT 期间收到重传的 FIN 包,会重新发送 ACK 包,并重置 2MSL 计时器。作用二:等待 2MSL 让本次连接的所有残留报文从网络中消失,防止这些过期报文干扰后续新连接。

对于一台服务器,有时候重启之后会显示bind不了,为什么???

因为你重启之后,内核维护状态,进入TIME_WAIT,此时端口在被占用,你就bind失败了,服务就起不来,那对于大业务,你要等60s才能重启,亏损大

是 Linux/UNIX 系统中用于设置套接字(socket)选项 的核心系统调用,它允许应用层修改内核中套接字的底层行为(如缓冲区大小、超时时间、TCP 特性等),是精细化控制网络连接的关键接口。setsockopt 设置 SO_REUSEADDR=1 的核心效果是:告诉内核 “允许绑定已被 TIME_WAIT 状态占用的端口”,打破默认的端口占用限制。

流量控制

接收端处理数据的速度是有限的 . 如果发送端发的太快 , 导致接收端的缓冲区被打满 , 这个时候如果发送端继续发送 ,就会造成丢包, 继而引起丢包重传等等一系列连锁反应 .
因此 TCP 支持根据接收端的处理能力 , 来决定发送端的发送速度 . 这个机制就叫做 流量控制 (Flow Control) ;
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;
窗口大小字段越大, 说明网络的吞吐量越高;
接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
发送端接受到这个窗口之后, 就会减慢自己的发送速度;
如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端.

发送方怎么在第一次发送的时候就知道对方接收缓存区的大小的???

早在通信前的三次握手就交换了双方的接收缓冲区大小了(16位窗口大小)

滑动窗口

对于一发一收的串行式通信:发了之后接收到ACK才能再次发送数据,这样的通信势必会慢

基于以上背景,提出并行

滑动窗口的本质就是缓冲区的一部分,我们将其进行了标记,就是算法学的滑动窗口,在窗口当中维护一个特定的条件,如果发送了数据,为了支持超时重传机制,我们需要维持数据,在没有收到ACK之前把数据保存在滑动窗口

思考问题

可以发送的数据量 = min(snd_wnd, snd_cwnd) - (snd_nxt - snd_una)

  • snd_wnd(对端剩余缓冲区):保证不把对端撑死(流量控制)。
  • snd_cwnd(网络承受力):保证不把网络堵死(拥塞控制)。

0:滑动窗口的设定就是有一个start和end指针去标记即可,维护一个区域,TCP 滑动窗口的大小(核心是「接收窗口 rwnd」和「拥塞窗口 cwnd」)是动态协商 + 自适应调整的结果,而非固定值 —— 接收窗口由接收端根据缓冲区剩余空间决定,拥塞窗口由发送端根据网络拥塞程度动态调整,最终发送端的实际发送窗口是两者的最小值。

1:可能向右也可能不变,但不会向左

2:不会一直不变,TCP 滑动窗口的大小(核心是「接收窗口 rwnd」和「拥塞窗口 cwnd」)是动态协商 + 自适应调整的结果,而非固定值 —— 接收窗口由接收端根据缓冲区剩余空间决定,拥塞窗口由发送端根据网络拥塞程度动态调整,最终发送端的实际发送窗口是两者的最小值。

3:丢包有两者情况,一数据丢了,二应答丢了,如果是后面的ACK就代表前面的收到了,需要滑动(比如0-100和201-300收到,101-200未收到,会一直发ACK101表示101前面的都收到了,需要重发101之后的,三次ACK101就会重发,发了101-200,后面ACK301代表前面的都收到了,直接更新滑动窗口)

4:窗口不动:发送端未收到新的 ACK(比如网络延迟),已发送未确认的数据范围不变,窗口保持不动;窗口变为 0rwnd=0:接收端缓冲区满→通过 ACK 告知发送端 rwnd=0,发送端停止发送(直到接收端发送 “窗口更新” 报文);实际窗口 = 0 时,发送端进入 “保活” 状态,只发小的探测报文,不发数据。

5:窗口是 “向后滑动 + 动态调整大小”,不是 “无限向后滑动”:当rwndcwnd变小时,实际窗口会收缩,限制 “向后滑动的范围”;若接收端缓冲区满(rwnd=0),发送端窗口会停止滑动(不再发送新数据),直到接收端read()数据后,rwnd>0,窗口才会继续滑动。内核把缓冲区设计成一个环形的结构,所以走到最后,后面会走到数据的起始

滑动窗口:

1.支持超时重传机制,保存数据

2.数据能够并行发送

拥塞控制

目前,所有考虑的都是端到端的问题,但是实际上网络也会出现问题,此时就需要拥塞控制

在一个局域网当中不仅仅只有你一台主机,还有很多台,如果网络中出现了问题,你觉得你重传9999条还好,但是这么多台主机都重传就导致网络更加堵塞,所以策略就是不要乱重传,而是有策略地传,我们的背景是不仅仅只有你一台主机

虽然 TCP 有了滑动窗口这个大杀器 , 能够高效可靠的发送大量的数据 . 但是如果在刚开始阶段就发送大量的数据 , 仍然可能引发问题.
因为网络上有很多的计算机 , 可能当前的网络状态就已经比较拥堵 . 在不清楚当前网络状态下 , 贸然发送大量的数据 ,是很有可能引起雪上加霜的.
TCP 引入 慢启动 机制 , 先发少量的数据 , 探探路 , 摸清当前的网络拥堵状态 , 再决定按照多大的速度传输数据 ;
此处引入一个概念程为拥塞窗口,发送开始的时候, 定义拥塞窗口大小为1;
每次收到一个ACK应答, 拥塞窗口加1MSS;(第一轮发1个+1,第二轮就可以发两个了,+2,第三轮发4个,+4,指数增长)
每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口;
像上面这样的拥塞窗口增长速度 , 是指数级别的 . " 慢启动 " 只是指初使时慢 , 但是增长速度非常快 . 为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.此处引入一个叫做慢启动的阈值
当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长
TCP 开始启动的时候 , 慢启动阈值等于窗口最大值 ;
在每次超时重发的时候 , 慢启动阈值会变成原来的一半 , 同时拥塞窗口置回 1(这个是旧的算法,新的算法可能会将拥塞窗口置4左右);(实际的算法有几种,自行了解)
少量的丢包 , 我们仅仅是触发超时重传 ; 大量的丢包 , 我们就认为网络拥塞 ;
TCP 通信开始后 , 网络吞吐量会逐渐上升 ; 随着网络发生拥堵 , 吞吐量会立刻下降 ;
拥塞控制 , 归根结底是 TCP 协议想尽可能快的把数据传输给对方 , 但是又要避免给网络造成太大压力的折中方案 .

延迟应答

如果接收数据的主机立刻返回 ACK 应答 , 这时候返回的窗口可能比较小 .
假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K;
但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;
在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M;
一定要记得 , 窗口越大, 网络吞吐量就越大, 传输效率就越高 . 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;
那么所有的包都可以延迟应答么? 肯定也不是 ;
数量限制: 每隔N个包就应答一次;
时间限制: 超过最大延迟时间就应答一次;
具体的数量和超时时间 , 依操作系统不同也有差异 ; 一般 N 2, 超时时间取 200ms;

捎带应答

在延迟应答的基础上 , 我们发现 , 很多情况下 , 客户端服务器在应用层也是 " 一发一收 " . 意味着客户端给服务器说了 "How are you", 服务器也会给客户端回一个 "Fine, thank you";
那么这个时候 ACK就可以搭顺风车 , 和服务器回应的 "Fine, thank you" 一起回给客户端

字节流

对于udp,你一次调用sendto发送100字节,那内核就会给100字节封装报头,整个发送过去

对于tcp,你一次调用write发送100字节,内核会根据滑动窗口,可能一次发20字节,封装报头发过去,可能下一次30,这个都说不定

调用write时, 数据会先写入发送缓冲区中;
如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
然后应用程序可以调用read从接收缓冲区拿数据;
另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做全双工

由于缓冲区的存在,read甚至可能一次只读一个字节,他不是等缓存区到你传入的参数len后才返回,有数据了就给你返回,实际可能只返回1字节

粘包问题

首先要明确, 粘包问题中的 "包" , 是指的应用层的数据包.
在TCP的协议头中, 没有如同UDP一样的 "报文长度" 这样的字段, 但是有一个序号这样的字段.站在传输层的角度, TCP是一个一个报文过来的. 按照序号排好序放在缓冲区中.
站在应用层的角度, 看到的只是一串连续的字节数据.
那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包.
那么如何避免粘包问题呢 ? 归根结底就是一句话 , 明确两个包之间的边界 .
对于定长的包, 保证每次都按固定大小读取即可; 例如上面的Request结构, 是固定大小的, 那么就从缓冲区从头开始按sizeof(Request)依次读取即可;
对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;
对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可);
所以http协议就是使用了\r\n来规定的,一个报文就是方法到body部分
思考 : 对于 UDP 协议来说 , 是否也存在 " 粘包问题 " ?
对于UDP, 如果还没有上层交付数据, UDP的报文长度仍然在. 同时, UDP是一个一个把数据交付给应用层. 就有很明确的数据边界.
站在应用层的站在应用层的角度, 使用UDP的时候, 要么收到完整的UDP报文, 要么不收. 不会出现"半个"的情况.
对于 UDP 这种“面向报文(数据报)”的协议来说,粘包从物理结构上就是不可能发生的。

总结可靠性

  • TCP 的可靠性是 “传输层包办”,应用层无需处理丢包 / 乱序 / 重复;
  • UDP 的不可靠性是 “传输层不管”,所有可靠性逻辑(重传、排序、去重)都需要应用层自己实现;

可靠性:指数据能无丢失、无重复、无乱序、按需到达地从发送端交付到接收端;

无丢失:有确认应答+超时重传

无重复:基于序号去重

无乱序:乱序缓冲区进行排序

流量控制:滑动窗口(rwnd)避免接收端缓冲区溢出,否则导致接收端垮掉

拥塞控制:拥塞窗口(cwnd)避免网络拥塞,可能加剧网络拥堵

延迟应答和捎带应答是为了增加效率

再详谈listen的第二个参数

之前我们介绍tcp的时候,先使用listen进行监听,后面再使用accept获取

这个参数叫backlog,之前我们简单提了一嘴,就是一个队列,允许backlog这么多个握手连接,一旦满了,其他客户端就连不上了(三次握手不能完成,可能可以完全前两次)

1. 半链接队列(用来保存处于 SYN_RECV 状态的请求)
2. 全连接队列( accpet 队列)(用来保存处于 established 状态,但是应用层没有调用 accept 取走的请求)
  • Linux 2.2 之前backlog 直接等于「半连接队列 + 全连接队列」的总长度上限;
  • Linux 2.2 及之后backlog 仅表示「全连接队列」的最大长度;半连接队列的长度由内核参数 net.ipv4.tcp_max_syn_backlog 独立控制(默认约 1024)。
  • POSIX 标准backlog 是 “建议值”,内核可根据系统资源调整(比如设为 5,内核可能实际取 10),但不会小于 1。

收到SYN之后,如果半连接队列还未满,服务端内核会创建轻量级的 struct request_sock(因为有拥塞窗口等等信息会重,如果遇到sync洪水内存会爆掉),然后加入半连接队列,后续如果收到ACK,检查如果全连接队列未满,删除半连接队列中的,然后创建重量级的 struct sock(具体来说是 struct tcp_sock 的基类部分)加入全连接队列,等待上层accept,如果全队列满了,就丢弃ACK

监听fd中的listen sock会维护这两个队列的

这里面还会详细分一些策略这里就不细讲了

当用户态程序调用 accept() 时,内核才真正开始“发户口本”:

  1. 从全连接队列中取出那个早已准备好的 struct sock
  2. 创建 struct socket:这是用户态与内核态的桥梁,并将 socket 与 sock 互相绑定。
  3. 创建 struct file:为这个连接分配一个文件描述符(fd),并将 file 与 socket 绑定。
  4. 将 fd 返回给用户态。

此时: filesocketsock 三位一体,彻底齐全。应用程序可以通过 fd 开始收发数据了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值