☰
半同步/半异步模式:事件驱动、请求队列与线程池实战
2026/10/26 6:58:49 网站建设 项目流程

做服务端开发这些年,有个模型我几乎每次带新人都会被问到,那就是半同步/半异步模式。它的名字起得很劝退,第一次听到的人十有八九会把它和内核的五种 IO 模型混在一起,觉得“半”这个字眼既不上不下又自相矛盾。可真到了要写一个能扛住几千并发连接的服务器时,你又绕不开它:单线程处理业务会被慢请求拖死,全异步写业务逻辑会把自己绕进回调地狱。这个模式本质上就是在两者之间划了一条线,让事件驱动层只管搬运,同步工作层只管干活,两边各司其职,用一道请求队列解耦。我接下来会把这条线画在哪、队列怎么写、线程开几个、参数怎么估、上线后崩在哪儿,全部摊开讲一遍。内容偏实战,需要你懂一点 C++、懂一点 epoll、知道什么是文件描述符,但不需要你写过完整的服务器框架。看完你应该能自己搭一个能压测、能跑通的最小可用版本,也能看懂别人框架里那些EventLoop、ThreadPool、PendingQueue到底在折腾什么。

1. 把“同步/异步”这两个词掰开揉碎

在动手写代码之前,有一个坑必须先填掉,否则后面全是糊涂账。半同步/半异步这个模式名字里的“同步”和“异步”,跟我们在讲 IO 模型时说的“同步 IO、异步 IO”,指的根本不是同一件事。这两个词在不同语境下含义不同,很多人第一次读相关章节时会觉得作者前后矛盾,其实是因为语境切换了。我把这两层含义分开摆一遍。

1.1 内核 IO 视角下的同步与异步

这个视角谈的是数据从内核缓冲区到用户缓冲区这一段搬运由谁来完成。同步 IO 的意思是:从发起调用到数据真正就绪可用,整个过程你的线程是被“绊住”的,必须等。像阻塞 read、非阻塞 read 配合轮询、select、poll、epoll 这些多路复用,统统归在同步 IO 里。为什么 epoll 也算同步?因为 epoll_wait 返回时只是告诉你“这个 fd 可读了”,接下来真正把数据从内核拷到用户态的recv调用,还是得你自己发起、自己等它完成。

异步 IO 则是另一个层级的概念。你发起一个请求,然后立刻返回干别的去,等数据已经完整拷贝到你的用户缓冲区之后,内核再通过某种方式通知你。这中间不需要你再发起任何读写调用。这种模型在 Linux 上长期只有 POSIX aio 这个半残品,用起来限制一大堆,实际生产里很少有人拿它写网络服务器。

这里要划重点:这套“同步/异步”的划分,讲的是 IO 数据的搬运方式,跟线程数量、是不是要写回调、事件循环怎么组织,没有任何必然联系。理解这一点,后面才不会被绕晕。

1.2 并发模式视角下的同步与异步

换到并发模式这个语境,“同步”和“异步”描述的是程序的执行流程是怎么被驱动的。

同步方式执行的代码,流程完全由你自己的代码顺序决定:调用 A 就执行 A,A 返回了再执行 B,每一步的先后顺序都在你的掌控之中,读到一半被挂起也是你自己调用的结果。异步方式则反过来,流程由外部事件来推动:你的程序平时趴在一段“等待事件”的代码里,某个事件到来了,才被叫醒去执行对应的处理逻辑,执行完了又趴回去。谁来处理、什么时候处理、处理多久,都不由主流程决定。

这个视角下,事件循环(event loop)是标志性的结构。主循环里一个epoll_wait挂在那儿,有事件就分发出去,没事件就睡着。业务代码是注册进去的回调函数,被事件触发才跑起来。这就是异步。

举个生活化的类比。同步像是你去窗口办事,排到自己了就站在窗口前把所有材料递上去、一项项办完、拿到结果再走。异步像是取号机:你把材料交给柜台,拿个号去旁边坐着等,广播叫到你的号你再回来。柜台那边按自己的节奏处理,你不占着窗口。

1.3 两套语义为什么会打架

问题的根源在于:异步事件驱动的方式,天生不适合写有状态、有阻塞、有先后依赖的业务逻辑。你想想,一段业务要查一次缓存、再查一次数据库、再写一条日志,如果每步都是回调,代码会被拆得七零八落,状态得靠上下文对象传递,出错时栈回溯简直是灾难。这就是所谓“回调地狱”,写过 Node.js 早期代码的人应该深有体会。

反过来,纯同步的阻塞处理方式写业务很爽,顺序清晰、调试方便,但一个慢请求就能把处理它的线程占死。如果你的并发量是几千,线程数却只有几十,那剩下的连接只能排队干等。

所以一个很自然的想法就出来了:把这两类工作拆开,交给不同的人做。IO 事件这种高频、轻量、容易用事件驱动描述的工作,交给异步层;业务逻辑这种低频、偏重、需要顺序执行的工作,交给同步层。两层之间放一个缓冲区做削峰填谷。这就是半同步/半异步模式的全部核心思想,剩下的都是工程细节。

2. 半同步/半异步的分层设计到底解决什么问题

理解了上面的动机,我们就能看懂这个模型的结构。它的骨架其实非常简单,三个组成部分:一个异步层、一个请求队列、若干个同步工作单元。但每个部分为什么要这么设计,里面有不少讲究,这部分我拆得细一点。

2.1 三个角色:事件驱动层、请求队列、同步工作层

异步层通常就是主线程或者一小组 IO 线程,职责非常纯粹:守着 epoll,等事件,把“哪个 fd 发生了什么类型的事件”打包成一个任务对象扔进队列。它不做任何业务处理,不做任何可能引起阻塞的系统调用。理想状态下它一圈循环的耗时是微秒级的,这样才能保证事件分发的实时性。

请求队列是两层之间的传送带,本质是一个线程安全的生产者消费者缓冲区。生产端是异步层,消费端是同步工作层。它承担了三件事:解耦、削峰、缓冲。解耦是说两层互不知道对方的存在,只管往队列里塞和从队列里拿;削峰是说瞬时流量尖峰会被队列吸收一部分,不至于直接压垮工作层;缓冲是说当工作层全忙时,新到的任务可以排队等一会儿,而不是被立刻拒绝。

同步工作层是真正干活的地方,一堆工作线程(或者工作进程)从队列里取任务,按顺序执行处理逻辑,可能是解析协议、查数据库、拼装响应、写回数据。在这些线程的视角里,执行流是完全顺序的、同步的,写起来就跟写普通的多线程程序一样,不需要关心事件循环那一套。

这种划分带来的直接好处是:每一层的复杂度都被控制住了。异步层只处理 IO,代码量小、逻辑简单、可以做到极致性能;同步层只处理业务,可以用最自然的顺序式写法,不用考虑事件驱动带来的状态机拆分问题。两边的开发难度都降低了。

2.2 为什么非得加一道队列,不能直接传函数调用

有读者可能会想:异步层检测到可读,直接调一下工作层的处理函数不就行了,为什么要绕一道队列?这个想法在小规模下确实能用,但它会带来三个问题。

第一是耦合。直接调用意味着异步层必须知道工作层的接口、线程模型、生命周期,将来换一种调度方式(比如从线程池换成协程池),异步层要跟着改。加了队列之后,两边只依赖一个任务结构体,替换任意一边都不影响另一边。

第二是背压和限流。队列天然就是个有限容器。当它满了,你可以有明确的选择:拒绝新任务、丢弃最旧的、给客户端回一个“稍后重试”、或者让生产者短暂等待。如果直接函数调用,这个“满了怎么办”的问题就不存在了,压力会一路传导到底层,最终表现为内存暴涨或者 OOM。

第三是并发安全性。直接调用意味着多个 IO 线程可能同时进入同一个业务函数,共享数据结构要处处加锁。而通过队列分派,你可以保证同一个连接的任务始终落到同一个工作线程(用 fd 取模或者哈希做亲和性绑定),这样单个连接的状态就不需要加锁,只在跨连接共享的数据上才需要同步。

注意:队列不是越多越好。一个进程里如果存在多个不同用途的队列,任务在多个队列之间流转会产生额外的延迟和死锁风险。我在一个项目里见过三层队列嵌套的设计,最后排查一个偶发的任务丢失问题花了两天,结论是中间某层的出队条件和入队条件互相等待。能用单层队列解决就别叠。

2.3 和半同步/半反应堆的区别在哪

这两个名字长得太像,几乎是所有人第一次接触时会混淆的点,我单独拿出来说。

半同步/半反应堆是另一种变体,它的特征是:异步层只用单线程处理事件监听,连读写操作也在异步层完成,然后只把“已经读完数据、可以直接处理的完整请求”投进队列,工作线程拿到的是已经处理好的数据,只做业务计算,不做任何 IO。这个模型在早期的 web 服务器里很常见,优点是工作线程完全不碰 socket,干净;缺点是异步层的负担极重,所有数据拷贝都在单线程里做,读大文件或者做 SSL 握手时会成为瓶颈。

半同步/半异步的经典形态则是:异步层只负责“发现有事件”,具体读多少、写多少、什么时候读,都交给工作线程自己去做。工作线程拿到 fd 之后可以自己决定调用几次 read、要不要分批、要不要挂起等待。这也是为什么它叫“半异步”——异步的部分只做到事件通知为止。

两者的性能差异在什么场景下会显现出来?我实测过一个很典型的对比:短小的 ping-pong 请求,两者差距不大,因为数据量小,拷贝开销可以忽略;但一旦上了大报文(比如几十 KB 的响应体),半反应堆的单线程异步层会迅速打满一个核,而半异步版本因为把读写摊到了多个工作线程上,能顺着核数线性扩展。这个差异在压测曲线上非常直观,前者的 QPS 曲线在某个点突然压平,后者的曲线还能继续往上走一段。

2.4 几代实现谱系的横向对比

把这几种常见架构放在一起看,选型的时候心里就有数了。下表的描述是工程常见形态的归纳,具体数值随场景浮动很大,仅供参考。

架构形态事件监听读写操作业务计算典型瓶颈适用量级
单线程 reactor单线程单线程单线程CPU 单核数百连接
半同步/半反应堆单线程单线程线程池异步层单核打满数千连接
半同步/半异步(经典)主线程工作线程工作线程队列锁竞争数千到数万
主从 reactor(一连接一线程循环)每线程独立每线程独立每线程独立线程数与连接数耦合数万连接
多进程 + 队列主进程工作进程工作进程进程间通信开销中等,看进程数

选型的时候我的判断顺序一般是:先看报文大小,小报文看半反应堆会不会先撞墙;再看业务是否阻塞(有没有数据库慢查询、有没有磁盘 IO),有阻塞就必须把读写和业务拆开;最后看连接数,超过一万就得考虑让每个线程自己管自己的 epoll,否则主线程的锁和唤醒开销会吃掉大部分收益。

3. 从零写一个能跑的最小版本

理论说完了,接下来动手。我会写一个最经典的 V1 版本:主线程跑 epoll,工作线程阻塞处理。代码能编译、能跑、能压测,注释里我会把关键取舍标出来。这个版本不完美,跑完之后我会专门花一节讲它的三个硬伤,以及怎么演进到更实用的 V2。

3.1 先定基础设施:选型与理由

IO 多路复用这块没得选,Linux 上就用 epoll。理由很直接:select 和 poll 每次调用都要把整个 fd 集合从用户态拷到内核态,时间复杂度是 O(n),连接数上千之后光是这次拷贝就够呛;epoll 则是内核里维护一棵红黑树,你注册一次,后续 epoll_wait 只返回就绪的那几个,时间复杂度是 O(就绪数)。这里我选 ET 还是 LT?V1 版本我用 LT,原因很简单:LT 更宽容,即使某次没读干净,下次还会再通知你,调试期不容易踩到“事件丢了”的坑。等程序稳了,再考虑切 ET 榨性能。

线程模型上用std::thread加一个固定大小的线程池,不用第三方库,减少依赖。队列用std::queue加互斥锁加条件变量,先跑通再说,后面再讨论怎么优化成无锁环形队列。

这里有个必须提前想清楚的问题:连接和线程的绑定关系。如果同一个 fd 的读事件被两个不同的工作线程先后取到,那就可能出现两个线程同时对同一个 fd 做read,或者一个线程在读、另一个在写同一个连接的缓冲区,数据错乱是必然的。所以从第一行代码起就要定规矩:同一时刻,一个 fd 只能有一个线程在动它。

实现手段就是EPOLLONESHOT。这个标志的意思是一次事件通知只生效一次,处理完之后必须重新epoll_ctl加回去,才会收到下一次通知。这样天然保证了同一个 fd 在处理期间不会被重复投递。

3.2 请求队列的线程安全实现

先写队列。这个类不大,但每个细节都有讲究,尤其是条件变量的用法。

// request_queue.h #pragma once #include <queue> #include <mutex> #include <condition_variable> #include <cstddef> enum class TaskType { NEW_CONN, // 主线程 accept 到了新连接 READABLE, // 某个已有连接可读 CLOSED // 连接需要关闭 }; struct Task { TaskType type; int fd; }; class RequestQueue { public: explicit RequestQueue(size_t capacity) : cap_(capacity), stop_(false) {} // 生产端:异步层调用 bool Push(Task t) { std::unique_lock<std::mutex> lk(mtx_); // 队列满时阻塞生产者,这是最简单的背压 not_full_.wait(lk, [this] { return stop_ || q_.size() < cap_; }); if (stop_) return false; q_.push(t); not_empty_.notify_one(); return true; } // 消费端:工作线程调用 bool Pop(Task& out) { std::unique_lock<std::mutex> lk(mtx_); not_empty_.wait(lk, [this] { return stop_ || !q_.empty(); }); if (q_.empty()) return false; // 只有 stop_ 时才会走到这里 out = q_.front(); q_.pop(); not_full_.notify_one(); return true; } void Stop() { std::lock_guard<std::mutex> lk(mtx_); stop_ = true; not_empty_.notify_all(); not_full_.notify_all(); } private: std::queue<Task> q_; std::mutex mtx_; std::condition_variable not_empty_; std::condition_variable not_full_; size_t cap_; bool stop_; };

几个容易被忽略的点我说明一下。

wait必须带谓词,不能写成裸的cv.wait(lk)。条件变量存在虚假唤醒,操作系统可能在你根本没收到 notify 的情况下把你叫醒,这时候你去q_.front()就是一个未定义行为。带上谓词的版本等价于while (!pred()) wait(lk);,醒来后会重新检查条件,这是唯一正确的写法。

notify_one放在锁内还是锁外?我这里放在锁内,严格来说是有轻微性能损失的:被唤醒的线程醒来后要先抢锁,而通知线程此时还没释放锁,抢不到,会立刻再睡一次。这个所谓的“惊群式唤醒”在队列很长、竞争激烈的场景下能测出差异。更优的写法是把 notify 挪到lk.unlock()之后,或者用std::unique_lock手动控制作用域。但要注意,挪到锁外时,被唤醒的线程有可能在新一轮锁竞争中被别的线程抢了先,导致本可以立刻处理的任务又被搁置——不过这在语义上没问题,只是延迟抖动。我个人的偏好是:短临界区的场景放锁内更简单,临界区长(比如里面还做了序列化)的一定要挪到锁外。

容量cap_给多大?先给个 1024 起步,压测后再调。这个值太小会频繁触发阻塞,太大会让排队延迟变长、内存占用上升。后面 5.2 节我会给一个基于 Little's Law 的估算方法。

3.3 异步层事件循环的实现

主线程的逻辑非常短,短到第一次写的人会觉得“就这?”

// reactor.cpp #include <sys/epoll.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <fcntl.h> #include <cerrno> #include <cstring> #include <cstdio> #include "request_queue.h" static int SetNonBlocking(int fd) { int old = fcntl(fd, F_GETFL, 0); if (old < 0) return -1; return fcntl(fd, F_SETFL, old | O_NONBLOCK); } static int CreateListenSocket(int port) { int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); if (fd < 0) return -1; int one = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(port); if (bind(fd, (sockaddr*)&addr, sizeof(addr)) < 0) { close(fd); return -1; } if (listen(fd, SOMAXCONN) < 0) { close(fd); return -1; } return fd; } void RunReactor(int listen_fd, int epfd, RequestQueue& queue) { epoll_event ev{}; ev.events = EPOLLIN; // LT 模式 ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); std::vector<epoll_event> events(1024); while (true) { int n = epoll_wait(epfd, events.data(), (int)events.size(), -1); if (n < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; } for (int i = 0; i < n; ++i) { int fd = events[i].data.fd; if (fd == listen_fd) { // 新连接交给工作线程去 accept queue.Push({TaskType::NEW_CONN, listen_fd}); } else { // 用 ONESHOT 语义:投递后先摘掉,处理完再装回去 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr); queue.Push({TaskType::READABLE, fd}); } } } }

注意这里有几个刻意的设计选择。

新连接不在这里 accept。这是半同步/半异步和半反应堆的关键分界点。accept本身在连接洪水时可能阻塞(虽然 listen fd 是非阻塞的,但 accept 循环里还要做地址转换、可能触发 TLS 握手等),更重要的是 accept 之后要把新 fd 注册进 epoll,这个操作要加锁。主线程只负责“通知有新连接来了”,具体的 accept 和注册交给工作线程,主循环的耗时就被压到最低。

用EPOLL_CTL_DEL代替EPOLLONESHOT。两者效果类似,都是防止重复投递。用 DEL 的好处是不依赖 oneshot 的内部语义,更直观;坏处是每次事件都要多一次系统调用。如果你追求极致,用EPOLLONESHOT标志并在处理完时EPOLL_CTL_MOD重装,系统调用次数一样,但语义上更清晰。我在这版里用 DEL,是为了让读者一眼看出“处理期间 fd 不在 epoll 里”这个事实。

事件数组大小 1024。这个值不是随便给的。epoll_wait的 maxevents 参数决定了单次最多返回多少个就绪事件。设太小会导致一轮处理不完,剩下的下一轮继续,增加延迟;设太大浪费栈以外的内存(这里是堆上的 vector,所以还好)。1024 是个惯用值,如果压测发现一轮经常返回接近 1024 个事件,说明事件处理速度跟不上到达速度,这时候调大只是治标,真正的解法是加工作线程或者优化单个任务的处理耗时。

3.4 同步层工作线程的实现

工作线程拿到任务之后干什么?这是真正体现“同步”的地方。它的代码看起来就是一个普普通通的顺序程序。

// worker.cpp #include <sys/epoll.h> #include <sys/socket.h> #include <unistd.h> #include <cerrno> #include <cstring> #include <cstdio> #include "request_queue.h" static void HandleRead(int epfd, int fd) { char buf[8192]; ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { // 这里换成你的业务处理:解析协议、查库、拼响应 const char* resp = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"; ssize_t left = (ssize_t)strlen(resp); while (left > 0) { ssize_t w = write(fd, resp, left); if (w <= 0) { if (errno == EINTR) continue; if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 简化处理 close(fd); return; } left -= w; } } else if (n == 0) { // 对端关闭 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr); close(fd); return; } else { if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) { // 不致命,继续 } else { close(fd); return; } } // 处理完毕,重新挂回去,等下一次事件 epoll_event ev{}; ev.events = EPOLLIN; ev.data.fd = fd; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); } void RunWorker(int epfd, RequestQueue& queue) { Task t; while (queue.Pop(t)) { switch (t.type) { case TaskType::NEW_CONN: { while (true) { sockaddr_in cli{}; socklen_t len = sizeof(cli); int cfd = accept(t.fd, (sockaddr*)&cli, &len); if (cfd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; break; } SetNonBlocking(cfd); epoll_event ev{}; ev.events = EPOLLIN; ev.data.fd = cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); } break; } case TaskType::READABLE: HandleRead(epfd, t.fd); break; default: close(t.fd); break; } } }

这段代码有两个地方值得停下来想想。

第一,accept用了循环。这是非阻塞 socket 的标准姿势:一次通知可能对应多个已经排队的新连接,如果只 accept 一个,剩下的要到下一轮事件才处理,会白白增加延迟。配合 LT 模式,即使你在循环里没取完,下一次epoll_wait还会通知你。

第二,HandleRead里的write处理是简化的。真实场景下,你写完响应之前不能把 fd 从 epoll 里摘掉,因为写缓冲区可能满(EAGAIN),这时候你需要注册EPOLLOUT事件,等可写的时候再续写。上面的代码在EAGAIN时选择了break,响应写了一半就丢了——这在演示里能接受,生产里是致命的。完整的做法是给每个连接维护一个发送缓冲区,EAGAIN时把剩余数据挂在缓冲区上,注册EPOLLOUT,等EPOLLOUT触发时继续发。这部分的复杂度是半同步/半异步实现里的主要工作量,我会在 4.3 节展开。

3.5 编译、运行与压测

把上面的代码拼起来,加上main函数,编译命令是:

g++ -std=c++17 -O2 -pthread \ reactor.cpp worker.cpp main.cpp \ -o half_sync_server

-pthread必须有,否则std::thread和线程安全的std::mutex会有问题(后者在某些实现下链接不上)。-O2在压测时一定要开,不开的话编译器的栈展开和原子操作会有明显差异,测出来的数字没有参考价值。

启动之后,用通用压测工具打一下,比如wrk,用-t指定线程数、-c指定连接数、-d指定时长:

wrk -t4 -c1000 -d30s http://127.0.0.1:8080/

我在一台 4 核 8G 的测试机上跑这套代码,工作线程开 8 个,纯空转返回 2 字节响应体,稳定在 8 万到 10 万 QPS 之间。这个数字没什么参考价值,因为响应体是假的,业务处理是空的。真正的意义在于看曲线的形状:随着-c从 100 涨到 1000,QPS 应该先线性上升然后走平;如果曲线中途出现剧烈的锯齿或者断崖,说明有锁竞争或者线程饥饿,这时候要用perf top看热点。

还有个小技巧:压测前先把ulimit -n调到 65535 以上,不然连接数一上去就会accept报EMFILE。这个坑我踩过不止一次,报错信息是Too many open files,但因为它出现在accept之后就很容易被忽略。

3.6 V1 版本暴露出来的三个硬伤

代码跑通了,但如果你真拿它上生产,很快就会撞上三道墙。

第一道墙是锁竞争。主线程epoll_ctl要往同一个 epoll 实例上加 fd,工作线程处理完也要加回来,多个线程同时操作同一个 epoll fd 时,内核内部是有锁的。连接数一多,这个锁就成了全局热点。更麻烦的是,所有工作线程共享同一个 epoll,epoll_wait由主线程独占,工作线程只能被动等通知,一旦主线程被一次长时间的accept循环卡住,整个服务的事件分发都会延迟。

第二道墙是阻塞操作。V1 里我在工作线程里做了同步的write循环,虽然是简化版,但即便换成完整的缓冲区版本,只要有任何一次读写在某个连接上变成慢操作(客户端接收窗口很小、网络抖动),这个工作线程就会被占住,队列里排队的其他任务只能干等。这违背了“同步层只做纯计算”的初衷。

第三道墙是 fd 归属混乱。主线程会把任何就绪的 fd 投进队列,哪个工作线程先抢到就归谁处理,同一个连接的两次事件可能落到不同线程上。如果你在连接上维护了状态(比如 HTTP 的 keep-alive 计数、TLS 的会话状态),就必须加锁,而加锁又回到第一道墙。

这三道墙指向同一个方向:让每个工作线程拥有自己的事件循环和自己的 epoll 实例。这就是 V2。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询