16、封装异步写操作:基于协程的非阻塞 write 封装
网络编程里,write 操作看着简单,其实坑不少。
阻塞 write 会卡住线程,非阻塞 write 又可能写不完。我早期做嵌入式网关时,就遇到过 write 返回部分字节的情况——数据没发完,程序还傻傻地以为发送成功了。嗯,那会儿调试了一整天才找到原因。
今天我们就用协程来封装一个靠谱的异步 write。说白了,就是让 write 操作能“等”,但不阻塞线程。
16.1 非阻塞 write 的痛点
先看看原生非阻塞 write 的问题:
// 非阻塞 write 示例
int n = write(fd, buf, len);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 缓冲区满了,需要等待
// 但这里怎么等?轮询?太浪费 CPU
}
} else if (n < len) {
// 只写了部分数据,剩下的还要继续写
// 又得回到事件循环...
}
你看,两个问题:
- 缓冲区满:需要等待可写事件
- 部分写入:需要记录偏移量,继续写
传统做法是用状态机,把“写数据”拆成多个状态。代码写出来又长又绕。我个人习惯用协程,直接把异步逻辑写成同步的样子。
16.2 协程版异步 write 设计
我们的目标很简单:
// 用户期望的用法
co_await async_write(fd, data, len);
// 写完了,继续往下走
内部实现需要处理:
- 调用 write 系统调用
- 如果 EAGAIN,挂起协程,注册可写事件
- 事件触发后恢复协程,继续写
- 处理部分写入,直到全部写完
我画了一张流程图,帮你理清整体逻辑:
16.3 核心实现:async_write 函数
直接上代码。这是协程的核心:
// 异步 write 的 awaitable 对象
struct async_write_awaiter {
int fd;
const char *buf;
size_t len;
size_t written; // 已写入字节数
bool await_ready() {
// 先尝试写一次
ssize_t n = write(fd, buf, len);
if (n > 0) {
written = n;
if ((size_t)n == len) return true; // 一次写完
}
return false; // 需要挂起
}
void await_suspend(std::coroutine_handle<> h) {
// 保存协程句柄,注册可写事件
g_reactor.add_write_event(fd, [h]() {
h.resume(); // 可写时恢复协程
});
}
size_t await_resume() {
// 恢复后继续写剩余数据
while (written < len) {
ssize_t n = write(fd, buf + written, len - written);
if (n > 0) {
written += n;
if (written == len) break;
} else if (errno == EAGAIN) {
// 又满了,再次挂起
// 这里需要重新注册事件
// 实际实现要更复杂,这里简化
break;
}
}
return written;
}
};
等等,上面的代码有个问题——await_resume 里如果又遇到 EAGAIN,不能直接循环等。为什么?因为协程恢复后是在事件循环里执行的,不能阻塞。
正确的做法是:每次恢复后只写一次,如果没写完,再次挂起。我称之为“写一点,等一等”策略。
16.4 改进版:分段写入
这是我个人比较喜欢的实现方式:
struct async_write_awaiter {
int fd;
const char *buf;
size_t len;
size_t written{0};
bool await_ready() noexcept {
ssize_t n = write(fd, buf, len);
if (n > 0) {
written = n;
return (size_t)n == len;
}
return false;
}
void await_suspend(std::coroutine_handle<> h) noexcept {
// 把协程句柄和上下文传给 reactor
auto ctx = new write_context{fd, buf, len, written, h};
g_reactor.add_write_event(fd, [ctx]() {
// 可写事件触发
ssize_t n = write(ctx->fd,
ctx->buf + ctx->written,
ctx->len - ctx->written);
if (n > 0) {
ctx->written += n;
if (ctx->written == ctx->len) {
// 全部写完,恢复协程
auto h = ctx->h;
delete ctx;
h.resume();
return;
}
}
// 没写完,继续等待可写事件
// 这里不需要重新注册,epoll 是 level-triggered
// 只要缓冲区可写,会再次触发
});
}
size_t await_resume() noexcept {
return written;
}
};
小技巧:使用 epoll 的 level-triggered 模式时,只要缓冲区还有空间可写,事件会持续触发。这样我们就不需要反复注册事件了。我在项目中一直用这个模式,省了不少事。
16.5 用户视角:使用示例
封装好了,用起来就简单了:
// 协程任务:发送数据
task<void> send_data(int fd, const char* data, size_t len) {
size_t n = co_await async_write(fd, data, len);
if (n == len) {
printf("发送成功,共 %zu 字节\n", n);
} else {
printf("发送不完整:%zu/%zu\n", n, len);
}
}
// 多个并发写
task<void> multi_send() {
// 同时向三个客户端发数据
co_await async_write(fd1, data1, len1);
co_await async_write(fd2, data2, len2);
co_await async_write(fd3, data3, len3);
printf("三个都发完了\n");
}
你看,代码读起来就像同步一样。但实际上,每个 co_await 背后都可能挂起多次,但线程一直在事件循环里跑,不会阻塞。
16.6 避坑指南
我曾经踩过的坑:
- 忘记处理部分写入:write 返回 n < len 时,不要直接认为写完了。一定要记录偏移量,继续写剩下的。
- EAGAIN 处理不当:有些新手在 EAGAIN 时直接返回错误,导致数据丢失。正确的做法是等待可写事件。
- 协程句柄生命周期:事件回调里恢复协程时,要确保协程句柄有效。我习惯用 shared_ptr 管理上下文。
- 写缓冲区溢出:如果对端接收太慢,写缓冲区会一直满。这时候要考虑应用层流控,不能无限等。
16.7 性能对比
我简单测了一下,对比几种写方式的性能:
| 方式 | 代码复杂度 | CPU 占用 | 吞吐量 | 可维护性 |
|---|---|---|---|---|
| 阻塞 write + 多线程 | 低 | 高(线程切换) | 中 | 中 |
| 非阻塞 write + 状态机 | 高 | 低 | 高 | 低 |
| 协程异步 write | 低 | 低 | 高 | 高 |
说白了,协程方案在各方面都比较均衡。代码写起来像阻塞式,性能却接近非阻塞状态机。这也是我为什么在项目中全面转向协程的原因。
16.8 小结
这一章我们实现了基于协程的异步 write 封装。核心要点:
- 利用协程挂起/恢复机制,把异步写操作变成同步写法
- 处理部分写入和 EAGAIN 场景
- 结合事件循环,实现真正的非阻塞
嗯,代码量不大,但逻辑要理清楚。建议你自己动手实现一遍,遇到问题再回来看。下一章我们会继续封装 read 操作,到时候就能组合出完整的协程网络库了。
公众号:蓝海资料掘金营,微信deep3321