三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

从 POSIX API 到网络协议栈:一条 TCP 连接的一生

从 POSIX API 到网络协议栈:一条 TCP 连接的一生

这篇文章想解决的问题——写代码的人只看到socket/send/recv,但一个 connect 背后是内核协议栈在干活。用"一条连接从创建到关闭"的视角,把 API 与内核实现对应起来。

一、总览

网络编程调用的 POSIX API——socket、bind、connect、listen、accept、send、recv、close——只是用户态与内核之间的接口,真正干活的是内核协议栈。其中 select/poll 是标准 POSIX API,而 epoll 不是,epoll 是 Linux 内核提供的接口。

每个 socket 背后是一个内核态的 socket 对象,内含 TCP 控制块 TCB;用户拿到的只是一个文件描述符 fd,通过 fd 找到 socket 对象,再操作对应的 TCB。socket 创建完成后,系统只分配了 fd 和一个空的 TCB——此时还没有分配收发缓冲区。

一条连接的一生:

客户端:socket → connect(发起三次握手)→ send/recv → close。

服务器:socket → bind(绑定 IP 与端口)→ listen(把 TCB 状态置为 LISTEN,分配半连接/SYN 队列与全连接/accept 队列;此后三次握手由内核协议栈完成、用户不可控)→ accept(取出已建立的连接,分配新的 fd 并与对应 TCB 建立映射)→ recv/send → close。

贯穿全文的六个关键认知:

  1. bind 可绑可不绑,不绑时由内核自动分配端口;connect 对 UDP 同样适用,只是告诉内核"只与这个对端通信",不等于建立连接,TCP 的 connect 才发起三次握手。
  2. TCP 是字节流协议:一个 TCB 对应一个 stream,是连续的字节流、没有消息边界,边界需由应用层自行划分。
  3. send/recv 只是用户态与内核缓冲区之间的拷贝,与真实网络传输是异步的;TCP 协议描述的是两个协议栈之间的数据通信。
  4. close 是文件系统函数而非网络函数:它减少 fd 的引用计数,当 socket 无引用时才进入 TCP 关闭流程——先回收 fd,再发送 FIN。断开连接不分客户端/服务器,只分主动方/被动方。
  5. 连接的生命周期从第一次握手就开始了:协议栈收到 SYN 即创建 TCB 副本放入半连接队列,第三次握手的数据包靠五元组从半连接队列查找匹配节点,ACK 后节点移入全连接队列。
  6. 攻击与防护:SYN 泛洪通过大量 SYN 耗尽半连接资源,用 listen 的 backlog 参数防护,它控制 SYN 队列长度、SYN+accept 队列总长度(即未分配 fd 的 TCB 数量)与 accept 队列长度;CC 攻击是应用层的 HTTP 泛洪;SYN、UDP、ICMP、HTTP 泛洪同属 DDoS。

以上六点会在后文逐一展开。

二、客户端视角:一条连接如何发起

客户端创建连接的五步,逐一对应内核里的动作:

socket:创建 fd

socket()在内核态创建一个 socket 对象(内含 TCB),返回一个整数 fd。用户态后续所有操作都以 fd 为凭据,内核通过 fd 找到对应的 socket 对象。此时 TCB 是空的——没有绑定地址、没有分配收发缓冲区。

bind:可绑可不绑

bind()的作用是把 IP 与端口绑定到这个 socket 上。但它是可选的:不调用 bind 时,内核会在 connect 时自动分配一个临时端口。客户端通常不绑(让内核挑端口),服务器必须绑(客户端需要知道连哪里)。

connect:发起三次握手

TCP 的connect()发起三次握手,这一步是同步阻塞的,握手完成后才返回。特别要澄清一点:UDP 也可以调 connect,它只是告诉内核"我只跟这个对端通信",后续 sendto/recvfrom 可简化为 send/recv,但不等于建立连接——UDP 本来就没有连接。

send / recv:用户态与内核之间的拷贝

send()把数据从应用程序复制到内核发送缓冲区,recv()把数据从内核接收缓冲区复制到应用程序。它们只保证"进出内核",不保证"到达对端",与网络上真正的传输是异步的。

close:关闭

close()减少 fd 引用计数,引用归零时进入 TCP 关闭流程:先回收 fd,再发 FIN。细节在第七节展开。

一句话串起来:客户端流程是 socket → connect → send/recv → close,其中只有 connect 涉及三次握手,其余都是对 fd 和内核缓冲区的操作。


三、服务器视角:socket 的完整生命周期

socket:创建内核对象

socket()在内核态创建 socket 对象,内含 TCB,返回 fd。注意:创建完成后,系统只分配了 fd 和一个空的 TCP 控制块,没有分配接收/发送缓冲区——缓冲区要到连接建立后才有实际意义。

bind:绑定地址

通过 fd 找到 socket 对象,把 IP 与端口绑定上去。服务器必须 bind,否则客户端不知道该连哪里。

listen:置状态,分配队列

listen()把 TCB 中的状态置为 TCL_STATUS_LISTEN(监听态),同时为 TCB 分配两条队列:半连接队列(SYN 队列)全连接队列(accept 队列)。从这一刻起,三次握手可以发生——但它是内核协议栈实现的,用户态不可控

握手的具体过程:收到 SYN 后,内核创建一个 TCB 副本放入半连接队列;收到第三次 ACK 后,半连接队列中的 TCB 节点移动到全连接队列。第三次握手的数据包如何从半连接队列中找到匹配的节点?靠五元组(源 IP、源端口、目的 IP、目的端口、协议)匹配。

accept:取出连接,分配新 fd

三次握手完成后,accept()从全连接队列中取出一个已建立的连接,为对应的客户端分配一个新的 fd,并把该 fd 与对应的 TCB 建立映射。此后这个连接就用这个新 fd 通信,与监听 fd 无关。

recv / send:与内核缓冲区的拷贝

recv 把数据从内核协议栈复制到应用程序,send 把数据从应用程序复制到内核协议栈。

close

见第七节。

一句话总结服务器流程:socket → bind → listen(置状态 + 分配两队列)→ 内核自动完成三次握手 → accept(取连接、分配新 fd)→ recv/send → close。


四、深入内核:三次握手与两条队列

三次握手的本质是内核协议栈的事,accept 只是"取结果",这也是它听起来简单但面试总考的原因。

时序:

  1. 客户端 SYN 到达 → 内核创建 TCB 副本,放入半连接队列。此时连接"已经开始存在"。
  2. 服务器回复 SYN+ACK。
  3. 客户端回复 ACK → 内核从半连接队列中取出对应节点(靠五元组匹配),移入全连接队列
  4. 之后accept()从全连接队列取走连接,完成。

关键点:

  • 半连接队列存的是"握手没完成"的连接;全连接队列存的是"握手已完成、等待 accept"的连接。
  • 从半连接到全连接的移动,发生在第三次握手 ACK 到达时,这一步也是内核完成的,用户态不可控。
  • 五元组是查找的依据:第三次 ACK 到达时,内核拿它的五元组去半连接队列里找匹配的 TCB 节点。
  • 所谓"listen 之后才可以握手",本质是:只有置为 LISTEN 的 TCB 才分配了两条队列,才有地方暂存这些连接。

五、字节流模型:TCP 没有消息边界

一个 TCB 对应一个 stream,也就是一条 TCP 字节流。

TCP 提供的是连续的字节流,它只保证字节的顺序和到达,不负责消息边界。发送方调两次 send 发了"hello""world",接收方可能一次 recv 就收到"helloworld",也可能分成三次收到。边界怎么划,是应用层自己的事——常见方案:定长消息、分隔符(如换行)、或长度前缀(包头带长度字段)。

这一点与 UDP 截然不同:UDP 是数据报,每次 sendto 对应一次 recvfrom,天然有边界;TCP 没有。


六、send/recv 与网络传输的异步性

send()把数据从应用程序复制到内核发送缓冲区就返回了,不等于数据已经发到网络recv()把数据从内核接收缓冲区复制给应用程序,也不代表缓冲区里一直有货。两者都只是"用户态 ↔ 内核态"的拷贝。

与网络上的实际传输是异步的:内核协议栈按自己的节奏把发送缓冲区里的数据切成报文段、发出、等待确认、超时重传、接收对端数据存入接收缓冲区。用户程序感知不到这个过程。

理解这一点的价值:TCP 协议描述的其实是两个协议栈之间的数据通信(双方的 TCP 状态机、序列号、确认、窗口),而不是两个应用程序之间。应用程序只跟自己的内核打交道。


七、close 是文件系统函数

close()不是网络函数,是文件系统函数——因为 fd 本来就是文件系统的概念。

它的原理:

  1. 减少 fd 的引用计数。一个 fd 可能被 fork 后父子进程共享,引用计数大于 1 时,close 只是减一,不真正关闭。
  2. 当引用计数归零、socket 无引用时,才进入 TCP 关闭流程:先把 fd 回收(从文件系统层面注销),再发送 FIN 给对方

关于断开连接,还有一个重要认知:断开连接不分客户端/服务器,只分主动方/被动方。谁先调用 close,谁就是主动方,负责发 FIN 进入四次挥手流程;另一方是被动方,收到 FIN 后回复 ACK。所以"客户端主动断开"只是常见情形,服务器同样可以主动 close。


八、连接的生命周期从第一次握手开始

TCP 连接的生命周期,从第一次握手就开始了:协议栈收到(或发出)SYN 的那一刻,就已经分配了对应的 TCP 连接(TCB 副本进半连接队列)。

这意味着:

  • accept()返回之前,连接其实已经存在了——只不过它在全连接队列里排队等着被取走。
  • 三次握手还没完成时,半连接队列里已经有连接对象存在。
  • 所以"连接何时建立"的答案不是"accept 返回时",而是"第一个 SYN 到达时"。

这个认知也解释了 SYN 泛洪为什么可怕——攻击者只需要发 SYN,不完成握手,就能让内核不断创建 TCB 副本,占满半连接队列。


九、网络攻击与防护

SYN 泛洪

原理:攻击者发送大量 SYN 包,不完成三次握手,让服务器在半连接队列里堆积大量"半截"连接,耗尽半连接资源,导致正常用户无法完成握手。

防护核心:listen()的 backlog 参数。它的含义在不同内核里有细微差别,但核心是控制队列容量,涉及三个层面:

  1. SYN 队列(半连接队列)长度;
  2. SYN + accept 队列的总长度——即未分配 fd 的 TCB 的数量。注意:全连接队列里的连接虽然握手完成,但还没被 accept,所以也没有 fd,同样受 backlog 约束;
  3. accept 队列(全连接队列)长度。

backlog 调大能容纳更多连接,但也要权衡:队列越长,被攻击时资源消耗越大。

CC 攻击(HTTP 泛洪)

这是应用层的攻击:攻击者模拟大量真实用户,不断发送 HTTP 请求,消耗 Web 服务器资源(CPU、内存、连接数、数据库查询),让服务器无法服务正常用户。它不像 SYN 泛洪那样攻击协议栈,而是攻击应用逻辑,所以更隐蔽。

共同归属:DDoS

SYN 泛洪、UDP 泛洪、ICMP 泛洪、HTTP 泛洪,本质都是分布式拒绝服务(DDoS)的不同实现:通过耗尽目标资源,让服务不可用。

← 返回列表