异步IO与信号驱动IO:两种"不阻塞"的真功夫
咱们搞IO多线程预加载,核心诉求就是一句话:别让线程闲着等数据。上一章聊了多路复用,那是靠内核帮我们盯着多个fd。但今天要讲的这两种——异步IO和信号驱动IO——路子更野:它们让你彻底甩掉轮询,让数据自己找上门。
我个人习惯把这两者放在一起讲,因为很多人容易搞混。它们看起来都像是"不用我去检查,事情办好了通知我",但内核的实现方式和应用场景差别挺大。我当年第一次用信号驱动IO时差点踩坑,嗯,咱们一个一个说清楚。
信号驱动IO:让信号替你跑腿
信号驱动IO的工作原理是什么?你想想看,传统IO里你得主动去读,或者用select/poll等在那里。但信号驱动IO的意思是:你告诉内核——"这个fd有数据了,给我发个SIGIO信号"。然后你该干嘛干嘛,信号来了再处理。
关键流程:
- 设置信号处理函数(比如SIGIO对应的handler)
- 通过fcntl把文件描述符设为O_ASYNC模式
- 指定接收信号的进程(通常是当前进程)
- 内核在有数据时发送SIGIO,触发handler
- 在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在Linux上有平台差异。 不同内核版本对O_ASYNC的支持程度不一样。我在CentOS 6上遇到过某类设备不支持O_ASYNC的情况。建议你写代码时先检查fcntl返回值。
- AIO要注意对齐和大小限制。 有些文件系统要求缓冲区内存对齐到扇区大小(通常是512字节)。我偷懒没对齐,结果aio_read返回EINVAL,排查了半天。
- 别在信号处理函数里干重活。 如果你非要用信号驱动,建议信号处理函数只往一个管道写一个字节,然后让主循环通过epoll在管道的另一端读取并处理。这样能避开所有重入陷阱。
- AIO的线程池回调要注意资源竞争。 我用SIGEV_THREAD时,曾经因为多个回调同时操作同一个共享队列导致数据错乱。后来加了一个无锁队列(librt自带的那种),或者用线程局部存储,才解决。