ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux C++四种并发服务器模型:从阻塞迭代到epoll实战

Linux C++四种并发服务器模型:从阻塞迭代到epoll实战 写Linux C网络编程有一个绕不开的分水岭你写的服务器到底是“能连上几个客户端”还是“能扛住上万个连接”这个分水岭背后正是业内常说的“四种CS模型”——阻塞式迭代服务器、多进程/多线程并发服务器、I/O多路复用服务器以及它们在工程上的各种变体。很多人在面试里能背出epoll是红黑树、能说出零拷贝但真要他动手把四种模型各写一遍再对着压测数据讲讲为什么就露馅了。这篇文章我就把这四种模型从代码到原理、从选型到踩坑完整拆开讲一遍适合正在学《UNIX网络编程》但还停在“看得懂、写不出”阶段的读者也适合准备C后端面试的人。1. 最朴素的阻塞式迭代服务器能跑通但只是起点1.1 最小可运行服务端长什么样先看一段最经典的echo服务端代码。这是所有CS模型的起点也是思维基线。#include cstring #include cstdio #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return 1; } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(9000); if (bind(listen_fd, (sockaddr*)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(listen_fd, 128) 0) { perror(listen); return 1; } while (true) { int conn_fd accept(listen_fd, nullptr, nullptr); if (conn_fd 0) { perror(accept); continue; } char buf[1024] {0}; ssize_t n read(conn_fd, buf, sizeof(buf)); if (n 0) { write(conn_fd, buf, n); } close(conn_fd); } }这段代码的逻辑很简单创建监听套接字bind到端口listen开始监听然后在while循环里accept、read、write、close。注意这里的accept返回的是一个全新的套接字——连接套接字它与listen_fd不是同一个fd。listen_fd负责接收新连接conn_fd负责和具体某个客户端收发数据。这个区分从第一种模型贯穿到第四种模型是理解后面所有内容的地基。1.2 两个阻塞点决定了它的能力上限这个模型的最大问题就是它同时只能服务一个客户端。为什么因为accept和read两个函数都是阻塞的。详细拆开来说accept在没有新连接到来时会一直卡在系统调用里直到内核把某个已完成握手的新连接交出来它才返回。read更麻烦如果客户端连接上来但一直不发数据服务端就会一直卡在这个连接的read上。这时候第二个客户端发来connect请求内核其实已经完成了三次握手把连接放进了listen队列但因为服务端进程还卡在第一个连接的read里accept根本没机会被调用第二个客户端的数据只能在内核缓冲区里排队等。这个问题在教科书里叫“串行处理”放在现实里就是活生生的“一个窗口的银行柜台”第一个人没办完业务后面排多长的队都没用。用它做本地调试工具或者教学演示完全够用但如果真有100个客户端同时连着就跑不动了。很多人会问那我把read改成非阻塞不就好了可以但非阻塞read带来的新问题是read会立刻返回-1你得轮询所有fd才能知道谁有数据这个轮询的成本在连接数变大之后同样吃不消。这就是第三种模型要解决的核心矛盾。1.3 别小看listen()的backlog参数再说一个新手容易忽略的点listen(fd, 128)里的128并不是“最大连接数”它是全连接队列的上限。内核为监听套接字维护了两个队列半连接队列和全连接队列。三次握手还没完成的连接放在半连接队列已经完成握手、等待accept取走的连接放在全连接队列。当全连接队列满了新的连接请求可能会被内核直接丢弃客户端表现为connect超时或直接失败。这个细节在第一种模型里还不明显因为处理速度慢导致队列很容易堆积。但到了epoll模型下如果你忘了在accept循环里把连接全部取走队列照样会爆。我见过不少人的服务端在连接数上来之后突然报“connect timeout”排查半天最后发现是backlog设置得特别小或者accept处理速度跟不上连接建立速度。这个问题在后面的ET模式里还会再出现先记住这个内核队列机制。2. 多进程与多线程并发上去了代价和隐患也跟着上去2.1 fork版连接处理与文件描述符继承陷阱既然阻塞迭代服务器的瓶颈是“一个时间段只能处理一个连接”最直觉的改进就是每来一个连接就fork一个子进程去处理父进程马上回到accept继续等待新连接。代码逻辑如下while (true) { int conn_fd accept(listen_fd, nullptr, nullptr); if (conn_fd 0) { continue; } pid_t pid fork(); if (pid 0) { // 子进程处理这个连接 close(listen_fd); char buf[1024] {0}; ssize_t n read(conn_fd, buf, sizeof(buf)); if (n 0) { write(conn_fd, buf, n); } close(conn_fd); exit(0); } // 父进程立刻返回继续accept close(conn_fd); }这段代码里最容易被忽略、也最容易出bug的是文件描述符的“引用计数”问题。fork会把父进程当前所有的文件描述符表完整复制一份到子进程也就是说listen_fd和conn_fd被父子进程同时持有。这就导致两个必须写的close子进程必须close(listen_fd)否则监听端口永远无法释放父进程必须close(conn_fd)否则conn_fd的引用计数不为0即使子进程已经close了连接也不会真正关闭。这个“引用计数”的机制在C/C里类似shared_ptr每个文件描述符在内核里对应一个file对象只有所有引用它的fd都被close才会真正释放。父子进程各自持有一份任何一方不close资源就一直被占着。这是多进程网络服务里最常见的坑没有之一。2.2 线程版连接处理与传参隐患多进程方案解决了并行处理的问题但进程创建的开销大而且进程间共享数据非常麻烦。于是很多人改用线程每来一个连接创建一个线程去处理。void* handle_conn(void* arg) { int conn_fd *(int*)arg; char buf[1024] {0}; ssize_t n read(conn_fd, buf, sizeof(buf)); if (n 0) { write(conn_fd, buf, n); } close(conn_fd); return nullptr; } while (true) { int conn_fd accept(listen_fd, nullptr, nullptr); if (conn_fd 0) { continue; } pthread_t tid; pthread_create(tid, nullptr, handle_conn, conn_fd); pthread_detach(tid); }这个版本有个非常隐蔽的坑pthread_create的第四个参数传的是conn_fd但conn_fd是循环里的局部变量。如果主线程快于子线程启动循环下一次的accept可能已经覆盖了conn_fd的值子线程拿到的conn_fd就不是它应该处理的那个了。更稳妥的做法是把conn_fd转换成一个指针大小再传进去pthread_create(tid, nullptr, handle_conn, (void*)(long)conn_fd);在handle_conn里再反向转回来。用这种“整数装箱”的方式就是为了避免传栈地址带来的竞争问题。别觉得这种低级错误不会发生在自己身上在多核环境下这是典型的“偶现bug”压测压力一上去就必现。2.3 进程与线程在并发场景下的真实代价fork和pthread看似都能解决并发问题但你把连接数往上提到几百甚至上千时第一种模型和第二种模型的差距就没那么大了因为它们共同的问题是一个活跃连接占用一个完整的执行单元。先算一笔账。线程默认栈大小通常是8MBulimit -s可以查。即使这只是虚拟内存预算但创建线程本身涉及内核线程的建立、线程栈的映射、调度器的介入1000个线程光调度开销就已经非常可观。CPU核数就那么多线程超过核数之后本质上就是让CPU在大量线程之间反复切换上下文切换的成本会逐渐超过业务逻辑本身的成本。多进程的问题更直接fork一个进程要复制页表、复制fd表、维护task_struct这个开销比创建线程大一个量级。再加上进程间通信只能靠管道、消息队列、共享内存写起来费劲调试更费劲。经典的做法是用“prefork”——提前fork出一批进程每个子进程都去accept同一个listen_fd而不是每来一个连接才fork一次。但prefork又引入了新的惊群问题连接到达时多个进程同时被唤醒只有一个是真正抢到accept资格的其他都白醒一场。2.4 多进程 vs 多线程快速对比维度多进程多线程创建开销高页表、fd表复制较低栈内核线程数据共享需要IPC复杂共享地址空间直接用变量同步手段管道/信号量/共享内存锁、条件变量隔离性好单进程崩溃不影响其他差一个线程段错误整个进程都挂崩溃恢复容易做watchdog重启难进程都没了适用连接量级几十到几百几百到几千但易触顶这张表不是在说谁比谁好而是要引出下一节的核心如果你已经理解了“连接多导致执行单元多执行单元多导致调度和切换成本飙升”那你自然就会想——能不能不让每个连接独占一个进程或线程这就是I/O多路复用要解决的问题。3. I/O多路复用单个线程如何调度上万个连接3.1 select/poll先把路趟出来但天花板太低I/O多路复用的核心思想是让一个线程同时监视很多个文件描述符哪个fd就绪了就去处理哪个。Linux下实现这个思路的API有三代select、poll、epoll。select的用法是这样的每次调用前把关心的fd放进fd_set集合调用后内核修改这个集合告诉你哪些fd可读可写。然后你遍历fd_set找到就绪的fd逐个处理。fd_set read_set; FD_ZERO(read_set); FD_SET(listen_fd, read_set); FD_SET(client_fd1, read_set); int max_fd max(listen_fd, client_fd1) 1; int n select(max_fd, read_set, nullptr, nullptr, nullptr); for (int i 0; i max_fd; i) { if (FD_ISSET(i, read_set)) { // 处理第i个fd } }select有三个硬伤。第一fd_set有上限默认是1024也就是说一个线程最多监视1024个fd这是内核里的FD_SETSIZE固定死的改起来要重新编译内核。第二每次调用select内核都要把整个fd_set从用户态拷贝到内核态返回时再拷回来几十上百个fd还好几千个fd本身就是一笔不小的开销。第三返回后你不知道具体是哪个fd就绪只能从0到max_fd全部扫一遍这个扫描是O(n)的。poll解决了fd_set上限的问题改用链表但它依然是每次全量拷贝、返回后线性扫描本质上的效率瓶颈没变。真正把这些痛点全部解决的是epoll。3.2 epoll的底层思路红黑树挂载回调唤醒epoll之所以是Linux下高并发服务的标配是因为它把“等谁就绪”这件事交给了内核而且内核通过回调机制把就绪的fd直接推给用户程序不是让用户程序自己去扫。具体来说epoll需要三个API配合使用。int epfd epoll_create(1); epoll_event ev{}; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); epoll_event events[1024]; int n epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { // 有新连接 } else { // 对应fd可读 } }epoll_create在内核里创建一个eventpoll对象这个对象内部有两张关键的数据结构一张红黑树用来存储你通过epoll_ctl添加进去的所有fd一张就绪链表用来存放已经就绪的fd。当你把某个fd添加进epoll时内核会给这个fd挂一个回调函数。一旦fd上有数据到达、连接建立等事件发生驱动层直接触发回调把该fd加入就绪链表。之后epoll_wait只需要检查就绪链表是否为空为空就睡眠不为空就把链表里的fd复制给用户空间。整个过程与总fd数量无关只与“当前到底有几个fd就绪”有关所以epoll的复杂度是O(1)的而select是O(n)。还有一个容易混淆的点epoll监听的fd数量上限不来自epoll本身而是来自系统的最大文件描述符数限制ulimit -n。上万个并发连接的服务几乎必然会碰到这个限制ulimit -n 1000000这类调优是运维基本功但这个调整属于系统环境配置在网络编程学习阶段可以先用小数值。3.3 LT与ET模式这轮最值得抠的细节epoll提供了两种触发模式水平触发Level TriggeredLT和边缘触发Edge TriggeredET。这是面试必问、实战必踩的点。LT是默认模式对应select的行为只要fd上还有数据没读完epoll_wait每次都会返回这个fd。比如你读了一半就返回了下次再调用epoll_wait它还是会告诉你这个fd可读。这个模式最安全最不容易丢数据也是大多数人的选择。ET就微妙得多。它的语义是“状态变化时通知一次”只有当fd从不可读变成可读、或者从可读变成不可读的那一刻才会通知你一次。如果你没把数据读完错过了这次通知那剩下的数据就只能等到下次有新数据进来才能被读到。特别容易发生的场景是客户端一次发了100个字节你只读了50个就返回剩下50个就这么堵在缓冲区里。这就是为什么ET模式要求你在一次可读事件里必须把数据读完——通常的做法是用while循环读一直读到返回EAGAIN为止。EAGAIN又牵扯出另一个要求ET模式下fd必须是非阻塞的。因为如果fd是阻塞的当你while循环read缓冲区里已经没数据时read会阻塞住整个线程你就没办法返回主循环继续处理其他事件了。只有非阻塞fd会在无数据时立即返回-1且errno为EAGAIN你看到EAGAIN就知道“读干净了可以退出循环”。3.4 ET模式下的服务端主循环代码前面讲了一堆理论这里给出一个ET模式下的完整骨架配合注释看一下就能理解LT和ET在实际代码里的差别。#include fcntl.h 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); // bind listen略 int epfd epoll_create(1); epoll_event ev{}; ev.events EPOLLIN | EPOLLET; // listen_fd也设成ET ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); epoll_event events[1024]; while (true) { int n epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { // ET模式下accept也要循环到EAGAIN否则会漏连接 while (true) { int conn_fd accept(listen_fd, nullptr, nullptr); if (conn_fd -1) break; // 队列空了 set_nonblocking(conn_fd); ev.events EPOLLIN | EPOLLET; ev.data.fd conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev); } } else { // 处理客户端数据同样要while循环读到EAGAIN int fd events[i].data.fd; char buf[4096]; while (true) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { write(fd, buf, n); } else if (n -1 errno EAGAIN) { break; // 读完了 } else { close(fd); // 对端关闭或出错 break; } } } } } }这段代码里的两个while循环是ET模式的标准写法accept要循环到返回-1read要循环到EAGAIN。漏掉任何一个都会在特定时机丢事件。很多“在低并发下好好的高并发下丢数据”的bug追根到底都是ET模式下的循环处理不彻底。我在实际工程里见过不少团队因为怕ET模式的坑干脆全程用LT。LT的代价是每次可读都会通知可能造成一定程度的重复唤醒但在绝大多数业务场景下LT的性能足够好而且代码不容易写错。所以ET和LT本就是“性能和风险”的取舍并没有绝对的优劣。4. 四种模型放到同一张表里横向比较选型逻辑一下就清楚了4.1 四张模型核心参数对照把四种模型放在一起看很多以前模糊的判断会变得非常清晰。模型并发能力每连接资源成本开发复杂度数据共享典型场景阻塞式迭代1无额外成本低无教学、单客户端工具多进程几十到几百高进程表内存复制中难需IPC老式FTP/HTTP服务多线程几百到几千中线程栈内核线程中高锁竞争直接共享易出bug中等并发应用服务I/O多路复用epoll数千到数十万极低一个线程管所有fd高事件状态机天然安全网关、IM、长连接服务这张表的横向逻辑是从左到右并发能力提升的同时开发复杂性也上升了。阻塞迭代完全没有并发能力但它为所有高层模型提供了基础心智模型。多进程和多线程本质上是“以资源换并发”用成倍的执行单元来并行处理连接。epoll则改变思路“以降频换并发”——线程数控制住了靠事件驱动去服务海量连接。4.2 按业务类型选型IO密集、CPU密集与混合场景选择哪种模型不能只看“并发高不高”还要看你的业务是I/O密集还是CPU密集。如果是I/O密集型的业务比如网关转发、IM消息推送、在线聊天大部分时间线程都在等网络数据这类业务非常适合epoll单线程或者epoll少量工作线程的组合。因为线程大部分时间在睡眠等待用epoll可以把这部分的等待成本降到最低。如果是CPU密集型的业务比如图像处理、音视频编解码、加密解密单纯用epoll就没太大意义了。你就算用epoll等来一个就绪事件处理它还是要消耗大量的CPU这时候反而应该把执行单元数量跟CPU核数对齐让每个核都有进程跑满。典型的做法是“进程数等于核数”每个进程内部再用epoll处理自己的I/O。混合场景是大多数真实服务的状态既要做大量的网络收发又要做一定的业务计算。这时候比较合理的组合是“epoll负责接入和分发 线程池负责业务计算”。主线程/主进程通过epoll拿到就绪fd把待处理的数据丢进一个任务队列线程池里的工作线程从队列里取任务执行。这种架构兼顾了epoll的高并发接入能力和线程池的并行计算能力。4.3 别被“epoll天下第一”带偏网上很多文章把epoll吹成万能神药但实际工程里选型远没有那么极端。epoll强在“连接数极多但活跃连接占比不高”的场景如果一万个连接同时都有数据要收发那单线程即使跑得再快读写也依然是串行的。这时候瓶颈就变成了单线程的处理能力你还是得靠多线程甚至多进程把流量分流出去。换一个角度说select和阻塞模型也并没有“过时”。很多嵌入式环境、轻量工具的代码里select仍然是主力。select的实现极其可靠跨平台性好代码写起来直白几十个连接规模下性能不比epoll差太多。选的不是“最先进”的模型而是“最匹配问题规模”的模型。所以我的建议是如果你在做课程设计、内部小工具、管理面板之类的东西阻塞迭代模型就够了如果是一个正经的线上服务并且预期连接数会涨直接上epoll或者基于epoll的Reactor框架多线程模型适合业务逻辑简单、连接数可控、团队对并发控制比较有把握的情况。不要为了用epoll而用epoll高并发从来不只是API选型问题它还包括业务拆分、缓存设计、限流降级等一系列配套工程。5. 实测踩坑与面试追问这些细节不会写在教科书里5.1 accept与EMFILE高并发下第一个隐藏雷在高并发场景下accept可能会返回-1错误的errno往往是EMFILE意思是没有可用的文件描述符了。默认情况下用户进程能打开的文件描述符数量是1024ulimit -n可以查到这个值在生产服务器上通常需要调大。但即使调大也总有上限一旦连接数触顶accept就会失败。这个坑的诡异之处在于如果你在epoll里监听的是listen_fd而accept失败后没有处理listen_fd会持续保持在可读状态导致epoll_wait反复被唤醒形成一个“空转风暴”CPU被打满但实际没处理任何连接。一个常见的处理技巧是“缺额fd预留”程序启动时提前打开一个空闲fd当accept返回EMFILE时先把预留的fd关掉再accept一次让这个新连接被成功接受然后立刻close掉最后再重新打开预留fd。这样就避免了epoll空转代价只是丢掉这个无法处理的连接。这是个很偏门的技巧但高并发压测时说不定就能救命。5.2 ET模式漏读漏accept必须循环到EAGAIN前面代码里其实已经提到了这里单独拎出来再说一次。ET模式下的两个循环是最容易写漏的一个是listen_fd上的accept循环一个是conn_fd上的read循环。漏掉accept循环的后果是全连接队列里同时排了3个新连接EPOLLIN事件只触发一次你只accept了一个另外两个就一直留在队列里直到有新连接再次触发事件你才有机会取走它们。如果连接请求是突发性的这个“丢失”会造成明显的连接延迟甚至客户端超时。漏掉read循环的后果前面说过数据会滞留在内核缓冲区读不到也不能触发新的EPOLLIN事件。除非客户端再次发数据否则那段数据就一直卡着。判断ET循环是否写对的方法很简单压测工具连发多包然后在服务端检查有没有“数据量对不上”的情况。跑一遍就知道。5.3 TIME_WAIT、端口耗尽与压测误判还有一个经常被误判的问题是TIME_WAIT状态。主动关闭连接的一方会进入TIME_WAIT状态持续大约2MSLLinux上通常为60秒。如果你用短连接做高并发压测客户端每个请求都新建连接压测跑完你会发现系统里堆积了大量TIME_WAIT状态的socket。这不是“连接泄漏”而是TCP协议为了保证最后一个ACK能可靠送达、以及防止旧连接的数据包串扰到新连接必须保留的状态。但服务端遇到大量TIME_WAIT是完全正常的尤其是服务端主动关闭连接时。压测时真正会出问题的反而是客户端端口耗尽客户端发起大量短连接每次连接用掉一个临时端口端口进入TIME_WAIT后无法立刻复用等端口池耗尽客户端就报“Address already in use”。解决这类问题通常需要调内核参数比如允许TIME_WAIT复用或快速回收。理解“客户端端口池”这个概念比死记参数名有用得多。5.4 惊群问题与内核的解法当多个进程或线程同时对同一个listen_fd调用accept或epoll_wait时如果有一个新连接到来内核会唤醒所有等待者但最终只有一个能accept成功其他被唤醒的执行单元都是空跑。这就是“惊群效应”。老的内核版本里Nginx就深受其害后来Nginx用“负载均衡锁”来避免所有worker同时被唤醒。现代Linux已经做了优化accept系统调用本身在内核层面已经加了这个互斥逻辑多线程同时accept同一个listen_fd通常只会有一个被唤醒。而epoll_wait这边从内核4.5版本开始Linux提供了EPOLLEXCLUSIVE事件标志加上之后对于同一组events多个等待的进程/线程只会有一个被唤醒有效抑制了epoll层面的惊群。实战里如果实现架构是“多线程共享一个listen_fd”建议把EPOLLEXCLUSIVE加在listen_fd的事件上。新版本内核编译程序时这一项的手感会比老版本流畅得多。5.5 面试官爱追问的几个底层问题最后把这几个问题串一下都是我在面试里被别人问过、也问过别人的答清楚这几点说明你是真的理解CS模型而不是背了个框架。为什么epoll比select快因为select每次调用都要把fd_set全量拷贝到内核遍历所有fd判断就绪状态整体是O(n)的epoll通过红黑树维护fd集合通过回调机制把就绪fd挂到就绪链表epoll_wait只把就绪链表复制到用户空间复杂度O(1)。代价是epoll的API调用次数更多、实现更复杂所以对于少量fd的场景epoll未必比select快。LT和ET在实际项目里怎么选图省心选LT追求极端性能、且能保证一次把数据处理干净选ET。ET还必须配套非阻塞fd和while循环读代码量会上去但响应延迟在大量小包场景下更优。为什么ET模式要求fd是非阻塞的因为ET模式通知的是“状态变化”你必须一次性读完。阻塞fd在缓冲区读空后会被卡住整个线程就废了。非阻塞fd返回EAGAIN你才能知道“当前已读尽”然后退出循环继续处理其他事件。多路复用省的是线程还是CPU省的是线程和上下文切换成本。它让一个线程能服务大量空闲连接使CPU在“真正有活干”的事情上投入使用而不是在大量睡眠线程之间来回切换。这些问题没有标准答案的“唯一解”但如果能把底层机制讲清楚并且结合你自己的压测或者踩坑经历比单纯背八股文有说服力得多。我个人的建议是学习这四种模型一定要动手各写一遍写完Reactor再回来看这些抽象概念会变得非常具体。如果你现在手头有个项目要选型我的体会是长连接高并发业务直接epoll起步少量连接或内嵌脚本工具用阻塞迭代就够多线程要慎重锁一旦多起来调试成本远大于模型本身。真正的高手不是只会调某个API而是能根据场景在几种模型之间快速切换。
返回列表