前言:

我们写的网络代码,不是直接操作网卡,也不是直接操作 TCP 协议栈,而是通过 POSIX API 调用内核。

无论你用什么高级语言写网络程序,它们的网络库底层最终都是调用操作系统的这套 POSIX socket API。Java 的 Socket、Python 的 socket 模块、Go 的 net 包,扒到最底层都是 socket()/bind()/recv() 这些。

C 代码 / WebServer / WebSocket Server 都属于应用层。应用层自己不会真正创建 TCP 连接,也不会自己发 SYN 包、ACK 包、FIN 包。这些事情是 内核协议栈 做的。

这个博客,来拆解具体的底层POSIX API的实现。

POSIX API 可以先粗暴理解成:
Linux / Unix 系统提供给应用程序使用的一套标准接口。

比如代码里写:

socket();
bind();
listen();
accept();
connect();
send();
recv();
close();

这些函数表面上是 C 函数,但它们背后会进入操作系统内核,让内核帮你完成网络相关操作。

POSIX API 是你能看到的函数接口
syscall 是进入内核的机制
kernel 是真正干活的地方

下面进入具体的拆解:

Socket():创建 fd + socket对象 + TCB

包含:1.fd  2.tcb 控制块

一个 tcb 就代表内核里的一条 TCP 连接(或一个监听点)。 它是内核为了管理这条连接而维护的一大坨状态数据。里面装了什么?至少包括:

  • 五元组:(sip, dip, sport, dport, proto) —— 源IP、目的IP、源端口、目的端口、协议。这是这条连接的唯一身份证
  • 连接状态:closed / listen / syn_sent / syn_recv / established ...
  • 序列号:seqnum / acknum(三次握手和数据传输都要用)。
  • 收发缓冲区:buffer
  • (监听套接字的 tcb 还会挂着)半连接队列 和 全连接队列

这里使用bitmap算法来分配fd,1表示已占用,0表示未用

然后alloc分配tcb

bind() :把本地 IP / 本地端口 填进这个 TCB

bind()可以:通过 fd 找到 TCB 然后把本地 IP 和本地 port 填进去
注意:只是填了信息,并没有建立连接,没开始握手。

listen():

把一个socket变成监听socket

listen()调用之前,一个socket还不能接收客户端连接。

listen()这个函数

1. 根据 fd 找到内核里的 socket / TCB
2. 把 TCP 状态改成 LISTEN
3. 初始化连接请求相关的队列(syn,accept)
4. 告诉内核:以后发到这个 IP:port 的 SYN,可以交给这个监听 socket 处理

listen的第二个参数backlog:

现在是:backlog 和内核连接队列长度有关,主要影响已经完成三次握手、等待 accept() 取走的连接队列容量。

然后转到下面先看一下全连接队列和半连接队列~

listen() 和两个队列的关系:就是让这个 socket 具备维护连接队列的能力。

客户端 connect() 和服务端 listen() 的关系:
当服务端执行到:listen(listenfd, 128);它处于:LISTEN 状态
然后客户端调用:connect(clientfd, server_addr);客户端内核开始发 SYN。
此时服务端监听 socket 收到 SYN,才会开始三次握手相关处理。
所以从内部状态看,服务端 listen():进入 LISTEN,准备收 SYN
客户端 connect():发出 SYN,触发三次握手
这俩是配合关系。

全连接队列和半连接队列:

客户端          服务端
| ---- SYN -------> |
| <--- SYN+ACK ---- |
| ---- ACK -------> |

首先,客户端发来一个SYN,服务端收到 SYN 后,会创建一个还没完全建立好的连接状态。这个连接还没有完成三次握手。所以它进入:半连接队列

当第三次握手完成:
客户端 ---- ACK ----> 服务端
服务端确认这个连接已经建立好了。
然后这个连接会从半连接队列转移到:
全连接队列

Connect():

connect() 会通过 fd 找到客户端自己的 socket / TCB,然后补全本地地址、对端地址,并触发 TCP 三次握手。
TCP 连接必须有本地 IP 和本地端口。
一个 TCP 连接至少要靠四元组区分:
本地 IP
本地端口
对端 IP
对端端口

所以客户端调用 connect() 时,如果你前面没有手动 bind(),内核会自动帮你做两件事:

1. 选择一个合适的本地 IP
2. 分配一个临时端口,也叫 ephemeral port

connect() 是客户端主动建立 TCP 连接的 API。它会通过 fd 找到客户端 TCB,补全本地 IP、本地临时端口、对端 IP、对端端口,然后由内核 TCP 协议栈发起三次握手。

阻塞 connect:等连接完成才返回
非阻塞 connect:可能返回 EINPROGRESS,表示连接正在进行

connect() 会根据 fd 找到内核中的 socket / TCB,
把对端 IP 和端口写入连接信息中。
如果客户端没有提前 bind,本地 IP 和临时端口会由内核自动选择。
随后 TCP 协议栈会发送 SYN 报文,进入三次握手流程。
对于阻塞 socket,connect 通常在三次握手成功或失败后返回;
对于非阻塞 socket,connect 可能返回 EINPROGRESS,表示连接正在进行。

TCP三次握手:

三次握手之前,双方状态是什么?

服务端已经执行:

socket();
bind();
listen();

服务端此时还没有 clientfd。因为 clientfd 是 accept() 之后才给应用层的。

客户端刚执行:

socket();

第一次握手:客户端发送 SYN

客户端调用:connect(clientfd, serveraddr);

内核会先补全客户端 TCB 然后客户端发送第一个报文:SYN

服务端监听 socket 收到客户端 SYN 后,内核会做几件事:

1. 检查这个 SYN 是不是发到自己监听的 IP:port
2. 为这个连接创建一个半连接状态
3. 记录客户端的四元组信息
4. 放入半连接队列
5. 回复 SYN + ACK

第二次握手:服务端发送 SYN + ACK

服务端收到客户端 SYN 后,会回复:

SYN + ACK

意思是:

我同意和你建立连接。
我确认收到了你的 SYN。
同时我也告诉你我的初始序号。

第三次握手:客户端发送 ACK

客户端收到服务端的 SYN + ACK 后,会检查:

服务端确认了我的 SYN
服务端也发来了自己的 SYN

于是客户端回复第三个报文:ACK

然后这时服务端会把这个连接从:半连接队列移动到全连接队列,可以等待accept()取走了

这时候应用层调用:

accept(listenfd, ...);

就可以从全连接队列里拿到这个连接,返回一个新的 fd:

int clientfd = accept(listenfd, ...);

Accept():

  1. 根据 listenfd 找到监听 socket
  2.  查看监听 socket 的全连接队列
  3.  如果全连接队列不为空,就取出一个已建立连接
  4. 为这个已建立连接分配一个新的 fd
  5.  把新的 fd 返回给应用层

采用水平触发,就是全连接队列里有事件就触发

采用边沿触发,需要while循环拿出全连接里面的事件

Send():

把用户态 buffer 里的数据,拷贝到内核 socket 发送缓冲区。
之后 TCP 协议栈会负责分段、加 TCP/IP 头、发送、ACK 确认和必要的重传。
对端应用什么时候 recv 到数据,不由本次 send 直接保证。

Send()成功返回,代表:
有x个字节从用户态 buffer 拷贝进了内核发送缓冲区。
不代表:
对端 recv 已经读到了x字节

Recv():
从内核 socket 接收缓冲区,把数据拷贝到用户态 buffer。
recv() 成功返回代表从内核接收缓冲区拷贝了x个字节到用户态 buf。

TCP 是字节流,没有消息边界,会出现粘包/拆包

TCP 可靠传输机制:

1.慢启动
2. 拥塞控制
3. 滑动窗口
4. 延迟确认
5. 超时重传

1.慢启动:TCP 刚开始传输时,不会一上来就疯狂发很多数据。

它会先少量发送,观察网络情况,再逐步增加发送量。

2.拥塞控制:

如果网络出现拥堵,TCP 会降低发送速度,避免把网络压垮。

这和你应用层 send() 调了几次没有直接对应关系。

你 send() 很快把数据塞进内核发送缓冲区,但内核真正往外发,会受拥塞控制影响。

3.滑动窗口:

滑动窗口控制的是:

发送方最多可以连续发送多少还没有被确认的数据

接收方会告诉发送方:

我的接收缓冲区还剩多少空间

如果接收方应用层一直不 recv(),接收缓冲区满了,窗口就会变小,发送方就不能继续猛发。

所以 recv() 不只是“拿数据”,它还间接影响对端还能不能继续发送。

4. 延迟确认:

接收方收到数据后,不一定马上单独发 ACK。

它可能稍微等一下,看有没有数据要一起带回去,或者等多个数据段一起确认。

这样可以减少网络包数量。

5. 超时重传:

发送方发出去的数据,如果迟迟收不到 ACK,就认为可能丢了。

于是 TCP 会重传。

这个也不是应用层代码做的,而是内核协议栈做的。

关闭连接/四次挥手:

1.fin(client → server):主动方调 close(),发出 fin 包,表示"我没有数据要发了"。注意:fin 只关闭"我发数据"这个方向,我还能继续收对方的数据。主动方状态:established → FIN_WAIT_1。

2. ack(server → client):被动方收到 fin,回一个 ack,表示"知道了,你的 fin 我收到了"。

  • 主动方收到这个 ack:FIN_WAIT_1 → FIN_WAIT_2。
  • 被动方此时:established → CLOSE_WAIT。就在这一刻,被动方的 recv() 返回 0(它知道对方不再发数据了)。

3. fin(server → client):被动方把自己手头的事处理完(可能还有数据要发完),然后自己也调 close(),发出自己的 fin,表示"我这边也没数据要发了"。被动方状态:CLOSE_WAIT → LAST_ACK。

4.ack(client → server):主动方收到被动方的 fin,回最后一个 ack。

  • 主动方状态:FIN_WAIT_2 → TIME_WAIT。
  • 被动方收到这个 ack:LAST_ACK → CLOSED(被动方先彻底关闭)。
  • 主动方在 TIME_WAIT 等待一段时间(2MSL)后,才 → CLOSED。

被动方出现大量close_wait?

没有及时调用 close

CLOSE_WAIT 是要靠应用程序主动调 close() 才能离开的状态。如果你的代码里 recv() 返回 0(对方已关)却忘了调 close()(或者代码逻辑卡住、fd 泄漏),这条连接就会永远卡在 CLOSE_WAIT

TIME_WAIT(主动方):

含义:主动方发完最后一个 ack 后进入 TIME_WAIT,等待 2MSL才真正 CLOSED。

为什么要等 2MSL?

两个原因:

  1. 保证最后那个 ack 能到达对方:万一这个 ack 丢了,被动方会重发 fin,主动方在 TIME_WAIT 期间还能再回一次 ack。如果主动方直接关了,被动方收不到 ack 会一直卡在 LAST_ACK。
  2. 让本次连接的"残留旧包"在网络中彻底消失:等 2MSL 确保旧连接的延迟报文全部过期,避免它们干扰用后相同五元组建立的新连接。

·  危害:大量 TIME_WAIT 通常出现在主动关闭方(常见于短连接的客户端,或主动关连接的服务端),会占用本地端口资源。这也是为什么"谁主动关谁背 TIME_WAIT"是个设计考量。

CLOSING(同时关闭):

如果双方几乎同时调 close(),两边几乎同时发 fin,于是出现"我发了 fin,还没等到你的 ack,就先收到了你的 fin"的情况。这时状态会进入 CLOSING(而不是正常的 FIN_WAIT_2)。

以上是POSIX API与网络协议栈的内容分享

零声社区资源链接:https://github.com/0voice

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐