第 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 选型建议——我踩过的坑
- 连接数 < 100:阻塞 I/O 简单够用,注意控制线程数。
- 连接数 100~1000:别纠结,直接上多路复用。非阻塞轮询的 CPU 浪费远比你想的严重。
- 连接数 > 1000:必须多路复用。选 epoll(Linux)或 IOCP(Windows)。
- 跨平台 SDK:用 libevent/libuv 封装底层多路复用,省掉平台差异的坑。
我曾经犯过的错:一个音频 SDK 最初用阻塞 I/O,连接数 300 就出现 5 秒以上的响应抖动。改成 epoll 后,5000 连接延迟低于 10ms。别等到压测炸了才换模型,一开始就要想清楚。