【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()的结果通常是一个整数 fdintfdsocket(AF_INET,SOCK_STREAM,0);if(fd0){perror(socket);return-1;}对应用层来说fd 像一个“句柄”对内核来说它背后关联着 socket 对象和协议控制块。对于 TCP 连接控制块里会保存本端地址、对端地址、端口、状态、窗口、序列号、重传定时器等信息。可以把它粗略理解成用户态 fd - 内核 file - socket - TCP 控制块这也是为什么我们说“fd 对应一个连接”更准确地说是“fd 通过内核对象间接引用一个连接”。三、bind()把 IP 和端口写入控制块服务器必须bind()因为它需要告诉内核我要监听哪个本地 IP 和端口。structsockaddr_inaddr{0};addr.sin_familyAF_INET;addr.sin_porthtons(8080);addr.sin_addr.s_addrhtonl(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、回复了 SYNACK但还没有收到第三次 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()发送 SYNseq x进入 SYN_SENT 2. 服务端收到 SYN进入 SYN_RCVD回复 SYN ACKseq yack x 1 3. 客户端收到后进入 ESTABLISHED回复 ACKack 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){intcfdaccept4(listenfd,NULL,NULL,SOCK_NONBLOCK);if(cfd0){// 把 cfd 加入 epoll后续关注读写事件add_epoll(epfd,cfd,EPOLLIN|EPOLLRDHUP|EPOLLET);continue;}if(errnoEAGAIN||errnoEWOULDBLOCK){// accept queue 已经取空break;}perror(accept4);break;}边缘触发的核心是“状态从无到有时通知一次”。如果你只accept()一次队列里剩下的连接可能不会再次触发通知导致连接被饿住。七、send/write与recv/read读写的是内核缓冲区send()并不等于“数据已经到达对端业务代码”它通常只是把数据拷贝到本机内核发送缓冲区后续由 TCP 协议栈负责分段、重传、拥塞控制和确认。ssize_tnsend(fd,data,len,0);if(n0){perror(send);}recv()也不是直接从网卡取数据而是从内核接收缓冲区读取已经到达、按序交付给应用层的数据。charbuf[4096];ssize_tnrecv(fd,buf,sizeof(buf),0);if(n0){// buf[0..n) 是本次读到的数据}elseif(n0){// 对端关闭连接}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(fd0||fdMAX_CONN){returnNULL;}returnconnections[fd];}这种方式查询快代价是数组空间固定。如果 fd 上限很大、连接稀疏也可以改成哈希表或对象池。九、完整代码片段epoll 边缘触发 Echo Server下面是一个精简版 Linux C 示例重点展示监听 socket、非阻塞、epoll_wait、循环accept/read的结构。#define_GNU_SOURCE#includearpa/inet.h#includeerrno.h#includefcntl.h#includenetinet/in.h#includestdio.h#includestdlib.h#includestring.h#includesys/epoll.h#includesys/socket.h#includeunistd.h#defineMAX_EVENTS1024staticintset_nonblock(intfd){intflagsfcntl(fd,F_GETFL,0);if(flags0)return-1;returnfcntl(fd,F_SETFL,flags|O_NONBLOCK);}staticvoidadd_epoll(intepfd,intfd,uint32_tevents){structepoll_eventev;memset(ev,0,sizeof(ev));ev.eventsevents;ev.data.fdfd;if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,ev)0){perror(epoll_ctl add);close(fd);}}intmain(void){intlistenfdsocket(AF_INET,SOCK_STREAM,0);if(listenfd0){perror(socket);return1;}inton1;setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,on,sizeof(on));structsockaddr_inaddr;memset(addr,0,sizeof(addr));addr.sin_familyAF_INET;addr.sin_porthtons(8080);addr.sin_addr.s_addrhtonl(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;}intepfdepoll_create1(0);if(epfd0){perror(epoll_create1);return1;}add_epoll(epfd,listenfd,EPOLLIN|EPOLLET);structepoll_eventevents[MAX_EVENTS];charbuf[4096];while(1){intnepoll_wait(epfd,events,MAX_EVENTS,-1);if(n0){if(errnoEINTR)continue;perror(epoll_wait);break;}for(inti0;in;i){intfdevents[i].data.fd;uint32_tevevents[i].events;if(fdlistenfd){while(1){intcfdaccept4(listenfd,NULL,NULL,SOCK_NONBLOCK);if(cfd0){add_epoll(epfd,cfd,EPOLLIN|EPOLLRDHUP|EPOLLET);continue;}if(errnoEAGAIN||errnoEWOULDBLOCK)break;perror(accept4);break;}continue;}if(ev(EPOLLERR|EPOLLHUP|EPOLLRDHUP)){close(fd);continue;}if(evEPOLLIN){while(1){ssize_tlenread(fd,buf,sizeof(buf));if(len0){// Echo读到什么就写回什么。真实业务要处理半包、粘包和写缓冲。ssize_toff0;while(offlen){ssize_twnwrite(fd,bufoff,(size_t)(len-off));if(wn0){offwn;}elseif(wn0(errnoEAGAIN||errnoEWOULDBLOCK)){break;}else{close(fd);break;}}continue;}if(len0){close(fd);break;}if(errnoEAGAIN||errnoEWOULDBLOCK){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 主动 closeFIN_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 客户端用来和前面的服务器配合测试。#includearpa/inet.h#includestdio.h#includestring.h#includesys/socket.h#includeunistd.hintmain(void){intfdsocket(AF_INET,SOCK_STREAM,0);if(fd0){perror(socket);return1;}structsockaddr_inaddr;memset(addr,0,sizeof(addr));addr.sin_familyAF_INET;addr.sin_porthtons(8080);inet_pton(AF_INET,127.0.0.1,addr.sin_addr);if(connect(fd,(structsockaddr*)addr,sizeof(addr))0){perror(connect);return1;}constchar*msghello tcp\n;send(fd,msg,strlen(msg),0);charbuf[1024];ssize_tnrecv(fd,buf,sizeof(buf)-1,0);if(n0){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