异步IO与信号驱动IO:两种"不阻塞"的真功夫

咱们搞IO多线程预加载,核心诉求就是一句话:别让线程闲着等数据。上一章聊了多路复用,那是靠内核帮我们盯着多个fd。但今天要讲的这两种——异步IO和信号驱动IO——路子更野:它们让你彻底甩掉轮询,让数据自己找上门。

我个人习惯把这两者放在一起讲,因为很多人容易搞混。它们看起来都像是"不用我去检查,事情办好了通知我",但内核的实现方式和应用场景差别挺大。我当年第一次用信号驱动IO时差点踩坑,嗯,咱们一个一个说清楚。

信号驱动IO:让信号替你跑腿

信号驱动IO的工作原理是什么?你想想看,传统IO里你得主动去读,或者用select/poll等在那里。但信号驱动IO的意思是:你告诉内核——"这个fd有数据了,给我发个SIGIO信号"。然后你该干嘛干嘛,信号来了再处理。

关键流程:

  1. 设置信号处理函数(比如SIGIO对应的handler)
  2. 通过fcntl把文件描述符设为O_ASYNC模式
  3. 指定接收信号的进程(通常是当前进程)
  4. 内核在有数据时发送SIGIO,触发handler
  5. 在handler里非阻塞读取数据

我曾在做一个网络代理程序时用过它。当时觉得select循环太占CPU,想试试信号驱动。代码写起来其实不复杂:

#include <fcntl.h>
#include <signal.h>
#include <unistd.h>

int sockfd;

void sigio_handler(int signo) {
    char buf[1024];
    int n;
    // 注意:在信号处理函数中尽量做最少的事
    while ((n = read(sockfd, buf, sizeof(buf))) > 0) {
        // 处理数据,或者放入队列
        process_data(buf, n);
    }
}

int main() {
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    // 设置属主进程
    fcntl(sockfd, F_SETOWN, getpid());
    // 设置为异步模式
    fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL) | O_ASYNC | O_NONBLOCK);

    signal(SIGIO, sigio_handler);

    // 主循环做其他工作
    while (1) {
        // 比如处理定时器、维护连接池等
    }
}

看着挺美对吧?但我当初踩过一个坑:信号处理函数里调用了不可重入的函数,导致死锁。后来才意识到,信号处理程序里只能调用异步信号安全的函数,或者用write向pipe写一个字节,让主循环通过epoll来处理。所以,你要用信号驱动IO,就得非常小心可重入问题。

⚠ 我曾经犯过的错: 在信号处理函数里直接malloc/free,结果程序莫名其妙崩溃。信号处理函数调用时,主程序可能正持有堆锁,再调malloc就死锁了。记住,信号处理函数里只能用有限的异步安全函数。

异步IO(AIO):还没开始就能结束

信号驱动IO其实还是同步的——你只是在等内核通知你"可以读了",然后你再去读。而真正的异步IO是:你告诉内核"去读这个文件,读完了叫我"。数据搬运工作全部由内核完成,你只负责用结果。

Linux原生的AIO(librt中的aio_read/aio_write)就是这个路子。不过说实话,我实际项目中用的更多的是POSIX AIO的封装,或者直接用libaio。这里我给大家看一个典型的异步读操作:

#include <aio.h>
#include <fcntl.h>
#include <errno.h>

struct aiocb cb;

void aio_completion_handler(sigval_t sigval) {
    struct aiocb *req = (struct aiocb *)sigval.sival_ptr;
    // 检查操作是否成功
    if (aio_error(req) == 0) {
        ssize_t n = aio_return(req);
        // 这里n是实际读取的字节数,数据已经在req->aio_buf中了
        printf("Read %zd bytes\n", n);
    }
}

void async_read_example() {
    int fd = open("data.bin", O_RDONLY);
    
    memset(&cb, 0, sizeof(cb));
    cb.aio_fildes = fd;
    cb.aio_buf = malloc(4096);
    cb.aio_nbytes = 4096;
    cb.aio_offset = 0;
    // 设置回调通知方式
    cb.aio_sigevent.sigev_notify = SIGEV_THREAD;
    cb.aio_sigevent.sigev_notify_function = aio_completion_handler;
    cb.aio_sigevent.sigev_value.sival_ptr = &cb;

    // 提交异步读请求
    if (aio_read(&cb) == -1) {
        perror("aio_read");
        exit(1);
    }
    
    // 继续做其他事情...
    do_other_work();
    
    // 最终等待完成(或者让回调处理)
    aio_suspend(&cb, 1, NULL);
}

注意这里:aio_read是立即返回的,内核会在后台把数据从磁盘拷贝到cb.aio_buf指向的内存里。数据准备好了,要么通过信号通知你,要么通过回调线程执行你的处理函数。这才是真正的"异步"。

💡 实用技巧: 如果你用SIGEV_THREAD回调方式,注意线程安全,因为回调是在独立的线程中执行的。我个人习惯用SIGEV_SIGNAL加一个自建的管道来串行化通知,避免在回调里加锁。

两者本质区别

很多人容易把信号驱动IO和异步IO混为一谈。我画了一个表格,帮你一眼看清:

特性 信号驱动IO 异步IO(AIO)
数据搬运 用户程序自己负责(在handler中read) 内核负责,直接写入用户缓冲区
通知时机 数据可读时发信号(就绪通知) 数据完成读写后发信号/回调(完成通知)
阻塞点 无阻塞,但read可能被信号打断(EINTR) 提交操作后完全无阻塞
重入安全性 高度受限,必须使用异步安全函数 回调在独立上下文,相对安全
适用场景 少量fd,要求低延迟、但能控制信号 大量IO操作,尤其磁盘IO
系统开销 每次信号会打断用户态流程,信号堆积时麻烦 需要维护AIO控制块,高并发时开销可观

说白了,信号驱动是"你喊我,我自己去搬";异步IO是"你帮我把砖搬过来,放好,再叫我"。前者适合网络场景下的快速通知,后者更适合大块数据的磁盘读写任务。

在实际预加载系统中怎么选

回到咱们课程的主题——IO多线程预加载。这两者哪个更合适?我个人的经验是:

  • 网络IO预加载(比如提前缓存即将访问的远程数据):信号驱动IO更轻量。我曾经用信号驱动IO做了一个连接池的预读模块,监控十几个连接,每次信号过来就去取数据,效果不错。但连接数超过几十个后,信号堆积就麻烦了——你得考虑信号队列溢出。
  • 磁盘IO预加载(比如预读文件块的元数据或内容):异步IO是优选。我在一个视频服务里,用libaio预读视频分片,配合多线程,磁盘利用率从60%提升到了85%以上。因为AIO可以批量提交大量IO请求,内核能优化磁盘寻道顺序。

还有一种混合策略:用信号驱动做网络事件通知,用异步IO做磁盘预读。我在一个边缘缓存节点上这么干过,效果还不错。这需要你精细设计两层的事件循环。

整体关系图

下面这张图梳理了这两种"不阻塞"IO机制的差异和选择路径:

异步IO与信号驱动IO技术对比与选型 IO 不阻塞的两种方式 信号驱动 IO (SIGIO) 内核通知「可读/可写」 用户程序自己调用 read 数据复制到用户缓冲区 异步 IO (AIO / libaio) 提交 aio_read/aio_write 内核负责数据搬运 完成后通知(信号/回调) 选型建议:少量网络连接 → 信号驱动;大量磁盘预读 → 异步IO

一些实战提醒

最后,给你几个我踩过坑之后总结的要点:

  • 信号驱动IO在Linux上有平台差异。 不同内核版本对O_ASYNC的支持程度不一样。我在CentOS 6上遇到过某类设备不支持O_ASYNC的情况。建议你写代码时先检查fcntl返回值。
  • AIO要注意对齐和大小限制。 有些文件系统要求缓冲区内存对齐到扇区大小(通常是512字节)。我偷懒没对齐,结果aio_read返回EINVAL,排查了半天。
  • 别在信号处理函数里干重活。 如果你非要用信号驱动,建议信号处理函数只往一个管道写一个字节,然后让主循环通过epoll在管道的另一端读取并处理。这样能避开所有重入陷阱。
  • AIO的线程池回调要注意资源竞争。 我用SIGEV_THREAD时,曾经因为多个回调同时操作同一个共享队列导致数据错乱。后来加了一个无锁队列(librt自带的那种),或者用线程局部存储,才解决。