【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/write与recv/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_WAIT或CLOSED。
至于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_WAIT或TIME_WAIT?这些问题的答案,基本都藏在 fd、缓冲区、队列和 TCP 状态机之间。
学习链接: https://github.com/0voice