IO 多路复用和多线程有什么区别?
两者都在解决"同时处理很多连接",但路径不同:多路复用把"等"集中到一个线程,靠事件循环分发; 多线程把"等"分摊到多个线程,每个线程阻塞在自己的连接上。前者省内存、省切换,但要求所有操作都是非阻塞的; 后者编程模型简单,但线程数与内存开销随连接数线性增长。
1. 两种模型
多路复用(Reactor)
┌─────────────────────────────┐
连接1 ─►│ │
连接2 ─►│ epoll_wait → 就绪列表 │──► 单线程/少量线程处理
连接3 ─►│ (一个线程等所有 fd) │
连接N ─►│ │
└─────────────────────────────┘
内存:N 个 fd + 一个就绪队列;线程数固定
多线程(Thread-per-Connection)
连接1 ──► 线程1(阻塞 read,睡在等数据)
连接2 ──► 线程2(阻塞 read,睡在等数据)
连接N ──► 线程N(每个线程 1~8MB 栈 + 内核调度实体)
内存:N × 栈;线程数 = 连接数(N 大时直接爆)
2. 对比
| 维度 | IO 多路复用 | 多线程阻塞 |
|---|---|---|
| 并发上限 | 万~百万(受 fd 与内存限制) | 几千(受线程栈与调度限制) |
| 内存占用 | 每连接几百字节 | 每线程 MB 级栈 |
| 上下文切换 | 少(线程数固定) | 多(线程多时调度开销显著) |
| 编程复杂度 | 高,状态机/回调/协程 | 低,同步写法 |
| 阻塞调用 | 一个阻塞就卡死整个循环 | 天然隔离,互不影响 |
| 调试排查 | 难(栈不直观) | 易(线程栈清晰) |
| 适用场景 | 高并发短连接、网关、IM | 低并发、CPU 密集、依赖阻塞库 |
3. 代码对比
// 多线程:一个连接一个线程,写法最直观
void *worker(void *arg) {
int fd = *(int *)arg;
char buf[4096];
for (;;) {
ssize_t n = read(fd, buf, sizeof buf); // 阻塞,本线程睡
if (n <= 0) break;
write(fd, buf, n);
}
close(fd);
return NULL;
}
// accept 之后 pthread_create(&t, NULL, worker, &fd);
// 多路复用:一个线程盯所有 fd,谁就绪处理谁
struct epoll_event evs[256];
for (;;) {
int n = epoll_wait(ep, evs, 256, -1); // 唯一的等待点
for (int i = 0; i < n; i++) {
int fd = evs[i].data.fd;
if (fd == listen_fd) { accept_new(); continue; }
handle_readable(fd); // 必须非阻塞,且要读到 EAGAIN
}
}
注意:事件循环里不能有阻塞调用。一次 read 卡住、一次慢 SQL,整个循环就停了,
所以多路复用通常要配非阻塞 IO + 业务线程池(Reactor + Worker 两级)。
4. 常见追问
Q:连接数不多时,多线程是不是更好? 通常是。线程模型简单、可读性强、能直接调用阻塞库(JDBC、文件 IO)。并发几百以内, 线程开销完全可接受,没必要为了"高性能"引入事件循环的复杂度。
Q:Go 的 goroutine 算哪一种? 都算又都不算:写法是"一连接一协程"(像多线程),底层是 netpoller(epoll)+ GMP 调度(像多路复用)。 用户写阻塞代码,运行时在阻塞点自动把协程挂起、把线程让给别人,兼顾了两者的优点。
Q:为什么 Redis 单线程能扛十万 QPS?
因为它的操作都是内存级,单次耗时微秒级,瓶颈在网络而非 CPU;epoll 让一个线程就能盯住所有连接,
避免了锁与上下文切换。真正的重活(大 key 删除、持久化)才交给后台线程。
Q:多路复用怎么利用多核?
常见做法是多开几个事件循环(master-worker),每个 worker 一个 epoll、绑一个核,用
SO_REUSEPORT 让内核把新连接分散到各 worker,Nginx 就是这套模型。
小结
多路复用是"少量线程 + 事件驱动",用非阻塞换并发密度;多线程是"大量线程 + 阻塞等待", 用内存换编程简单。选型的判断点:连接数是否上万、是否必须调用阻塞库、团队能否驾驭事件循环。 答这题最好补一句"goroutine 是二者的融合",能体现对运行时调度的理解。