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,挂起协程,注册可写事件
  • 事件触发后恢复协程,继续写
  • 处理部分写入,直到全部写完

我画了一张流程图,帮你理清整体逻辑:

协程异步 write 执行流程 开始 async_write 调用 write(fd, buf+offset, remain) 尝试写入剩余数据 判断返回值 n < 0 ? n == len ? 全部写完 更新 offset,继续循环 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