libevent evhttp多线程改造:主从Reactor架构与性能调优实战
1. 从单线程瓶颈说起为什么要在evhttp上做多线程改造如果你用libevent的evhttp写过HTTP服务大概率经历过这样一个阶段单线程跑得好好的压测一上来就原形毕露。QPS卡在几百到一千出头CPU明明有八个核只有一个核在冒烟其余七个在围观。这不是代码写得烂而是evhttp默认的事件循环模型决定的——一个event_base对应一个线程所有连接、请求解析、业务处理、响应写回全挤在这一个线程里排队。我最早接触evhttp是在一个内部工具项目上当时的需求很简单接收HTTP请求做点数据转换返回JSON。单线程版本开发半天就跑通了本地测试响应时间两毫秒感觉良好。结果一上压测环境并发拉到200就开始出现请求堆积响应时间飙到几百毫秒再往上直接超时。排查下来发现瓶颈根本不在业务逻辑而是event_base的事件循环被大量socket读写事件占满业务处理代码根本没机会执行。这时候摆在面前的路有两条一是换框架上nginxlua或者直接写多进程模型二是继续用evhttp但把多线程架构搭起来。换框架意味着重写而且evhttp的API确实简洁好用不想轻易放弃。于是选择了第二条路也就有了后来反复折腾多线程evhttp的经历。多线程evhttp的核心思路其实不复杂主线程负责监听端口、接受新连接然后把连接分发给工作线程每个工作线程有自己的event_base和事件循环独立处理分配给自己的连接上的所有HTTP事务。听起来简单但实际落地时会遇到一堆细节问题——连接怎么传递、event_base怎么管理、线程间如何同步、优雅退出怎么实现。这些细节处理不好轻则性能上不去重则出现诡异的崩溃和内存泄漏。这篇文章就是把这些年踩过的坑、试过的方案、最终稳定运行的架构完整梳理一遍。适合已经会用evhttp写单线程服务、想进一步提升并发能力的开发者也适合正在选型HTTP服务架构、想了解libevent多线程模型的朋友。我会从最基础的多线程模型设计讲起逐步深入到连接分发、线程管理、性能调优和问题排查每个环节都给出可运行的代码片段和实测数据。2. 多线程evhttp的三种架构模式与选型依据2.1 主从Reactor模式最稳妥的经典方案主从Reactor模式是多线程网络服务的标准解法evhttp天然支持这种架构。具体来说主线程创建一个监听socket绑定到自己的event_base上只监听读事件。当有新连接到来时主线程accept返回一个新的连接fd然后通过某种机制把这个fd传递给某个工作线程。工作线程收到fd后在自己的event_base上创建一个evhttp_connection绑定这个fd后续这个连接上的所有读写事件、请求解析、响应发送都在工作线程的事件循环里完成。这种模式的好处是职责清晰主线程只负责accept工作线程只负责处理已建立的连接。主线程的负载很轻不会成为瓶颈工作线程之间相互独立一个线程的阻塞不会影响其他线程。缺点是连接传递需要线程间通信通常用管道、socketpair或者eventfd来实现。我最初实现时用的是管道主线程accept后把fd通过管道写给工作线程工作线程从管道读端读到fd后创建evhttp_connection。这个方案简单直接但有个坑——管道是字节流fd是整数直接写int过去读出来没问题但如果多个fd连续写入读端需要循环读取每次读一个int。更麻烦的是如果工作线程处理不过来管道写满会阻塞主线程的accept循环。后来改用socketpair配合非阻塞写情况好一些但依然需要处理写缓冲满的情况。2.2 多监听端口方案简单但有限制另一种思路是每个工作线程独立监听同一个端口利用操作系统的SO_REUSEPORT特性让内核在多个监听socket之间做负载均衡。这样每个线程完全独立不需要连接传递架构最简单。Linux 3.9以后支持SO_REUSEPORT多个线程bind同一个端口不会报错内核会把新连接均匀分发到各个监听socket上。这个方案我在一个内部服务上试过代码量最少每个线程就是一个完整的单线程evhttp服务启动N个线程就完事。但问题也很明显第一SO_REUSEPORT的负载均衡不完全均匀特别是在连接生命周期差异大的场景下可能出现某个线程连接数远多于其他线程的情况第二无法做精细的连接控制比如想根据连接来源做定向分发就做不到第三某些操作系统或容器环境对SO_REUSEPORT的支持不完整移植性差。2.3 线程池任务队列灵活但复杂度高还有一种更灵活的方案主线程accept后不直接分发连接而是把连接fd放入一个任务队列工作线程从队列里取任务。这种模式可以动态调整工作线程数量也可以在任务队列层面做限流和优先级控制。但evhttp的连接是长生命周期的一个连接可能持续几分钟甚至几小时把连接当作任务放进队列并不合适——工作线程取到连接后要一直持有直到连接关闭队列实际上变成了连接分配器。我最终选择的是主从Reactor模式用socketpair做连接传递配合非阻塞写和连接队列做缓冲。这个方案在性能和复杂度之间取得了比较好的平衡下面详细展开实现细节。架构模式优点缺点适用场景主从Reactor职责清晰负载可控移植性好需要线程间传递连接实现稍复杂通用场景推荐首选多监听端口实现最简单无连接传递负载可能不均依赖SO_REUSEPORT快速原型连接短小的场景线程池队列灵活可动态调整连接生命周期管理复杂需要精细控制的特殊场景3. 连接分发的实现细节从accept到工作线程3.1 主线程的accept循环与连接封装主线程的event_base上只注册监听socket的读事件。回调函数里调用accept4比accept多一个flags参数可以设置SOCK_NONBLOCK和SOCK_CLOEXEC拿到新连接的fd。这里有个细节accept4拿到的fd默认是阻塞的但后续要交给工作线程的event_base管理必须设置为非阻塞。用accept4的SOCK_NONBLOCK标志一步到位比accept后再fcntl设置更高效。拿到fd后不能直接把这个裸fd传给工作线程。因为evhttp_connection的创建需要evhttp对象而evhttp对象是绑定到特定event_base的。工作线程有自己的event_base和evhttp对象所以传递的应该是fd工作线程收到后再用自己evhttp对象创建connection。// 主线程accept回调 void accept_cb(evutil_socket_t listener, short event, void *arg) { struct event_base *base (struct event_base *)arg; struct sockaddr_storage ss; socklen_t slen sizeof(ss); int fd accept4(listener, (struct sockaddr*)ss, slen, SOCK_NONBLOCK | SOCK_CLOEXEC); if (fd 0) { // 处理错误EAGAIN表示没有更多连接 return; } // 将fd分发给工作线程 dispatch_connection(fd); }3.2 socketpair传递fd的完整实现socketpair创建一对相互连接的socket主线程持有写端工作线程持有读端。主线程把fd作为整数写入工作线程读出整数后创建evhttp_connection。这里的关键是处理非阻塞写和部分写的情况。// 主线程分发连接 void dispatch_connection(int fd) { // 轮询选择工作线程 static int next_worker 0; struct worker *w workers[next_worker]; next_worker (next_worker 1) % worker_count; // 非阻塞写fd到socketpair int sent 0; while (sent sizeof(fd)) { ssize_t n write(w-notify_send_fd, ((char*)fd) sent, sizeof(fd) - sent); if (n 0) { if (errno EAGAIN || errno EWOULDBLOCK) { // 写缓冲满等待可写事件 // 这里简化处理实际应该注册写事件 // 或者把fd放入待发送队列 usleep(100); continue; } // 其他错误关闭fd close(fd); return; } sent n; } }工作线程侧在notify_send_fd对应的读端注册读事件回调里读取fd并创建连接// 工作线程接收连接 void notify_cb(evutil_socket_t fd, short event, void *arg) { struct worker *w (struct worker *)arg; int conn_fd; ssize_t n read(fd, conn_fd, sizeof(conn_fd)); if (n ! sizeof(conn_fd)) { // 读取不完整或出错 return; } // 用工作线程的evhttp创建连接 struct evhttp_connection *evcon evhttp_connection_base_new(w-base, w-http, NULL, 0); // 绑定已接受的fd evhttp_connection_set_fd(evcon, conn_fd); // 设置回调 evhttp_connection_set_closecb(evcon, close_cb, w); }注意evhttp_connection_set_fd这个函数在较新版本的libevent中才提供。如果用的是老版本需要用bufferevent_socket_new创建bufferevent再用evhttp_connection_base_bufferevent_new绑定。版本兼容性是个大坑后面会专门讲。3.3 连接队列与背压处理上面的简化代码在写缓冲满时用usleep轮询这在生产环境不可接受。正确的做法是维护一个待发送fd队列当socketpair可写时再继续发送。同时要设置队列上限超过上限时直接关闭新连接实现背压保护。struct pending_fd { int fd; struct pending_fd *next; }; // 每个工作线程维护一个待发送队列 struct worker { int notify_send_fd; int notify_recv_fd; struct pending_fd *pending_head; struct pending_fd *pending_tail; int pending_count; struct event *write_event; // ... }; // 分发时先尝试直接写失败则入队 void dispatch_connection(int fd) { struct worker *w select_worker(); if (w-pending_count MAX_PENDING) { // 队列满拒绝连接 close(fd); return; } ssize_t n write(w-notify_send_fd, fd, sizeof(fd)); if (n sizeof(fd)) { return; // 发送成功 } // 入队等待 struct pending_fd *p malloc(sizeof(*p)); p-fd fd; p-next NULL; if (w-pending_tail) { w-pending_tail-next p; } else { w-pending_head p; } w-pending_tail p; w-pending_count; // 注册写事件 if (!w-write_event) { w-write_event event_new(w-base, w-notify_send_fd, EV_WRITE | EV_PERSIST, write_cb, w); event_add(w-write_event, NULL); } }这套机制保证了主线程永远不会因为工作线程处理慢而阻塞同时通过队列上限防止内存无限增长。实测在4核机器上4个工作线程队列上限设为1024压测到5000并发连接时依然稳定。4. 工作线程的事件循环与evhttp配置4.1 每个线程独立的event_base和evhttp工作线程启动后第一件事是创建自己的event_base和evhttp对象。这里有个容易忽略的点evhttp对象必须和event_base绑定而且每个线程的evhttp对象是独立的不能跨线程共享。有些开发者想省事全局创建一个evhttp对象然后多个线程共用这是绝对错误的——evhttp内部有大量非线程安全的状态跨线程使用必然出问题。void *worker_thread(void *arg) { struct worker *w (struct worker *)arg; // 创建独立的event_base w-base event_base_new(); if (!w-base) { return NULL; } // 创建独立的evhttp w-http evhttp_new(w-base); if (!w-http) { event_base_free(w-base); return NULL; } // 设置通用回调 evhttp_set_gencb(w-http, request_handler, w); // 注册notify读事件 w-notify_event event_new(w-base, w-notify_recv_fd, EV_READ | EV_PERSIST, notify_cb, w); event_add(w-notify_event, NULL); // 进入事件循环 event_base_dispatch(w-base); // 清理 evhttp_free(w-http); event_base_free(w-base); return NULL; }4.2 evhttp的超时与连接限制配置evhttp默认的超时设置可能不适合所有场景。比如默认的请求超时是50秒对于内部服务来说太长了一个慢客户端可能占用连接很久。可以通过evhttp_set_timeout调整单位是秒。另外evhttp_set_max_headers_size和evhttp_set_max_body_size要设置合理值防止恶意大请求耗尽内存。// 设置请求超时秒 evhttp_set_timeout(w-http, 10); // 设置最大请求头大小 evhttp_set_max_headers_size(w-http, 8192); // 设置最大请求体大小 evhttp_set_max_body_size(w-http, 1024 * 1024); // 1MB // 设置每个连接的输出缓冲上限 evhttp_set_max_connections(w-http, 10000);这些参数需要根据实际业务调整。比如上传接口的body size要放大内部RPC调用超时可以设短一些。我一般会在配置文件里把这些参数暴露出来不同环境用不同配置。4.3 请求处理中的线程安全注意事项工作线程内部是单线程的请求处理回调里访问的数据结构不需要加锁。但如果多个工作线程需要共享某些数据比如全局计数器、缓存就必须加锁或者用线程安全的数据结构。我见过有人在请求回调里直接操作全局链表结果多线程下链表指针错乱导致崩溃。一个常见的需求是统计总请求数。可以用原子变量#include stdatomic.h static atomic_long total_requests 0; void request_handler(struct evhttp_request *req, void *arg) { atomic_fetch_add(total_requests, 1); // ... 处理请求 }如果共享数据比较复杂建议用读写锁或者把共享数据拆分成每线程独立的部分最后再汇总。比如每个线程维护自己的计数器需要读取总数时遍历所有线程的计数器求和。5. 优雅退出与资源回收的完整方案5.1 信号处理与线程通知服务需要支持优雅退出收到SIGINT或SIGTERM后停止接受新连接等待正在处理的请求完成然后清理资源退出。多线程环境下信号处理要特别小心——信号可能被任意线程接收而event_base的event_loopbreak只能从该base所属的线程调用。标准做法是主线程注册信号处理函数收到信号后设置一个全局退出标志然后通过socketpair通知所有工作线程退出事件循环。static volatile sig_atomic_t g_shutdown 0; void signal_handler(int sig) { g_shutdown 1; } // 主线程事件循环中定期检查退出标志 void check_shutdown_cb(evutil_socket_t fd, short event, void *arg) { if (g_shutdown) { // 通知所有工作线程退出 for (int i 0; i worker_count; i) { event_base_loopbreak(workers[i].base); } // 退出主线程事件循环 event_base_loopbreak(main_base); } }5.2 连接清理与内存释放工作线程退出事件循环后需要清理所有未完成的连接。evhttp_free会释放evhttp对象及其管理的连接但如果有正在处理的请求需要等请求完成。可以在退出前设置一个短超时等待一段时间让正在处理的请求完成。// 工作线程退出流程 void worker_cleanup(struct worker *w) { // 停止接受新连接 event_del(w-notify_event); event_free(w-notify_event); // 等待正在处理的请求完成最多等5秒 struct timeval tv {5, 0}; event_base_loopexit(w-base, tv); event_base_dispatch(w-base); // 释放evhttp会关闭所有连接 evhttp_free(w-http); event_base_free(w-base); // 释放待发送队列 struct pending_fd *p w-pending_head; while (p) { struct pending_fd *next p-next; close(p-fd); free(p); p next; } }5.3 避免资源泄漏的检查清单多线程服务的资源泄漏往往比单线程更难排查。以下是我总结的检查清单每个accept的fd是否都有对应的close包括分发失败时关闭、工作线程创建连接失败时关闭。每个malloc的pending_fd是否都释放了包括正常发送后释放和退出时清理。每个event_new创建的event是否都event_free了每个event_base_new创建的base是否都event_base_free了每个evhttp_new创建的http是否都evhttp_free了线程是否都join了避免僵尸线程。用valgrind的helgrind工具可以检测线程错误用memcheck检测内存泄漏。不过valgrind对libevent的误报比较多需要仔细甄别。6. 性能调优与实测数据6.1 工作线程数量的选择工作线程数量不是越多越好。每个线程有自己的event_base和事件循环线程切换有开销。一般来说工作线程数设置为CPU核心数或核心数1比较合适。如果是IO密集型场景大量小请求可以适当增加如果是CPU密集型场景请求处理耗CPU设置为核心数即可。我在4核8G的机器上做过对比测试用wrk压测请求处理逻辑是简单的JSON序列化线程数QPS平均延迟CPU使用率132003.1ms25%261001.6ms48%4115000.9ms85%8118000.9ms92%16112001.0ms95%可以看到4线程时QPS已经接近线性扩展8线程提升有限16线程反而略有下降。所以在这个场景下4线程是最优选择。6.2 连接分发策略的优化简单的轮询分发在大多数场景下够用但如果请求处理时间差异很大可能导致某些线程积压。可以改进为基于负载的分发每个工作线程维护一个当前连接数计数器分发时选择连接数最少的工作线程。struct worker *select_worker(void) { struct worker *best workers[0]; int min_conn atomic_load(best-conn_count); for (int i 1; i worker_count; i) { int cnt atomic_load(workers[i].conn_count); if (cnt min_conn) { min_conn cnt; best workers[i]; } } return best; }连接数在工作线程创建连接时递增连接关闭时递减。这个策略在长连接场景下效果明显短连接场景下和轮询差别不大。6.3 内核参数与编译优化libevent的编译选项对性能有影响。建议开启epoll支持Linux默认开启关闭不必要的功能。编译时加-O2优化如果追求极致性能可以加-O3和-marchnative。内核参数方面几个关键项# 增加文件描述符上限 ulimit -n 65535 # 调整TCP backlog sysctl -w net.core.somaxconn65535 # 加快TIME_WAIT回收 sysctl -w net.ipv4.tcp_tw_reuse1 # 增加本地端口范围 sysctl -w net.ipv4.ip_local_port_range1024 65535这些参数在压测时影响很大。特别是文件描述符上限默认1024在高并发下很快就不够用了。7. 踩坑实录那些让我熬夜的诡异问题7.1 evhttp_connection_set_fd的版本兼容性前面提到用evhttp_connection_set_fd绑定已接受的fd这个函数在libevent 2.1.8之后才提供。如果用的是Ubuntu 18.04自带的libevent 2.1.8刚好有如果是更老的版本比如2.0.21就没有这个函数。替代方案是用buffereventstruct bufferevent *bev bufferevent_socket_new(base, fd, BEV_OPT_CLOSE_ON_FREE); struct evhttp_connection *evcon evhttp_connection_base_bufferevent_new(base, NULL, bev, NULL, 0);但要注意bufferevent_socket_new创建的bufferevent默认是阻塞的需要设置非阻塞。而且BEV_OPT_CLOSE_ON_FREE标志确保bufferevent释放时关闭fd避免泄漏。7.2 多线程下的DNS解析问题evhttp作为服务端通常不需要DNS解析但如果请求处理中需要发起HTTP客户端请求比如调用其他服务就会用到evhttp_connection_base_new的DNS解析功能。这个功能在多线程下有问题——libevent的DNS解析默认使用全局的evdns_base多线程共享会有竞争。解决方案是每个工作线程创建自己的evdns_basew-dns_base evdns_base_new(w-base, 1); // 创建客户端连接时使用 struct evhttp_connection *client evhttp_connection_base_new(w-base, w-dns_base, host, port);7.3 连接关闭回调中的陷阱evhttp_connection_set_closecb设置的回调在连接关闭时调用。这个回调里不能直接释放evhttp_connection因为libevent内部还在使用它。正确的做法是在回调里做标记稍后清理。我见过有人在closecb里直接evhttp_connection_free结果导致use-after-free崩溃。void close_cb(struct evhttp_connection *evcon, void *arg) { struct worker *w (struct worker *)arg; // 不要在这里free evcon // 只做计数递减和标记 atomic_fetch_sub(w-conn_count, 1); // 如果需要清理关联数据用event_base_once延迟执行 event_base_once(w-base, -1, EV_TIMEOUT, deferred_cleanup, evcon, (struct timeval){0, 0}); }7.4 压测时的TIME_WAIT堆积短连接压测时服务端主动关闭连接会产生大量TIME_WAIT状态的socket占用本地端口。如果端口耗尽新连接无法建立。除了调整内核参数还可以让客户端主动关闭连接或者使用长连接。在evhttp中可以通过设置响应头Connection: keep-alive来保持长连接减少TIME_WAIT。evhttp_add_header(evhttp_request_get_output_headers(req), Connection, keep-alive);但长连接也有代价连接保持期间占用文件描述符和内存。需要根据实际场景权衡。8. 从单机到多机这个架构还能怎么扩展单机多线程evhttp服务在4核机器上跑到1万QPS左右对于大多数内部服务已经够用。如果需求继续增长可以考虑几个扩展方向。第一个方向是多进程多线程。主进程fork出多个子进程每个子进程内部再跑多线程evhttp。这样可以利用多核同时避免单进程内存限制。进程间用SO_REUSEPORT或者主进程分发连接。这个架构比纯多线程复杂但扩展性更好。第二个方向是加一层负载均衡。前面挂一个nginx或者LVS把请求分发到多个evhttp服务实例。每个实例还是单机多线程架构但整体容量可以水平扩展。这个方案对代码改动最小只需要保证服务无状态即可。第三个方向是异步化改造。如果请求处理中有阻塞操作比如读磁盘、查数据库可以考虑把这些操作也异步化用libevent的bufferevent或者集成其他异步库。这样工作线程永远不会阻塞可以用更少的线程支撑更高的并发。我在实际项目中的选择是先用单机多线程架构把服务跑起来压测找到瓶颈。如果瓶颈在CPU加线程或换更好的机器如果瓶颈在IO考虑异步化如果单机到顶了再加负载均衡做水平扩展。不要一开始就设计过度复杂的架构简单能跑通、能支撑当前业务量就是好架构。最后分享一个调试技巧多线程服务的日志一定要带线程ID否则出问题时根本分不清是哪个线程的行为。可以在日志宏里加上pthread_self()的返回值排查问题时一目了然。这个习惯帮我省了无数个加班的夜晚。