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:不等待,但你要主动问

非阻塞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模式下如果没读完,下次不会再通知你,所以代码要写得严谨。

核心要点:epoll + 非阻塞I/O + ET模式,是Linux低延迟系统的黄金组合。我在多个交易系统中验证过,单线程可以轻松处理上万连接,延迟在微秒级。

异步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的完成通知机制(信号或回调)在低延迟场景下可能引入额外的延迟抖动。

我的建议:对于网络I/O,优先用epoll。对于磁盘I/O,可以考虑AIO或io_uring(Linux 5.1+)。io_uring是新一代的异步I/O框架,性能更好,但需要较新的内核版本。

四种模型对比

模型 线程阻塞 CPU利用率 连接数 延迟 适用场景
阻塞I/O 少(≤几十) 高(线程切换) 简单客户端
非阻塞I/O 中(轮询开销) 中等 少量连接+其他任务
I/O多路复用 高(事件驱动) 大量(上千) 低(微秒级) 交易系统、服务器
异步I/O 大量 低(但抖动可能大) 磁盘I/O、io_uring

一张图看懂I/O模型演进

下面这张SVG图,展示了四种I/O模型的核心区别。从阻塞到异步,本质上是「谁等数据」和「怎么通知」的变化。

I/O模型演进与核心区别 阻塞I/O 线程:挂起等待 CPU:空闲 通知:无 效率:低 适合:简单客户端 非阻塞I/O 线程:轮询检查 CPU:轮询消耗 通知:无(主动问) 效率:中 适合:少量连接 I/O多路复用 线程:事件等待 CPU:高效利用 通知:事件驱动 效率:高 适合:交易系统 异步I/O 线程:自由 CPU:高效利用 通知:回调/信号 效率:高(有抖动) 适合:磁盘I/O 核心变化:从「线程等数据」到「数据通知线程」 关键指标对比 延迟:阻塞I/O > 非阻塞I/O > 多路复用 ≈ 异步I/O CPU效率:阻塞I/O < 非阻塞I/O < 多路复用 ≈ 异步I/O 连接数:阻塞I/O < 非阻塞I/O < 多路复用 ≈ 异步I/O 实现复杂度:阻塞I/O < 非阻塞I/O < 多路复用 < 异步I/O 推荐:交易系统首选 epoll(多路复用),磁盘I/O考虑 io_uring(异步)

怎么选?我的实战建议

说了这么多,到底怎么选?我直接给结论:

  • 做行情网关、交易核心:用epoll + 非阻塞I/O + ET模式。这是经过实战检验的黄金组合。
  • 做日志写入、文件操作:考虑io_uring(内核5.1+)或AIO。磁盘I/O的延迟比网络高几个数量级,异步模型能帮你隐藏这部分延迟。
  • 做简单工具、测试脚本:阻塞I/O就够了,别过度设计。
  • 跨平台需求:考虑libuv或boost.asio,它们封装了不同平台的I/O多路复用机制。
最后说一句:I/O模型没有银弹。选型要看你的场景——连接数、延迟要求、CPU预算、内核版本。我见过有人用epoll处理两个连接,也见过有人用阻塞I/O处理上千连接(然后挂了)。嗯,选对工具,事半功倍。
专注资料整理