IO 多路复用和多线程有什么区别?


一则或许对你有用的小广告

欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书

  • 《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于 Spring AI + Spring Boot 3.x + JDK 21...查看介绍

  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...查看介绍;演示链接:http://116.62.199.48:7070/

  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/

  • 新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍

截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 基础掌握度:光会背两者的定义不够,面试官真正想确认的是你理解 “阻塞等待” 发生在哪里、线程在等待期间是什么状态,这是所有 IO 模型的核心。

  2. 系统资源视角:考察你是否能用 “资源消耗” 的视角看问题,线程是昂贵的(栈内存、上下文切换),fd 是廉价的。理解了这点,C10K 问题自然就懂了。

  3. 工程实践认知:真实世界的高性能框架(Netty、Redis、Nginx)不是二选一,而是组合使用。面试里能主动讲到这一层的候选人,其实不多。

核心答案

先甩结论:这两者不冲突,是 “应对多个连接” 的两种思路,生产上还经常配合使用。

对比维度 IO 多路复用 多线程(Thread per Connection)
核心思想 一个线程监听多个连接,谁就绪处理谁 每个连接分配一个线程,各管各的
阻塞位置 阻塞在 select / epoll_wait,但同时在等 N 个连接 每个线程阻塞在自己的 read
线程数量 通常少量线程管理海量连接 线程数 ≈ 连接数
资源消耗 低,fd 很廉价 高,每线程有栈内存 + 调度开销
上下文切换 频繁线程切换
连接数上限 可支撑 C10K 甚至 C100K 几千连接就把系统拖垮了
编程复杂度 高,事件驱动 + 回调,容易写出状态机地狱 低,同步阻塞模型,代码直观
数据共享 单线程天然无锁(Redis 吃这个红利) 需要加锁保护共享数据
典型代表 Redis、Nginx、Netty 传统 Tomcat BIO(老版本)、老牌 FTP 服务

一句话概括:IO 多路复用是 “一个人看多部电话”,多线程是 “每部电话配一个接线员”。

IO 多路复用多线程
IO 多路复用多线程

深度解析

一、先搞清楚问题背景:为什么会有这两个方案

假设你要写一个 TCP 服务器,同时服务 1 万个客户端。最朴素的写法是这样:

// 朴素方案:一个线程处理一个连接
while (true) {
    Socket socket = serverSocket.accept();
    new Thread(() -> {
        try (socket) {
            byte[] buf = new byte[1024];
            int len;
            // 线程阻塞在这里,直到对端发数据
            while ((len = socket.getInputStream().read(buf)) != -1) {
                socket.getOutputStream().write(buf, 0, len);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }).start();
}

这段代码能用,100 个连接毫无压力。但 1 万个连接呢?算笔账:

  • 内存:Java 线程默认栈大小 1MB(-Xss 参数),1 万个线程光栈就要 10GB 虚拟内存,直接扛不住
  • CPU:1 万个线程大部分时间都阻塞在 read 上干等着,但内核还得轮流调度它们,上下文切换的 CPU 开销能把机器吃空
  • 创建销毁:频繁建线程本身也是开销

问题的根源在哪?线程阻塞在 read 上的时候,它占着资源却不干活。线程这个 “资源” 太贵了,用来干 “等数据” 这种事纯属浪费。

线程阻塞 read
线程阻塞 read

IO 多路复用就是冲着这个痛点来的:等待这件事,交给一个专门的系统调用去干,一个线程就能同时盯 N 个连接。

二、IO 多路复用是怎么工作的

看一张图,以 Linux 的 epoll 为例:

讲解一下这个流程:

  • 注册阶段:服务端把所有客户端连接(fd)注册到内核的一个 epoll 实例里,底层用红黑树存储,增删查都是 O(log n)
  • 等待阶段:线程调用 .epoll_wait() 阻塞,此时线程是 “一个线程等所有连接” 的状态
  • 通知阶段:某个连接的数据到达网卡,内核通过中断回调把这个 fd 挂到就绪链表里
  • 处理阶段.epoll_wait() 返回就绪 fd 列表,注意返回的只有就绪的那几个,线程拿到的直接就是 “哪些连接有数据” 的答案,不用遍历全部连接

epoll 就绪 fd
epoll 就绪 fd

Java NIO 里对应的就是 Selector

Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
// 非阻塞模式是多路复用的前提
server.configureBlocking(false);
server.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    // 阻塞在这,等效于 epoll_wait
    selector.select();
    Set<SelectionKey> keys = selector.selectedKeys();
    Iterator<SelectionKey> it = keys.iterator();
    while (it.hasNext()) {
        SelectionKey key = it.next();
        it.remove(); // 处理完必须移除,否则下次还会收到
        if (key.isAcceptable()) {
            // 新连接进来,注册到同一个 selector
            SocketChannel client = server.accept();
            client.configureBlocking(false);
            client.register(selector, SelectionKey.OP_READ);
        } else if (key.isReadable()) {
            // 只处理真正有数据的连接
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buf = ByteBuffer.allocate(1024);
            while (client.read(buf) > 0) {
                buf.flip();
                client.write(buf);
                buf.clear();
            }
        }
    }
}

看到没,整个服务器从头到尾就一个线程,1 万个连接也一样跑。这就是 Redis 单线程也能扛住 10 万 QPS 的底气。

三、两种方案的模型对比

两种模型的本质区别,说白了就一点:

  • 多线程:把 “等待” 的成本摊到多个线程上,靠内核调度实现并发。每个线程的代码是同步阻塞的,写起来舒服,但线程本身是重资源
  • IO 多路复用:把 “等待” 集中到一个系统调用上,用事件通知代替盲目等待。线程数下去了,代价是代码变成事件驱动,处理逻辑被拆成一个个回调,状态管理全靠自己

所以这题的深层答案是:多线程解决的是 “并发执行” 的问题,IO 多路复用解决的是 “高效等待” 的问题。一个管干活的人手,一个管等待时的资源占用,根本不是一个维度的事。

四、真实世界:两者往往是组合拳

面试官如果追问 “那 Netty 为什么既有 EventLoopGroup 又有 epoll”,就说明他想听这一层。

Netty 的经典主从 Reactor 模型:

  • BossGroup:专门用 IO 多路复用盯 accept 事件,就一个线程
  • WorkerGroup:默认 CPU 核数 × 2 个线程,每个线程各管一个 Selector,处理已建立连接的读写事件
  • 读写如果特别重(比如解码大报文),再丢给业务线程池

Redis 6.0 也是类似操作:主线程依然用多路复用处理命令(保证无锁),但把网络读写这块拆出了独立的 IO 线程。你看,多路复用负责 “等”,多线程负责 “算”,各干各的活。

IO 多路复用组合
IO 多路复用组合

五、select / poll / epoll 顺带说一下

这道题的追问大概率会拐到这三个系统调用上,简单过一遍:

系统调用 fd 上限 就绪检测方式 时间复杂度
select 1024(FD_SETSIZE) 每次调用把全量 fd 集合拷进内核,内核线性扫描 O(n)
poll 无硬限制(pollfd 数组) 同 select,还是全量拷贝 + 线性扫描 O(n)
epoll 无硬限制 注册一次,内核事件回调挂就绪链表,只返回就绪的 O(就绪数)

epoll 快的本质:把 “每次全量传入 + 全量扫描” 换成了 “一次注册 + 事件回调”。fd 数越多,这个差距越夸张,Linux 上的高并发服务基本都指着它。

面试高频追问

  1. 追问一:epoll 一定比多线程好吗?

    不是。连接数少(比如几十个)、且每个连接的处理都很重(大文件传输、密集计算)的场景下,thread per connection 反而更简单直接,代码可维护性好得多。技术选型看场景,不看信仰。

  2. 追问二:epoll 的 LT(水平触发)和 ET(边缘触发)区别?

    LT 是默认模式,只要缓冲区还有数据就一直通知;ET 只在状态变化时通知一次,必须一次性把数据读完(配合非阻塞 fd 循环读到 EAGAIN)。ET 减少 epoll_wait 唤醒次数,但编程更难。JDK NIO 用的是 LT,Netty 自研的 native epoll transport 默认用 ET。

  3. 追问三:为什么 Redis 用单线程还能那么快?

    纯内存操作 + IO 多路复用避免线程开销 + 单线程无锁无竞争 + 避免上下文切换。注意 6.0 之后网络 IO 部分已经多线程化了(默认关闭,要手动开 io-threads),命令执行还是单线程。

  4. 追问四:Java 的 BIO、NIO、AIO 分别对应什么 IO 模型?

    BIO 是同步阻塞;NIO 对应 IO 多路复用(Linux 上底层就是 epoll);AIO 是异步 IO(Windows IOCP 较成熟,Linux 的实现基于 epoll 模拟,Netty 曾支持后又放弃了)。

常见面试变体

  • 变体一:select、poll、epoll 的区别?
  • 变体二:什么是 Reactor 模式?单 Reactor 单线程、单 Reactor 多线程、主从 Reactor 的区别?
  • 变体三:Redis 为什么快?为什么 6.0 要引入多线程?
  • 变体四:什么是 C10K 问题?怎么解决?

记忆口诀

多线程管干活,多路复用管等待;一个费人(线程),一个省人;真到生产,组合上阵。

总结

IO 多路复用和多线程别当成二选一:前者用少量线程监听海量连接,解决 “等待太贵” 的问题;后者提供并发执行能力,解决 “干活人手” 的问题。线程是重资源(栈内存 + 上下文切换),fd 是轻资源,高并发网络服务的标准答案就是 epoll 做事件分发 + 少量线程做业务处理,Netty、Redis、Nginx 全是这个套路。把 “等” 和 “算” 分开这个核心思想讲明白,这道题就赢了。