尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Linux网络编程深度指南:从Socket到epoll的高并发实践

Linux网络编程深度指南:从Socket到epoll的高并发实践 1. 先说清楚为什么我还要写一份Linux网络编程指南做了这么多年Linux后端和嵌入式开发我太清楚网络编程这摊水有多深了。市面上的资料要么是教科书式的理论推演要么是复制粘贴的demo堆砌真正能从客户端connect上服务器讲到高并发下epoll为什么还是被Linux内核调度器坑了的文章少得可怜。这篇东西就是把我这些年踩过的坑、验证过的结论、以及面试时最喜欢问的底层细节一次性整理出来希望能帮那些刚入门的兄弟少走弯路也让有一定基础的朋友对着查漏补缺。先说清楚这篇文章覆盖的内容范围从Socket编程模型入手讲到TCP协议栈行为对应用层的真实约束然后花大篇幅拆IO多路复用的演进逻辑——从select到poll再到epoll每层都解释为什么需要它、它解决了什么、它又引入了什么新问题最后聊几个真正影响线上业务的高级特性比如TCP_NODELAY、SO_REUSEADDR、非阻塞IO与LT/ET模式配合时的注意点。整篇文章以Linux C/Python混合视角来写重点放在原理和实战取舍上而不是API手册复读。如果你是刚刚接触网络编程或者已经被select、epoll这些概念绕晕了又或者写了好几年业务代码但说不清为什么服务端必须listen、客户端不需要这类问题这篇文章都适合你。我尽量做到每个结论都能追溯到内核行为或协议规范而不是大家都这么写所以这么写。2. Socket编程底层逻辑从一次connect看TCP状态机的真实运转2.1 服务器端Socket全流程每一个系统调用背后发生了什么先来一个最常见的服务端初始化代码我用C语言写因为最贴近内核接口。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(1); } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; 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(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(1); } if (listen(listen_fd, 128) 0) { perror(listen); exit(1); } while (1) { struct sockaddr_in client_addr; socklen_t len sizeof(client_addr); int conn_fd accept(listen_fd, (struct sockaddr *)client_addr, len); if (conn_fd 0) { perror(accept); continue; } printf(accept connection from %s:%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); close(conn_fd); } return 0; }这段代码里每一步都有讲究。socket()创建的不是连接而是一个文件描述符它对应内核中的一个socket对象这个对象内部维护着发送缓冲区、接收缓冲区、等待队列等一系列数据结构。bind()把socket对象和一个具体的IP:端口绑定注意INADDR_ANY表示监听所有网卡地址如果你只希望某个内网网卡提供服务这里就要改成具体IP。listen()做了两件关键的事情第一把socket状态从CLOSED切换到LISTEN第二创建全连接队列accept队列和半连接队列SYN队列参数128表示全连接队列的最大长度。这个参数在很多老代码里写的是5或者10但在高并发场景下如果accept处理速度跟不上连接建立速度这个队列一旦满了内核就会丢弃新的SYN包客户端表现就是connect超时。这里有个很多新手完全没意识到的问题accept()返回的不是你调用listen()的那个fd而是一个全新的fd。listen_fd始终留在监听状态每个新连接都有自己独立的conn_fd有自己的读写缓冲区互不干扰。所以服务端的fd数量会随着连接数增长这就是为什么高并发服务必须考虑文件描述符上限。2.2 客户端视角connect的阻塞与超时阈值客户端代码相对简单int sock_fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(8080); inet_pton(AF_INET, 127.0.0.1, server_addr.sin_addr); int ret connect(sock_fd, (struct sockaddr *)server_addr, sizeof(server_addr)); if (ret 0) { perror(connect); close(sock_fd); return -1; }connect()内部发生的是TCP三次握手客户端发送SYN服务端回复SYNACK客户端再回ACK。三次握手完成前客户端这个fd的状态是SYN_SENT服务端对应连接状态是SYN_RECV。一个非常实际的问题connect超时时间到底由谁决定Linux内核里TCP的SYN重传次数由/proc/sys/net/ipv4/tcp_syn_retries控制默认是6次加上指数退避算法总超时时间大约127秒。这就是为什么你connect一个不存在的IP时程序会卡在那里一分多钟才报错。如果你写的是客户端工具建议给connect设置超时方法是将fd设为非阻塞然后通过select/poll/epoll等待可写事件再调用getsockopt(SO_ERROR)获取真实的连接结果。// 非阻塞connect超时控制核心逻辑 int ret connect(fd, ...); if (ret 0 errno EINPROGRESS) { struct timeval tv {3, 0}; // 3秒超时 fd_set wset; FD_ZERO(wset); FD_SET(fd, wset); if (select(fd 1, NULL, wset, NULL, tv) 0) { int err 0; socklen_t len sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, err, len); if (err 0) { // 连接成功 } } }这里的核心原理是非阻塞connect发起后握手过程在内核协议栈中继续用户态通过等待可写事件来感知握手完成。但可写事件也可能是连接失败比如对端RST所以必须用SO_ERROR确认结果这是高手和新手之间一个很明显的分界线。2.3 TIME_WAIT、CLOSE_WAIT让无数人栽跟头的两个状态先看一段线上排障的经典输出$ ss -ant | head -20 State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* ESTAB 0 0 192.168.1.10:8080 192.168.1.20:54321 TIME_WAIT 0 0 192.168.1.10:8080 192.168.1.20:54322 CLOSE_WAIT 14 0 192.168.1.10:8080 192.168.1.20:54323TIME_WAIT是被动关闭方通常是先发送FIN的一方的状态需要等待2MSL默认60秒后才彻底消失。存在意义有两个一是确保最后的ACK能到达对端如果ACK丢失对端会重发FIN此时如果连接已经销毁对端会收到RST而不是正常关闭二是防止旧连接的延迟数据包干扰新连接因为不会有相同四元组的新连接在2MSL内被建立。但是CLOSE_WAIT就是另一个故事了。CLOSE_WAIT意味着对端已经发了FIN而本端程序还没调用close()关闭这个fd。大部分CLOSE_WAIT堆积的根因是业务代码忘了关闭fd比如某个分支里read返回0或-1之后没有close或者用了多线程但每个线程持有同一个fd的副本只关闭了其中一个。我记得有一次排查一个Java网关大量CLOSE_WAIT最后发现是HTTP keep-alive连接被对端关闭后框架内部的连接池没有及时感知重试逻辑又把旧连接拿出去用。这种问题靠调内核参数是治标不治本必须从业务代码层面保证所有路径最终都会close。3. 数据收发与缓冲区read/write的语义远比你想的复杂3.1 流式协议没有消息边界这是TCP最反直觉的特性TCP是流协议不是消息协议。这句话我强调多少遍都不过分。很多新手写了个send()就以为对端会一次性recv()到同样长度的数据实测经常出现客户端连续发送三次hello共15字节服务端一次recv()可能返回10字节第二次返回5字节。反过来客户端一次发送100KB服务端可能分10次recv()才能读完。原因在于数据从应用层到内核缓冲区再到网卡、传输链路的每一层都会做了拆分和合并。TCP有MSS最大分段大小默认1460字节约束超过就要分段Nagle算法会把小包合并成大包再发接收方的内核缓冲区也可能因为调度顺序把连续的数据一次性交给应用层。所以TCP编程必须自己处理粘包和半包。常用方案有三种固定长度消息每个消息固定N字节不足补零适合指令类场景。长度前缀法消息头固定4字节uint32_t网络字节序表示消息体长度然后跟上消息体。这是最通用的方案。特殊分隔符消息以\n或\r\n结尾适合文本协议但要注意消息体里不能出现该分隔符。我实测过一个典型场景用Python的socket发送结构化的二进制协议数据如果不用长度前缀做分包对端C语言程序解析会崩得一塌糊涂。后来统一改为4字节长度头payload格式再也没出过问题。这个约定最好在做架构设计时就定下来而不是等到联调出问题再补。3.2 缓冲区与阻塞语义为什么read返回0就表示对端关闭每个TCP socket在内核里都有两个缓冲区发送缓冲区send buffer和接收缓冲区recv buffer。write()/send()调用实际上是把数据从用户空间拷贝到内核发送缓冲区然后内核协议栈异步地把数据发出去。read()/recv()则是把内核接收缓冲区里的数据拷贝到用户空间。一个关键细节阻塞模式下read()返回0的唯一含义是对端关闭了连接收到了FIN不要把它和没有数据混为一谈。如果对端只是暂时没发数据read()会一直阻塞在那里直到有数据或者连接关闭。非阻塞模式下read()返回-1且errnoEAGAIN或EWOULDBLOCK才表示当前没有数据可读此时需要等待可读事件再次触发。这个ECONNRESET的errno也常见意思是对端发送RST强制关闭连接通常是因为对端进程崩溃、或者往一个已经关闭的连接上写数据。还有一点很多人忽略write()成功并不等于数据到达对端。它只表示数据拷贝到了本端内核缓冲区之后可能丢失比如中途网络断开对端没收到。TCP只能保证如果连接存在且最终正常关闭数据不重不漏无法保证实时到达。对于强一致性场景必须业务层做ACK确认。3.3 SO_SNDBUF和SO_RCVBUF修改缓冲区会影响什么内核默认的收发缓冲区大小可以通过/proc/sys/net/ipv4/tcp_rmem和tcp_wmem查看默认一般在几十KB到几百KB范围内动态调整。但业务上经常需要主动控制int send_buf_size 1024 * 1024; // 1MB setsockopt(sock_fd, SOL_SOCKET, SO_SNDBUF, send_buf_size, sizeof(send_buf_size)); int recv_buf_size 1024 * 1024; setsockopt(sock_fd, SOL_SOCKET, SO_RCVBUF, recv_buf_size, sizeof(recv_buf_size));注意内核实际设置的缓冲区大小会是设定值的两倍因为内核要为sk_buff等管理结构预留额外空间。你设1MB实际可用可能是2MB。调大接收缓冲区可以减少因缓冲区满导致的内核丢弃数据包提升吞吐调大发送缓冲区则能提高write()的吞吐峰值让应用层写入更少被阻塞。不过缓冲区不是越大越好。太大意味着单连接占用的内存多高并发下内存压力剧增而且缓冲区太大在延迟敏感场景下会让报文积压反而增加了端到端延迟。一般的建议是先跑业务压测观察ss -anp里每个socket的Send-Q和Recv-Q是否经常处于接近上限的值如果经常满才需要调整。4. IO多路复用从select到epoll不只是API换了4.1 select的局限为什么它撑不起高并发select是最早的多路复用接口它的工作方式是每次调用都传入三个fd集合可读、可写、异常内核遍历这些fd检查是否有事件发生然后把就绪的fd集合返回给用户态用户态还要再次遍历去处理。fd_set read_fds; FD_ZERO(read_fds); FD_SET(listen_fd, read_fds); FD_SET(conn_fd, read_fds); struct timeval timeout {5, 0}; int ret select(max_fd 1, read_fds, NULL, NULL, timeout); if (ret 0) { for (int fd 0; fd max_fd; fd) { if (FD_ISSET(fd, read_fds)) { // 处理该fd的可读事件 } } }select的问题至少有四个第一fd集合大小有上限通常1024改内核宏重新编译才能扩大第二每次调用都要把整个fd集合从用户态拷贝到内核态fd多时开销惊人第三内核需要线性扫描所有fd时间复杂度O(n)第四用户态拿到结果后不知道具体是哪些fd就绪又要线性扫描一遍。所以select在几百个并发连接时还能勉强撑住上千就开始明显吃力上万基本不可用。但不少老项目还在用select跑长连接主要是代码改动成本太高属于历史包袱。4.2 poll修掉了上限但没修掉性能poll把fd_set换成了struct pollfd数组摆脱了1024上限改成了动态数组的fd数目但每次调用仍然要把整个数组从用户态拷贝到内核态内核仍然要线性遍历所有fd检查事件用户态仍需遍历数组找到就绪的fd。时间复杂度还是O(n)只是n的上限变大了。struct pollfd fds[1024]; fds[0].fd listen_fd; fds[0].events POLLIN; int ret poll(fds, 1024, 5000); if (ret 0) { for (int i 0; i 1024; i) { if (fds[i].revents POLLIN) { // 处理 } } }poll在几千个连接时比select稍微从容点但没有改变海量fd无差别遍历的本质问题。另一个小坑是POLLIN和POLLOUT的事件处理要特别注意共存的极端情况一个socket同时可读可写时如果代码里只处理了POLLIN忘了处理POLLOUT会导致写事件一直pending在某些内核版本上可能表现为性能抖动甚至活锁。虽然不是普遍问题但我确实见过线上代码靠反复全面poll掩盖了处理逻辑遗漏。4.3 epoll事件驱动到底赢在哪epoll是Linux特有的高效IO多路复用方案核心数据结构是红黑树就绪链表。每次epoll_ctl()注册一个新的fd监听事件时在内核里向红黑树插入一个节点当fd上有事件发生时内核通过回调机制把fd挂载到就绪链表中。epoll_wait()调用时只是把就绪链表里的fd返回给用户态如果没有就绪事件进程睡眠。int epfd epoll_create(1024); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[1024]; while (1) { int n epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listen_fd) { // accept新连接 } else if (events[i].events EPOLLIN) { // 处理读事件 } } }对比select/pollepoll有三个本质区别第一拷贝开销消失fd集合在注册时就已经交给内核不需要每次调用重复拷贝第二扫描开销消失内核只需要维护就绪链表epoll_wait返回的数量最多是就绪事件数而不是总fd数第三时间复杂度注册O(log n)等待O(就绪数)基本只受事件数影响。但epoll有两个典型陷阱。第一个是EPOLLET边缘触发默认是水平触发LT只要缓冲区有数据就会持续通知ET模式下fd从不可读到可读只会通知一次如果这次没读完后续不会再通知必须循环read直到EAGAIN。ET的好处是减少系统调用次数坏处是一旦代码处理不当数据会滞留在缓冲区里永远没机会被处理。我见过生产事故一个ET模式网关程序某次read循环没判断EAGAIN就退出结果该连接上后续数据全部卡死客户端以为服务器hang住了。第二个陷阱是在多线程模型里多个线程同时对同一个epoll fd调用epoll_wait()时如果一个fd事件被唤醒多个线程可能同时被唤醒并处理同一事件需要额外的锁或者busy loop检查来避免重复处理。4.4 epoll 非阻塞IO的正确组合高并发服务几乎都是epoll 非阻塞IO 事件循环的搭配。原因很简单如果socket是阻塞的当epoll_wait告诉你这个fd可读你调用read()可能需要一次或多次才能读完如果数据量很大比如100MB阻塞read会阻塞整个事件循环线程其他fd的事件无法及时处理整个服务就停了。标准做法是accept返回的新conn_fd立刻设置O_NONBLOCK然后在事件循环里用read/write处理每次read循环到EAGAIN为止。这里有个细节accept()本身也要考虑EAGAIN。如果你用LT模式accept前最好再确认一下listen_fd确实可读如果用ET模式accept需要循环调用直到返回EAGAIN否则可能只accept了队列里的一小部分连接剩下的连接虽然全连接队列里排着队但不会再次触发可读事件客户端就会一直等待服务端accept。// ET模式下accept的正确姿势 while (1) { int conn_fd accept(listen_fd, (struct sockaddr *)cli_addr, len); if (conn_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) { break; } else { perror(accept); break; } } set_nonblocking(conn_fd); epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev); }这个循环accept到EAGAIN的细节是ET模式最容易踩的坑之一也是面试官最喜欢追问的细节。5. 高级特性实战把每个选项参数的含义彻底搞清楚5.1 TCP_NODELAYNagle算法和延迟ACK的相爱相杀Nagle算法是TCP层为了减少小包数量设计的如果发送缓冲区里有未被ACK的数据新数据必须拼接到旧数据后面一起发核心逻辑是一次只能有一个未确认的小包。这个算法在处理交互式命令比如Telnet时能合并大量小包减轻网络拥塞。但在现代业务里它经常成为延迟的元凶。问题是Nagle算法和TCP延迟ACK机制会互相配合产生额外的等待Nagle等ACK才发新数据延迟ACK最多等40ms才回ACK为了把ACK和数据一起捎带两个机制叠加就可能出现一个小的写请求多等40ms的情况。虽然实际中两个机制有时会因为半关闭或数据量等条件被打破但延迟问题确实难以彻底避免。对低延迟要求高的场景比如RPC调用、游戏服务端建议直接关闭int flag 1; setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, flag, sizeof(flag));这个选项的作用是禁用Nagle算法让每个write()都立即触发TCP段发送不用等ACK合并。代价是可能导致大量小包增加网络开销。但如果每次write的数据本来就接近MSS关闭Nagle的影响就很小。5.2 SO_REUSEADDR和SO_REUSEPORT别再被bind失败折磨做过服务端的人应该都遇到过bind: Address already in use。最典型场景是服务崩溃后旧连接还处在TIME_WAIT状态立即重启服务时报错。解决方法是int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));SO_REUSEADDR允许新监听socket绑定到处于TIME_WAIT状态的地址端口组合。因为TIME_WAIT的socket四元组和新的监听socket不冲突一个是已建立的连接一个是监听socket但默认情况下内核仍然禁止这种bind开启这个选项后就可以顺利重启。还有一个进阶选项是SO_REUSEPORT它允许多个socket监听同一个IP:端口内核自动做负载均衡。这在多进程/多线程模型中很好用每个worker进程单独创建监听socket都设置SO_REUSEPORT内核根据连接hash决定新连接进入哪个进程。实测环境下SO_REUSEPORT能显著减少多进程抢accept锁的竞争问题。但注意内核版本需要3.9而且要求所有复用端口的socket都设置该选项否则不生效。int reuse_port 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, reuse_port, sizeof(reuse_port));5.3 SO_LINGER控制close()到底等不等数据发完默认情况下close()返回后内核仍然会尝试把发送缓冲区里剩余的数据发送出去只是close()立即返回不等待确认。如果你需要确保数据可靠送达可以设置SO_LINGERstruct linger lg; lg.l_onoff 1; lg.l_linger 0; // RST发送丢弃未发送数据 setsockopt(sock_fd, SOL_SOCKET, SO_LINGER, lg, sizeof(lg));l_linger0的特殊含义是close()时立即发送RST而不是FIN丢弃缓冲区所有未发送数据。这在某些场景下有意为之比如放弃一个不正常连接但如果在业务高峰期误用会导致对端收到RST表现为主机崩溃一样的错误。更常用的设置是l_linger30close()会阻塞直到发送缓冲区数据发完或30秒超时但这个阻塞可能让调用方线程卡住所以用之前要评估链路可靠性。实际项目中除非有必须的可靠性要求否则我不建议轻易动SO_LINGER。默认的四次挥手关闭流程在绝大多数情况下是合理的。唯一常见用例是避免大量TIME_WAIT堆积——用RST关闭连接可以不进入TIME_WAIT但代价是抛弃了可靠关闭语义对端可能读到不完整的数据流。5.4 TCP_KEEPALIVE应用层心跳之外的最后防线网线被拔了、对端机器断电、路由器故障TCP连接不会主动感知可能一直保持ESTABLISHED状态。内核提供的TCP keepalive机制定时探测对端是否存活默认配置是tcp_keepalive_time7200秒2小时无数据传输后开始探测tcp_keepalive_intvl75秒每75秒探测一次tcp_keepalive_probes9次连续9次无响应视为断开2小时对于绝大多数业务来说太长了。可以在应用层设置int keep_alive 1; setsockopt(sock_fd, SOL_SOCKET, SO_KEEPALIVE, keep_alive, sizeof(keep_alive)); int keep_idle 60; // 60秒无数据后开始探测 int keep_intvl 10; // 每10秒探测一次 int keep_cnt 3; // 3次无响应视为断开 setsockopt(sock_fd, IPPROTO_TCP, TCP_KEEPIDLE, keep_idle, sizeof(keep_idle)); setsockopt(sock_fd, IPPROTO_TCP, TCP_KEEPINTVL, keep_intvl, sizeof(keep_intvl)); setsockopt(sock_fd, IPPROTO_TCP, TCP_KEEPCNT, keep_cnt, sizeof(keep_cnt));但keepalive不是万能的它只能确认对端主机网络栈还能响应不能确认对端进程是否活着。比如java进程死锁、goroutine全部阻塞TCP栈可能照常响应ACK。所以关键业务还是必须自己设计应用层心跳机制定期发送心跳包超时未收到就主动断开重连。5.5 非阻塞IO和事件循环里write返回EAGAIN的处理非阻塞socket在发送缓冲区满时write()会返回-1且errnoEAGAIN。新手常常把这种情况当错误处理直接close结果就是数据莫名丢失、连接莫名断开。正确处理是把数据放入用户态待发送队列注册EPOLLOUT事件等待缓冲区有空间后再发送。// 伪代码示例 void on_write_ready(int fd) { while (1) { ssize_t n send(fd, send_buf sent_len, send_len - sent_len, 0); if (n 0) { sent_len n; if (sent_len send_len) { // 数据全部发完注销EPOLLOUT事件 epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev_without_write); break; } } else if (n 0 errno EAGAIN) { // 缓冲区满继续等待EPOLLOUT break; } else if (n 0) { // 真实错误关闭连接 close(fd); break; } } }一个容易忽略的问题EPOLLOUT是水平触发时只要发送缓冲区没满这个事件会不断触发。所以如果没数据要发千万不要注册EPOLLOUT否则就是空转烧CPU。正确做法是有数据待发送时才注册发完立刻注销。这个模型就是高并发网络服务器的基础一份数据从read到处理到write每个阶段都在事件循环中流转任何一步遇到缓冲区满或数据没到齐都要靠状态机挂起等待相应事件再继续。6. 实战案例手写一个支持万级并发连接的echo服务器把前面的概念串起来写一个完整的C语言echo服务器epoll ET 非阻塞IO 简单的协议解析框架。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include errno.h #include sys/epoll.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define MAX_EVENTS 1024 #define BUFFER_SIZE 8192 static void set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8080); addr.sin_addr.s_addr htonl(INADDR_ANY); bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)); listen(listen_fd, 128); set_nonblocking(listen_fd); int epfd epoll_create(1024); struct epoll_event ev, events[MAX_EVENTS]; ev.events EPOLLIN | EPOLLET; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); if (n 0) { perror(epoll_wait); continue; } for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { // accept所有连接直到EAGAIN while (1) { struct sockaddr_in cli_addr; socklen_t len sizeof(cli_addr); int conn_fd accept(listen_fd, (struct sockaddr *)cli_addr, len); if (conn_fd 0) { if (errno ! EAGAIN errno ! EWOULDBLOCK) { perror(accept); } break; } set_nonblocking(conn_fd); ev.events EPOLLIN | EPOLLET; ev.data.fd conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev); } } else { // echo逻辑读多少回写多少 int fd events[i].data.fd; char buf[BUFFER_SIZE]; while (1) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { ssize_t offset 0; while (offset n) { ssize_t w write(fd, buf offset, n - offset); if (w 0) { offset w; } else if (w 0 errno EAGAIN) { // 写缓冲区满这里需要挂起等待EPOLLOUT简化实现直接退出 // 实际生产代码要用待发送队列 break; } else { close(fd); break; } } if (n sizeof(buf)) { // 数据读完了 break; } } else if (n 0) { // 对端关闭 close(fd); break; } else { if (errno EAGAIN) { // ET模式数据读完 break; } close(fd); break; } } } } } return 0; }这个echo服务器基本结构可以直接扩展成各种业务服务器把read到的数据丢进业务逻辑队列处理完后通过EPOLLOUT事件把响应发回去。注意代码里write缓冲区满时的处理简化了生产环境必须实现发送队列和EPOLLOUT注册。这里想表达的是事件循环的心智模型就是等事件、处理事件、注册新事件三件事循环往复理解了这个模型看Redis、Nginx等源码会轻松很多。7. 排查网络问题的工具箱从工具输出反推内核状态7.1 ss和netstat先看连接状态再下手排查线上网络问题我第一件事就是跑ss -ant这个命令比netstat快且信息更多。重点关注LISTEN队列溢出ss -lnt里的Send-Q表示accept队列最大长度Recv-Q表示当前排队的连接数量。如果Recv-Q长期接近Send-Q说明accept处理速度跟不上。TIME_WAIT堆积高并发短连接场景下TIME_WAIT几万个很常见但会占用fd和内存。如果严重到影响新连接可考虑开启tcp_tw_reuse仅对客户端有效或调整tcp_fin_timeout。CLOSE_WAIT堆积重点是找代码里没close的路径不是调参能解决的。$ ss -lnt State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 128 128 0.0.0.0:8080 0.0.0.0:*Recv-Q接近128说明accept队列满了客户端会开始connect超时。7.2 tcpdump抓包三次握手和四次挥手一抓见分晓当连接建立或断开的行为不符合预期时抓包是最直观的手段$ sudo tcpdump -i eth0 tcp port 8080 -nn -A观察SYN、SYNACK、ACK的来回顺序就能判断握手在哪一步断了。比如只看到客户端发SYN服务端没回SYNACK可能是服务端全连接队列满把SYN丢了或者防火墙直接丢弃了SYN包。断开时如果看到RST而不是FIN说明某端主动异常终止往往是代码里设置了SO_LINGER l_linger0或者进程崩溃时内核发了RST。7.3 压测工具选型ab简单场景够用但单线程模型压不了高并发。wrk基于epoll的高性能压测工具适合测吞吐和延迟分布。locustPython编写脚本灵活适合模拟业务复杂场景。自定义压测客户端如果测试的是私有二进制协议直接用Python的socket库写并发脚本注意用gevent或asyncio做协程并发不要用线程线程GIL会成为瓶颈。压测时一定要看延迟分布不要只看平均延迟。TCP编程里P99和P999延迟往往能反映出缓冲区配置、Nagle算法是否关闭等问题。平均延迟100ms、P999延迟2000ms的系统和平均延迟120ms、P999延迟180ms的系统前者的问题明显更严重。8. 我踩过的那些坑几条能帮你省一周时间的经验最后分享几个我实际经历过、并且花了不少时间才搞明白的坑希望对你有直接帮助。第一个教训不要把阻塞IO和非阻塞IO混在一个线程里用。之前接手过一个老项目accept返回的fd没有设置非阻塞但事件循环里又用了epoll导致某个连接的读事件触发后阻塞read把整个线程卡死其他所有连接全部延迟。最后排查了一整天才定位到是某个连接被对端发了个超大包read阻塞等待完整数据。这种问题在测试环境不容易出现因为测试数据量小生产环境一个配置错误的客户端就能触发。第二个教训SO_REUSEADDR不是万能的别指望它能解决所有bind失败问题。如果你遇到的是Address already in use且ss -ant里看不到TIME_WAIT可能是另一个进程还在监听这个端口或者有TCP连接处于LAST_ACK等非TIME_WAIT状态。这时候开启SO_REUSEADDR也救不了需要查进程、清理连接。先把问题定位准确再动手不要盲目叠选项。第三个教训TCP性能调优不是单点调一个参数就能见效的。我曾经为了优化一个文件传输服务的吞吐单独调大了SO_SNDBUF、关了Nagle、调了内核的tcp_rmem结果性能反而下降了。后来用iperf3测了基线发现瓶颈在网卡队列和中断处理调socket参数完全没用。所以调优前务必先做基线测试确认瓶颈在TCP还是业务代码。第四个教训不要在代码里用sleep来等待网络事件。见过不少代码参考阻塞IO在非阻塞模型里循环read sleep(1)这种设计从根上就是错的。事件循环的优势在于等待不消耗CPU一旦引入sleep要么延迟无意义地增大要么CPU空转。如果遇到不知道什么时候有数据正确方法是注册事件并用epoll_wait等待而不是轮询。9. 下一步往哪走从会写到写得好中间还差这些功课到这里Socket、IO多路复用和常见高级特性基本讲完了。但Linux网络编程的深度远不止如此如果你在工作中继续深入下面这些主题值得继续研究Reactor与Proactor模式理解Redis单线程Reactor为什么能扛高并发Netty的EventLoop又是怎么回事。一句话版本Reactor通过IO多路复用驱动事件循环业务处理在回调中执行Proactor把IO读写都交给内核完成事件通知的是IO已完成Linux的io_uring就是Proactor方向。io_uringLinux 5.1引入的异步IO接口直接操作SQE/CQE队列减少系统调用次数在高IOPS场景下优势明显。很多新项目已经开始用liburing封装它了。内存拷贝优化sendfile、splice、零拷贝技术解决的是从磁盘到网卡的数据搬运问题不用经过用户态缓冲区。大文件传输场景下性能提升非常显著。TCP拥塞控制算法cubic是Linux默认但数据中心内部网络用bbr效果更好。需要改内核参数或者模块加载策略。用户态协议栈DPDK、AF_XDP这些技术把网卡数据直接到用户态处理绕过内核协议栈是超高性能网关的方向。但复杂度极高一般业务用不上了解一下原理即可。我个人的体会是Linux网络编程学习的核心不是记API而是建立数据从进程到网卡再对端进程中间每一步有哪些机制参与的完整图景。当你看到一个问题能想起这个行为可能是Nagle、延迟ACK、缓冲区满、拥塞控制中的哪一个导致的你就已经超过绝大多数只写业务代码的工程师了。写到现在这些内容基本覆盖了标题里承诺的范围。如果你在实际开发中遇到了其他奇怪的问题欢迎按照文章里的排查思路自己推演一遍大部分问题都能从内核状态和协议行为上找到答案。
返回列表