网络编程里的那些坑:我对比了epoll、线程池和io_uring,发现90%的人用错了场景

🔑 关键词:网络编程,epoll,select,io_uring,TCP参数调优

📖 摘要:一个业余程序员在写TCP服务器时踩过的真实坑,从select的1024限制到epoll的ET模式丢事件,再到线程池内存爆炸。附具体参数和调试步骤,不说套话。

开头先说个真事

去年接了个小项目,给一个硬件设备做TCP服务端,并发量撑死200。我兴冲冲上了epoll+非阻塞IO,结果调试花了三周,最后改回阻塞IO+线程池,代码少了一半,反而更稳。这件事让我重新审视那些“标准答案”。很多教程上来就教你非阻塞+事件循环,但没人告诉你:如果你的连接数不超过500,线程池加阻塞IO就是最优解,没有之一。

图片

第一个坑:select的1024不是开玩笑的

我最早用的是select,因为跨平台简单。但Linux下FD_SETSIZE默认就是1024,你往数组里塞第1025个fd,程序直接段错误。更气人的是,select每次调用都要把整个fd集合从用户态拷到内核态,然后内核遍历一遍找就绪的。我实测过,200个连接时select和epoll的CPU占用差不多;但到了800个连接,select的CPU直接飙到15%,而epoll还是2%左右。epoll的原理是红黑树存fd + 就绪链表,你调epoll_wait只需要拿就绪链表。但有个致命细节:ET模式(边缘触发)下,你必须在一个循环里read到返回EAGAIN为止,否则第二个包来了不会触发新事件。我第一次写ET模式就漏了这个循环,结果客户端发第二个包时服务器完全没反应,抓包看到数据都到了内核缓冲区,但用户态就是不读。后来加了while ((n = read(fd, buf, sizeof(buf))) > 0)才搞定。顺便说个参数:epoll_wait的超时时间,线上服务别设-1(死等),设1000毫秒足够,这样能定期检查退出标志。

图片

第二个坑:线程池不是无限开线程的

很多人一听线程池就觉得“每个连接一个线程”太土。但你算过账没有?Java线程默认栈大小是1MB(64位Linux下实际是1MB左右,不是8MB,那是Windows),1000个线程就是1GB内存。但你可以用-Xss256k把栈调小,1000线程只要256MB。而Python的GIL虽然不能并行,但阻塞IO会让出GIL,所以用ThreadingTCPServer处理200个连接完全没问题。我对比过:一个简单的echo服务器,200并发下,Python线程池版的延迟中位数是1.2ms,而asyncio版是0.9ms,差距不大。但线程池版的代码量只有asyncio的三分之一。具体步骤:用socketserver.ThreadingTCPServer,设置request_queue_size=128(这是listen的backlog),然后重写handle方法。别忘了设置allow_reuse_address = True,对应SO_REUSEADDR,不然重启服务时经常报Address already in use。至于ulimit -n,默认1024,你得在/etc/security/limits.conf里加* soft nofile 65535和* hard nofile 65535,然后sysctl -w fs.file-max=100000。这些数字都是我踩坑后记在笔记本上的。

图片

第三个坑:粘包和Nagle算法能让你怀疑人生

有一次线上反馈说客户端每次请求要40ms,我一开始以为是业务逻辑慢,后来用tcpdump抓包发现:客户端发完第一个数据包后,第二个小包等了40ms才发出去。原因是Nagle算法在等ACK,而服务端又开了延迟ACK(默认40ms)。解决办法就一行:setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on))。设完之后延迟降到1ms以下。至于粘包,我见过有人用\r\n当分隔符,结果二进制数据里正好有\r\n,直接解析乱套。更靠谱的是长度前缀:先发4字节大端整数表示包体长度,再发包体。我写了个Python小函数:struct.pack('>I', len(data)) + data,接收端先recv(4)拿到长度,再循环recv直到收满。注意:recv的返回值可能小于请求的长度,必须用循环。这个坑我调了三天,因为测试环境数据量小没暴露,线上大包一拆就出问题。

图片

第四个坑:io_uring被吹过头了

2023年之后到处都在吹io_uring,说是网络编程的未来。我好奇试了一下,写了个简单的回显服务器,确实快——但前提是你内核版本5.10以上,而且liburing的API还在变。如果你只是做个内部管理后台的API,连接数不超过1000,io_uring带来的复杂度远超收益。我的独立观点:网络编程的瓶颈往往不是IO模型,而是协议设计和序列化。你花一周把select改成epoll,性能提升10%;但把JSON换成Protobuf,性能提升可能30%。判断标准我总结了个表:连接数<500且不追求极致延迟,用阻塞IO+线程池;500~10000,用epoll或kqueue;>10000且你愿意折腾内核版本,再考虑io_uring。最后分享个调试技巧:用strace -f -e trace=network -p <pid>看你的服务每次请求调了多少次recv和send,如果recv次数远大于逻辑包数,说明你没处理好粘包。这些具体数字和步骤,都是我笔记本上翻出来的,不是什么标准答案。

图片

🏷️ 标签: