


💪 今日博客励志语录:
真正属于你的东西,不是某一次运气给你的高度,而是环境变化以后,你依然能够重新爬上去的能力。
思维导图
亲手实现的主从 Reactor 网络服务器
│
├── Main Reactor:accept → 创建 TCPConnection → 连接分发
│
└── Sub Reactor:epoll + eventfd + timerfd + 任务队列
│
├── fd 就绪 → 读/写回调
├── 非阻塞 recv + ET → 读到 EAGAIN
└── HTTP 增量解析 → 得到完整请求
│
▼
当前 Worker 直接执行业务回调
│
遇到耗时业务怎么办?
/ \
CPU 密集型计算 等待数据库 / RPC
│ │
占用 CPU 线程 等待外部结果
│ │
可选计算线程池 是否必须创建业务线程池?
│
┌─────────────────┴────────────────┐
▼ ▼
同步 API + 工作线程 真正的非阻塞异步 API
│ │
线程池开销仍然存在 Reactor 监听完成条件
│
C++20 协程挂起 / 恢复
│
数据库客户端库负责协议细节
│
MySQL fd 就绪 → 推进客户端库操作
│
操作真正完成了吗?
/ \
否 是
│ │
继续监听相关事件 找到等待的协程句柄
│
直接 resume() 或入队
│
继续业务 / 返回 HTTP 响应
一、问题的起点:我们原来已经有一套主从 Reactor 网络服务器
在讨论协程之前,我认为没有必要一上来就讲 co_await、等待器或者协程帧。因为如果脱离原来的网络服务器,很容易出现一个问题:
协程的语法似乎都能看懂,但是却不知道为什么要把它引入现有项目,也不知道它到底解决了原来架构中的什么问题。
所以,这里先把视角拉回到此前实现过的网络服务器。
1. 为什么采用主从 Reactor?
对于一个需要维护大量 TCP 连接的网络服务器,如果给每个连接都创建一个专门的线程,那么随着连接数上升,线程栈、内核调度以及上下文切换等成本也会不断增加。事实上,大量连接可能长期处于空闲状态,并不需要一直占用一个线程。
因此,我们采用 I/O 多路复用 + 事件驱动 的方式,让少量固定的线程管理大量连接:
- 一个主 Reactor 线程负责接受新连接。
- 固定数量的从 Reactor 线程负责连接后续的 I/O 就绪事件与业务处理。
- 每条连接通常被分配给一个固定的从线程,从而让连接上下文尽量由单个线程管理。
图 1|主从 Reactor 网络服务器架构
这里要强调,主从 Reactor 并不是说主线程处理业务、从线程处理其他业务。两者最核心的区别,在于连接接入与连接后续事件处理的职责分离。
2. 主线程收到新连接之后做了什么?
假设一个客户端建立了 TCP 连接,主线程通过 accept / accept4 获取新的连接 fd。
接下来,它会为这个连接创建对应的 TCPConnection 对象,用于保存连接生命周期中的上下文,例如:
// 架构示意,省略具体类型和资源管理
struct TCPConnection {
int fd;
Buffer inputBuffer; // 接收到的 TCP 字节流
Buffer outputBuffer; // 待发送的数据
HttpRequest request; // 当前正在解析的 HTTP 请求
HttpParseState state; // HTTP 增量解析状态
ReadCallback onRead;
WriteCallback onWrite;
};
同时,我们还设计了一个连接表,本质上是哈希表,记录连接 fd 与连接对象之间的映射:
fd ──────> TCPConnection
10 连接 A 的读写缓冲区、回调和解析状态
11 连接 B 的读写缓冲区、回调和解析状态
12 连接 C 的读写缓冲区、回调和解析状态
创建并初始化连接对象后,主线程选择一个从线程,将该连接分发过去。
这里有一个重要的实现细节:主线程并不需要直接操作从线程的 epoll 实例。我们可以把“注册新连接”封装成一个任务,放进选中的从线程的任务队列。
// 伪代码:由选中的 Worker 线程完成 fd 注册
worker->queueTask([conn] {
worker->registerConnection(conn);
});
这里的“任务队列”不是一个回调函数,而是一个保存多个待执行任务的队列;其中的每个任务可以用
std::function<void()>等可调用对象表示。
如果主线程和从线程都要访问同一张连接表,还需要明确表的所有权与同步规则。也可以让主线程只负责创建对象以及连接表插入而后续状态操作全部由对应 Worker 完成,以减少共享状态。具体选择取决于原项目的实现。
3. 为什么任务投递之后还要使用 eventfd?
因为从线程此时可能正阻塞在 epoll_wait() 中。
主线程虽然已经把任务放进队列,但如果没有通知机制,从线程就不一定能够及时执行这个任务。
所以,每个 Worker 可以维护一个 eventfd,并把它注册到自己的 epoll 实例中:
主线程 从线程
│ │
├── queueTask(注册新连接) │
│ │ │
│ ▼ │
│ 任务队列 │
│ │
└── 写 eventfd ────────────────> epoll_wait 被唤醒
│
▼
处理通知
│
▼
执行待办任务
│
▼
注册连接到 epoll
这里我们需要区分两个东西:
任务队列负责保存“接下来执行什么”;eventfd 负责通知“有任务需要执行”。
eventfd 不负责传输实际的任务内容,它只是一个适合接入事件循环的通知机制。读取 eventfd 时也需要正确消费其计数,避免无意义的重复唤醒。
除了 eventfd,从线程还可以把 timerfd 注册到 epoll 中,用于定时任务、连接超时、心跳等。timerfd 不是凭空执行回调,它同样是通过 fd 就绪事件进入事件循环;实际定时策略还可能配合用户态定时器容器来管理。
4. 从线程的 EventLoop 到底在做什么?
现在,一个从线程的基本执行模型可以简化为:
void EventLoop::loop() {
while (!quit_) {
int n = epoll_wait(epfd_, events_, MAX_EVENTS, -1);
// 先处理本轮就绪 fd
for (int i = 0; i < n; ++i) {
dispatchEvent(events_[i]);
}
// 再集中处理任务队列
auto tasks = takePendingTasks();
for (auto& task : tasks) {
task();
}
}
}
图 2|从线程事件循环
注意,这里“先处理全部就绪 fd,再处理任务队列”是我们当前采用的事件循环策略,而不是 Reactor 规定的唯一实现方式。
epoll 关心的也不是“这是 HTTP fd 还是 eventfd”,它只负责报告哪些注册的 fd 满足了对应的就绪条件。fd 对应什么对象、应该执行什么回调,是我们在用户态维护的分发关系。
因此,收到就绪事件之后:
- 客户端 socket:根据可读、可写、错误等事件执行对应回调。
eventfd:消费通知,并让任务队列中的工作有机会被执行。timerfd:消费定时事件并处理到期任务。
认识了这一点,我们再看 HTTP 请求的读取过程。
二、一个 HTTP 请求在原来的 Reactor 中是如何被推进的?
1. TCP 是字节流协议:fd 可读不等于 HTTP 请求完整
假设客户端向服务器发送:
POST /login HTTP/1.1
Host: example.com
Content-Length: 11
hello world
从应用层来看,这是一个完整的 HTTP 请求。但对于 TCP 来说,它只是连续的字节流。
一次 recv() 可能只收到请求行,也可能收到部分请求头,甚至一次读到多个 HTTP 请求的数据。TCP 不会帮我们保留 HTTP 请求报文的边界。
因此,在读事件就绪时,服务器需要先将内核 TCP 接收缓冲区中当前可读的数据搬运到用户态输入缓冲区,然后再尝试解析请求。
2. ET 模式为什么要求非阻塞读取?
原项目采用边缘触发 EPOLLET。对于非阻塞 socket,典型读回调会不断调用 recv,直到遇到 EAGAIN / EWOULDBLOCK,即当前没有更多数据可以立即读取。
void readAvailableBytes(int fd, Buffer& input) {
char buf[4096];
for (;;) {
ssize_t n = ::recv(fd, buf, sizeof(buf), 0);
if (n > 0) {
input.append(buf, static_cast<size_t>(n));
} else if (n == 0) {
// 对端关闭连接,进行相应清理
break;
} else if (errno == EINTR) {
continue;
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 当前已读尽
} else {
// 处理真正的 socket 错误
break;
}
}
}
这是为说明 ET 读取原则而简化的代码;真实实现还需要让调用方区分正常读尽、连接关闭与错误,并考虑每轮读取的公平性。
为什么要读到 EAGAIN?因为 ET 侧重状态边沿通知;如果明明还有数据却提前停下来,再次进入等待时不一定能依赖重复通知来处理剩余的数据。
因此,处理完一次读写事件,只要连接仍需要继续监听,就要在合适的时机调整并重新激活对应事件,而不是只在“HTTP 请求未收全”时才考虑重新注册。
3. 为什么还需要 TCPConnection + HTTP 增量解析状态机?
我们先认识一个问题:即使 socket 当前已经读到了 EAGAIN,也不意味着 HTTP 报文已经完整。
例如,请求头声明 Content-Length: 100,但当前正文只收到了 20 字节。
这时,网络层已经没有更多字节可读,可是应用层显然还需要等待后续 80 字节。
于是,我们必须保存当前解析的中间状态:
enum class HttpParseState {
RequestLine,
Headers,
Body,
Complete
};
第一次读取时可能已经解析完成请求行和部分请求头,第二次再收到数据后,就应该沿着此前保存的状态继续解析,而不是每次都从头扫描整个报文。
图 3|HTTP 增量解析与非阻塞读事件
因此,TCPConnection 中的输入缓冲区、当前 HttpRequest 对象和协议解析状态,实际上共同承担了跨越多次 I/O 事件保存处理进度的作用。
对于 HTTP/1.1 持久连接,还需要考虑:同一次读取可能包含多个完整请求,或者包含“一个完整请求 + 下一个请求的一部分”。因此,解析器还应正确消费已处理字节,并为下一条请求重置相应状态。
到了这一步,我们已经实现了一个重要目标:
某条连接的数据没收全,就保存中间状态,让 Worker 去处理其他已就绪连接,而不是阻塞线程等待该连接剩余的数据。
这套机制本身已经很优秀,接下来引入协程并不意味着必须推翻它。
三、原来的 Reactor 真正容易遇到什么问题?
现在假设某个 HTTP 请求已经成功解析成 HttpRequest,接下来需要执行对应的业务逻辑。
原来的写法可能是:
void onRead(TCPConnection* conn) {
readAvailableData(conn);
auto requests = parseCompleteRequests(conn);
for (auto& req : requests) {
processBusiness(conn, req); // 直接同步执行业务
}
}
这里有个非常重要的事实:同一个 Worker 在同一时刻,只能在这条线程上执行一段代码。
epoll_wait() 即使一次返回了 100 个就绪事件,事件循环仍然需要逐个调用回调函数。假设第一个回调开始执行后迟迟不返回,那么后面的事件就只能等待。
1. 如果业务处理非常轻量呢?
例如,请求只需要读取内存中的某个状态、拼装一小段响应数据,整个处理过程非常短。
这种情况下,直接在 Worker 中执行,反而可能是简洁且高效的选择。
因为不需要额外进行任务投递、线程间同步、结果回传以及其他调度操作。
2. 如果业务处理需要持续占用 CPU 800ms 呢?
例如:
void processBusiness() {
heavyCalculation(); // 持续进行 CPU 计算约 800ms
}
那么在这约 800ms 内,当前 Worker 会一直执行这段计算。其他就绪 fd 的处理就可能明显延迟。
引入一个专门的业务线程池当然是一种思路:Worker 解析完成之后,把重计算任务放入线程池,计算完成后再把结果送回原来的 Worker。
但是这样也会带来任务队列操作、线程间通知、共享数据同步以及调度成本。我们需要衡量业务计算本身的成本、排队等待成本、额外调度开销以及连接所允许的延迟。
所以,并不存在一条适用于所有业务的规则说“所有请求都必须进入业务线程池”,也不能反过来说“线程池一定比直接回调慢”。
真正要问的是:
这个业务任务究竟是在持续使用 CPU,还是大部分时间只是在等待某个外部操作完成?
这就自然引出了协程。
四、理解协程之前,先从普通函数的逻辑执行流出发
1. 普通函数:沿着函数控制流执行到返回
假设:
void process() {
stepA();
stepB();
stepC();
}
如果我们从一个逻辑执行流的视角观察,忽略操作系统的时间片抢占,并将线程被抢占前后的执行过程拼接起来,那么普通函数的执行可以看成:
函数开始 → stepA → stepB → stepC → 函数返回
这里所谓“线性”,强调的是一个函数调用自身的逻辑控制流,而不是说函数不能包含条件分支或循环,也不是说操作系统一定会让它连续占用 CPU 直到返回。
普通函数当然可能因为线程抢占而暂停,但这种暂停属于线程调度层面;普通函数本身并没有给我们提供一个独立的接口,用来在某个语句处保存这一调用的局部执行状态,返回事件循环,稍后再从该语句附近恢复。
2. 协程:同样有控制流,但是可以主动挂起与恢复
协程函数同样有自己的逻辑执行顺序,只不过它可以在执行过程中暂停,等条件满足后继续执行。
Task<void> process() {
stepA();
co_await someAsyncOperation();
stepC();
}
从逻辑上看,它仍然可以完成 stepA → 等待操作 → stepC 这条业务链,只是中间不要求线程始终停留在当前任务上。
图 4|同一个线程上多个协程交替向前推进
现在,我们可以重新理解一个 Worker 线程:
线程是实际执行代码的载体,协程是在线程上被组织起来的一条条可挂起、可恢复的逻辑任务执行流。
例如任务 A 等待数据库响应时,A 可以先挂起;同一个 Worker 就可以继续处理请求 B、C 的业务。A 的等待条件满足后,再安排它恢复。
这里需要特别区分:多个协程在同一个线程上交替执行,并不等于它们在同一个 CPU 核上同时并行计算。另外,协程恢复也并不天然绑定创建它的线程;如果运行时允许,可以在其他线程上恢复,但在我们的 Reactor 方案中,会尽量让连接状态回到所属 Worker 操作。
3. C++20 为什么称为无栈协程?
在典型有栈协程实现中,协程拥有可被保留的独立栈,切换时需要保存并恢复相应执行上下文。
而 C++20 采用的是无栈协程思路:编译器把协程函数转换成能够在挂起点恢复的代码,并使用协程帧保存跨挂起点仍然需要的局部变量、promise 对象和其他必要状态。
所谓无栈,不代表运行协程时完全不用线程调用栈,而是挂起时不需要保留一整套独立的协程调用栈。
为了建立直观认识,可以把编译器生成的恢复逻辑理解成下面这样的状态机:
// 概念模型!并不代表编译器一定生成 switch
void resume(CoroutineFrame* frame) {
switch (frame->state) {
case 0:
// 首次执行的逻辑
frame->state = 1;
return; // 模拟挂起
case 1:
// 从第一个挂起点继续
frame->state = 2;
return;
case 2:
// 从第二个挂起点继续
return;
}
}
真正生成的当然还是 CPU 可以执行的机器指令,switch 只是便于理解的模型。C++20 标准也并不强制编译器必须采用某种固定的状态编号布局。
所以,协程的关键不是 CPU 变成了另一种执行方式,而是编译器替我们实现了一个能够保存必要状态、并从指定挂起点继续运行的执行过程。
4. co_await 与等待器:编译器和运行时各负责什么?
写出一个 co_await expression,并不代表编译器自动启动数据库查询,也不代表一定发生挂起。
编译器会按照 C++20 的等待器协议,调用满足接口要求的等待器方法:
struct SomeAwaiter {
bool await_ready();
void await_suspend(std::coroutine_handle<> h);
Result await_resume();
};
它们各自负责什么?
await_ready():检查当前是否可以不挂起就继续执行;如果返回true,本次等待可以跳过挂起。await_suspend(handle):在需要挂起时参与登记等待关系、安排后续恢复等工作。其返回类型还可以是bool或std::coroutine_handle<>,不同返回值会改变控制转移行为。await_resume():在继续执行时取出结果,或者报告异步操作失败。
这里有一个必须建立的边界:
编译器负责协程语言机制和状态保存,真正的异步操作怎么启动、完成后如何通知、协程何时恢复,则需要等待器及其配套运行时来设计。
C++20 给了程序员很大的自由度,但它并没有直接提供一个通用的网络任务调度器。
五、协程与异步不是一个维度:为什么两者又会结合得如此紧密?
这是整篇文章的一个关键转折点。
1. 首先要明确:协程关注的是任务执行流
我们可以把一个 HTTP 业务请求理解为一个任务,而一个任务又由多个有依赖关系的动作组成:
解析好的 HttpRequest
↓
查询数据库
↓
处理查询结果
↓
构造 HttpResponse
↓
返回客户端
协程允许我们用接近顺序代码的方式描述这个任务,并且在某个动作暂时无法推进时挂起。
2. 异步关注的是操作如何发起、推进和完成
比如一次数据库查询,当前线程是否必须等到查询完成才能继续处理其他工作?
如果调用的是同步阻塞接口,那么等待期间线程可能被阻塞。
如果操作具备异步执行能力,那么可以先发起操作,之后在结果可用时收到完成通知或通过相应机制推进操作。
所以:
协程解决“这一条任务执行流如何挂起与恢复”;异步解决“某个操作等待期间是否要占住当前线程、以及怎样知道它完成”。
它们并不是同义词。
3. 为什么协程里面不能随便放阻塞函数?
因为协程仍然要由线程执行。
假设:
Task<void> handle() {
blockingDatabaseQuery(); // 同步阻塞调用
co_return;
}
即使 handle() 是协程,也不会把 blockingDatabaseQuery() 神奇地变成非阻塞调用。当前 Reactor 线程仍然可能被同步阻塞住。
同理,如果业务是连续执行 800ms 的 CPU 密集计算,即使包装成协程,也没有凭空产生一个可以高效“等待”的外部操作。可以人工拆分计算、设置让出点改善调度公平性,但总计算量并不会减少,更不会自动获得真正并行的 CPU 计算能力。
因此,业务层是否适合引入协程,关键不在于“这是业务代码”,而在于:业务处理是否包含大量可以用非阻塞方式等待的动作。
六、同样是“异步查 MySQL”,为什么有的方案仍需要线程池?
假设业务任务需要执行一次耗时较长的数据库查询。这里必须区分两条实现路线。
图 5|两类异步实现路径
1. 方案 A:把同步阻塞查询放进业务线程池
如果我们手头只有同步 MySQL API,可以把查询提交给一个额外的工作线程:
Reactor 线程:业务协程发起查询
↓
把同步数据库查询投递到工作线程池
↓
当前协程挂起,Reactor 继续处理其他连接
↓
工作线程调用阻塞的 MySQL API
↓
查询结束,把结果 / 恢复任务投递回原 Reactor
↓
业务协程恢复,处理结果
这个方案对于 Reactor 来说确实可以表现为异步,但不能说我们“用了协程就省去了线程池”。
因为阻塞的工作只是被卸载到另一个线程,任务投递、线程间通知、队列同步等成本仍然存在。
协程在这里主要改善了业务层表达方式,让它可以继续写成 co_await query() 这样的顺序流程。
2. 方案 B:使用真正的非阻塞数据库客户端
现在换一种情况:数据库客户端支持非阻塞网络 I/O,并且可以把完成条件接入事件循环。
我们需要注意,应用服务器访问 MySQL 时,角色发生了变化:
浏览器 / App ──HTTP/TCP──> 我们的服务器
│
│ 此时我们的服务器是 MySQL 客户端
▼
MySQL 服务端
服务器向 MySQL 发出 SQL 请求后,真正执行 SQL 的是 MySQL 服务端;我们的 Reactor 线程并不需要在这段时间内一直占用 CPU 等待响应。
数据库结果返回时,本质上仍会通过客户端与数据库之间的通信连接到达应用进程。只要驱动提供了合适的非阻塞机制,应用服务器就可以依靠事件驱动方式推进查询操作。
这里,无需再专门创建一个本地线程,用同步数据库 API 卡在那里等待查询返回。
当然,这并不代表查询没有成本,MySQL 服务端依然要做计算、读取索引、锁等待等工作;我们节省的是应用服务器等待期间的线程占用。
七、一个很自然的设计:保留原来的网络 I/O,只在业务层引入协程
认识了前面的区别之后,架构选择其实就很清晰了。
我们原有的网络 I/O 层已经通过 epoll + 非阻塞 recv/send + HTTP 增量解析 解决了“网络数据不足时不能阻塞 Worker”的问题。
因此,没有必要为了用协程而强行把整套 HTTP 读写回调重写一遍。
这里可以采用混合式设计:
HTTP 客户端连接
│
▼
原有 Main/Sub Reactor
│
▼
epoll → HTTP 读回调 → TCPConnection 输入缓冲区
│
▼
HTTP 增量解析,获得完整 HttpRequest
│
▼
创建业务协程 Task(一个请求对应一个业务任务)
│
├── 普通轻量计算:直接执行
│
├── 异步数据库 / RPC:co_await,必要时挂起
│
└── 重 CPU 任务:必要时卸载到计算线程池
│
▼
业务结果交给原 Reactor 发送 HTTP 响应
伪代码可以写成:
void onHttpReadable(const std::shared_ptr<TCPConnection>& conn) {
readAvailableData(conn.get());
auto requests = parseCompleteRequests(conn.get());
for (auto& request : requests) {
// spawn 必须接管 Task 生命周期
spawn(handleRequest(conn, std::move(request)));
}
}
Task<void> handleRequest(
std::shared_ptr<TCPConnection> conn,
HttpRequest request
) {
auto user = co_await asyncDb.queryUser(request);
auto response = buildHttpResponse(user);
co_await sendThroughOwnerReactor(conn, std::move(response));
}
这些是架构示意接口,并非标准库现成 API。实际实现还要考虑响应顺序、连接关闭以及生命周期。
此时我们没有让协程取代 Reactor,而是做了职责划分:
- Reactor: 管理和分发 fd 就绪事件。
- HTTP 解析器: 管理字节流到请求对象的增量解析。
- 业务协程: 组织一个 HTTP 请求内部的多阶段业务流程。
- 异步客户端与等待器: 关联操作的等待条件、结果与协程句柄。
八、真正难的问题来了:MySQL 响应由谁接收?
到这里我们可能会卡住。
因为原来的 EventLoop 已经很明确:先处理一轮 epoll 就绪事件,再集中执行任务队列。但业务协程异步发出 SQL 请求以后,数据库返回的结果究竟从哪里进入原来的事件循环?
1. 第一个认识:epoll 并不只会监听 HTTP 客户端 fd
我们过去使用 epoll 时,监听的主要是 HTTP 客户端 socket。
但对于 epoll 而言,被监听的对象本质上只是满足其支持条件的文件描述符,它并不认识“HTTP 请求”“数据库结果”这些应用层概念。
因此,如果使用 TCP 连接 MySQL,数据库客户端通信也对应着一个底层 socket。只要客户端库提供受支持的方式让外部事件循环等待其 socket 的可读/可写条件,我们就可以把它纳入同一个 Worker 的 epoll。
Worker epoll
├── HTTP 客户端 fd=10 → HTTP 读写回调
├── HTTP 客户端 fd=11 → HTTP 读写回调
├── MySQL 连接 fd=20 → 数据库客户端推进回调
├── eventfd → 任务通知
└── timerfd → 定时器 / 超时
这里的 fd=20 只是举例,数据库连接也可能使用 Unix Domain Socket 等其他通信方式。
关键不是把 MySQL 改成我们自己实现的 socket 客户端,而是让 epoll 帮客户端库等待它所需要的 I/O 条件。
2. 第二个认识:MySQL 客户端库已经封装了协议,我们不应该绕过它
之前使用 C/C++ 的 MySQL 客户端时,可能写过这样的代码:
MYSQL* mysql = mysql_init(nullptr);
// mysql_real_connect(...) 建立连接
// mysql_real_query(...) 执行 SQL
// mysql_store_result(...) 取得查询结果
这里的 MYSQL* 是数据库客户端连接句柄,不等于 socket fd。
它还管理着协议状态、连接信息、结果集等内部资源。客户端库负责完成 SQL 请求编码、数据收发、MySQL 协议解析和结果集处理。
所以,即使拿到了客户端库允许暴露的底层 socket,也不应该绕过库、自己直接用 recv() 抢读 MySQL 响应。这样可能破坏驱动维护的协议状态。
正确的设计应该是:
业务协程
│
▼
数据库客户端库:开始/继续非阻塞操作
│
├── 若暂未完成:告知等待条件
│ │
│ ▼
│ Reactor epoll 监听 fd
│ │
│ ▼
│ 满足可读/可写/超时条件
│ │
└────────────────┘
│
▼
继续调用客户端库 API 推进操作
│
▼
客户端库确认查询及结果处理完成
│
▼
恢复业务协程
3. 第三个认识:不是所有数据库客户端库都有同一套 API
这里一定要避免把不同客户端库的接口混在一起。
**MySQL 官方 libmysqlclient 的 C API(8.0.16 起)**提供如 mysql_real_query_nonblocking()、mysql_store_result_nonblocking() 等异步接口,返回 NET_ASYNC_NOT_READY、NET_ASYNC_COMPLETE、NET_ASYNC_ERROR 等状态。它的文档介绍了重复推进非阻塞调用的方法,但不能据此直接假定它与某个其他库具有完全相同的公开 socket / 事件等待接口。
MariaDB Connector/C 的非阻塞 API则提供另外一种明确的接入方式,例如:
// 以下函数属于 MariaDB 的非阻塞 C 客户端 API
mysql_real_query_start(...);
mysql_real_query_cont(...);
mysql_get_socket(mysql);
MariaDB 的 _start() / _cont() 通过返回的 MYSQL_WAIT_READ、MYSQL_WAIT_WRITE、MYSQL_WAIT_TIMEOUT 等等待条件,让调用方将操作与 poll、epoll 等事件机制衔接起来。
因此,如果准备实际改造服务器,应该先确定使用哪一个数据库客户端库,以及它有没有受支持的非阻塞事件循环集成接口,再写等待器,而不是先写死一个数据库 fd 的读回调。
4. 用 MariaDB 的 API 来看这个过程更容易理解
下面只展示关键函数形态,省略完整的连接、结果集和错误处理:
// 具体函数签名及等待状态以所用 MariaDB Connector/C 版本为准
int query_result = 0;
int wait_status = mysql_real_query_start(
&query_result, mysql, sql, sql_length
);
if (wait_status != 0) {
// 这个数据库操作暂时没有完成
// 根据 MYSQL_WAIT_READ / WRITE / TIMEOUT 等条件
// 将驱动公开的 socket 和超时条件接入事件循环
}
之后,假设 epoll 检测到了对应的条件:
// 当外部等待条件满足时,用实际发生的条件继续推进
wait_status = mysql_real_query_cont(
&query_result, mysql, ready_status
);
if (wait_status != 0) {
// 仍未完成:更新后续需要等待的条件
} else {
// 本阶段查询 API 已完成;检查 query_result
// 如果是 SELECT,还需要按库协议正确取得结果集
}
这里有两个容易忽略的细节。
第一,数据库 fd 一次可读,并不等于整个 SQL 查询已经完成。 客户端库可能还要进行多轮读取、发送、结果集处理以及超时检测。
第二,查询命令的发送阶段完成,也不一定等于业务所需的整个结果集已经准备好。例如对于 SELECT,还需要通过对应的结果获取 API 完成后续处理。只有等待器承诺的整个操作真正完成,才能让业务协程通过 await_resume() 取出结果。
九、从数据库 fd 到协程句柄:整个恢复链条终于闭环
现在假设我们使用支持外部事件循环接入的非阻塞数据库客户端,而且数据库连接已经建立好。
1. 先建立一个关联:fd → DbConnection → 当前等待者
对于 HTTP 连接,我们之前维护的是 fd → TCPConnection。
那么对于数据库连接,同样可以维护一份自己的上下文:
struct DbConnection {
int fd; // 来自客户端库的受支持获取接口
// 数据库客户端句柄与非阻塞操作状态
// 当前查询结果 / 错误 / 超时 / 注册事件
std::coroutine_handle<> waiting; // 当前操作的等待者(概念示意)
};
因此,更适合的映射并不是简单地永远固定为:
fd → coroutine_handle
而是:
数据库 fd
↓
DbConnection(数据库客户端状态 + 正在执行的操作)
↓
当前操作的等待者 / coroutine_handle
原因很简单:连接除了对应协程,还需要维护协议驱动状态、结果集、异常、超时和操作生命周期等信息。
如果未来使用数据库连接池,一个连接可能先后服务许多协程,并不是永久绑定某一个任务。甚至对于同一数据库连接,是否能同时发起多条查询还受到客户端库和数据库协议约束,不能仅凭有多个协程就假定可以任意并发复用。
2. 一个业务协程发起数据库查询
假设业务代码是:
Task<HttpResponse> handleRequest(HttpRequest req) {
User user = co_await asyncDb.queryUser(req.userId);
co_return buildResponse(user);
}
当执行到 co_await 时,等待器会参与异步操作的启动或状态检查。
如果数据库结果尚未准备好,那么我们记录当前协程句柄,并让协程挂起。至于句柄应该挂在哪个操作状态上、谁拥有它、什么时候可以销毁,是等待器/运行时必须保证的内容。
此时当前 Worker 并没有阻塞等待 MySQL,而是继续执行原来的 epoll 事件循环。
3. MySQL fd 就绪时,调用数据库客户端回调
假设 MySQL 服务端向客户端连接返回了数据,数据库 socket 满足可读条件。
现在 epoll_wait() 检测到的可能不是 HTTP 客户端 fd,而是 MySQL 客户端库对应的 fd。
于是我们调用注册好的数据库事件回调。但注意,这个回调的职责不是直接解码 MySQL 报文,而是通知客户端库继续推进此前尚未完成的操作。
下面是高度概括的架构伪代码:
void onMysqlEvent(DbConnection& db, Event event) {
auto state = db.continueAsyncOperation(event);
if (state == OperationState::Pending) {
db.updateInterestedEvents(); // 继续等待读/写/超时
return;
}
// 已经完成或失败:先保存结果或错误,再取出等待者
db.publishCompletedResult();
auto h = db.takeWaitingCoroutine();
// 如果恢复条件及生命周期均满足,可选择直接恢复
if (h) {
h.resume();
}
}
请注意这里是伪代码。现实中,取出句柄、保存完成结果、移除回调、管理底层连接和操作对象的先后顺序,必须仔细设计,不能照搬成可运行代码。
4. 为什么不一定需要任务队列?
因为当前回调已经运行在对应 Worker 的事件循环线程上。
如果数据库操作已经真正完成,而且恢复协程的条件满足,那么我们完全可以:
handle.resume();
这就意味着,数据库事件回调会同步地把 CPU 执行流程转入对应协程,从它的挂起点继续执行。
图 6|MySQL 异步操作完成与业务协程恢复
此时执行链条可以被简化为:
epoll_wait 检测数据库 fd 就绪
↓
执行 DbConnection 的事件回调
↓
调用客户端库 API 继续推进操作
↓
客户端库确认整个异步操作完成
↓
保存结果,获取等待协程句柄
↓
handle.resume()
↓
业务协程 await_resume() 取得结果
↓
执行业务后续逻辑
↓
构造 HTTP 响应并交给原 Reactor 发送
因此,我们并不需要为了恢复协程,再把同一个线程中已经准备好的恢复任务强行绕一圈任务队列。
十、把一次完整的异步 HTTP → MySQL → HTTP 请求串起来
经过前面这些推导,我们终于可以完整地观察一次请求:
第一阶段:接入与解析。 主 Reactor 建立 TCP 连接,创建连接上下文并投递注册任务;选中的 Worker 通过 epoll 接收网络可读事件,非阻塞读取字节流,使用 TCPConnection 中保存的状态机增量解析 HTTP 请求。
第二阶段:进入业务协程。 当解析到完整 HttpRequest 后,创建对应业务 Task。这个 Task 在逻辑上代表处理当前 HTTP 请求的业务执行流。
第三阶段:启动数据库异步操作。 协程执行到 co_await asyncDb.query(...),调用数据库客户端库的非阻塞接口。如果操作尚未完成,建立数据库操作状态与当前协程句柄的关联,随后挂起协程。
第四阶段:Worker 继续工作。 Reactor 线程继续处理其他客户端连接、定时器与任务队列,而不是阻塞等待 SQL 返回。
第五阶段:数据库响应到达。 当数据库连接的 I/O 条件满足时,原来的 epoll 检测到对应 fd 就绪,数据库回调调用客户端库 API 继续推进操作。
第六阶段:确认真正完成。 如果客户端库仍需要后续 I/O,就更新读写/超时监听,继续等待;如果查询及业务所需结果处理已完成,则保存结果或错误。
第七阶段:恢复业务协程。 可以直接在当前 Reactor 线程执行 handle.resume(),也可以先把恢复任务加入 EventLoop 的任务队列。
第八阶段:返回 HTTP 响应。 协程从 co_await 之后继续运行,处理数据库结果,组织响应,再由所属 Worker 负责发送数据。对于非阻塞写入,可能仍需将剩余数据留在输出缓冲区,等待后续可写事件继续发送。
整个过程依旧是在原来的 Reactor 之上完成的。变化的不是 epoll 的本质,而是业务任务在等待外部操作时,能够被保存并在条件满足后恢复。
十一、总结:不是用协程替代 Reactor,而是让两者各司其职
回顾整个推导过程,我们最开始并不是为了使用协程而使用协程。
而是已经实现了一套主从 Reactor 服务器,依靠 epoll、非阻塞 I/O、读写回调以及 HTTP 增量解析,让少量 Worker 线程管理大量连接。对于没有收完整的请求,我们保存解析状态并等待下一次就绪事件;这一层原本就运行得很好。
真正需要进一步思考的是请求进入业务逻辑之后。
如果业务只是轻量计算,继续在 Worker 中直接执行即可;如果需要长时间使用 CPU,协程无法凭空减少计算量,可以考虑计算线程池;如果业务主要在等待数据库、RPC 等外部操作,那么非阻塞异步 I/O 与协程就具有很强的互补性。
对于异步 MySQL,我们也不需要绕过客户端库,自己读取和解析数据库协议。正确的设计是:让数据库客户端库维护 MySQL 通信与结果处理状态,让 Reactor 提供它所需要的 I/O 就绪通知,让等待器记录等待关系,最后通过协程句柄恢复业务执行流。
最终形成的职责分工如下:
| 层次 | 使用机制 | 主要解决的问题 |
|---|---|---|
| 连接接入 | Main Reactor | 建立连接并选择从线程 |
| 网络事件 | Sub Reactor + epoll | 感知 fd 的读写就绪、错误与定时通知 |
| 网络数据收发 | 非阻塞 I/O + 读写回调 | 避免单个连接因为网络等待阻塞 Worker |
| HTTP 协议处理 | TCPConnection + 增量状态机 | 处理 TCP 字节流、分包及跨事件解析 |
| 业务任务 | C++20 协程 | 以顺序形式组织可挂起、可恢复的业务流程 |
| 数据库异步操作 | 支持非阻塞的 MySQL/MariaDB 客户端库 | 推进数据库通信并获得结果 |
| 协程恢复 | coroutine_handle::resume() / EventLoop 任务队列 | 在操作完成后继续原业务执行流 |
| CPU 重任务(可选) | 独立计算线程池 | 避免长时间计算占用 Reactor 线程 |
最后,我认为真正值得记住的不是某个 co_await 的语法,而是下面这条因果链:
原 Reactor 已经解决了网络 I/O 的非阻塞等待 → 业务处理仍可能因外部操作等待而占用线程 → 采用真正的非阻塞客户端执行外部操作 → 让业务协程在等待期间挂起 → 复用原 Reactor 的事件驱动机制感知结果 → 客户端库推进并确认操作完成 → 直接恢复或调度恢复对应协程。
这就是我们从一个自己实现的主从 Reactor 服务器,一步步推导到 Reactor + C++20 协程 + 异步 MySQL 混合架构的完整思路。
协程不是对 Reactor 的推翻,而是给已有事件驱动模型补上了“业务执行流可以挂起并恢复”这一层抽象。

转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2301_80260194/article/details/169067083




