news 2026/6/27 11:00:48

【C/C++】从 POSIX Socket 到 TCP 生命周期:一文理解网络 IO 的核心原理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【C/C++】从 POSIX Socket 到 TCP 生命周期:一文理解网络 IO 的核心原理

【C/C++】从 POSIX Socket 到 TCP 生命周期:一文理解网络 IO 的核心原理

一、先建立一张总图:socket API 调用链

客户端与服务器的 API 看起来是两条不同的路径,但它们最终都围绕同一件事:让用户态代码拿到一个文件描述符fd,并通过这个fd操作内核里的 TCP 连接。

典型客户端调用顺序:

socket() -> bind() 可选 -> connect() -> send()/recv() -> close()

典型服务器调用顺序:

socket() -> bind() -> listen() -> accept() -> recv()/send() -> close()

这里有一个很重要的理解:网络编程表面上是在读写fd,本质上是在驱动内核维护 socket、TCP 控制块、收发缓冲区以及 TCP 状态机。

二、socket():创建 fd 与 TCP 控制块

socket()的结果通常是一个整数 fd:

intfd=socket(AF_INET,SOCK_STREAM,0);if(fd<0){perror("socket");return-1;}

对应用层来说,fd 像一个“句柄”;对内核来说,它背后关联着 socket 对象和协议控制块。对于 TCP 连接,控制块里会保存本端地址、对端地址、端口、状态、窗口、序列号、重传定时器等信息。

可以把它粗略理解成:

用户态 fd -> 内核 file -> socket -> TCP 控制块

这也是为什么我们说“fd 对应一个连接”,更准确地说是“fd 通过内核对象间接引用一个连接”。

三、bind():把 IP 和端口写入控制块

服务器必须bind(),因为它需要告诉内核:我要监听哪个本地 IP 和端口。

structsockaddr_inaddr={0};addr.sin_family=AF_INET;addr.sin_port=htons(8080);addr.sin_addr.s_addr=htonl(INADDR_ANY);if(bind(fd,(structsockaddr*)&addr,sizeof(addr))<0){perror("bind");return-1;}

客户端通常不需要显式bind()。如果不手动绑定,内核会自动选择一个本地临时端口。只有在需要固定本地端口、多网卡选择出口 IP、或做特殊网络测试时,客户端才常见显式bind()

四、listen(backlog):进入 LISTEN,并准备队列

listen(fd, backlog)不是“开始 accept”,而是把 socket 变成监听 socket,让 TCP 状态进入LISTEN,并准备处理连接建立过程中的队列。

常见理解里,服务端连接建立会涉及两个队列:

  • SYN Queue:半连接队列,保存已经收到 SYN、回复了 SYN+ACK,但还没有收到第三次 ACK 的连接。
  • Accept Queue:全连接队列,三次握手完成后,连接进入这里,等待应用层accept()取走。

历史上,不同内核版本对backlog的语义有所变化。工程上更实用的记法是:

  • listen(backlog)影响全连接排队能力。
  • SYN 队列还会受到tcp_max_syn_backlog、SYN Cookie 等机制影响。
  • accept()不及时,会让全连接队列堆积,最终导致新连接建立变慢或失败。

可以用下面的命令观察相关配置:

sysctlnet.core.somaxconnsysctlnet.ipv4.tcp_max_syn_backlog ss-lnt

五、TCP 三次握手:确认双方初始序列号

三次握手不只是“连上了”,更关键的是双方同步初始序列号,并确认双方收发能力正常。

简化过程如下:

1. 客户端 connect(),发送 SYN,seq = x,进入 SYN_SENT 2. 服务端收到 SYN,进入 SYN_RCVD,回复 SYN + ACK,seq = y,ack = x + 1 3. 客户端收到后进入 ESTABLISHED,回复 ACK,ack = y + 1 4. 服务端收到第三次 ACK,连接进入 ESTABLISHED,并进入 accept 队列

第三次 ACK 到达服务端时,内核会根据五元组查找对应半连接:

源 IP、源端口、目的 IP、目的端口、协议

找到后,连接从半连接队列迁移到全连接队列。此时应用层调用accept(),才能得到一个新的连接 fd。

一个容易忽略的点:P2P 同时打开

普通 C/S 模型里,服务器先listen(),客户端再connect()。但 TCP 协议本身支持 simultaneous open:双方都没有处于LISTEN,而是同时发起connect(),双方互相发送 SYN,也可能建立连接。这类场景在 P2P、打洞和协议实验里更容易遇到。

六、accept():从全连接队列取连接

accept()做的事情可以粗略理解为:

从 accept queue 取出一个已完成握手的连接 为这个连接分配一个新的 fd 让应用层后续通过这个 fd recv/send

如果监听 fd 设置了边缘触发EPOLLET,必须把监听 fd 设置成非阻塞,并且在一次事件通知里循环accept(),直到返回EAGAIN

while(1){intcfd=accept4(listenfd,NULL,NULL,SOCK_NONBLOCK);if(cfd>=0){// 把 cfd 加入 epoll,后续关注读写事件add_epoll(epfd,cfd,EPOLLIN|EPOLLRDHUP|EPOLLET);continue;}if(errno==EAGAIN||errno==EWOULDBLOCK){// accept queue 已经取空break;}perror("accept4");break;}

边缘触发的核心是“状态从无到有时通知一次”。如果你只accept()一次,队列里剩下的连接可能不会再次触发通知,导致连接被饿住。

七、send/writerecv/read:读写的是内核缓冲区

send()并不等于“数据已经到达对端业务代码”,它通常只是把数据拷贝到本机内核发送缓冲区,后续由 TCP 协议栈负责分段、重传、拥塞控制和确认。

ssize_tn=send(fd,data,len,0);if(n<0){perror("send");}

recv()也不是直接从网卡取数据,而是从内核接收缓冲区读取已经到达、按序交付给应用层的数据。

charbuf[4096];ssize_tn=recv(fd,buf,sizeof(buf),0);if(n>0){// buf[0..n) 是本次读到的数据}elseif(n==0){// 对端关闭连接}else{perror("recv");}

TCP 在传输阶段还会涉及:

  • 滑动窗口:控制发送方最多可以发送多少未确认数据。
  • 慢启动:拥塞窗口从小到大试探网络容量。
  • 拥塞控制:根据丢包、延迟等信号调节发送速率。
  • 延迟确认:接收端可能稍后再 ACK,以减少小包。
  • 超时重传:数据迟迟没有确认时重新发送。

这些机制都说明:应用层的一次send(),不等于网络上的一次完整传输。

八、epoll + Reactor:把连接存储和事件分发解耦

当连接数上来之后,服务器通常不会为每个连接创建一个线程,而是用epoll等 IO 多路复用机制管理大量 fd。

典型 Reactor 思路:

连接表保存 fd -> Connection epoll 负责监听 fd 就绪事件 epoll_wait 返回活跃事件 Reactor 根据事件类型分发给不同 handler

如果你要优化 Reactor 的连接存储,可以考虑按 fd 直接索引一个连接数组。比如预分配1048576个槽位:

#defineMAX_CONN1048576typedefstructconnection{intfd;charrbuf[4096];charwbuf[4096];size_twlen;}connection_t;staticconnection_t*connections[MAX_CONN];connection_t*get_conn(intfd){if(fd<0||fd>=MAX_CONN){returnNULL;}returnconnections[fd];}

这种方式查询快,代价是数组空间固定。如果 fd 上限很大、连接稀疏,也可以改成哈希表或对象池。

九、完整代码片段:epoll 边缘触发 Echo Server

下面是一个精简版 Linux C 示例,重点展示监听 socket、非阻塞、epoll_wait、循环accept/read的结构。

#define_GNU_SOURCE#include<arpa/inet.h>#include<errno.h>#include<fcntl.h>#include<netinet/in.h>#include<stdio.h>#include<stdlib.h>#include<string.h>#include<sys/epoll.h>#include<sys/socket.h>#include<unistd.h>#defineMAX_EVENTS1024staticintset_nonblock(intfd){intflags=fcntl(fd,F_GETFL,0);if(flags<0)return-1;returnfcntl(fd,F_SETFL,flags|O_NONBLOCK);}staticvoidadd_epoll(intepfd,intfd,uint32_tevents){structepoll_eventev;memset(&ev,0,sizeof(ev));ev.events=events;ev.data.fd=fd;if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev)<0){perror("epoll_ctl add");close(fd);}}intmain(void){intlistenfd=socket(AF_INET,SOCK_STREAM,0);if(listenfd<0){perror("socket");return1;}inton=1;setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&on,sizeof(on));structsockaddr_inaddr;memset(&addr,0,sizeof(addr));addr.sin_family=AF_INET;addr.sin_port=htons(8080);addr.sin_addr.s_addr=htonl(INADDR_ANY);if(bind(listenfd,(structsockaddr*)&addr,sizeof(addr))<0){perror("bind");return1;}if(listen(listenfd,SOMAXCONN)<0){perror("listen");return1;}if(set_nonblock(listenfd)<0){perror("set_nonblock");return1;}intepfd=epoll_create1(0);if(epfd<0){perror("epoll_create1");return1;}add_epoll(epfd,listenfd,EPOLLIN|EPOLLET);structepoll_eventevents[MAX_EVENTS];charbuf[4096];while(1){intn=epoll_wait(epfd,events,MAX_EVENTS,-1);if(n<0){if(errno==EINTR)continue;perror("epoll_wait");break;}for(inti=0;i<n;++i){intfd=events[i].data.fd;uint32_tev=events[i].events;if(fd==listenfd){while(1){intcfd=accept4(listenfd,NULL,NULL,SOCK_NONBLOCK);if(cfd>=0){add_epoll(epfd,cfd,EPOLLIN|EPOLLRDHUP|EPOLLET);continue;}if(errno==EAGAIN||errno==EWOULDBLOCK)break;perror("accept4");break;}continue;}if(ev&(EPOLLERR|EPOLLHUP|EPOLLRDHUP)){close(fd);continue;}if(ev&EPOLLIN){while(1){ssize_tlen=read(fd,buf,sizeof(buf));if(len>0){// Echo:读到什么就写回什么。真实业务要处理半包、粘包和写缓冲。ssize_toff=0;while(off<len){ssize_twn=write(fd,buf+off,(size_t)(len-off));if(wn>0){off+=wn;}elseif(wn<0&&(errno==EAGAIN||errno==EWOULDBLOCK)){break;}else{close(fd);break;}}continue;}if(len==0){close(fd);break;}if(errno==EAGAIN||errno==EWOULDBLOCK){break;}perror("read");close(fd);break;}}}}close(epfd);close(listenfd);return0;}

编译运行:

gcc-O2-Wall-Wextraepoll_echo.c-oepoll_echo ./epoll_echo

客户端可以用nc测试:

nc127.0.0.18080

十、TCP 四次挥手:为什么主动关闭方会 TIME_WAIT

连接关闭时,close()会回收用户态 fd,并驱动 TCP 发送 FIN。常见过程如下:

简化状态迁移:

A 主动 close:FIN_WAIT_1 -> FIN_WAIT_2 -> TIME_WAIT -> CLOSED B 被动关闭:CLOSE_WAIT -> LAST_ACK -> CLOSED

为什么经常是四次?因为 TCP 是全双工协议。A 说“我不发了”不代表 B 也立刻不发。B 可以先 ACK A 的 FIN,等业务层也关闭后再发送自己的 FIN。

TIME_WAIT的意义主要有两个:

  • 确保最后一个 ACK 有机会被对端收到;如果丢了,对端会重传 FIN。
  • 等待网络中旧报文自然消失,避免影响后续复用同一四元组的新连接。

还有一种特殊情况:双方同时close(),都在FIN_WAIT_1时先收到对方 FIN,就会进入CLOSING,最终仍会走向TIME_WAITCLOSED

至于shutdown(),它用于半关闭连接,例如只关闭写方向但继续读。普通业务如果不需要半关闭,直接close()更简单;如果协议需要“我发完了,但还要等你响应”,再考虑shutdown(fd, SHUT_WR)

十一、客户端最小代码片段

最后给一个最小 TCP 客户端,用来和前面的服务器配合测试。

#include<arpa/inet.h>#include<stdio.h>#include<string.h>#include<sys/socket.h>#include<unistd.h>intmain(void){intfd=socket(AF_INET,SOCK_STREAM,0);if(fd<0){perror("socket");return1;}structsockaddr_inaddr;memset(&addr,0,sizeof(addr));addr.sin_family=AF_INET;addr.sin_port=htons(8080);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,(structsockaddr*)&addr,sizeof(addr))<0){perror("connect");return1;}constchar*msg="hello tcp\n";send(fd,msg,strlen(msg),0);charbuf[1024];ssize_tn=recv(fd,buf,sizeof(buf)-1,0);if(n>0){buf[n]='\0';printf("recv: %s",buf);}close(fd);return0;}

总结

网络 IO 的学习不能只停留在 API 名字上。更好的方式是把 API、内核对象和 TCP 状态机对应起来:

  • socket()创建 fd,并关联内核 socket/TCP 控制块。
  • bind()设置本地 IP 和端口。
  • listen()进入监听状态,并准备连接队列。
  • connect()触发三次握手。
  • accept()从全连接队列取出连接,并返回新的 fd。
  • send/recv操作的是内核缓冲区,不等于数据立刻到达对端应用。
  • epoll负责事件通知,Reactor 负责事件分发。
  • close()触发连接关闭,主动关闭方通常会进入TIME_WAIT

当这些概念连成一条线,Linux 网络编程里的很多“为什么”就会变得清楚:为什么 ET 模式要非阻塞?为什么 accept 要循环?为什么 send 可能只写入部分数据?为什么服务端会出现大量CLOSE_WAITTIME_WAIT?这些问题的答案,基本都藏在 fd、缓冲区、队列和 TCP 状态机之间。

学习链接: https://github.com/0voice

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/27 10:53:06

论文降重降AI工具怎么选?主流方案实测与避坑指南

痛点&#xff1a;AI辅助写作后&#xff0c;AIGC检测成了新难题 越来越多的同学用大模型辅助写论文&#xff0c;初稿效率翻倍&#xff0c;但一提交学校系统&#xff0c;AIGC检测结果飘红。明明是自己构思的框架&#xff0c;只不过让AI帮忙润色或扩写&#xff0c;却被判定为“疑…

作者头像 李华
网站建设 2026/6/27 10:52:17

如何高效配置键盘映射:Windows用户的终极定制指南

如何高效配置键盘映射&#xff1a;Windows用户的终极定制指南 【免费下载链接】sharpkeys SharpKeys is a utility that manages a Registry key that allows Windows to remap one key to any other key. 项目地址: https://gitcode.com/gh_mirrors/sh/sharpkeys 还在为…

作者头像 李华
网站建设 2026/6/27 10:40:37

校平机与激光切割机联线,到底能解决什么问题?

在现代金属加工车间&#xff0c;玛哈特校平机与激光切割机的联线作业正在成为越来越普遍的生产模式。表面上看&#xff0c;把两台设备"连"在一起只是个布局问题&#xff0c;但实际上&#xff0c;这种集成方式深刻改变了金属板材的加工逻辑——从"先备料再切割&q…

作者头像 李华
网站建设 2026/6/27 10:40:25

网盘直链下载助手:一键获取9大网盘真实下载链接的终极指南

网盘直链下载助手&#xff1a;一键获取9大网盘真实下载链接的终极指南 【免费下载链接】Online-disk-direct-link-download-assistant 一个基于 JavaScript 的网盘文件下载地址获取工具。基于【网盘直链下载助手】修改 &#xff0c;支持 百度网盘 / 阿里云盘 / 中国移动云盘 / …

作者头像 李华