news 2026/10/13 1:37:34

C语言多线程HTTP服务器实战:基于evhttp的高并发架构设计与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言多线程HTTP服务器实战:基于evhttp的高并发架构设计与实现

1. 为什么选择 evhttp 做多线程服务器

1.1 从一次压测事故说起

早些年我接手过一个内部工具的后端服务,功能很简单:接收客户端上报的 JSON 数据,做一轮校验和聚合,再写进本地存储。最初用的是一个单线程的 HTTP 服务,开发阶段一切正常,几十个请求打过来响应都在毫秒级。结果上线当天,同时在线客户端一多,服务直接卡死,请求排队排到超时,日志里全是连接被重置的记录。

那次事故之后我复盘了很久,问题的根子在于单线程模型下,一个慢请求会把后面所有请求全部堵住。HTTP 服务本质上是 IO 密集型任务,等待网络读写的时间远大于 CPU 计算时间,单线程串行处理等于把大量时间浪费在等待上。要解决这个问题,思路无非两条:一是多进程,二是多线程。多进程模型隔离性好但内存开销大、进程间通信麻烦;多线程模型共享内存、切换开销小,对于这种 IO 密集型的服务更合适。

选型的时候我对比过几个方案。自己从 socket 开始写 HTTP 解析,工作量太大而且容易在边界情况上翻车;用现成的高层框架,又觉得对底层控制不够,出问题不好排查。最后落到libevent这个库上,它自带的evhttp模块提供了一套轻量级的 HTTP 服务端接口,底层基于事件循环,天然适合做高并发。更重要的是,它足够“薄”,你能清楚地知道每一层在干什么,出问题的时候能顺着调用栈一路查下去。

这篇文章要聊的,就是怎么用 C 语言配合 evhttp 搭一个多线程 HTTP 服务器。核心要解决的问题有三个:怎么让多个线程各自跑一个事件循环、怎么把监听 socket 的接受连接这件事安全地分发给多个线程、怎么在多线程环境下正确处理请求和共享数据。适合有一定 C 语言基础、了解 socket 编程、想深入理解高并发服务端实现的读者。如果你只是想要一个能跑的服务,直接上成熟框架更省事;但如果你想搞清楚“多线程 + 事件循环”这套组合拳到底怎么打,那这篇内容应该对你有用。

1.2 evhttp 的能力边界与适用场景

在动手之前,有必要先把 evhttp 的能力边界说清楚,免得用错地方。

evhttp 是 libevent 提供的一个 HTTP 服务端和客户端实现。服务端部分的核心对象是evhttp,你把它绑定到一个event_base上,再绑定监听地址,它就能自动处理连接的接受、HTTP 请求的解析、响应的发送。你只需要注册一个请求处理回调,在回调里根据请求的 URI 和方法决定返回什么内容。

它擅长的事情:高并发的短连接或长连接请求处理、轻量级的 REST 接口、内部服务之间的通信、需要精细控制底层行为的场景。它的性能在同类轻量库中属于第一梯队,因为底层用的是 epoll(Linux)或 kqueue(BSD/macOS)这类高效的事件通知机制,没有多余的抽象层。

它不擅长的事情也得说清楚。evhttp 不提供路由框架、不提供模板引擎、不提供 ORM,这些都要你自己写或者配合其他库。它对 HTTP/2 的支持有限,如果你需要 HTTP/2 或者 WebSocket 的高级特性,可能需要考虑其他方案。另外,它的文档相对简略,很多行为需要读源码才能确认,这对使用者的要求会高一些。

我个人的经验是,evhttp 最适合做内部微服务之间的通信层或者需要嵌入到现有 C 项目里的 HTTP 接口。它的体积小、依赖少、编译快,塞进一个已有的 C 工程里几乎没有负担。如果你要做的是面向公网的大型 Web 服务,那还是用更上层的方案更稳妥。

1.3 多线程模型的核心思路

单线程事件循环的问题前面说了,一个慢请求会阻塞整个循环。多线程的思路是:每个线程跑一个独立的事件循环,各自处理一部分连接。这样即使某个线程上的某个请求处理慢了,也只影响这个线程上的连接,其他线程不受影响。

但这里有个关键问题:监听 socket 只有一个,多个线程怎么协调谁来接受新连接?常见的做法有两种。

第一种是主线程接受连接,然后把连接分发给工作线程。主线程只负责 accept,拿到新的 fd 之后通过某种机制(比如管道、队列)传给某个工作线程,工作线程把这个 fd 注册到自己的 event_base 上。这种做法的好处是接受连接这件事是串行的,不存在惊群问题;坏处是多了一次线程间传递,而且主线程可能成为瓶颈。

第二种是多个线程同时监听同一个 socket,谁先 accept 到算谁的。这种做法省去了分发的开销,但需要处理“惊群”问题——多个线程同时被唤醒,只有一个能成功 accept,其他的白跑一趟。Linux 内核在较新的版本里对 accept 的惊群做了优化,但为了保险,通常还是会配合一些同步机制。

我这次采用的是第一种思路的变体:主线程负责 accept,通过一个简单的轮询或者负载均衡策略把新连接分配给工作线程。这样逻辑清晰,每个线程的职责明确,出问题也好定位。下面会详细讲具体怎么实现。

2. 核心组件拆解与关键数据结构

2.1 event_base 与线程的关系

event_base是 libevent 的核心对象,你可以把它理解为一个“事件循环的实例”。每个 event_base 管理着一组文件描述符和定时器,event_base_dispatch或者event_base_loop调用之后,它会阻塞等待事件发生,然后调用对应的回调。

关键点来了:一个 event_base 只能被一个线程使用。这不是建议,是硬性约束。如果你在多个线程里同时对同一个 event_base 调用 dispatch,行为是未定义的,轻则事件丢失,重则程序崩溃。所以多线程服务器的基本结构就是:每个工作线程创建自己的 event_base,各自跑各自的循环。

那主线程的 event_base 呢?主线程也需要一个 event_base 来监听监听 socket 上的可读事件(也就是有新连接到来)。所以整个程序里会有 N+1 个 event_base:一个给主线程做 accept,N 个给工作线程处理请求。

这里有个容易踩的坑:libevent 在初始化的时候,如果检测到多线程环境,需要调用evthread_use_pthreads()来启用线程安全支持。这个调用必须在创建任何 event_base 之前完成,否则后面多线程操作 event_base 的时候会出现各种诡异问题。我一开始就忘了加这一句,结果程序跑起来随机崩溃,查了两天才定位到。

#include <event2/event.h> #include <event2/thread.h> int main() { // 必须在任何 event_base 创建之前调用 evthread_use_pthreads(); // 后续才能安全地在多线程中使用 libevent // ... }

2.2 监听 socket 的创建与绑定

监听 socket 的创建用的是标准的 socket 编程接口,但有几个细节需要注意。

首先是SO_REUSEADDR选项。这个选项的作用是让服务器重启的时候能够立即绑定到之前的端口,而不用等待 TIME_WAIT 状态结束。没有这个选项的话,你每次重启服务都要等几十秒甚至几分钟,调试的时候非常痛苦。

int listen_fd = socket(AF_INET, SOCK_STREAM, 0); int reuse = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));

然后是绑定和监听。绑定的时候把地址设为INADDR_ANY表示监听所有网卡,端口根据实际需求指定。listen的第二个参数是 backlog,表示等待接受连接的队列长度。这个值设太小的话,高并发场景下会出现连接被拒绝的情况;设太大也没意义,内核有上限。一般设 128 到 1024 之间比较合适,我通常用 512。

struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); 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)); listen(listen_fd, 512);

还有一个细节是把监听 socket 设为非阻塞。虽然主线程用 event_base 来监听可读事件,理论上不会阻塞在 accept 上,但在某些边界情况下(比如多个连接同时到达),accept 可能会阻塞。设为非阻塞之后,即使 accept 返回 EAGAIN 也不会卡住,处理起来更安全。

int flags = fcntl(listen_fd, F_GETFL, 0); fcntl(listen_fd, F_SETFL, flags | O_NONBLOCK);

2.3 工作线程的数据结构设计

每个工作线程需要维护自己的状态,我把它封装成一个结构体:

typedef struct worker_thread { pthread_t tid; // 线程 ID struct event_base *base; // 该线程的事件循环 struct event *notify_event; // 用于接收新连接通知的事件 int notify_fd; // 通知用的管道读端 int notify_send_fd; // 通知用的管道写端 // ... 其他线程私有数据 } worker_thread_t;

这里用了一个管道来做线程间通知。主线程 accept 到新连接之后,把连接 fd 通过管道发给某个工作线程,工作线程在管道可读事件里读取 fd,然后把它注册到自己的 event_base 上。为什么用管道而不是直接操作工作线程的数据结构?因为管道是线程安全的,写入和读取都是原子操作,不需要额外的锁。而且管道的可读事件可以很方便地集成到 event_base 的事件循环里,工作线程在等待网络事件的同时也能响应通知。

管道的创建用pipe()或者pipe2()。pipe2可以顺便设置O_NONBLOCK和O_CLOEXEC,省去后面再调用 fcntl 的麻烦。

int fds[2]; pipe2(fds, O_NONBLOCK | O_CLOEXEC); worker->notify_fd = fds[0]; // 读端 worker->notify_send_fd = fds[1]; // 写端

2.4 连接分发的策略选择

主线程 accept 到新连接之后,要决定把它分配给哪个工作线程。最简单的策略是轮询:维护一个计数器,每次加一取模,轮流分配。这种策略实现简单,在请求处理时间比较均匀的场景下效果不错。

稍微复杂一点的是基于负载的分配:每个工作线程维护一个当前连接数的计数,主线程选择连接数最少的那个。这种策略在请求处理时间差异较大的场景下更均衡,但需要读取各个线程的计数,涉及同步问题。

还有一种思路是一致性哈希:根据客户端 IP 做哈希,同一个客户端的连接总是分配到同一个线程。这种策略在某些需要保持会话状态的场景下有用,但实现起来更复杂。

我这次用的是轮询,因为测试场景下请求处理时间比较均匀,轮询已经足够。如果实际场景中请求处理时间差异大,可以换成基于负载的策略。这里的关键是分发策略要和工作线程的处理能力匹配,没有银弹。

3. 完整实现流程与关键代码

3.1 主线程的 accept 循环

主线程的核心任务是监听 listen_fd 上的可读事件,accept 新连接,然后分发给工作线程。

static void accept_callback(evutil_socket_t fd, short what, void *arg) { server_t *server = (server_t*)arg; while (1) { struct sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr); int client_fd = accept(fd, (struct sockaddr*)&client_addr, &addr_len); if (client_fd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { // 没有更多连接了,退出循环 break; } // 其他错误,记录日志后继续 perror("accept"); break; } // 设置客户端 socket 为非阻塞 evutil_make_socket_nonblocking(client_fd); // 轮询选择工作线程 int idx = server->next_worker; server->next_worker = (server->next_worker + 1) % server->worker_count; // 通过管道把 fd 发给工作线程 worker_thread_t *worker = &server->workers[idx]; write(worker->notify_send_fd, &client_fd, sizeof(client_fd)); } }

这里有几个细节值得展开说。

为什么用 while 循环反复 accept?因为事件通知是“水平触发”的,只要 listen_fd 上还有未处理的连接,事件就会持续触发。如果一次只 accept 一个连接,在高并发场景下会频繁进出回调,效率低。用 while 循环一次性把队列里的连接全部取出来,效率更高。但要注意,accept 返回 EAGAIN 的时候必须退出循环,否则会死循环。

为什么要把客户端 socket 设为非阻塞?因为工作线程后续会用 evhttp 来处理这个连接,evhttp 内部是基于事件循环的,要求 socket 是非阻塞的。如果 socket 是阻塞的,evhttp 在读写的时候可能会卡住整个事件循环。

管道写入会不会阻塞?如果管道缓冲区满了,write 会阻塞。但在实际场景中,管道缓冲区通常足够大(默认 64KB),而且主线程 accept 的速度和工作线程处理的速度大致匹配,不太容易写满。如果担心这个问题,可以把管道写端也设为非阻塞,写不进去的时候记录日志或者做其他处理。

3.2 工作线程的事件循环

工作线程启动之后,创建自己的 event_base,注册管道可读事件,然后进入事件循环。

static void notify_callback(evutil_socket_t fd, short what, void *arg) { worker_thread_t *worker = (worker_thread_t*)arg; int client_fd; while (read(fd, &client_fd, sizeof(client_fd)) == sizeof(client_fd)) { // 为新连接创建 evhttp 连接对象 struct evhttp_connection *conn = evhttp_connection_base_new( worker->base, NULL, NULL, 0); // 这里需要把 client_fd 关联到连接上 // 具体做法见下文 } } static void* worker_main(void *arg) { worker_thread_t *worker = (worker_thread_t*)arg; // 创建工作线程的 event_base worker->base = event_base_new(); // 注册管道可读事件 worker->notify_event = event_new( worker->base, worker->notify_fd, EV_READ | EV_PERSIST, notify_callback, worker); event_add(worker->notify_event, NULL); // 进入事件循环 event_base_dispatch(worker->base); return NULL; }

这里有个关键问题:怎么把 accept 到的 client_fd 交给 evhttp 处理?evhttp 的标准用法是evhttp_bind_socket绑定一个监听地址,然后它自己 accept。但我们的场景是主线程已经 accept 了,需要把已有的 fd 交给 evhttp。

libevent 提供了一个函数evhttp_accept_socket_with_handle,但它也是绑定监听 socket 的。对于已经 accept 的 fd,需要用另一种方式:创建一个bufferevent,把 fd 关联上去,然后用evhttp_connection_base_bufferevent_new创建连接对象。

// 为 client_fd 创建 bufferevent struct bufferevent *bev = bufferevent_socket_new( worker->base, client_fd, BEV_OPT_CLOSE_ON_FREE); // 创建 evhttp 连接 struct evhttp_connection *conn = evhttp_connection_base_bufferevent_new( worker->base, NULL, bev, NULL, 0); // 设置请求处理回调 evhttp_connection_set_timeout(conn, 30); evhttp_connection_set_closecb(conn, close_callback, worker); // 把连接关联到 evhttp 服务器对象 evhttp_connection_set_server(conn, worker->http);

等等,这里有个问题:evhttp_connection_set_server这个函数在较新版本的 libevent 中才有,而且用法可能因版本而异。更通用的做法是:每个工作线程创建一个 evhttp 对象,但不绑定监听 socket,而是通过evhttp_connection_base_bufferevent_new创建连接后,手动触发请求处理。

实际上,更简洁的做法是使用evhttp_associate_new_request_with_connection或者类似的机制。但不同版本的 libevent 接口有差异,我建议的做法是:每个工作线程创建一个 evhttp 对象,用evhttp_bind_socket_with_handle绑定一个 socketpair 的一端,主线程把 client_fd 通过 socketpair 传过去。这样 evhttp 就能像处理普通连接一样处理这些 fd。

不过这样绕了一圈,复杂度上去了。我实际采用的是一个更直接的做法:不用 evhttp 的连接管理,直接用 bufferevent 和 evhttp 的请求解析接口。具体来说,用evhttp_connection_base_bufferevent_new创建连接,然后调用evhttp_connection_set_server关联到 evhttp 对象。这个函数在 libevent 2.1 之后的版本里是存在的。

// 工作线程初始化时创建 evhttp 对象 worker->http = evhttp_new(worker->base); evhttp_set_gencb(worker->http, request_handler, worker); // 收到新连接时 struct bufferevent *bev = bufferevent_socket_new( worker->base, client_fd, BEV_OPT_CLOSE_ON_FREE); struct evhttp_connection *conn = evhttp_connection_base_bufferevent_new( worker->base, NULL, bev, NULL, 0); evhttp_connection_set_server(conn, worker->http);

如果evhttp_connection_set_server在你的 libevent 版本里不存在,那就需要换一种方式:用evhttp_accept_socket让 evhttp 自己 accept。但这样又回到了单线程 accept 的问题。折中的方案是:每个工作线程绑定一个独立的监听端口,主线程不做 accept,而是通过某种方式把客户端引导到不同的端口。这种做法在某些场景下可行,但不够优雅。

我查了一下 libevent 的源码,evhttp_connection_set_server这个函数在 2.1.1 版本之后是存在的,如果你的环境是比较新的 Linux 发行版,应该没问题。如果实在没有,可以考虑升级 libevent 或者用其他方式绕过。

3.3 请求处理回调的实现

请求处理回调是业务逻辑的入口。evhttp 在解析完一个完整的 HTTP 请求之后,会调用你注册的回调函数。

static void request_handler(struct evhttp_request *req, void *arg) { worker_thread_t *worker = (worker_thread_t*)arg; // 获取请求方法和 URI const char *uri = evhttp_request_get_uri(req); enum evhttp_cmd_type cmd = evhttp_request_get_command(req); // 只处理 GET 和 POST if (cmd != EVHTTP_REQ_GET && cmd != EVHTTP_REQ_POST) { evhttp_send_error(req, HTTP_BADMETHOD, "Method Not Allowed"); return; } // 根据 URI 路由到不同的处理函数 if (strcmp(uri, "/api/health") == 0) { handle_health(req, worker); } else if (strcmp(uri, "/api/data") == 0) { handle_data(req, worker); } else { evhttp_send_error(req, HTTP_NOTFOUND, "Not Found"); } }

在回调里,你可以通过evhttp_request_get_input_buffer获取请求体,通过evhttp_request_get_output_buffer或者evhttp_send_reply发送响应。

static void handle_health(struct evhttp_request *req, worker_thread_t *worker) { struct evbuffer *buf = evbuffer_new(); evbuffer_add_printf(buf, "{\"status\":\"ok\",\"thread\":%lu}", (unsigned long)pthread_self()); evhttp_add_header(evhttp_request_get_output_headers(req), "Content-Type", "application/json"); evhttp_send_reply(req, HTTP_OK, "OK", buf); evbuffer_free(buf); }

这里有个性能上的注意点:不要在回调里做阻塞操作。比如读文件、查数据库、调用外部服务,这些操作如果耗时较长,会阻塞整个工作线程的事件循环,导致这个线程上的其他请求全部排队。如果确实需要做阻塞操作,应该把它放到单独的线程池里,回调里只做异步的提交和结果回调。

我踩过的一个坑是在回调里直接调用了一个同步的日志写入函数,那个函数在磁盘 IO 繁忙的时候会阻塞几百毫秒。结果就是每隔一段时间,某个工作线程就会卡住,上面的请求全部超时。后来把日志改成异步写入,问题才解决。

3.4 多线程下的数据共享与同步

多线程服务器绕不开的一个问题就是共享数据的同步。哪些数据是共享的,哪些是线程私有的,必须分清楚。

线程私有的数据:event_base、evhttp 对象、bufferevent、连接相关的状态。这些数据只在单个线程内访问,不需要加锁。

共享的数据:全局配置、统计计数器、缓存、连接池等。这些数据可能被多个线程同时读写,需要同步机制。

最简单的同步方式是互斥锁。比如一个全局的请求计数器:

static pthread_mutex_t counter_lock = PTHREAD_MUTEX_INITIALIZER; static long request_count = 0; static void increment_counter(void) { pthread_mutex_lock(&counter_lock); request_count++; pthread_mutex_unlock(&counter_lock); }

如果计数器只是用来做统计,对精度要求不高,可以用原子操作来避免锁的开销:

#include <stdatomic.h> static atomic_long request_count = 0; static void increment_counter(void) { atomic_fetch_add(&request_count, 1); }

原子操作在 x86 平台上通常编译成一条lock前缀的指令,比互斥锁轻量得多。但原子操作只适用于简单的读写和算术运算,复杂的数据结构还是需要锁。

还有一个容易忽略的点是内存可见性。在多线程环境下,一个线程写入的数据不一定立即对其他线程可见,编译器优化和 CPU 缓存都可能导致问题。互斥锁和原子操作都隐含了内存屏障,能保证可见性。如果用了无锁的数据结构,需要自己处理内存屏障。

我个人的经验是:能用线程私有数据就用线程私有,必须共享的数据尽量用简单的同步机制。复杂的无锁数据结构虽然性能好,但调试难度大,出问题的时候很难定位。在大多数场景下,互斥锁的开销是可以接受的,没必要为了那一点性能提升引入复杂性。

4. 常见问题与排查技巧实录

4.1 程序启动就崩溃或随机崩溃

这是多线程服务器最常见的问题,原因通常有几个。

忘记调用evthread_use_pthreads()。这个是最常见的。libevent 默认不是线程安全的,如果不调用这个函数就多线程操作 event_base,会出现各种随机崩溃。症状是程序跑一段时间后突然挂掉,gdb 的调用栈指向 libevent 内部。解决方法就是在 main 函数最开始调用evthread_use_pthreads()。

在多个线程里操作同一个 event_base。前面说过,一个 event_base 只能被一个线程使用。如果你不小心在主线程和工作线程里都调用了同一个 event_base 的 dispatch,或者在工作线程里操作了主线程的 event_base,就会出问题。排查方法是检查每个 event_base 的创建和使用是否都在同一个线程内。

管道读写的数据大小不匹配。如果写端写了一个 int,读端按 long 来读,就会读到错误的数据,可能导致后续操作访问非法内存。这个问题的排查方法是检查读写两端的类型和大小是否一致。

4.2 连接被拒绝或超时

如果客户端连接被拒绝,首先检查监听 socket 的 backlog 是否太小。在高并发场景下,如果 accept 的速度跟不上连接到达的速度,等待队列会满,新的连接会被内核拒绝。把 backlog 调大一些通常能缓解。

如果连接建立成功但请求超时,可能是工作线程的事件循环被阻塞了。检查请求处理回调里是否有阻塞操作,比如同步的文件 IO、数据库查询、外部服务调用。把这些操作改成异步的,或者放到单独的线程池里。

还有一种可能是文件描述符耗尽。每个连接占用一个 fd,如果并发连接数超过进程的 fd 限制,新的连接就无法建立。用ulimit -n查看当前限制,必要时调大。

# 查看当前限制 ulimit -n # 临时调大 ulimit -n 65535

4.3 内存泄漏与资源未释放

evhttp 相关的对象需要手动释放,忘记释放会导致内存泄漏。常见的泄漏点包括:

  • evhttp_request对象在回调结束后由 libevent 自动释放,不需要手动释放
  • evbuffer对象需要手动evbuffer_free
  • evhttp_connection对象在连接关闭时由 libevent 释放,但如果手动创建了 bufferevent,需要确保BEV_OPT_CLOSE_ON_FREE选项被设置
  • event_base和event对象在程序退出时需要释放

排查内存泄漏可以用 valgrind:

valgrind --leak-check=full --show-leak-kinds=all ./server

valgrind 会报告所有未释放的内存块和它们的分配位置,根据调用栈就能定位到泄漏点。

4.4 性能不达预期

如果服务器的吞吐量上不去,可以从几个方面排查。

工作线程数量是否合理。线程太少,CPU 利用不充分;线程太多,上下文切换开销大。一般来说,工作线程数量设置为 CPU 核心数或者核心数的两倍比较合适。可以用nproc查看核心数。

是否有锁竞争。如果多个线程频繁争抢同一把锁,性能会急剧下降。用perf或者gprof做性能分析,看看时间花在哪里。如果锁竞争严重,考虑减小锁的粒度或者改用无锁数据结构。

网络配置是否合理。检查 TCP 的缓冲区大小、拥塞控制算法等参数。在高并发场景下,适当调大 socket 缓冲区能提升吞吐量。

# 查看当前 TCP 缓冲区设置 sysctl net.core.rmem_max sysctl net.core.wmem_max # 临时调大 sysctl -w net.core.rmem_max=16777216 sysctl -w net.core.wmem_max=16777216

4.5 常见问题速查表

问题现象可能原因排查方法解决方案
程序随机崩溃未调用 evthread_use_pthreads检查 main 函数开头在创建 event_base 前调用
连接被拒绝backlog 太小查看 listen 参数调大 backlog 到 512 或更高
请求超时回调中有阻塞操作检查回调函数改为异步或放到线程池
内存持续增长对象未释放valgrind 检查确保 evbuffer 等对象被释放
吞吐量低线程数不合理或锁竞争perf 分析调整线程数,减小锁粒度
fd 耗尽并发连接数超过限制ulimit -n调大 fd 限制

5. 一些实操心得与扩展思路

5.1 调试多线程程序的几个技巧

多线程程序的调试比单线程麻烦得多,因为问题往往不是稳定复现的。我总结了几条经验。

用日志记录线程 ID。在每个关键操作前后打印线程 ID 和时间戳,出问题的时候能清楚地看到哪个线程在什么时候做了什么。日志最好写到单独的文件,避免多线程写同一个文件时的交错问题。

用 gdb 的 thread 命令。程序崩溃后,用gdb加载 core 文件,然后用info threads查看所有线程的状态,用thread <n>切换到指定线程,用bt查看调用栈。这样能快速定位是哪个线程出的问题。

用 ThreadSanitizer 检测数据竞争。编译的时候加上-fsanitize=thread选项,运行时会检测数据竞争并报告。这个工具对排查多线程问题非常有用。

gcc -fsanitize=thread -g -o server server.c -levent -levent_pthreads -lpthread

5.2 性能优化的几个方向

如果基本功能已经跑通,想进一步提升性能,可以考虑这几个方向。

使用连接池。如果请求处理需要访问数据库或者外部服务,维护一个连接池能避免频繁建立连接的开销。

启用 TCP_NODELAY。对于小数据量的请求,Nagle 算法会导致延迟增加。设置TCP_NODELAY可以禁用 Nagle 算法,降低延迟。

int nodelay = 1; setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay));

使用 sendfile 或 splice。如果响应内容是文件,用sendfile可以避免数据在内核空间和用户空间之间的拷贝,提升性能。

调整 event_base 的优先级。libevent 支持为不同的事件设置优先级,把关键事件设为高优先级,能减少延迟。

5.3 这个架构还能怎么扩展

这套多线程 evhttp 服务器的骨架可以往几个方向扩展。

加入线程池处理阻塞任务。前面提到回调里不能做阻塞操作,但实际业务中总有一些操作是阻塞的。可以维护一个单独的线程池,回调里把阻塞任务提交到线程池,处理完之后再通过管道或者 event_base 的event_active机制通知回工作线程。

支持 HTTPS。libevent 提供了bufferevent_openssl模块,可以把普通的 bufferevent 替换成 SSL 的 bufferevent,从而支持 HTTPS。配置稍微复杂一些,需要加载证书和私钥。

加入优雅关闭机制。程序收到 SIGTERM 信号后,应该停止接受新连接,等待正在处理的请求完成,然后释放资源退出。这需要注册信号处理函数,并在信号处理函数里调用event_base_loopbreak来退出事件循环。

加入健康检查和监控接口。暴露一个/metrics接口,返回当前的连接数、请求数、错误数等指标,方便监控系统采集。

这套代码我后来在一个内部工具上跑了很长时间,稳定性不错。中间遇到过几次问题,基本都是前面提到的那些坑。多线程服务器的难点不在于写出来,而在于写对、写稳。希望这些经验能帮你少走一些弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 1:35:53

ESP32 AEC失效的四大根因与工程级调优指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:35:53

Python爬虫实战:用Requests和正则搞定教务系统课表提取

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:35:12

C#手写算法与数据结构:性能、内存与生产级落地指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:35:04

计算机组成原理运算器习题解析:补码、Booth算法与浮点数运算

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:34:40

离线环境用RPM包安装GCC:本地源搭建与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:34:40

精密单点定位(PPP)原理、误差建模与实战收敛指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华