TCP并发服务器实战:从accept队列到epoll事件驱动的高并发架构

发布时间:2026/10/9 8:23:56
TCP并发服务器实战:从accept队列到epoll事件驱动的高并发架构 做后端这些年TCP并发服务器是我见过讨论最多、也最容易在线上翻车的基础设施。很多人学会了socket编程知道bind、listen、accept三个函数但一旦要面对“几千个客户端同时在线”这种需求就发现原来的单线程模型完全不够用。这篇文章就是围绕TCP并发服务器展开的从TCP连接的本质、并发模型的选择到一个基于epoll的可运行实现再到生产环境里高频踩坑的排查思路最后聊聊Modbus TCP和物联网设备接入这些真实场景。适合已经写过简单socket程序、想进一步搞懂高并发连接管理的开发者也适合准备面试时把IO模型彻底理清楚的求职者。1. 先弄懂TCP并发服务器到底在“并发”什么1.1 一次TCP连接背后的内核工作很多人误以为TCP并发服务器的难点在于“同时处理多个连接”但真正难的是理解连接生命周期里内核替我们干了多少事。当你调用socket()创建套接字再bind()到端口然后listen()进入监听状态这时候内核已经在为这个监听socket维护两个内部队列半连接队列SYN队列和全连接队列accept队列。客户端发来一个带SYN的握手包内核把这条连接放进半连接队列并回应SYNACK客户端再回一个ACK三次握手完成这条连接被移入全连接队列等待用户的accept()取走。也就是说accept()并不是建立连接它只是从已就绪队列里取出一条连接。这个认知非常重要高并发下“连接被建立”和“应用层来得及处理”是两回事。如果应用accept()得慢全连接队列会堆积内核随后开始丢弃SYN包或直接响应RST客户端看到的就是Connection refused或超时。每条TCP连接在内核里其实是一个四元组源IP、源端口、目的IP、目的端口。服务器端口是固定的但客户端端口千变万化所以一台Linux服务器理论上可以同时维持几十万甚至上百万条TCP连接。真正限制并发数的不是端口而是文件描述符上限、内存大小、以及应用层的处理能力。这个底层模型决定了我们所有并发方案的设计方向。1.2 最原始的串行服务器为什么撑不住先看一个教科书里经常出现的反例int listen_fd socket(AF_INET, SOCK_STREAM, 0); bind(listen_fd, ...); listen(listen_fd, 64); while (1) { int conn_fd accept(listen_fd, NULL, NULL); handle_client(conn_fd); // 一直读到客户端关闭才返回 }这段代码在只有一个客户端的时候是没有问题的。但如果客户端A连接上来后服务端进入handle_client()里面可能因为等待数据而阻塞在recv()上。此时客户端B发起连接虽然三次握手在内核完成了连接已经被放进了全连接队列但服务器永远不会调用下一次accept()所以B的应用层请求得不到任何处理。更糟的是如果B发的SYN超过队列容量内核会把它丢掉B端感知到的就是“连不上”。这种阻塞式单线程模型的最大问题不是“不能处理并发”而是“一个慢客户端能拖死整个服务器”。accept()阻塞、recv()阻塞、send()阻塞任何一步阻塞都会让主循环停滞。所以并发服务器的本质需求不是让CPU同时做好多件事而是让进程/线程不要被单个连接的等待卡死从而能持续接纳新连接并及时响应多个客户端。2. 并发模型怎么选多进程、多线程、事件驱动还是协程2.1 多进程模型的隔离与代价最早也是最朴素的并发方案是fork()。服务器accept()得到新连接后fork()一个子进程去处理这个连接父进程继续accept()。子进程即使崩溃也不会影响父进程和其他客户端隔离性很好。而且进程是操作系统最原始的调度单位没有用户态锁竞争写起来很直白。但它的缺点同样明显每个进程都有独立的地址空间和文件表创建和销毁的开销很大。哪怕用进程池复用几万个并发时内存和调度开销也非常可观。此外还有经典的“惊群”问题多个进程同时阻塞在accept()上一个连接到达时所有进程都被唤醒但只有一个能抢到连接其余白白消耗CPU。现代方案会使用SO_REUSEPORT让内核把新连接负载均衡到多个监听socket或者对监听socket开启EPOLLEXCLUSIVE但多进程模型在“超高并发且要求强隔离”的场景才值得优先考虑比如某些网络设备程序。2.2 多线程模型共享内存的甜蜜与毒药相比进程线程的创建开销小共享进程内存通信方便。常见的做法是每来一个连接就创建一个线程或者用一个线程池接活。线程池可以避免频繁创建销毁但真正的瓶颈在于池里的线程如果都阻塞在某个连接的recv()或业务IO上池子再大也会枯竭。你以为线程多就并发高其实线程上下文切换、同步锁、CPU缓存失效都会吃掉大量性能。我见过一个最典型的线上事故服务器用300个线程的线程池处理长连接其中一个连接的对端突然拔网线因为TCP没有感知断电的机制那些等在这个连接上的线程会一直阻塞在recv()直到系统默认的keepalive超时通常两小时。结果线程池被打满所有正常连接全部饥饿。所以多线程模型适合“并发数有限、任务短平快”的场景比如内网小型服务而不是海量长连接。2.3 事件驱动select、poll与epoll的进化史为了绕开“一个线程只能等一个socket”的问题IO多路复用出现了。它的核心思想很简单让一个线程把很多socket交给内核去监听内核发现哪个socket可读可写了再告诉应用去处理。这样就不会因为某个连接没数据而卡死其他人。select()是最早的接口但它有三个硬伤单个进程可监听的fd集合有上限通常1024每次调用都要把整个fd集合从用户态拷贝到内核态线性扫描所有fd导致性能随fd数量上升而下降。poll()解决了上限问题但依然要线性扫描和重复拷贝。直到Linux的epoll才真正把复杂度降下来应用通过epoll_ctl()注册感兴趣的事件内核用红黑树管理这些fd就绪事件通过回调机制加到就绪链表里epoll_wait()只返回就绪列表复杂度接近O(1)。这是目前Linux上实现高并发TCP服务器的默认选择。epoll还提供了两种触发模式水平触发LT和边缘触发ET。LT只要缓冲区里有数据每次epoll_wait()都会告诉你可读不容易漏事件但可能重复唤醒ET只有状态从无到有时才通知一次效率更高但要求你必须把缓冲区里的数据读完否则剩下的数据要等下一次新数据到来才能触发。所以ET模式必须以非阻塞IO配合循环read()读到最后返回EAGAIN才算读完。新手建议先用LT稳定后再挑战ET。2.4 协程模型用同步写法享受异步效率协程不是内核提供的机制而是用户态调度。比如Go的goroutine底层其实也是epoll事件循环但编译器把“等待IO”变成了一个状态机的挂起和恢复。你在协程里写conn.Read(buf)看起来和同步阻塞写一模一样但线程不会真的卡住而是把这个协程挂起去执行其他就绪协程。这极大降低了编程复杂度同时保持了高并发能力。对比下来几种模型的取舍没有绝对优劣关键看业务特征模型单连接成本适合最大并发规模编程复杂度典型场景多进程高进程几千低但进程间通信麻烦隔离要求高、连接数中等的服务多线程中线程1万以内中要处理锁和惊群短连接密集、计算型服务epoll 状态机低回调10万以上高代码逻辑容易碎长连接、高并发网关协程低用户态栈10万以上低同步写法网络IO密集追求开发效率3. 手写一个基于epoll的TCP并发服务器附C实现思路3.1 核心设计一个循环搞定所有事件我用C语言实现一个最简的epoll回显服务器目的是把事件驱动的骨架说清楚。核心数据结构就是一个epoll_event数组加一个“连接对象”数组来保存每个socket对应的业务状态。#define MAX_EVENTS 1024 #define MAX_CONN 65535 struct conn { int fd; char rbuf[4096]; size_t rlen; }; int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); // 关键让端口释放后能立即重用 int one 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, one, sizeof(one)); struct sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(8080); bind(listen_fd, (struct sockaddr*)addr, sizeof(addr)); // 生产环境建议调大backlog同时注意/proc/sys/net/core/somaxconn listen(listen_fd, 1024); int ep_fd epoll_create(1); struct epoll_event ev {0}; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(ep_fd, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[MAX_EVENTS]; while (1) { int n epoll_wait(ep_fd, events, MAX_EVENTS, -1); for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { // 有新连接到达 struct sockaddr_in peer; socklen_t len sizeof(peer); int conn accept(listen_fd, (struct sockaddr*)peer, len); // 把新conn设置为非阻塞 int flags fcntl(conn, F_GETFL, 0); fcntl(conn, F_SETFL, flags | O_NONBLOCK); struct epoll_event c_ev {0}; c_ev.events EPOLLIN | EPOLLET; // 边缘触发 c_ev.data.fd conn; epoll_ctl(ep_fd, EPOLL_CTL_ADD, conn, c_ev); } else { int fd events[i].data.fd; char buf[4096]; // ET下必须循环读到EAGAIN while (1) { ssize_t nread read(fd, buf, sizeof(buf)); if (nread 0) { // 这里回显真实业务会做协议解析 write(fd, buf, nread); } else if (nread 0) { // 对端关闭 epoll_ctl(ep_fd, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { break; // 数据读完了 } close(fd); // 真实错误 break; } } } } } return 0; }这段代码虽然很短但已经把事件驱动最重要的机制都覆盖了。申请资源时设置SO_REUSEADDR是为了解决服务重启后旧连接还在TIME_WAIT状态导致bind失败的问题设置非阻塞是为了配合ET模式避免卡死在read()上每次处理连接事件都用循环读到EAGAIN保证边缘触发没有漏数据。3.2 连接状态管理别只盯着读写事件上面的代码只是回显实际生产中每个连接往往需要维护自己的状态协议解析到哪一步了、消息长度是多少、是否需要定时器。于是struct conn里要保存收包缓冲区和协议解析偏移不能简单用一个临时buf。这里最容易踩的坑是“半包”TCP是字节流一次read()可能只读到半个消息下次read()才是剩下半个。初学者如果每次都假设读到一个完整请求就会出现消息错乱。建议每个连接维护一个动态增长的接收缓冲区read()到内核数据就追加到缓冲区尾部然后循环尝试从缓冲区里解析出完整的包。解析出完整包后把剩余数据往前挪继续解析直到缓冲区里不足一个包为止。这是所有网络库的基础操作值得自己手写一遍。通信事件EPOLLIN之外epoll还支持EPOLLOUT也就是socket发送缓冲区有空闲可以写数据时通知你。如果业务响应很大一个write()可能写不完你不想阻塞就先把剩余数据挂到连接对象上然后注册EPOLLOUT等可写事件再把剩余数据写完。不处理EPOLLOUT大响应时很容易出现数据只发出去一半的情况。3.3 压测验证你的服务器到底能扛多少并发写完服务器后先别急着上线用测试工具压一压。最简单的验证方式是telnet 127.0.0.1 8080连上后随便敲点字符看有没有回显。批量压测可以用wrk或ab。比如wrk -t4 -c100 -d30s http://127.0.0.1:8080/-c100表示保持100个并发连接这是最基础的连接压力。如果想测长连接需要用自定义客户端不断新建TCP连接并发送请求。压测时重点监控几个指标每秒请求数、延迟分位数、错误率。同时用ss -s看当前socket状态统计。如果错误率很高多半是accept队列满或者TIME_WAIT耗尽端口这直接引入第4节的那些问题。4. 生产环境高频问题与排查实录4.1 TIME_WAIT多到爆主动关闭方的惩罚TCP协议里主动关闭连接的一方会进入TIME_WAIT状态要等待2MSL默认60秒左右才完全关闭。原因有两个一是确保最后的ACK能到达对端二是防止旧连接的延迟报文干扰新连接。问题在于如果服务器主动关闭了大量短连接系统里会堆满TIME_WAIT在客户端视角就是端口或连接被占用。压测过的同学一定见过这个错误Cannot assign requested address那就是客户端短暂地把本地端口耗尽了。排查命令是netstat -ant | grep TIME_WAIT或ss -ant state time-wait。解决思路有几个把短连接改成长连接或者通过sysctl net.ipv4.tcp_tw_reuse1允许内核在安全前提下复用TIME_WAIT状态的连接注意tcp_tw_reuse只对客户端出站连接生效更极端的调整net.ipv4.tcp_fin_timeout但会降低协议健壮性。最好的办法还是从业务角度减少主动关闭次数。4.2 客户端明明连接上了服务器却总是accept不过来如果你发现客户端连接时偶尔报“Connection refused”但端口又正常监听着很可能是全连接队列满了。Linux的listen()第二个参数指定的backlog和内核参数/proc/sys/net/core/somaxconn取较小值作为全连接队列上限。队列满后新的SYN会被丢弃客户端多次超时后报错。排查时先用ss -lnt查看监听socket的Recv-Q特别是ss -lnt中显示listen socket的Send-Q是backlog上限而Recv-Q是当前积压的连接数。如果Recv-Q接近Send-Q说明应用层accept跟不上。另一种现象是内核统计里TCPExtListenDrops持续增长。调优方向应用初始化时把listen()的backlog调大同时把net.core.somaxconn和net.ipv4.tcp_max_syn_backlog调大。但根本还是得加快accept和处理速度否则调大队列只是延迟问题爆发的时间。4.3 粘包与半包TCP字节流协议的宿命TCP不保留消息边界你发送两个send()对端可能一个recv()就全收到了也可能一个send()被拆成多个recv()。这就是经常说的粘包和拆包。解决方式只有编辑层协议要么每个消息用固定长度帧要么用“包头长度字段包体”的结构。我这里给一个简单的粘包处理流程定义头部4字节表示包体长度解析时先检查缓冲区是否已有4字节有则取出长度N再检查缓冲区是否已有N字节有则取出一整个包剩余数据继续处理。所有网络库的协议层几乎都是这个套路。注意字节序C语言里建议用ntohl()把网络序转为主机序否则在x86上解析大端协议会出错。4.4 连接保活TCP keepalive和业务心跳是两个东西服务器维护大量长连接时如果某个客户端断电断网服务器可能很久都发现不了。TCP自带的keepalive探测默认需要2小时才开始且中间间隔75秒一共探测9次总耗时约11分钟对大多数业务来说太慢了。所以必须做应用层心跳客户端每隔30秒发一个心跳包服务器超过90秒没收到就判定连接死了主动关闭释放资源。实现方式很简单每个连接对象保存last_active时间戳消息处理时更新服务器主循环每隔几秒扫描一遍连接表超时连接直接踢掉。需要注意心跳包本身会占流量但也要防止有些客户端只在有数据时才发心跳——设计时要把心跳机制说清楚否则服务器会误杀正常连接。4.5 实战案例镜像仓库推送失败“dial tcp ... connection refused”怎么查这条报错我见过太多次Harbor 推送失败 get https://192.168.209.133/v2/: dial tcp 192.168.209.133:443: connect: connection refused。看起来是TLS问题但关键是最后的connection refused说明TCP连接根本没建立成功。排查思路按照从下往上走先确认目标IP是否可达ping 192.168.209.133能通则说明基础网络没问题。确认端口是否监听ss -lnt | grep 443如果Harbor服务没起来或配置只监听了80就会拒绝443。确认防火墙iptables -L或云安全组规则有没有放行443端口。确认Harbor的nginx容器是否正常运行docker ps很多时候Harbor依赖的容器异常退出端口就没了。如果服务在线但依然拒绝看一下监听socket的Recv-Q是不是爆满。曾经有客户内部大量监控脚本同时连Harbor把accept队列塞满新客户端一律被内核拒绝从应用日志完全看不出来。这个案例说明TCP并发服务器的问题不一定都在应用层内核连接队列、防火墙、端口复用这些底层因素常常是被忽略的根因。排查时一定要用排除法别一上来就怀疑证书和协议。4.6 Windows机器上的TCP调优一个被忽略的开关很多开发机是Windows遇到TCP延迟、连接不稳定时我们往往会想到Linux的sysctl却忘了Windows也有类似的全局设置。比如这条命令netsh int tcp set global timestampsenabled它启用了TCP时间戳选项。时间戳可以在高带宽长链路下更准确地估算RTT帮助拥塞控制算法做决策也能配合PAWS机制防止旧包干扰。如果TCP连接经常出现奇怪的延迟检查当前设置用netsh int tcp show global把timestamps从disabled改为enabled往往能改善。不过要注意任何全局TCP参数修改都会影响本机所有连接最好先在测试机验证不要在核心生产环境乱改。Windows和Linux传输层可选参数非常多遇到诡异问题时先确认两端协议栈参数是否一致再动手调。5. 从通用服务器到行业场景Modbus TCP、物联网与UDP对比5.1 工业领域Modbus TCP并发服务器要特别注意什么Modbus TCP是工业自动化里最常用的协议之一很多PLC、伺服驱动器、变频器都支持。它的模型是主站通常为PLC或上位机主动发起请求从站设备返回响应。如果你要写一个Modbus TCP服务器模拟设备或做协议转换网关同样需要并发能力因为现场会有多个主站同时读数据。Modbus TCP报文结构很简单MBAP报文头7字节功能码1字节数据。MBAP头里包含事务标识符、协议标识符固定0、长度和单元标识符长度字段注意是“后续字节数”不是整个报文长度。在TCP并发服务器里要重点处理半包问题因为TCP不会帮你保证一帧完整必须按照长度字段去缓冲和拆分。另外响应要及时很多PLC超时时间只有几百毫秒服务器如果处理慢了主站就会频繁报超时。此时事件驱动模型比多线程模型更有优势因为每个连接占用的资源极少可以同时对上百个主站请求做出毫秒级响应。5.2 物联网设备接入哪怕只有一个小WiFi模块像ESP01S这种巴掌大的WIFI模块写一段小程序就能通过TCP把数据发给手机或云端。但一个云平台要同时接入几十万台这样的设备服务器端面临的压力和工业场景完全不同连接数巨大、消息频率时高时低、设备经常断电重连、有些模块为了省电会定期断开再连接。这种场景下TCP并发服务器必须做好连接生命周期管理。因为设备可能大量处于“半开”状态应用层心跳检测远远比协议解析更重要。很多物联网厂商把业务协议设计得非常轻一条消息就几个字节反而是连接的建立和断开花费大量系统资源。如果你用“每连接一线程”的模型几万台设备同时重连线程池立刻被打爆。换成epoll或Go协程才能用一台普通服务器扛住几十万长连接。ESP01S这类模块能发TCP消息只是第一步服务器端怎么稳定承载才是物联网后台的核心挑战。5.3 别忘了还有UDP什么时候不要选TCP写并发服务器不代表永远只写TCP。TCP提供可靠、有序、面向字节流的传输代价是握手延迟、队头阻塞、流量控制。UDP没有这些机制但延迟低、开销小适合实时音视频、游戏同步、DNS这类场景。UDP并发服务器不存在“accept”和“连接状态”的概念内核只负责收包需要应用层自己通过源IP和源端口区分对端自己处理丢包重传、乱序和心跳保活。所以做技术选型时先回答三个问题应用是否能容忍丢包是否需要按序到达是否需要长连接状态如果三个都是“是”用TCP并发服务器否则UDP可能更合适。回到TCP服务器本身不管你最终选择了哪种模型核心永远是“事件驱动状态管理”把每个连接的协议解析和超时处理做好并发能力自然会上去。我个人在实际操作中的体会是并发服务器最大的坑往往不在“高并发”而在“状态混乱”。ET模式忘了非阻塞、粘包解析错一位、心跳超时设太短导致设备被误杀这些看似细枝末节的问题在连接数上来以后都会被无限放大。我在压测前都会先写好完整的日志和监控把每一条连接的生命周期打出来用灰度流量慢慢放量再滚到全网。最后分享一个小技巧别急着在第一天就上epoll自定义状态机先用协程或现成框架把业务跑通再根据性能瓶颈去优化大多数业务其实到不了百万并发稳定好维护比“最牛逼的技术”重要得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询