第 2 章 并发连接模型:阻塞 I/O vs 非阻塞 I/O vs 多路复用

刚做 SDK 时,我总以为“并发就是多开几个线程”。后来发现,用错了 I/O 模型,线程再多也扛不住。今天我把三种模型拆开讲清楚——你听完就知道,为什么有的 SDK 能轻松撑万级连接,而有的连 500 连接就开始抖。

2.1 阻塞 I/O——最朴素的“一人一坑”模式

阻塞 I/O 写法简单:每个连接分配一个线程,accept 等待连接,然后 read 等待数据。数据不到,线程就挂起。

while (1) {
    int fd = accept(listen_fd, ...);   // 阻塞在这里
    pthread_t tid;
    pthread_create(&tid, NULL, handle_client, &fd);
}

// 处理线程
void* handle_client(void* arg) {
    int fd = *(int*)arg;
    char buf[1024];
    read(fd, buf, sizeof(buf));      // 阻塞等待数据
    // 处理并返回
}

我当年在某个设备端 SDK 里就是这么干的。客户端规模从 50 涨到 200,线程数也跟着涨到 200。然后呢?频繁上下文切换,内存暴涨,连接还没稳,线上先崩了。后来查:每个线程默认栈 8MB,200 个线程就是 1.6GB——你的设备吃得消?

避坑指南:我曾经接手过一个项目,测试时一切正常,一上线就卡死。就是因为每连接一线程,在 UID 限制 1024 的场景下直接爆了。别在新项目里用这种模型了,除非连接数 < 20。

2.2 非阻塞 I/O——从“等”变成“问”

非阻塞解决的是“等”的问题。把 socket 设成 O_NONBLOCK,read 立即返回,无数据就返回 -1。然后你轮询所有连接。

for (int i = 0; i < MAX_CLIENTS; i++) {
    int n = read(client_fds[i], buf, sizeof(buf));
    if (n > 0) { /* 处理数据 */ }
    else if (n == -1 && errno != EAGAIN) {
        /* 关闭或重试 */
    }
}

你想想看,这样就不用每个连接一个线程了。但问题来了——CPU 在“疯狂轮询”。1000 个连接,每次循环都要检查一遍,大部分连接空转,CPU 直接 100%。说白了,就是用 CPU 换线程,治标不治本。

我的经验:非阻塞 I/O 在连接数极少的场景(比如传感器数据采集)还能凑合用。但在高并发 SDK 里,轮询带来的 CPU 浪费一点也不比线程切换少。

2.3 多路复用——真正的“一夫当关”

多路复用的核心是:交给内核去“等”,等到了再通知你。select/poll/epoll 都是这个思路,但 epoll 在 Linux 上性能最好。

int epfd = epoll_create(1);
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

struct epoll_event events[MAX_EVENTS];
while (1) {
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < nfds; i++) {
        if (events[i].events & EPOLLIN) {
            // 处理读事件
        }
    }
}

epoll 只返回就绪的连接,不会浪费 CPU 去轮询空连接。我参与过一个即时通讯 SDK,用 epoll 处理 5000 并发连接,CPU 占用不到 10%。为什么?因为绝大多数连接在“等”,内核只在有事件时唤醒你。

关键点:多路复用让一个线程能兼顾成千上万的连接。它是现代高性能 SDK 的基石。选择 epoll(Linux)/ kqueue(macOS)/ IOCP(Windows)即可。

2.4 三种模型核心对比

模型核心机制并发能力CPU 利用率编程复杂度典型场景
阻塞 I/O每连接一线程< 500低(线程挂起)低少量长连接、控制台
非阻塞 I/O轮询所有连接几百高(忙等)中超小规模、简单协议
多路复用内核事件通知万级以上低(随事件触发)高网关、消息推送、高性能 I/O

你看,阻塞 I/O 成本在线程,非阻塞 I/O 成本在 CPU,只有多路复用做到了“无事休眠,有事干活”。

2.5 选型建议——我踩过的坑

  1. 连接数 < 100:阻塞 I/O 简单够用,注意控制线程数。
  2. 连接数 100~1000:别纠结,直接上多路复用。非阻塞轮询的 CPU 浪费远比你想的严重。
  3. 连接数 > 1000:必须多路复用。选 epoll(Linux)或 IOCP(Windows)。
  4. 跨平台 SDK:用 libevent/libuv 封装底层多路复用,省掉平台差异的坑。

我曾经犯过的错:一个音频 SDK 最初用阻塞 I/O,连接数 300 就出现 5 秒以上的响应抖动。改成 epoll 后,5000 连接延迟低于 10ms。别等到压测炸了才换模型,一开始就要想清楚。

三种并发连接模型对比 并发连接请求 阻塞 I/O 非阻塞 I/O 多路复用 每连接一线程 accept/read 阻塞等待 轮询所有连接 忙等,CPU 空转 内核事件通知 epoll_wait 等待 高并发首选:多路复用