Web服务器高并发底层原理:三次握手、epoll与非阻塞IO协同机制
1. 这不是教科书里的网络课而是一线工程师每天在写的代码底座你打开浏览器输入一个网址页面几毫秒就刷出来——背后不是魔法而是一整套精密咬合的齿轮从TCP三次握手建立连接到服务器用多进程、多线程或协程处理成百上千并发请求再到内核用epoll高效监听数万socket状态变化最后靠非阻塞IO把CPU从“傻等数据”中彻底解放出来。这些词——#web服务器、#三次握手四次挥手、#多进程、#多线程、#多协程、#非阻塞、#epoll、#网络通信——从来不是割裂的知识点而是同一张生产级HTTP服务骨架上的不同关节。我做过6个高并发Web后端系统从日均5万PV的内部管理平台到峰值QPS破8000的实时数据看板所有稳定运行的底层逻辑都绕不开这八个关键词组成的闭环。它们解决的不是“能不能跑”的问题而是“能不能扛住真实流量、不卡、不崩、不拖垮机器”的问题。如果你正在写一个需要同时响应几十个用户请求的Python Flask服务却还在用time.sleep(1)模拟耗时操作如果你的Node.js服务一上压测就CPU飙到95%、响应延迟翻倍如果你用Go写了goroutine却没意识到默认net/http的连接复用和超时控制会悄悄吃掉你的内存——那这篇内容就是为你写的。它不讲抽象理论只讲我在Linux服务器上敲过的每一行关键配置、改过的每一个超时参数、抓包验证过的三次握手时间差、以及epoll_wait返回值为0时到底该不该立刻重试。适合刚学完《计算机网络》但写不出稳定服务的开发者也适合做了三年业务但第一次被运维叫去查“为什么连接数暴涨到65535”的后端同学。2. 内容整体设计与思路拆解为什么现代Web服务器必须是“组合拳”而非单点突破2.1 单一技术无法应对真实网络场景的三重压力很多初学者容易陷入一个误区以为学会某个“高大上”的技术就能一劳永逸。比如看到“epoll比select快”就认定只要换上epoll就万事大吉或者听说“协程比线程轻量”就直接把所有服务改成async/await。但真实世界远比技术宣传复杂。我曾接手一个用Tornado写的监控接口开发时测试QPS 2000很稳上线后某天凌晨突然大量超时。抓包发现客户端发完HTTP请求后服务端迟迟不回ACK——不是代码慢而是内核连接队列溢出了。原因很简单这个服务用了协程模型但accept()调用仍是阻塞式当瞬间涌入3000个SYN包时主线程卡在accept()里来不及处理后续SYN全被丢弃客户端重传形成雪崩。这说明网络通信是一个分层协作系统任何一层的短板都会成为整个链路的瓶颈。我们拆解真实压力来源连接建立压力海量设备尤其是IoT终端频繁建连断连三次握手过程本身就有开销。Linux内核维护两个队列SYN队列存放半连接和Accept队列存放已完成三次握手但尚未被应用accept()取走的连接。如果Accept队列满了内核会直接丢弃新完成的握手包客户端感知就是“连接超时”。请求处理压力每个HTTP请求背后可能是数据库查询、文件读写、外部API调用。这些操作天然有I/O等待。若用多线程模型每个线程独占栈内存默认8MB1000个并发请求就吃掉8GB内存还没算业务逻辑消耗若用多进程fork开销大进程间通信麻烦若只用单线程阻塞IO一个慢查询就能让所有用户排队。资源调度压力当并发连接数从几百升到几万传统轮询如select/poll需要遍历所有fd时间复杂度O(n)CPU很快成为瓶颈。而epoll通过红黑树就绪链表将事件通知复杂度降到O(1)但它的威力只有配合非阻塞IO才能完全释放——否则线程仍会卡在read()或write()上epoll监听再快也没用。提示没有银弹。选择多进程、多线程还是协程本质是在内存占用、上下文切换开销、编程复杂度、I/O等待容忍度之间做权衡。例如某高校实验室的图像识别API单次推理耗时800ms且CPU密集用多进程能充分利用多核而某电商的秒杀库存扣减90%时间在Redis网络等待用协程非阻塞IO更合适。2.2 架构选型不是“技术炫技”而是匹配业务特征的务实决策我参与过三个典型项目其技术选型差异极大但逻辑高度一致先定义业务的I/O特征再倒推模型。项目A政务审批系统低频、长事务特征日均请求2000但单次流程涉及7个外部系统调用平均耗时4.2秒其中95%是网络等待。选型Python Gunicorn多进程 同步requests。理由并发量极小用协程反而增加调试难度多进程隔离性好一个请求崩溃不影响其他同步代码逻辑直白政务系统最怕不可预测的异步状态。项目B实时行情推送服务高频、短连接特征每秒接收5000条WebSocket消息需广播给2万个在线客户端单次广播耗时50ms。选型Go 原生net/http 自研连接池。理由Go goroutine开销仅2KB2万连接内存占用可控原生HTTP Server对长连接支持成熟避免引入第三方框架带来的不确定延迟。项目CAI模型训练平台Web界面突发、混合负载特征平时QPS 50但用户点击“启动训练”时需同时调用Kubernetes API创建Pod、上传数据集到对象存储、更新数据库状态三个操作耗时差异大API调用200ms上传10GB数据可能10分钟。选型Node.js Express worker_threads stream pipeline。理由主线程处理HTTP路由和轻量逻辑重IO任务如大文件上传扔进worker线程避免阻塞事件循环stream实现边接收边上传内存不爆仓。这三个案例共同指向一个结论技术栈的选择必须回答三个问题平均并发连接数是多少单次请求的I/O等待占比多高业务对错误隔离性和调试便利性的要求有多强忽略任一问题都可能在流量高峰时付出远超预期的运维代价。2.3 核心技术链的协同逻辑从三次握手到epoll就绪的完整生命周期理解单个技术点容易但真正决定服务稳定性的是它们如何串联成一条无断点的数据流。我们以一次典型的HTTP GET请求为例追踪其在服务端的完整旅程三次握手阶段内核空间客户端发送SYN → 服务端回复SYN-ACK → 客户端发送ACK。此时连接进入ESTABLISHED状态被放入Accept队列。关键参数net.core.somaxconnAccept队列长度默认128若业务并发高必须调大否则队列满后新连接被丢弃。连接获取阶段用户空间服务端调用accept()从Accept队列取出连接。若使用阻塞IO此调用会挂起线程若用非阻塞IOepoll则accept()立即返回无论队列是否有连接无连接时返回EAGAIN。请求读取阶段用户空间调用recv()读取HTTP请求头。非阻塞模式下若数据未到recv()返回EAGAIN线程不等待可去处理其他就绪fd阻塞模式则卡死在此。业务处理阶段用户空间解析URL、查询数据库、生成HTML。此阶段CPU密集应避免在此做任何I/O等待。响应写入阶段用户空间调用send()发送HTTP响应。同样受非阻塞影响若socket发送缓冲区满send()返回EAGAIN需注册EPOLLOUT事件等内核通知“缓冲区有空位”再重试。四次挥手阶段内核空间客户端发送FIN → 服务端回复ACK → 服务端发送FIN → 客户端回复ACK。注意服务端发送FIN后进入LAST_ACK状态若客户端ACK丢失服务端会重传FIN直到超时由net.ipv4.tcp_fin_timeout控制默认60秒期间该socket占用端口和内存。这条链路上任何一个环节的阻塞或配置不当都会引发连锁反应。比如tcp_fin_timeout设得过大大量TIME_WAIT状态socket堆积会快速耗尽本地端口65535个导致新连接失败。而epoll的价值正是把步骤2、3、5中原本需要轮询或阻塞等待的环节变成“事件驱动”——内核只在socket真正就绪可读/可写/出错时才通知应用应用线程全程不空转、不挂起。3. 核心细节解析与实操要点穿透概念直击配置与代码中的生死线3.1 三次握手与四次挥手不只是教科书图示而是可测量的性能指标很多人以为三次握手是“瞬间完成”的实际在网络质量波动时它可能成为首屏加载的最大瓶颈。我曾用Wireshark抓包分析某CDN节点发现其三次握手平均耗时127ms因跨省骨干网抖动而业务逻辑只占38ms。这意味着优化业务代码毫无意义必须从网络层入手。关键可调参数与实测影响参数位置默认值调优建议实测效果net.ipv4.tcp_syncookies/proc/sys/net/ipv4/0高并发Web服务器建议设为1SYN洪泛攻击时内核用cookie替代SYN队列避免队列溢出丢包net.core.somaxconn/proc/sys/net/core/128设为65535需同时调大net.core.netdev_max_backlogAccept队列扩容后万级并发下连接建立成功率从72%升至99.8%net.ipv4.tcp_tw_reuse/proc/sys/net/ipv4/0设为1仅客户端有效服务端慎用客户端快速重用TIME_WAIT端口降低连接耗尽风险net.ipv4.tcp_fin_timeout/proc/sys/net/ipv4/60设为30需评估业务TIME_WAIT socket存活时间减半端口回收更快注意修改内核参数后必须重启网络服务或执行sysctl -p生效。我踩过一次坑在生产环境改了somaxconn但忘了sysctl -p压测时连接数卡在128死活上不去排查两小时才发现是参数没生效。四次挥手的隐藏陷阱主动关闭方的TIME_WAIT状态服务端通常是被动关闭方收到客户端FIN后回复ACKFIN但某些场景下服务端会主动调用close()此时它进入TIME_WAIT。一个TIME_WAIT socket会占用一个本地端口持续2*MSLMaximum Segment Lifetime默认60秒。若服务端每秒新建1000个连接并主动关闭60秒内将累积6万个TIME_WAIT socket远超65535端口上限。解决方案不是盲目调小tcp_fin_timeout可能引发数据包混乱而是让客户端主动关闭HTTP/1.1默认keep-alive客户端控制断连时机使用连接池复用socket避免频繁建连断连若必须服务端主动关启用net.ipv4.tcp_tw_recycle已废弃不推荐或升级到较新内核用net.ipv4.tcp_fin_timeout精细控制3.2 多进程/多线程/多协程内存、调度、调试的三角平衡术这三种模型的本质区别在于如何划分“执行单元”与“资源隔离边界”。选错模型轻则浪费资源重则引发难以复现的竞态错误。多进程如Nginx、Gunicorn每个进程有独立内存空间、PID、文件描述符表。优势是强隔离——一个进程崩溃不影响其他劣势是进程间通信IPC成本高内存占用大每个Python进程约50MB起步。适用场景CPU密集型、需强稳定性的服务。实操心得Gunicorn的--workers参数不要简单设为CPU核心数。我曾将8核机器设为8 workers结果因数据库连接池不足每个worker独占连接MySQL报“Too many connections”。最终调整为min(2*CPU1, (max_connections-10)/per_worker_connections)即优先保证数据库连接充足。多线程如Tomcat、Java Web容器线程共享进程内存通过锁mutex保护临界区。优势是线程创建销毁快IPC通过共享内存实现劣势是锁竞争激烈时性能骤降且一个线程崩溃可能导致整个进程退出。适用场景I/O密集型、有成熟线程安全库的业务。关键细节Java的-Xss参数控制线程栈大小。默认1MB若开1000个线程仅栈就吃掉1GB内存。某次压测中我们将-Xss256k线程数从800提升到3500QPS反升12%因为更多线程能并发等待I/O。多协程如Go goroutine、Python asyncio协程是用户态轻量级线程由运行时调度无内核态切换开销。一个Go程序轻松启动百万goroutine内存占用仅KB级。但协程不是万能的它要求所有I/O操作必须是非阻塞的否则一个阻塞调用会让整个M:N调度器卡死。血泪教训某Python项目用asyncio requests结果requests是同步阻塞库导致所有协程在requests.get()处集体挂起。正确做法是用aiohttp或httpx它们原生支持async/await。三者性能对比实测同一台16GB内存服务器压测工具wrk多进程Gunicorn 4 workersQPS 3200内存占用 1.8GB多线程Tomcat 200 threadsQPS 4100内存占用 2.3GB多协程Go net/httpQPS 8900内存占用 420MB差距源于协程无上下文切换开销内存分配极致精简且Go runtime对网络I/O做了深度优化。3.3 非阻塞IO与epoll为什么“不等”比“等得快”更重要阻塞IO和非阻塞IO的根本区别在于线程是否因等待I/O而放弃CPU使用权。这看似微小却决定了系统吞吐量的天花板。阻塞IO示例伪代码int sockfd socket(AF_INET, SOCK_STREAM, 0); connect(sockfd, addr, sizeof(addr)); // 此处线程挂起直到连接建立或超时 recv(sockfd, buf, sizeof(buf), 0); // 此处线程挂起直到数据到达一个线程在同一时刻只能处理一个socket。要支持1000并发就得开1000个线程内存和调度开销巨大。非阻塞IO epoll示例核心逻辑int epfd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; // 监听可读事件 ev.data.fd sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, ev); while(1) { int nfds epoll_wait(epfd, events, MAX_EVENTS, -1); // 阻塞在此但只等“有事发生” for(int i0; infds; i) { if(events[i].events EPOLLIN) { ssize_t n recv(events[i].data.fd, buf, sizeof(buf), MSG_DONTWAIT); // 非阻塞读 if(n -1 errno EAGAIN) continue; // 数据未到跳过 else if(n 0) process_request(buf, n); // 有数据处理 } } }关键点epoll_wait()只在有socket就绪时才返回recv()加MSG_DONTWAIT标志确保绝不阻塞。一个线程即可管理数万socket。epoll三大核心机制详解红黑树RBT存储所有被监听的fd。epoll_ctl(ADD)时插入DEL时删除MOD时更新时间复杂度O(log n)。就绪链表Ready List内核维护的双向链表存放当前就绪的fd。epoll_wait()直接从此链表取数据O(1)。eventpoll结构体每个epoll实例对应一个包含RBT根节点、就绪链表头、等待队列用于epoll_wait休眠等。注意epoll_wait的timeout参数设为-1表示永久阻塞设为0表示立即返回轮询不推荐浪费CPU。生产环境常用1~10ms平衡响应速度与CPU占用。3.4 Web服务器实战配置Nginx与自研服务的参数黄金组合Nginx是epoll模型的典范其配置直接体现了上述原理。我将生产环境验证过的关键配置拆解如下# /etc/nginx/nginx.conf worker_processes auto; # 自动匹配CPU核心数避免过多worker争抢CPU worker_rlimit_nofile 65535; # 每个worker最大打开文件数必须epoll监听fd数 events { use epoll; # 明确指定epoll避免自动选择select/poll worker_connections 65535; # 每个worker最多处理65535连接总连接数worker_processes * worker_connections multi_accept on; # 一个epoll_wait返回后尽可能多地accept()连接减少系统调用 } http { # 连接管理 keepalive_timeout 65; # HTTP keep-alive超时避免连接长期空闲 keepalive_requests 100; # 单个连接最多处理100个请求防止单连接占用过久 client_header_timeout 10; # 读取请求头超时防慢速攻击 client_body_timeout 10; # 读取请求体超时 # 缓冲区优化 client_header_buffer_size 1k; # 请求头缓冲区太小会触发大内存分配 large_client_header_buffers 4 4k; # 大请求头缓冲区 client_max_body_size 10m; # 最大请求体防恶意上传 # Gzip压缩 gzip on; gzip_types text/plain application/json application/javascript; }自研服务Go语言关键参数// 创建HTTP Server时 server : http.Server{ Addr: :8080, ReadTimeout: 5 * time.Second, // 读取请求头/体超时防慢速攻击 WriteTimeout: 10 * time.Second, // 写入响应超时防后端服务卡死 IdleTimeout: 60 * time.Second, // keep-alive空闲超时及时释放连接 Handler: router, } // 启动时设置系统级限制 if rLimit, err : syscall.Getrlimit(syscall.RLIMIT_NOFILE); err nil { rLimit.Cur 65535 syscall.Setrlimit(syscall.RLIMIT_NOFILE, rLimit) }实操心得ReadTimeout和WriteTimeout必须设置某次线上事故因数据库慢查询导致响应卡住WriteTimeout未设连接一直挂着最终连接数打满新请求全部拒绝。加上后超时连接自动关闭服务迅速恢复。4. 实操过程与核心环节实现手把手搭建一个可压测的epoll Web服务器原型4.1 从零开始用C语言实现最小可行epoll服务器含三次握手验证为彻底理解底层我用C写了不到200行的epoll服务器原型它能处理HTTP GET请求并返回Hello epoll。重点不在功能完整而在展示每个环节的衔接。步骤1创建监听socket并设为非阻塞int listenfd socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); // 关键SOCK_NONBLOCK if (listenfd 0) err_exit(socket); struct sockaddr_in serv_addr; memset(serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family AF_INET; serv_addr.sin_addr.s_addr INADDR_ANY; serv_addr.sin_port htons(8080); // 绑定并监听 bind(listenfd, (struct sockaddr*)serv_addr, sizeof(serv_addr)); listen(listenfd, 128); // backlog设为128对应somaxconn // 创建epoll实例 int epfd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, ev);步骤2主循环处理事件struct epoll_event events[1024]; while(1) { int nfds epoll_wait(epfd, events, 1024, -1); // 阻塞等待事件 for(int i0; infds; i) { if(events[i].data.fd listenfd) { // 新连接到来 struct sockaddr_in cli_addr; socklen_t cli_len sizeof(cli_addr); int connfd accept4(listenfd, (struct sockaddr*)cli_addr, cli_len, SOCK_NONBLOCK); // accept4支持非阻塞 if(connfd 0) { if(errno EAGAIN || errno EWOULDBLOCK) continue; // 无连接可取继续 else err_exit(accept); } // 将新连接加入epoll监听 ev.events EPOLLIN | EPOLLET; // EPOLLET启用边缘触发减少事件重复通知 ev.data.fd connfd; epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, ev); } else { // 已有连接可读 char buf[1024]; ssize_t n recv(events[i].data.fd, buf, sizeof(buf)-1, MSG_DONTWAIT); // 非阻塞读 if(n 0) { buf[n] \0; // 简单解析HTTP GET生产环境用专业parser if(strncmp(buf, GET , 4) 0) { const char* response HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello epoll; send(events[i].data.fd, response, strlen(response), MSG_NOSIGNAL); } close(events[i].data.fd); // 简单处理立即关闭 } else if(n 0) { // 对端关闭连接 close(events[i].data.fd); } else if(errno EAGAIN || errno EWOULDBLOCK) { // 数据未到忽略 continue; } else { close(events[i].data.fd); } } } }验证三次握手启动服务器后用tcpdump -i lo port 8080抓包同时用curl http://127.0.0.1:8080。Wireshark中可见清晰的SYN→SYN-ACK→ACK三步且accept4()调用发生在ACK之后证明连接已完全建立。4.2 Python asyncio服务规避常见陷阱的完整实现Python的asyncio易用但陷阱密布。以下是一个生产级可用的模板import asyncio import aiohttp from aiohttp import web # 全局连接池避免每次请求新建session connector aiohttp.TCPConnector( limit100, # 总连接数上限 limit_per_host30, # 单host连接数上限 keepalive_timeout30, # 连接保活时间 force_closeFalse # 不强制关闭复用连接 ) async def handle_request(request): # 模拟耗时I/O调用外部API try: async with aiohttp.ClientSession(connectorconnector) as session: async with session.get(https://httpbin.org/delay/1) as resp: data await resp.json() return web.json_response({status: ok, data: data}) except asyncio.TimeoutError: return web.json_response({error: timeout}, status504) except Exception as e: return web.json_response({error: str(e)}, status500) # 主应用 app web.Application() app.router.add_get(/, handle_request) # 启动时预热连接池 async def init_app(app): # 创建一个session用于预热 app[session] aiohttp.ClientSession(connectorconnector) app.on_startup.append(init_app) # 关闭时清理 async def cleanup_app(app): await app[session].close() app.on_cleanup.append(cleanup_app) if __name__ __main__: # 生产环境用gunicorn或uvicorn此处仅演示 web.run_app(app, host0.0.0.0, port8080, access_log_format%a %t %r %s %b %{Referer}i %{User-Agent}i %Dus)关键配置说明TCPConnector的limit和limit_per_host必须根据后端服务能力设置否则可能触发对方限流。keepalive_timeout设为30秒与Nginx的keepalive_timeout对齐避免连接提前关闭。on_startup中预热session避免首个请求因创建连接而延迟。4.3 压测与调优用wrk量化验证各模型性能差异用标准工具wrk进行对比压测参数统一wrk -t4 -c1000 -d30s http://127.0.0.1:80804线程1000并发30秒。服务类型QPS平均延迟99%延迟内存占用关键观察Nginx静态文件3820026ms42ms12MBCPU利用率78%无错误Go net/http2150046ms89ms45MBGC暂停时间1ms稳定Python asyncio1420070ms156ms180MB内存增长平缓无泄漏Gunicorn同步5300188ms420ms1.2GB内存飙升明显第20秒开始超时调优实录初始Go服务QPS仅1200099%延迟210ms。pprof分析发现runtime.mallocgc耗时高。添加GOGC20降低GC频率后QPS升至18000。Python服务内存从180MB降至95MB将aiohttp.ClientSession从request scope改为app scope全局单例避免重复创建。所有服务在ulimit -n 65535下运行否则too many open files错误频发。5. 常见问题与排查技巧实录那些文档里不会写的血泪经验5.1 连接数打满的10种死法及诊断清单生产环境中“Connection refused”或“Too many open files”是最常见的告警。以下是我在6个项目中总结的10种典型死因及排查命令现象可能原因快速诊断命令解决方案connect: connection refusedAccept队列溢出ss -lntgrep :8080 查看Recv-Q是否0socket: too many open files进程打开文件数超限ulimit -nlsof -p pid | wc -lulimit -n 65535代码中及时close()socket大量TIME_WAIT主动关闭方过多netstat -an | grep TIME_WAIT | wc -l客户端主动关或调小tcp_fin_timeoutepoll_wait返回0无事件但CPU 100%strace -p pid -e epoll_wait检查是否误用水平触发LT且未读完数据改用边缘触发ET循环读recv()返回EAGAIN但无EPOLLIN事件fd未正确注册cat /proc/pid/fd | wc -l确认epoll_ctl(ADD)成功fd未被意外关闭响应延迟突增网络抖动或路由问题mtr --report example.com检查中间节点丢包率联系网络团队内存持续增长连接未释放或缓存泄漏pmap -x pidgcore pid分析用tracemallocPython或pprofGo定位泄漏点CPU 100%但QPS低锁竞争或死循环perf top -p pid检查热点函数优化锁粒度或算法SSL握手超时证书链不全或OCSP响应慢openssl s_client -connect example.com:443 -servername example.com补全证书链禁用OCSP stapling若不必要四次挥手卡在FIN_WAIT2对端未发ACKnetstat -an | grep FIN_WAIT2检查客户端代码或调整net.ipv4.tcp_fin_timeout提示ss命令比netstat更快更准。ss -s显示socket统计摘要ss -ltnp显示监听端口及进程是日常巡检第一命令。5.2 抓包分析三次握手与四次挥手的黄金步骤Wireshark是网络问题的终极武器但新手常不知从何下手。我的标准化流程过滤目标连接在Wireshark过滤栏输入ip.addr 192.168.1.100 and tcp.port 8080替换为你的服务IP和端口。定位三次握手找第一个SYN包右键→“Follow”→“TCP Stream”Wireshark自动高亮该连接所有包。测量握手耗时在SYN包上右键→“Time since previous displayed packet”查看SYN→SYN-ACK→ACK各段耗时。正常应在10ms内若100ms检查网络链路。分析四次挥手在TCP Stream中找FIN包。注意FIN_WAIT1、CLOSE_WAIT、TIME_WAIT状态转换。若大量CLOSE_WAIT说明服务端未调用close()若大量TIME_WAIT检查是否服务端主动关。导出问题包右键→“Export Selected Packet(s)”保存为.pcap供团队分析。5.3 epoll事件漏报的隐蔽原因与修复epoll漏报事件是极其隐蔽的bug表现为“连接明明有数据但epoll_wait就是不返回”。我遇到过两次原因1水平触发LT模式下未读完数据LT模式下只要socket可读epoll_wait就会持续返回该fd。但如果recv()只读了部分数据如HTTP头读完但体未读下次epoll_wait仍会返回但若代码逻辑错误地跳过就造成“漏报”假象。修复在LT模式下recv()必须循环读到EAGAIN为止或改用边缘触发ET模式此时epoll_wait只在状态变化时通知一次必须一次性读完。原因2fd被重复epoll_ctl(ADD)某次重构中我在accept()后对新fd调用了两次epoll_ctl(ADD)导致内核认为这是两个独立fd但实际指向同一socket。结果epoll_wait有时返回旧fd有时返回新fd