POSIX API和网络协议栈
前言:
我们写的网络代码,不是直接操作网卡,也不是直接操作 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():
- 根据 listenfd 找到监听 socket
- 查看监听 socket 的全连接队列
- 如果全连接队列不为空,就取出一个已建立连接
- 为这个已建立连接分配一个新的 fd
- 把新的 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?
两个原因:
- 保证最后那个 ack 能到达对方:万一这个 ack 丢了,被动方会重发 fin,主动方在 TIME_WAIT 期间还能再回一次 ack。如果主动方直接关了,被动方收不到 ack 会一直卡在 LAST_ACK。
- 让本次连接的"残留旧包"在网络中彻底消失:等 2MSL 确保旧连接的延迟报文全部过期,避免它们干扰用后相同五元组建立的新连接。
· 危害:大量 TIME_WAIT 通常出现在主动关闭方(常见于短连接的客户端,或主动关连接的服务端),会占用本地端口资源。这也是为什么"谁主动关谁背 TIME_WAIT"是个设计考量。
CLOSING(同时关闭):
如果双方几乎同时调 close(),两边几乎同时发 fin,于是出现"我发了 fin,还没等到你的 ack,就先收到了你的 fin"的情况。这时状态会进入 CLOSING(而不是正常的 FIN_WAIT_2)。
以上是POSIX API与网络协议栈的内容分享
零声社区资源链接:https://github.com/0voice
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)