I/O模型基础:从阻塞到异步,我们到底在选什么?
各位同学,今天我们来聊聊I/O模型。这个话题,说白了就是「你的程序怎么等数据」。我做了这么多年低延迟系统,可以负责任地告诉你——选错I/O模型,延迟直接翻倍,甚至翻十倍。
先抛个问题:你的交易程序在等待网络数据时,CPU在干嘛?是傻等?是轮询?还是干别的活?不同的答案,对应着不同的I/O模型。
阻塞I/O:最朴素的等待
阻塞I/O,就是最原始的方式。你调用一个read(),数据没到,线程就挂在那了。嗯,就像你去餐厅点餐,厨师没做好,你就站在柜台前干等,啥也不干。
// 阻塞I/O示例
int sock = socket(AF_INET, SOCK_STREAM, 0);
connect(sock, ...);
char buf[1024];
// 这一行会阻塞,直到数据到达
int n = read(sock, buf, sizeof(buf));
// 数据到了,继续执行
process(buf, n);
我在早期做行情网关时,就吃过这个亏。一个线程处理一个连接,行情来了,所有线程都在等数据。CPU利用率低得可怜,但线程上下文切换开销却高得吓人。说白了,阻塞I/O适合连接数少的场景,比如一个客户端连一个服务端。但在量化交易里,你往往要同时监控几十个交易所、几百个品种,阻塞模型根本扛不住。
非阻塞I/O:不等待,但你要主动问
非阻塞I/O,就是调用read()时,如果数据没到,立即返回一个错误码,而不是挂起线程。你想想看,这样线程就可以去干别的事了。
// 非阻塞I/O示例
int sock = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
connect(sock, ...);
char buf[1024];
while (1) {
int n = read(sock, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) {
// 数据还没到,先干点别的
do_other_work();
continue;
}
// 数据到了
process(buf, n);
break;
}
非阻塞I/O的好处是线程不会被卡住。但坏处也很明显——你得不停地轮询。轮询本身消耗CPU,而且你无法精确知道数据什么时候到。我个人习惯是在低延迟场景下尽量避免纯轮询,因为CPU时间片很宝贵。
I/O多路复用:一个线程管一堆连接
这才是低延迟系统的核心。I/O多路复用,就是用一个线程同时监控多个文件描述符,哪个准备好了就处理哪个。select、poll、epoll,这三兄弟你都得认识。
select:老前辈,但有限制
select是最早的,能监控的文件描述符数量有限(通常是1024)。每次调用都要把整个fd_set从用户态拷贝到内核态,效率不高。
// select示例
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock1, &readfds);
FD_SET(sock2, &readfds);
struct timeval tv = {1, 0}; // 超时1秒
int ret = select(max_fd + 1, &readfds, NULL, NULL, &tv);
if (ret > 0) {
if (FD_ISSET(sock1, &readfds)) {
// sock1可读
}
if (FD_ISSET(sock2, &readfds)) {
// sock2可读
}
}
我记得刚入行时,用select监控几十个连接,性能还行。但到了几百个,就开始卡了。原因很简单——每次select都要遍历所有fd,O(n)的复杂度,连接越多越慢。
poll:改进版,但仍有瓶颈
poll去掉了1024的限制,用链表管理fd。但每次调用还是要遍历所有fd,性能瓶颈依然存在。
// poll示例
struct pollfd fds[2];
fds[0].fd = sock1;
fds[0].events = POLLIN;
fds[1].fd = sock2;
fds[1].events = POLLIN;
int ret = poll(fds, 2, 1000); // 超时1秒
if (ret > 0) {
if (fds[0].revents & POLLIN) {
// sock1可读
}
if (fds[1].revents & POLLIN) {
// sock2可读
}
}
poll比select好一些,但本质上还是「全量扫描」。在量化交易中,连接数可能上千,poll的O(n)遍历就成了瓶颈。
epoll:Linux下的王者
epoll是Linux下真正的解决方案。它用事件驱动的方式,只返回就绪的fd,复杂度O(1)。
// epoll示例
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock1;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock1, &ev);
ev.data.fd = sock2;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock2, &ev);
struct epoll_event events[10];
int n = epoll_wait(epfd, events, 10, 1000);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
int fd = events[i].data.fd;
// 处理就绪的fd
}
}
我个人习惯在交易系统中用epoll的ET模式(边缘触发),配合非阻塞I/O。为什么?因为LT模式(水平触发)会重复通知,增加不必要的系统调用。ET模式只通知一次,你必须一次性把数据读完。嗯,这里要注意,ET模式下如果没读完,下次不会再通知你,所以代码要写得严谨。
异步I/O:彻底解放线程
异步I/O(AIO)是另一种思路。你发起一个read操作,然后立即返回。内核帮你把数据拷贝到缓冲区,完成后通知你。整个过程线程不需要等待。
// 异步I/O示例(Linux AIO)
struct iocb cb;
struct io_event events[1];
int fd = open("data", O_RDONLY | O_DIRECT);
char *buf = aligned_alloc(512, 1024);
io_prep_pread(&cb, fd, buf, 1024, 0);
struct iocb *cbs[] = {&cb};
io_submit(ctx, 1, cbs);
// 立即返回,可以做其他事
do_other_work();
// 等待完成
struct timespec timeout = {1, 0};
io_getevents(ctx, 1, 1, events, &timeout);
// 数据已就绪
process(buf, 1024);
异步I/O听起来很美,但实际用起来坑不少。我在项目中试过Linux AIO,发现它对文件I/O支持不错,但对网络I/O的支持有限。而且,AIO的完成通知机制(信号或回调)在低延迟场景下可能引入额外的延迟抖动。
四种模型对比
| 模型 | 线程阻塞 | CPU利用率 | 连接数 | 延迟 | 适用场景 |
|---|---|---|---|---|---|
| 阻塞I/O | 是 | 低 | 少(≤几十) | 高(线程切换) | 简单客户端 |
| 非阻塞I/O | 否 | 中(轮询开销) | 中等 | 中 | 少量连接+其他任务 |
| I/O多路复用 | 否 | 高(事件驱动) | 大量(上千) | 低(微秒级) | 交易系统、服务器 |
| 异步I/O | 否 | 高 | 大量 | 低(但抖动可能大) | 磁盘I/O、io_uring |
一张图看懂I/O模型演进
下面这张SVG图,展示了四种I/O模型的核心区别。从阻塞到异步,本质上是「谁等数据」和「怎么通知」的变化。
怎么选?我的实战建议
说了这么多,到底怎么选?我直接给结论:
- 做行情网关、交易核心:用epoll + 非阻塞I/O + ET模式。这是经过实战检验的黄金组合。
- 做日志写入、文件操作:考虑io_uring(内核5.1+)或AIO。磁盘I/O的延迟比网络高几个数量级,异步模型能帮你隐藏这部分延迟。
- 做简单工具、测试脚本:阻塞I/O就够了,别过度设计。
- 跨平台需求:考虑libuv或boost.asio,它们封装了不同平台的I/O多路复用机制。