基于epoll的Reactor模式:用C语言实现高并发回声服务器

发布时间:2026/10/19 14:08:16
基于epoll的Reactor模式:用C语言实现高并发回声服务器 前阵子做一个内部工具需要同时维护上百个TCP长连接用老办法一个连接开一个线程跑起来没多久内存就顶不住了。翻了翻实现发现问题不在业务逻辑而在并发模型本身。后来把代码切到Reactor模型一个进程一个线程就把问题消化掉了CPU占用还降了一大截。Reactor这个词搞网络编程的人多少都听过。面试里常问开源框架里到处是它的影子但真让你说清楚它是什么、为什么能扛高并发、怎么一步步实现很多人又会卡壳。这篇文章就把这条链路彻底打通从Reactor的诞生背景和核心原理讲起然后手把手用C语言和epoll实现一个自发自送的TCP服务器。所谓自发自送就是客户端发什么服务端原样送回什么技术上叫做echo服务是最适合验证网络链路的实验场景。你不需要任何第三方库一台装了Linux的虚拟机或者云主机就够跟着敲完整个Reactor的骨架就长在你脑子里了。1. 从阻塞模型到Reactor高并发服务器的思路演变1.1 传统多线程模型简单但贵得离谱先聊最原始、也是大多数人第一反应能想到的模型多线程阻塞式服务。来一个连接就开一个线程线程里调accept阻塞等待连接建立后再调read阻塞等数据数据到了就处理处理完继续读。逻辑看起来顺理成章代码写起来也直观但问题一个比一个要命。第一个问题是线程太贵。每个线程默认栈大小是8MB你创建一千个线程光栈就吃掉8GB内存还没算线程控制块、线程局部存储这些开销。我见过一个内部服务并发连接刚到两百个进程内存已经涨到2GB业务没怎么跑内存先告急了。第二个问题是线程切换的成本。系统里的线程一多CPU花在上下文切换上的时间比例直线上升真正用来读写数据的CPU时间反而被挤占。一千个线程在同一个多核CPU上抢时间片每次切换都要保存和恢复寄存器、栈指针、调度信息这些开销是纯浪费。还有一个更隐蔽的问题阻塞调用让线程在等数据期间完全闲置。一个连接大部分时间没有数据可读read一直挂在那里线程占着调度机会却什么也不干。这相当于饭店给每张桌子配一个服务员等着客人随时叫人哪怕客人十分钟不说话服务员也只能干站着。资源利用率的账怎么算都是亏的。你想优化就得把线程数量控制住但线程少了新连接又处理不过来这就是传统模型的死结。1.2 I/O多路复用让一个线程看管所有连接能不能让一个线程同时盯着很多连接哪个有数据再处理哪个这就是I/O多路复用的核心思路。select、poll、epoll都是干这件事的它们让一个线程监控一大批文件描述符内核发现某个描述符就绪了就告诉应用程序。在Linux上现在主流选择是epoll。拿select对比一下select每次调用都要把全部fd集合从用户态拷贝到内核态内核再逐个轮询复杂度是O(N)而且单个进程能监控的fd数量默认也就1024个。epoll把fd组织在一棵红黑树里内核只对有状态变化的fd做通知应用程序调用epoll_wait拿到的就是已经就绪的事件列表复杂度接近O(1)。在连接数几千、几万的场景下epoll的优势是碾压级的。从代码组织的角度看多路复用解决了用什么等事件的问题但它没有解决事件来了怎么办。传统写法里你还是在函数里判断fd、分情况处理代码一多就乱成一锅粥。我们需要一个稳定的结构来组织哪些fd感兴趣、什么事件来了做什么这就是Reactor要干的事。1.3 Reactor模式的本质事件来了才处理Reactor直译是反应堆它还有一个更直观的名字叫事件循环。核心特征是反客为主应用程序不主动去读数据而是把关心的fd和对应的处理函数注册到Reactor里然后进入循环等待。内核通知这个fd有数据可读了Reactor才去查表调用事先注册好的处理函数。用前面服务员的话说Reactor就是一个领班手里有一本花名册记录着每张桌子、每位客人的需求。客人不招手领班就坐在前台等着客人一招手领班按照花名册安排对应服务员过去。这里的核心是注册-等待-分发-处理四个动作循环往复。注意事件处理函数里不能有阻塞调用读socket必须是非阻塞的否则你处理一个慢连接整个事件循环就被卡住其他所有连接都跟着遭殃。模式本身不复杂复杂的是这个模型衍生出来的各种变体。先把最基础的单线程Reactor吃透后面看多线程Reactor、多Reactor模型都会顺很多。2. Reactor模式的核心组成与工作原理2.1 四个核心角色一个标准的Reactor实现基本上由四个角色凑成事件源就是文件描述符包括监听socket、客户端socket甚至管道、定时器fd都算。多路分发器在Linux上就是epoll职责是监控一大波事件源告诉Reactor谁就绪了。Reactor核心持有事件循环和事件注册表。事件循环就是一个while(1)包着epoll_wait事件注册表就是fd到回调函数的映射。事件处理器针对不同事件写的处理函数比如处理新连接的回调、处理可读数据的回调。这四个角色配合起来就是把谁在关心什么事件、事件发生了做什么这两件事分离。epoll负责第一件事回调函数负责第二件事Reactor核心只做转达。这样你可以随时给一个新的fd挂上一个新的回调不需要改动事件循环本体扩展性很好。2.2 一次完整的事件处理流程拿我们要写的echo服务走一遍完整流程。服务启动创建监听socket把它注册到Reactor监听EPOLLIN事件。Reactor进入epoll_wait挂起等待。客户端发起connect内核完成TCP三次握手后监听socket变成可读状态epoll_wait返回Reactor发现就绪的fd是监听socket查表找到回调调用handle_accept。handle_accept里accept出新连接拿到新的客户端fd马上给这个fd也注册EPOLLIN事件回调设为handle_read。然后Reactor回到epoll_wait继续等。客户端发送hello客户端fd可读epoll_wait再次返回调用handle_read。handle_read从fd里读出数据原样写回给客户端这就是echo的自发自送。客户端关闭连接下次read返回0handle_read识别到EOF调用close_conn删除这个fd的事件并close。从头到尾整个过程在单线程里完成没有等待、没有锁、没有线程切换。这是Reactor最让人舒服的地方逻辑是线性推进的调试起来不用考虑并发竞争。2.3 单线程Reactor为什么能扛住高并发一听到单线程很多人第一反应是性能上限低这是误解。网络服务绝大多数时间是在等I/O真正的CPU计算量很小。等I/O这件事epoll已经帮你做完了一个线程在epoll_wait里挂起时CPU几乎零消耗。等到有事件了才处理处理完立刻回到挂起状态CPU不会空转轮询利用率反而高。单线程还有一个巨大的好处没有锁。多线程程序里的互斥、同步、原子操作在单线程Reactor里全部不存在。这省掉的不仅仅是加锁解锁的开销还有一堆肉眼看不见的坑比如锁竞争导致的线程唤醒风暴、缓存一致性协议导致的性能下降。当然这里有一个前提你的事件处理函数必须足够快。回调里不允许有耗时计算不允许有阻塞系统调用。一旦某个回调卡了几十毫秒所有连接都会等在这几十毫秒后面这种事在线上出过太多次了。解决办法不是放弃Reactor而是把重活挪到别处第6章会细讲。3. 动手实现基于epoll的Reactor回声TCP服务器3.1 技术选型与最小数据结构实现环境建议用Linuxgcc编译不需要任何第三方库。选择epoll做多路分发器选择C语言是因为它离操作系统最近能把Reactor的核心机制看得最清楚。用Python的asyncio也能写但那些封装把底层的fd、回调、事件循环藏得太深不适合理解原理。最核心的数据结构其实就两个一个是事件注册表一个就绪事件数组。我做了一个最小的封装typedef struct reactor reactor_t; typedef void (*event_cb)(reactor_t *r, int fd, uint32_t events); typedef struct event_item { uint32_t events; event_cb cb; } event_item; struct reactor { int epfd; struct epoll_event events[MAX_EVENTS]; event_item items[MAX_EVENTS]; };items数组以fd作为下标存着这个fd关心的事件类型和处理回调。这是一个极度简化但完全够用的做法数组下标天然完成fd到回调的映射。生产级实现会用哈希表或者动态增长的指针数组但作为教学版本数组版本最大的优点是一眼就能看懂。3.2 事件循环与连接处理第一版核心逻辑事件循环是整个Reactor的心脏长这样void event_loop(reactor_t *r) { while (1) { int n epoll_wait(r-epfd, r-events, MAX_EVENTS, -1); if (n 0) { if (errno EINTR) continue; perror(epoll_wait); break; } for (int i 0; i n; i) { int fd r-events[i].data.fd; uint32_t evs r-events[i].events; if (r-items[fd].cb) r-items[fd].cb(r, fd, evs); } } }这里有一个实际开发中很容易漏掉、但线上一定会踩的细节epoll_wait如果被信号打断返回值是-1errno是EINTR。这种情况下正确的做法是continue而不是直接退出循环。很多新手的服务莫名其妙退出最后查出来就是没处理EINTR。多线程程序里信号处理更是常见加了这句话能省很多事。然后是事件注册和修改。我用一个函数统一处理新增和修改int event_set(reactor_t *r, int fd, uint32_t events, event_cb cb) { int op (r-items[fd].cb NULL) ? EPOLL_CTL_ADD : EPOLL_CTL_MOD; struct epoll_event ev; memset(ev, 0, sizeof(ev)); ev.events events; ev.data.fd fd; int rc epoll_ctl(r-epfd, op, fd, ev); if (rc 0) { r-items[fd].events events; if (cb) r-items[fd].cb cb; } return rc; }注意EPOLL_CTL_MOD的用途同一个fd改事件类型时不需要删掉重加直接MOD就行。这个函数在设计上允许cb传NULL表示只改事件不换回调后面写事件升级时会用上。accept回调和读回调是第一版的核心void handle_accept(reactor_t *r, int listen_fd, uint32_t events) { struct sockaddr_in addr; socklen_t len sizeof(addr); int cfd accept(listen_fd, (struct sockaddr *)addr, len); if (cfd 0) return; set_nonblocking(cfd); event_set(r, cfd, EPOLLIN, handle_read); } void handle_read(reactor_t *r, int fd, uint32_t events) { char buf[BUFFER_SIZE]; int n read(fd, buf, sizeof(buf)); if (n 0) { write(fd, buf, n); } else if (n 0) { close_conn(r, fd); } else { if (errno ! EAGAIN errno ! EWOULDBLOCK) close_conn(r, fd); } }这个第一版能跑起来echo功能完全正常。但我必须指出来它在两个地方埋了雷一是read到数据后直接write如果发送缓冲区满了write返回-1EAGAIN这些剩下的数据就丢了。二是handle_read只调用一次read如果一次没读完数据会残留。这两个问题在下一小节一起解决。3.3 升级写出从直接write到输出缓冲升级的思路不复杂给每个连接挂一块输出缓冲区read到的数据先塞进缓冲区然后尝试发送。发送不完整的部分留在缓冲区里注册EPOLLOUT事件等socket可写时再继续发。这个输出缓冲 写事件的组合是所有工业级网络框架的标配。先给连接增加一个状态结构typedef struct conn_info { char buf[BUFFER_SIZE]; int len; int offset; } conn_info;buf是输出缓冲len是待发送数据总长度offset是已经发到哪了。Reactor里加一个数组struct reactor { int epfd; struct epoll_event events[MAX_EVENTS]; event_item items[MAX_EVENTS]; conn_info conns[MAX_EVENTS]; };核心函数是try_send_datavoid try_send_data(reactor_t *r, int fd) { conn_info *conn r-conns[fd]; while (conn-offset conn-len) { int n write(fd, conn-buf conn-offset, conn-len - conn-offset); if (n 0) { conn-offset n; } else if (n 0 (errno EAGAIN || errno EWOULDBLOCK)) { event_set(r, fd, EPOLLIN | EPOLLOUT, NULL); return; } else { close_conn(r, fd); return; } } if (conn-offset conn-len) { conn-len 0; conn-offset 0; event_set(r, fd, EPOLLIN, NULL); } }handle_read变成把数据放入缓冲然后调用try_send_datavoid handle_read(reactor_t *r, int fd, uint32_t events) { char buf[BUFFER_SIZE]; int n read(fd, buf, sizeof(buf)); if (n 0) { conn_info *conn r-conns[fd]; if (conn-len n BUFFER_SIZE) { close_conn(r, fd); return; } memcpy(conn-buf conn-len, buf, n); conn-len n; try_send_data(r, fd); } else if (n 0) { close_conn(r, fd); } else { if (errno ! EAGAIN errno ! EWOULDBLOCK) close_conn(r, fd); } }handle_write的作用就是socket可写了继续把剩余数据发出去void handle_write(reactor_t *r, int fd, uint32_t events) { try_send_data(r, fd); }这套设计的精妙之处在于写不出去的数据不会丢会一直留在缓冲区里等待EPOLLOUT事件到达后再续传。而EPOLLOUT事件在socket发送缓冲区有空闲时才会触发这就保证了我们不会忙轮询。很多刚接触非阻塞I/O的人看到EPOLLOUT会觉得莫名其妙其实它的语义就是你现在可以往这个fd里写数据了不会阻塞。3.4 完整代码与编译运行把上面的模块拼起来补上socket初始化和close_conn就是一个完整的可运行程序#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include fcntl.h #include sys/epoll.h #include sys/socket.h #include netinet/in.h #define MAX_EVENTS 1024 #define BUFFER_SIZE 4096 #define LISTEN_PORT 20080 typedef struct reactor reactor_t; typedef void (*event_cb)(reactor_t *r, int fd, uint32_t events); typedef struct event_item { uint32_t events; event_cb cb; } event_item; typedef struct conn_info { char buf[BUFFER_SIZE]; int len; int offset; } conn_info; struct reactor { int epfd; struct epoll_event events[MAX_EVENTS]; event_item items[MAX_EVENTS]; conn_info conns[MAX_EVENTS]; }; static int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } static int reactor_init(reactor_t *r) { memset(r, 0, sizeof(*r)); r-epfd epoll_create(1); return r-epfd; } static int event_set(reactor_t *r, int fd, uint32_t events, event_cb cb) { int op (r-items[fd].cb NULL) ? EPOLL_CTL_ADD : EPOLL_CTL_MOD; struct epoll_event ev; memset(ev, 0, sizeof(ev)); ev.events events; ev.data.fd fd; int rc epoll_ctl(r-epfd, op, fd, ev); if (rc 0) { r-items[fd].events events; if (cb) r-items[fd].cb cb; } return rc; } static void close_conn(reactor_t *r, int fd) { epoll_ctl(r-epfd, EPOLL_CTL_DEL, fd, NULL); r-items[fd].cb NULL; r-items[fd].events 0; r-conns[fd].len 0; r-conns[fd].offset 0; close(fd); } static void try_send_data(reactor_t *r, int fd) { conn_info *conn r-conns[fd]; while (conn-offset conn-len) { int n write(fd, conn-buf conn-offset, conn-len - conn-offset); if (n 0) { conn-offset n; } else if (n 0 (errno EAGAIN || errno EWOULDBLOCK)) { event_set(r, fd, EPOLLIN | EPOLLOUT, NULL); return; } else { close_conn(r, fd); return; } } conn-len 0; conn-offset 0; event_set(r, fd, EPOLLIN, NULL); } static void handle_read(reactor_t *r, int fd, uint32_t events) { char buf[BUFFER_SIZE]; int n read(fd, buf, sizeof(buf)); if (n 0) { conn_info *conn r-conns[fd]; if (conn-len n BUFFER_SIZE) { close_conn(r, fd); return; } memcpy(conn-buf conn-len, buf, n); conn-len n; try_send_data(r, fd); } else if (n 0) { close_conn(r, fd); } else { if (errno ! EAGAIN errno ! EWOULDBLOCK) close_conn(r, fd); } } static void handle_write(reactor_t *r, int fd, uint32_t events) { try_send_data(r, fd); } static void handle_accept(reactor_t *r, int listen_fd, uint32_t events) { struct sockaddr_in addr; socklen_t len sizeof(addr); int cfd accept(listen_fd, (struct sockaddr *)addr, len); if (cfd 0) return; set_nonblocking(cfd); event_set(r, cfd, EPOLLIN, handle_read); } static int create_listen_socket(int port) { int fd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(port); bind(fd, (struct sockaddr *)addr, sizeof(addr)); listen(fd, 128); set_nonblocking(fd); return fd; } static void event_loop(reactor_t *r) { while (1) { int n epoll_wait(r-epfd, r-events, MAX_EVENTS, -1); if (n 0) { if (errno EINTR) continue; perror(epoll_wait); break; } for (int i 0; i n; i) { int fd r-events[i].data.fd; uint32_t evs r-events[i].events; if (r-items[fd].cb) r-items[fd].cb(r, fd, evs); } } } int main() { reactor_t *r malloc(sizeof(reactor_t)); reactor_init(r); int listen_fd create_listen_socket(LISTEN_PORT); event_set(r, listen_fd, EPOLLIN, handle_accept); printf(echo server on port %d\n, LISTEN_PORT); event_loop(r); return 0; }编译运行gcc -Wall -O2 -o reactor_echo reactor_echo.c ./reactor_echo启动后如果没有报错监听端口就是20080。这个完整版本包含了输出缓冲能正确处理大流量下的写阻塞虽然代码不长但已经是工业级Reactor的微型骨架。有一个需要说明的简化点items和conns数组都用固定大小MAX_EVENTS下标直接是fd。Linux默认进程fd上限通常有几千甚至几万但对于高并发场景fd数可能超过1024这时候数组方式就不够用了。生产级做法是用动态哈希表或链表管理但教学版本用数组更直观把原理讲清楚后再换数据结构成本并不高。4. 端到端实测自发自送功能怎么验证4.1 测试思路验证点远比能连上多很多新手写完服务端启动后看到端口被监听了就以为完事了。其实能连上只是最低标准对于echo服务至少还要验证四件事连接能否建立三次握手是否走通客户端发送的数据服务端能否原样送回字节一个不差多连接并发时事件分发是否正确连接之间会不会串数据客户端断开后服务端能否正确关闭连接、回收事件。这四件事逐项验证过才能说这个服务器的基本功能是靠谱的。4.2 用nc和python把echo链路跑起来最简单的测试工具是nc。开一个终端运行服务器另一个终端执行nc 127.0.0.1 20080连接建立后随便输入一行字hello reactor你会立刻看到hello reactor这就是自发自送。你发什么原样收到什么。按CtrlD关闭连接服务端日志没报错说明关闭流程正常。如果机器上没装nc用Python一行也能测python3 -c import socket; ssocket.create_connection((127.0.0.1,20080)); s.sendall(bhello\\n); print(s.recv(1024))输出bhello\n就证明链路是通的。python -c这种方式在写脚本化测试的时候很灵活经常被我用来快速验证一个端口服务是否正常。4.3 多连接并发与进程资源观察单连接通只能说明基础链路没问题Reactor最怕的是多连接下事件分发串台。我习惯的做法是开三个终端每个都nc连上去各自发送不同内容比如第一个终端发abc第二个发123第三个发xyz。如果三个终端都只收到自己发的内容说明每个fd的回调隔离是正常的。再多做一步用一条shell命令模拟几十个并发连接seq 1 100 | xargs -P 20 -I {} sh -c echo conn-{} | nc -w 1 127.0.0.1 20080 /dev/null这条命令会开20个并行进程每个建立一个连接发送一条消息。服务端如果稳定扛下来没有报错没有崩溃说明基础的并发能力是有的。顺手再开一个终端用top观察进程资源。你会看到这个单线程的Reactor进程CPU占用很低内存占用也就几MB。同样是100个连接如果用多线程阻塞模型内存随随便便上几百MB这个对比非常直观。5. 常见问题与排查技巧实录5.1 事件循环卡死的元凶阻塞socket和漏读我在实际给同事排查Reactor问题时遇到最多的一个现象是服务刚启动时正常过一会儿整个服务就像死了一样一个新连接都进不来。查到最后绝大多数都是同一个原因某个socket被设成了阻塞模式或者某个回调里出现了阻塞调用。比如handle_read里如果犯了懒直接用阻塞模式的read数据没到的时候read就会一直挂在那里整个事件循环停摆其他所有连接全部超时。这不是某个连接变慢的问题而是一颗老鼠屎坏了一锅汤的问题。排查方法很简单用strace或者打印日志看epoll_wait是不是长时间没有返回。一旦确认事件循环卡住优先检查所有fd的O_NONBLOCK标志再检查回调函数里有没有sleep、阻塞read、阻塞write、同步锁这种危险操作。另一个隐蔽的坑是漏读。如果是水平触发模式fd可读时epoll会一直通知你直到你把数据读完。但如果你在handle_read里一次只读几个字节或者每次都只调用一次read不循环会导致这个fd一直被通知其他fd的处理被严重推迟。这虽然不是死循环但效果和卡死差不多服务表现为某个连接狂占CPU其他连接饿死。5.2 EAGAIN不是错误非阻塞读写的正确姿势非阻塞模式下read/write在没有数据或者缓冲区满时会返回-1并设置errno为EAGAIN有时候也叫EWOULDBLOCK。这个返回值不是错误它的意思是现在没法继续了等事件就绪再来。很多新手看到-1就当作异常处理直接把连接关闭结果客户端明明还活着连接就被服务端强行掐断了。正确的姿态是读到EAGAIN就停止本次读取回到事件循环等下一次EPOLLIN写入遇到EAGAIN就把剩余数据留在缓冲区注册EPOLLOUT继续等。我把这个规则总结成两句话——读到EAGAIN是暂时没有了写到EAGAIN是缓存满了换时间再写。还有个细节EAGAIN和EWOULDBLOCK在Linux上是同一个值判断时用errno EAGAIN || errno EWOULDBLOCK更稳妥。虽然实际开发中很多人只判断一个但严谨一点没坏处。5.3 水平触发还是边沿触发选哪个epoll支持两种触发模式这是绕不开的选型问题。直接给结论新手和大多数业务场景用水平触发LT追求极致性能的老鸟才考虑边沿触发ET。对比项水平触发 LT边沿触发 ET通知时机fd有事件就反复通知直到处理完fd状态变化时通知一次读数据要求可以一次不读完下次还会通知必须循环读一次读到EAGAIN否则可能丢数据写数据习惯写事件触发条件宽松不易漏写事件需要自己管理EPOLLOUT细节更多适用人群入门、稳定优先高性能场景、对epoll理解深我教学用的一直是LT原因是LT模式下即使代码写得不够严谨顶多是多几次事件通知不容易发生数据丢失。ET模式下如果handle_read不循环读到EAGAIN剩下的数据就不会再通知了这种bug排查起来非常痛苦。等LT版本稳定跑了一段时间理解了事件驱动本质之后再去尝试ET也不迟。5.4 连接关闭、缓冲残留与定时器缺失客户端异常断开比如拔网线或者强制kill进程服务端不会立刻感知到。如果一直不处理这个半开连接会占用fd和缓冲区资源。Linux里的TCP保活机制默认要几个小时才触发对于服务端来说太慢了。实际开发中会给每个连接记录最后活跃时间配合定时器定期扫描超过N秒没有数据的连接直接关闭。这个功能在纯epoll里需要自己实现一般是用带超时的epoll_wait每隔一段时间检查一次所有连接的时间戳。连接正常关闭时read会返回0这时候要执行清理动作EPOLL_CTL_DEL删除事件、释放连接上下文、close fd。漏掉任何一步轻则fd泄漏重则脏数据残留在事件注册表里后续epoll_wait可能返回一个已经关闭的fd导致回调访问无效内存。所以close_conn这个函数虽然只有几行却是我最喜欢写注释的地方。还有一个可能踩的坑close fd之后这个fd数字可能被后续新的socket复用。如果事件注册表里还残留旧回调新连接上来时可能误触发旧逻辑。这也是为什么close_conn里必须把items[fd].cb清空不能省。6. 单线程Reactor的边界再扩展6.1 事件回调里坚决不能有重活单线程Reactor最大的软肋就是回调函数里的CPU密集型计算。比如收到一个请求后服务端要压缩数据、做加密、处理复杂的业务规则这些计算动辄几十毫秒这段时间里事件循环是停摆的所有连接的读写都被冻住。我实际测过一个回调里加一段20毫秒的模拟计算50个并发连接的延迟立刻从1毫秒飙升到1秒体验断崖式下跌。解决办法是计算与I/O分离。思路是Reactor只管接受连接和收发数据拿到完整请求后把业务计算任务丢给线程池去执行Reactor立刻回到事件循环继续处理其他网络事件。线程池算完后把结果通过管道或者eventfd写回来触发Reactor的读事件再由Reactor把结果发给客户端。这套模式有个学名叫半同步半异步实现起来比单线程Reactor复杂不少但思路本质还是网络事件循环不能被脏活累活拖住。6.2 从单Reactor到多Reactor单线程Reactor再扛不住怎么办比如要支撑百万级别的连接或者CPU核心很多但单线程只能跑一个核这时候就要引入多Reactor架构。思路很清晰一个main reactor只负责accept新连接accept到连接后分发给多个sub reactor之一。每个sub reactor跑在各自的线程里各自有一个事件循环专门处理自己名下的连接读写。这样多个CPU核可以并行干活每个核跑一个事件循环性能基本随核数增长。这种架构在业内已经是标准答案。很多开源网络框架都是这个套路主线程accept工作线程各自跑Reactor每个连接绑定到一个固定工作线程避免了锁竞争。从单Reactor到多Reactor的演进路径很平滑你先把本章的echo实现跑熟理解事件循环怎么运转再去看多Reactor就是量变到质变的感觉。6.3 下一步可以玩什么如果你照着文章把代码敲完并且理解了每个函数的职责下一步我建议按这几个方向延伸给echo服务加上粘包处理比如自定义一个4字节长度内容的协议格式练习读写缓冲区的边界管理。加入心跳和超时机制用epoll_wait的timeout参数实现定期扫描关闭N秒无数据的连接。把单线程Reactor改成Reactor线程池业务计算放到线程池里跑感受一下半同步半异步架构。试着实现一个最简单的HTTP静态文件服务器把echo的数据换成读取本地文件并返回这会逼着你处理请求解析和响应组装。每完成一个你对事件驱动的理解都会加深一层。这些改造的代码量都不大但踩过的坑会在你脑子里留下很深的印象比单纯看框架源码高效得多。我个人在实际操作中的体会是单线程Reactor是最适合练手感的网络编程模型它把所有复杂并发问题都收敛到一个顺序执行的事件循环里非常容易调试。等你真的在线上遇到瓶颈再去碰多线程、多Reactor、用户态协议栈这些进阶方案就会很清楚它们到底在解决什么问题。先把基础版本的代码跑起来用nc多压几轮把EAGAIN和EPOLLOUT这些边界玩明白比什么都强。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询