15、封装异步读操作:基于协程的非阻塞 read 封装

大家好,我是你们的老朋友。

今天我们来聊聊一个很实际的问题——如何用协程封装非阻塞的 read 操作

说实话,我在做嵌入式网络协议栈的时候,最头疼的就是 read 阻塞。你想想看,一个线程卡在 read 上,啥也干不了。后来我改用非阻塞 + 协程,整个世界清净了。

为什么需要封装异步 read?

传统的 read 调用,默认是阻塞的。什么意思?就是程序执行到 read 那行,如果数据没到,它就傻等着。这在单线程里简直是灾难。

非阻塞 read 呢?它不会等,立即返回。但问题来了——你怎么知道数据什么时候到?轮询?太浪费 CPU。信号?太复杂。

协程给了我们第三条路:让 read 看起来是同步的,实际上是异步的

核心思想:协程发起 read 后,主动让出 CPU。等数据就绪了,调度器再把它唤醒。这样既不用轮询,也不用多线程。

非阻塞 read 的底层基础

在 Linux 下,要把 fd 设为非阻塞,很简单:

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

然后 read 就不会阻塞了。但返回 -1 且 errno 为 EAGAIN 或 EWOULDBLOCK 时,说明数据还没到。

嗯,这里要注意:不是所有 fd 都支持非阻塞。比如某些设备驱动,你设了也没用。我踩过这个坑,后来老老实实加了判断。

协程封装的设计思路

我们要封装一个函数,叫 async_read。它接收 fd、缓冲区、长度,返回实际读到的字节数。

内部逻辑是这样的:

  1. 尝试非阻塞 read。
  2. 如果读到数据,直接返回。
  3. 如果返回 EAGAIN,把当前协程挂起,注册到 epoll 等待可读事件。
  4. 事件触发后,恢复协程,再次 read。

小技巧:我习惯在协程的私有数据里保存 fd 和回调信息。这样调度器恢复时,能直接拿到上下文。

代码实现:核心结构

先定义协程的等待结构:

typedef struct async_waiter {
    int fd;
    coroutine_t *co;
    struct async_waiter *next;
} async_waiter_t;

每个等待的协程,都挂在一个全局的等待链表上。epoll 触发时,遍历链表,找到对应的协程并恢复。

然后是 async_read 的主体:

ssize_t async_read(int fd, void *buf, size_t count) {
    ssize_t n;
    
    // 先尝试非阻塞读
    n = read(fd, buf, count);
    if (n > 0) return n;
    if (n == 0) return 0;  // EOF
    
    if (errno != EAGAIN && errno != EWOULDBLOCK) {
        return -1;  // 真正的错误
    }
    
    // 数据没到,挂起协程
    async_waiter_t waiter;
    waiter.fd = fd;
    waiter.co = current_co;
    
    // 注册到 epoll
    epoll_add(fd, EPOLLIN, &waiter);
    
    // 让出 CPU
    co_yield();
    
    // 被唤醒后,再次读取
    return read(fd, buf, count);
}

注意:上面的代码是简化版。实际项目中,你要处理多个协程同时等待同一个 fd 的情况。我曾经漏掉这个,导致数据被一个协程读走,另一个协程永远等不到。

调度器的配合

协程不能自己恢复自己,得靠调度器。调度器的主循环大致是这样:

while (1) {
    // 处理所有就绪的协程
    for (每个就绪协程) {
        co_resume(co);
    }
    
    // 如果没有就绪协程,等待 epoll
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        async_waiter_t *w = events[i].data.ptr;
        co_ready(w->co);  // 标记为就绪
    }
}

说白了,调度器就是个事件循环。它负责把 epoll 的事件,转化成协程的恢复操作。

SVG 流程图:异步 read 的完整流程

异步 read 协程封装流程图 调用 async_read 尝试非阻塞 read 有数据? 返回读取字节数 挂起协程,注册 epoll 等待事件,恢复后重读

实际项目中的坑

我来说几个我踩过的坑:

  • 信号打断:read 返回 -1 不一定是 EAGAIN,还可能是 EINTR(被信号中断)。这时候要重试,不是挂起。
  • 短读:read 返回的字节数可能小于你请求的。对于流式 socket,这很正常。但如果你在读固定长度的消息头,就得循环读。
  • 协程栈溢出:每个协程的栈是有限的。如果你在 async_read 里分配大数组,小心栈溢出。我一般用堆上的缓冲区。

我的习惯:在 async_read 外面再包一层,处理短读和 EINTR。这样上层调用者永远拿到完整的数据,或者明确的错误。

性能考量

协程版的 read 比阻塞版好在哪?

对比项 阻塞 read 协程 async_read
CPU 利用率 低(线程挂起) 高(主动让出)
并发数 受线程数限制 可轻松上万
编程复杂度 中(需理解协程)
上下文切换开销 大(内核态) 小(用户态)

说白了,协程版用一点点编程复杂度,换来了巨大的性能提升。尤其是在高并发场景下,效果非常明显。

总结

封装异步 read,本质上就是把非阻塞 I/O 和协程调度粘合在一起。你不需要改 read 本身,只需要在它返回 EAGAIN 时,让协程睡一会,等数据来了再叫醒它。

我个人觉得,这是协程最优雅的应用之一。它让异步编程变得像同步一样自然,又保留了非阻塞的高效。

嗯,今天就聊到这。代码示例可以在我的课程仓库里找到,记得自己动手跑一遍。

核心要点回顾

  • 非阻塞 read + epoll + 协程 = 高性能异步 I/O
  • 注意处理 EAGAIN、EINTR、短读
  • 调度器负责将 epoll 事件转化为协程恢复
  • 用户态切换,开销远小于线程切换

公众号:蓝海资料掘金营,微信deep3321