侧边栏壁纸
  • 累计撰写 143 篇文章
  • 累计创建 20 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

一台服务器如何扛住 10 万连接?

YaFuX
2026-06-21 / 0 评论 / 0 点赞 / 1 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

先看一段最经典的 Java Socket 服务端代码:

try (ServerSocket server = new ServerSocket(9090)) {
    while (true) {
        Socket client = server.accept();

        new Thread(() -> {
            handle(client);
        }).start();
    }
}

它的逻辑非常自然:

等待连接
   ↓
创建线程
   ↓
等待数据
   ↓
处理请求

只有一个客户端时,这种写法几乎没有问题。

100 个客户端连接时,也能正常工作。

但如果连接数变成:

1 000
10 000
100 000

问题就来了:

一万个连接,是否意味着需要一万个线程?

线程都在等待数据时,为什么还会消耗大量资源?

把线程提前放进线程池,能不能解决问题?

把 Socket 设置成非阻塞后,为什么程序还是可能占满 CPU?

一个线程遍历十万个连接,真的比十万个线程更快吗?

Selector 为什么只返回“就绪”的连接?

select、poll 和 epoll 到底有什么区别?

epoll 是不是由网卡中断直接通知 Java 程序?

epoll 是异步 I/O 吗?

Java 21 有了虚拟线程以后,NIO 和 Netty 是否已经过时?

这些问题共同指向一个经典主题:

C10K

C10K 讨论的并不只是“能不能创建一万个 Socket”,而是:

一台服务器怎样以可接受的内存、CPU 和调度成本,同时管理大量网络连接?

Dan Kegel 的经典 C10K 页面系统整理了线程、非阻塞 I/O、事件通知和操作系统接口等解决思路,成为后来高并发网络编程的重要历史入口。

本文将沿着这条演进路线,理解:

BIO
  ↓
线程池
  ↓
非阻塞 I/O
  ↓
手工轮询
  ↓
select
  ↓
poll
  ↓
epoll
  ↓
Java Selector
  ↓
EventLoop

最后再讨论虚拟线程出现后,这条技术路线发生了什么变化。


一、C10K 是什么?

C10K 中的:

C = Concurrent Connections
10K = 10 000

表示一台服务器同时维持一万个客户端连接。

但要注意:

一万个连接
≠
一万 QPS
≠
一万个请求同时执行
≠
每秒传输一万条消息

连接数、请求并发、吞吐量和延迟是不同指标。

例如,一台即时通信服务器可能保持十万个连接,但绝大多数客户端都处于空闲状态:

100 000 条 TCP 连接
    ↓
每秒只有 2 000 条连接发送消息

另一台接口服务器可能只有几千条连接,却承担每秒数万次请求。

因此,C10K 真正考验的是:

大量连接的状态管理
文件描述符管理
线程与调度成本
Socket 缓冲区内存
就绪事件发现
业务处理能力

它不是一个单独的 API 问题,而是整个服务端并发模型的问题。


二、BIO 卡在哪?

BIO 可以理解为经典的阻塞式 I/O 模型。

一个典型服务端包含两个阻塞点:

Socket client = server.accept();

等待新连接。

以及:

int count = input.read(buffer);

等待客户端发送数据。

完整模型为:

主线程
  ↓
accept() 等待连接
  ↓
获得 Socket
  ↓
创建处理线程
  ↓
主线程继续 accept()

处理线程
  ↓
read() 等待数据
  ↓
处理业务

课程素材也把传统 BIO 概括为:主线程阻塞在 accept(),每获得一个连接就创建一个独立线程,再由该线程阻塞读取数据。

1. 为什么需要多线程?

假设服务端只有一个线程:

while (true) {
    Socket client = server.accept();
    handle(client);
}

handle() 中执行:

input.read(buffer);

如果第一个客户端连接后一直不发送数据,主线程就会永远停在这个连接的 read() 上。

后续客户端即使已经完成三次握手,也无法被应用继续处理。

所以传统 BIO 会把读取操作交给其他线程:

监听线程
   ├── 连接 A → 线程 A
   ├── 连接 B → 线程 B
   ├── 连接 C → 线程 C
   └── 连接 D → 线程 D

这就是经典的:

一连接一线程

2. 线程在等待什么?

多数长连接并不会一直传输数据。

一个连接的生命周期可能是:

连接建立
   ↓
等待 30 秒
   ↓
收到一条消息
   ↓
处理 5 毫秒
   ↓
再等待 60 秒

这意味着处理线程绝大多数时间都不在计算,而是在等待:

线程 A:等待网络数据
线程 B:等待网络数据
线程 C:等待网络数据
线程 D:等待网络数据

阻塞等待本身不会持续占用一个 CPU 核心,但传统 Java 平台线程通常对应操作系统线程。大量平台线程仍会消耗线程栈、内核任务结构和运行时管理资源,并增加调度、监控和垃圾回收相关成本。OpenJDK 在设计虚拟线程时,也明确把操作系统线程数量有限、成本较高视为传统线程模型的扩展瓶颈。


三、线程池够吗?

为了避免不断创建和销毁线程,可以使用线程池:

ExecutorService executor =
        Executors.newFixedThreadPool(200);

while (true) {
    Socket client = server.accept();
    executor.submit(() -> handle(client));
}

线程池确实解决了一部分问题:

复用线程
限制线程数量
减少频繁创建线程
控制系统资源上限

但它没有消除阻塞。

假设线程池只有 200 个线程,前 200 个客户端都建立长连接,并且一直等待数据:

200 个线程
    ↓
全部阻塞在 read()

第 201 个连接提交的任务只能进入工作队列。

于是:

连接已经建立
任务也已经提交
但没有线程读取它的数据

扩大线程池也只能推迟问题:

200 → 2 000 → 10 000

线程池解决的是:

线程怎样复用

而不是:

为什么每条空闲连接都要占用一个平台线程

课程素材中也指出,线程池可以减少线程创建成本,但无法消除大量线程在少量 CPU 核心上被调度的成本。


四、非阻塞是什么?

解决方向是:

没有连接或数据时,不要让当前线程一直等待。

Java NIO 可以把 ServerSocketChannel 设置为非阻塞:

ServerSocketChannel server =
        ServerSocketChannel.open();

server.bind(new InetSocketAddress(9090));
server.configureBlocking(false);

此时执行:

SocketChannel client = server.accept();

如果当前没有等待接收的连接,不会阻塞线程,而是立即返回:

null

Java 官方文档明确规定,非阻塞模式下没有待接收连接时,ServerSocketChannel.accept() 会立即返回 null。它返回的新 SocketChannel 默认仍是阻塞模式,因此通常还要单独调用 configureBlocking(false)

读取也可以设置为非阻塞:

client.configureBlocking(false);

int count = client.read(buffer);

返回值含义为:

count > 0
    读取到 count 个字节

count == 0
    当前没有可立即读取的数据

count == -1
    对端关闭,已经到达流末尾

非阻塞 SocketChannel 的读取只会读取当前立即可用的数据,因此可能返回 0;到达流末尾时返回 -1。

这样,一个线程就有机会同时管理多个连接。


五、轮询为何变慢?

最直接的非阻塞服务端可以这样实现:

try (ServerSocketChannel server = ServerSocketChannel.open()) {
    server.bind(new InetSocketAddress(9090));
    server.configureBlocking(false);

    List<SocketChannel> clients = new ArrayList<>();
    ByteBuffer buffer = ByteBuffer.allocate(4096);

    while (true) {
        SocketChannel client;

        while ((client = server.accept()) != null) {
            client.configureBlocking(false);
            clients.add(client);
        }

        Iterator<SocketChannel> iterator = clients.iterator();

        while (iterator.hasNext()) {
            SocketChannel channel = iterator.next();

            buffer.clear();
            int count = channel.read(buffer);

            if (count > 0) {
                buffer.flip();
                handle(channel, buffer);
            } else if (count == -1) {
                channel.close();
                iterator.remove();
            }
        }
    }
}

这段代码确实只用了一个线程。

但它引入了新的问题。

假设服务器管理十万个连接,只有三个连接有数据:

连接总数:100 000
就绪连接:3

程序仍然需要:

检查连接 1
检查连接 2
检查连接 3
……
检查连接 100 000

才能找到那三个连接。

每轮扫描的工作量取决于:

全部连接数

而不是:

本轮真正活跃的连接数

课程素材中的手工 NIO 模型正是把新连接加入列表,然后在同一个循环中遍历全部客户端;随着连接数增长,遍历和探测成本也会增长。

1. 空转问题

如果循环不休眠:

accept → 扫描全部连接 → accept → 再扫描

即使没有任何网络事件,CPU 也会不断执行循环。

这叫:

Busy Polling
忙轮询

2. 休眠问题

可以在循环末尾加入:

Thread.sleep(10);

CPU 使用率会下降,但事件可能刚好在休眠开始后到达:

数据到达
   ↓
线程还要睡 10 毫秒
   ↓
才能开始处理

于是延迟增加。

我们真正需要的不是:

应用不断询问每条连接是否就绪

而是:

让内核告诉应用,哪些连接已经就绪

六、多路复用来了

I/O 多路复用的核心思路是:

把很多文件描述符交给内核
        ↓
告诉内核关注哪些事件
        ↓
线程阻塞等待
        ↓
内核返回已经就绪的描述符
        ↓
应用只处理就绪连接

从:

应用遍历十万个连接

变成:

内核返回本轮就绪的三个连接

模型如下:

fd 4  ─┐
fd 5   │
fd 6   │
fd 7   ├──→ 多路复用器 ──→ fd 7、fd 108、fd 9201 就绪
...    │
fd N  ─┘

这里的“复用”表示:

一个线程的等待能力,被复用于很多 I/O 对象。

需要注意,多路复用器返回的是:

Readiness
就绪状态

例如:

可以 accept
可以 read
可以 write
连接发生异常

它不会自动替应用执行完整业务。

程序仍然需要调用:

accept()
read()
write()

因此,select、poll、epoll 和 Java Selector 通常属于:

同步 I/O 就绪通知

而不是“内核已经替应用把所有数据处理完成”的异步完成模型。


七、select 的代价

select() 可以同时监控多组文件描述符:

可读
可写
异常

它解决了:

一个线程只能等待一个 fd

的问题。

但在 Linux 上,它存在几个明显限制。

1. 描述符限制

Linux 手册明确警告,select() 只能监控数值小于 FD_SETSIZE 的文件描述符;常见的 FD_SETSIZE 为 1024,手册建议现代应用改用 poll()epoll()

注意,这个限制针对的是:

文件描述符编号

而不只是集合中元素数量。

如果进程当前出现:

fd = 1500

即使只监控这一个 fd,也无法直接放入标准的 fd_set

2. 集合需要重复准备

select() 会修改传入的 fd 集合,标记哪些描述符已经就绪。

因此下一轮调用前,应用通常需要重新构造关注集合:

准备 fd 集合
    ↓
select()
    ↓
集合被修改
    ↓
扫描就绪 fd
    ↓
重新准备集合

3. 仍需扫描

应用需要从 0 扫描到传入的最大描述符范围,检查每个 fd 是否被标记为就绪。

因此,select() 的处理成本仍然会受到描述符规模影响。

它解决了“线程数量”的问题,但没有彻底解决“大量空闲连接反复扫描”的问题。


八、poll 改了什么?

poll()select() 的目标相同:

等待一组文件描述符进入可执行 I/O 的状态。

区别是,poll() 使用一个结构体数组描述关注对象:

struct pollfd {
    int   fd;
    short events;
    short revents;
};

应用可以为每个 fd 指定关注事件:

POLLIN
POLLOUT
POLLERR

Linux 手册把 poll() 定义为等待一组文件描述符中的某一个进入 I/O 就绪状态;描述符通过 pollfd 数组传入。

相比 select()

不使用固定大小的 fd_set
不受传统 FD_SETSIZE=1024 的直接限制
接口表达更自然

但每次调用时,仍然要把数组提供给内核;返回后,应用也要检查数组中哪些元素的 revents 被设置。

所以它的基本模式仍然是:

提交全部关注对象
    ↓
内核检查
    ↓
应用扫描结果数组

当监控对象非常多、活跃对象很少时,处理成本依然与全部监控对象有关。


九、epoll 快在哪?

Linux 的 epoll 把“注册关注对象”和“等待就绪事件”拆开了。

主要接口为:

epoll_create1()
epoll_ctl()
epoll_wait()

1. 创建实例

int epfd = epoll_create1(0);

内核创建一个 epoll 实例,并返回一个文件描述符。

2. 注册事件

epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);

告诉内核:

我要关注 fd
关注可读、可写或其他事件

以后需要修改或删除时,分别使用:

EPOLL_CTL_MOD
EPOLL_CTL_DEL

3. 等待事件

int count = epoll_wait(
        epfd,
        events,
        maxEvents,
        timeout
);

内核返回已经就绪的事件。

epoll 内部维护两个重要概念:

Interest List
    应用注册并关注的文件描述符集合

Ready List
    当前已经具备就绪事件的文件描述符集合

epoll_wait() 主要从就绪列表中取得事件,而不是让应用每次重新提交并扫描所有连接。Linux 的 epoll 文档明确区分了 interest list 和 ready list。

可以抽象为:

第一次:
把 fd 4、5、6、7 注册给 epoll

之后:
epoll_wait()
      ↓
只返回本轮就绪的 fd 5、7

这正适合:

连接很多
活跃连接较少
长连接较多
事件分布稀疏

的场景。

4. epoll 不是绝对 O(1)

经常有人把 epoll 简化成:

select:O(n)
epoll:O(1)

这种记忆方式过于粗糙。

epoll 仍然存在:

注册和修改事件的成本
内核维护就绪状态的成本
唤醒等待线程的成本
把事件复制到用户空间的成本
处理本轮就绪事件的成本

如果一轮有十万个 fd 同时就绪,应用仍然需要处理十万个事件。

更准确的优势是:

epoll 避免了应用在每次等待时反复提交、扫描大量没有事件的描述符,使等待成本更接近本轮真正发生的事件规模。


十、两种触发方式

epoll 支持两种常见触发模式:

Level Triggered
Edge Triggered

1. 水平触发

水平触发是默认模式。

只要 fd 当前仍然可读:

第一次 epoll_wait → 返回可读
应用只读了一部分
第二次 epoll_wait → 仍然返回可读

它像一个持续存在的状态:

只要水位还高于线
就继续通知

水平触发更容易编写正确代码。

2. 边缘触发

边缘触发只重点通知状态发生变化的时刻:

不可读 → 可读

如果应用收到事件后只读取了一小部分数据,剩余数据仍在缓冲区中,但没有发生新的“不可读到可读”变化,就可能暂时收不到新的通知。

因此,边缘触发通常要求:

使用非阻塞 fd
收到事件后持续读取
直到 read 返回 EAGAIN

Linux epoll 手册也建议,在 Edge Triggered 模式下使用非阻塞描述符,并持续处理直到读写返回 EAGAIN,以避免阻塞或遗漏剩余工作。

所以:

ET 比 LT 高级

并不等于:

ET 在任何场景都更快、更好

错误的 ET 代码反而更容易造成连接“假死”。


十一、epoll 靠中断吗?

网卡收到数据时,硬件中断或轮询机制可能参与把数据送入内核网络栈。

但不能简单描述为:

网卡中断
   ↓
直接通知 epoll
   ↓
直接回调 Java

实际链路更接近:

网卡收到数据
    ↓
驱动与内核网络栈处理
    ↓
数据进入 Socket 接收缓冲区
    ↓
Socket 就绪状态发生变化
    ↓
相关等待机制被唤醒
    ↓
fd 进入 epoll 的 Ready List
    ↓
epoll_wait() 返回
    ↓
Java Selector 返回 SelectionKey

而且 epoll 不只能监控网络 Socket。

它还可以与:

管道
eventfd
timerfd
signalfd
部分设备 fd

等可轮询文件描述符配合。

因此:

中断可能是某些网络事件的上游来源,但 epoll 的核心抽象是文件描述符就绪通知,而不是“网卡中断回调器”。


十二、Selector 怎么用?

Java NIO 对多路复用提供了统一抽象:

Selector
SelectableChannel
SelectionKey

1. 创建 Selector

Selector selector = Selector.open();

2. 注册监听 Channel

ServerSocketChannel server =
        ServerSocketChannel.open();

server.bind(new InetSocketAddress(9090));
server.configureBlocking(false);

server.register(
        selector,
        SelectionKey.OP_ACCEPT
);

3. 等待事件

selector.select();

select() 会等待至少一个注册 Channel 出现感兴趣的就绪事件。

Java Selector 会通过底层 SelectorProvider 向操作系统查询已注册 Channel 的就绪状态,并维护注册键、已选择键和已取消键集合。

4. 处理事件

Iterator<SelectionKey> iterator =
        selector.selectedKeys().iterator();

while (iterator.hasNext()) {
    SelectionKey key = iterator.next();

    // 当前事件处理完成后,必须移除。
    iterator.remove();

    if (!key.isValid()) {
        continue;
    }

    if (key.isAcceptable()) {
        accept(selector, key);
    }

    if (key.isReadable()) {
        read(key);
    }
}

一个简化的完整服务端如下:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;

public final class SelectorServer {

    public static void main(String[] args) throws IOException {
        try (Selector selector = Selector.open();
             ServerSocketChannel server =
                     ServerSocketChannel.open()) {

            server.bind(new InetSocketAddress(9090));
            server.configureBlocking(false);
            server.register(selector, SelectionKey.OP_ACCEPT);

            while (true) {
                selector.select();

                Iterator<SelectionKey> iterator =
                        selector.selectedKeys().iterator();

                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();
                    iterator.remove();

                    if (!key.isValid()) {
                        continue;
                    }

                    if (key.isAcceptable()) {
                        accept(selector, key);
                    }

                    if (key.isReadable()) {
                        read(key);
                    }
                }
            }
        }
    }

    private static void accept(
            Selector selector,
            SelectionKey key
    ) throws IOException {

        ServerSocketChannel server =
                (ServerSocketChannel) key.channel();

        SocketChannel client;

        while ((client = server.accept()) != null) {
            client.configureBlocking(false);

            ConnectionContext context =
                    new ConnectionContext(
                            ByteBuffer.allocate(4096)
                    );

            client.register(
                    selector,
                    SelectionKey.OP_READ,
                    context
            );
        }
    }

    private static void read(SelectionKey key)
            throws IOException {

        SocketChannel client =
                (SocketChannel) key.channel();

        ConnectionContext context =
                (ConnectionContext) key.attachment();

        ByteBuffer buffer = context.readBuffer();
        buffer.clear();

        int count = client.read(buffer);

        if (count == -1) {
            key.cancel();
            client.close();
            return;
        }

        if (count == 0) {
            return;
        }

        buffer.flip();

        String message =
                StandardCharsets.UTF_8.decode(buffer).toString();

        System.out.println(message);
    }

    private record ConnectionContext(
            ByteBuffer readBuffer
    ) {
    }
}

这段代码展示的是基本事件循环,真实网络协议还必须处理:

半包与粘包
消息边界
字符解码状态
写半包
写缓冲区
背压
连接超时
异常关闭
并发注册

Selector 只解决:

哪些 Channel 当前就绪

不会自动解决应用协议。


十三、EventLoop 做什么?

Selector 服务端的核心循环是:

等待事件
   ↓
取出就绪连接
   ↓
分发处理
   ↓
再次等待

这就是 EventLoop 的基本思想:

while (!stopped) {
    selector.select();
    processSelectedKeys();
    runPendingTasks();
}

EventLoop 的关键原则是:

EventLoop 线程只负责短小、非阻塞的事件处理。

假设在 EventLoop 中直接执行:

查询数据库 3 秒
调用远程接口 5 秒
压缩大文件 10 秒
执行复杂计算

那么这个线程管理的其他连接也无法及时得到处理:

连接 A 执行慢任务
        ↓
连接 B、C、D 的事件全部等待

所以成熟框架通常会把工作分成:

EventLoop
    负责连接与 I/O 事件

业务线程或任务系统
    负责可能阻塞或耗时的业务

课程素材后半部分也把 Selector、EventLoop、EventLoopGroup 和事件执行器串联成一个精简版 Netty 骨架。


十四、十万连接还缺什么?

使用 epoll 或 Selector,并不代表服务器就能自动承受十万连接。

1. 文件描述符

一条已接收的 Socket 连接通常会占用一个文件描述符。

进程能够打开的 fd 数量受到:

RLIMIT_NOFILE

限制。

超过限制时,常见错误为:

Too many open files
EMFILE

Linux 还存在系统范围的 file-max,以及限制 RLIMIT_NOFILE 最高可提高到多少的 nr_openRLIMIT_NOFILE 并不是 root 用户可以无条件突破的无限值。

可以查看:

ulimit -n
cat /proc/$$/limits
cat /proc/sys/fs/file-max
cat /proc/sys/fs/nr_open

2. 内核内存

每条 TCP 连接还需要维护:

TCP 状态
发送缓冲区
接收缓冲区
重传状态
定时器
队列节点
Socket 结构

十万条连接即使都处于空闲状态,也不可能完全不占内存。

3. 应用内存

应用可能为每个连接保存:

ByteBuffer
协议解析状态
用户会话
待发送消息
认证信息
超时任务
业务上下文

假设每条连接平均占用 20 KiB:

100 000 × 20 KiB
≈ 1.9 GiB

这还不包含内核 Socket 内存。

4. 客户端端口

服务端可以使用同一个监听端口承载大量连接,因为已建立 TCP 连接由本地和远端地址、端口组合区分。

但压测客户端连接同一目标时,可能先耗尽本地临时端口范围。

可以通过:

增加源 IP
增加客户端机器
连接不同目标地址
调整临时端口范围

扩展测试能力。

因此:

客户端端口耗尽

不等于:

服务端只能接受 65535 条连接

5. 业务能力

维持十万个空闲连接,和处理十万个活跃连接完全不同。

真正的上限还取决于:

消息频率
单条消息大小
TLS 开销
序列化成本
数据库能力
网络带宽
CPU 核心数
垃圾回收
下游服务

所以高并发测试不能只观察:

成功建立了多少 TCP 连接

还要观察:

吞吐量
P99 延迟
CPU
内存
网络带宽
丢包与重传
EventLoop 延迟
队列长度
错误率

十五、虚拟线程改了什么?

Java 21 正式引入虚拟线程后,经典的“一连接一平台线程”结论需要重新限定。

虚拟线程不是操作系统线程的一对一包装。

大量虚拟线程可以复用较少的平台线程。当虚拟线程在支持的 JDK 阻塞 I/O 操作上等待时,运行时通常可以挂起该虚拟线程,并释放承载它的平台线程去执行其他任务。JEP 444 的目标之一,就是让简单的 thread-per-request 编程模型重新具备较好的扩展能力。

一个虚拟线程服务端可以写成:

try (ServerSocket server = new ServerSocket(9090);
     ExecutorService executor =
             Executors.newVirtualThreadPerTaskExecutor()) {

    while (true) {
        Socket client = server.accept();

        executor.submit(() -> {
            handle(client);
            return null;
        });
    }
}

这样可以保留清晰的阻塞式代码:

一个连接
   ↓
一个虚拟线程
   ↓
顺序 read / write

同时避免为每条阻塞连接永久占用一个操作系统线程。

但虚拟线程没有消除:

文件描述符限制
Socket 内核内存
应用连接状态
网络带宽
数据库连接上限
业务并发控制
下游容量

虚拟线程主要降低的是:

等待任务占用平台线程的成本

它不会让 CPU 密集型任务无限并发,也不会让十万个连接变成零成本。

因此,现代 Java 服务端存在两条都合理的路线:

阻塞 API + 虚拟线程

以及:

非阻塞 Channel + Selector / Netty

前者强调简单、顺序化的编程模型。

后者强调事件循环、精细的缓冲管理、协议处理和成熟的网络框架生态。

它们不是简单的“新技术淘汰旧技术”。


十六、常见误区

1. C10K 就是一万 QPS

错误。

它最初关注的是同时维持和处理大量并发连接,不直接等于请求吞吐量。

2. BIO 必然一连接一个操作系统线程

需要限定。

传统平台线程 BIO 经常采用一连接一平台线程;使用虚拟线程后,可以保留一连接一线程的代码结构,但不再一对一长期占用操作系统线程。

3. 线程池解决了 BIO 阻塞

错误。

线程池限制并复用线程,但阻塞连接仍会占用线程池中的工作线程。

4. 设置非阻塞就会自动高性能

错误。

如果应用手工遍历十万个 Channel,它仍然会消耗大量 CPU。

5. 非阻塞就是异步

错误。

非阻塞表示调用不能立即完成时快速返回。

异步则强调提交操作后,由系统在操作完成时提供完成结果。

6. Selector 会自动读取数据

错误。

Selector 只报告 Channel 已经就绪,应用仍需调用 accept()read()write()

7. epoll 直接处理业务数据

错误。

epoll 负责事件等待和就绪通知,不负责应用协议解析和业务处理。

8. epoll 完全依赖网卡中断

错误。

网络中断可能触发上游数据处理,但 epoll 面向的是通用文件描述符就绪状态,并不限于网卡。

9. epoll 永远是 O(1)

不严谨。

epoll 避免每轮扫描全部空闲 fd,但注册、状态维护、唤醒和就绪事件处理仍然有成本。

10. Edge Triggered 一定更快

错误。

ET 减少部分重复通知,但代码更复杂,必须正确使用非阻塞 I/O,并读取到 EAGAIN

11. SocketChannel.read() 返回 0 表示断开

错误。

非阻塞模式下返回 0 通常表示当前没有立即可读的数据;返回 -1 才表示流结束。

12. select 在所有系统上最多只能处理 1024 个连接

不够准确。

Linux 标准 fd_setFD_SETSIZE 常为 1024,而且限制的是 fd 编号;其他系统和实现细节可能不同。Linux 手册明确建议现代高并发程序使用 poll 或 epoll。

13. Linux 没有异步 I/O

错误。

Selector 和 epoll 主要是就绪通知机制,但 Linux 还提供 AIO、io_uring 等异步接口。io_uring 使用提交队列和完成队列发起、完成异步 I/O。

14. 单线程一定比多线程快

错误。

单个 EventLoop 可以高效管理大量连接,但无法并行使用多个 CPU 核心,也不能承受长时间业务阻塞。

实际系统通常使用:

多个 EventLoop
+
合理的连接分配
+
业务任务调度

15. 虚拟线程让 Netty 过时了

错误。

虚拟线程降低阻塞式并发的线程成本,但 Netty 仍提供成熟的协议管线、缓冲区、背压、连接管理和事件循环体系。


十七、如何选择?

连接不多,逻辑简单

可以使用:

阻塞 Socket
+
平台线程池

代码简单,排查容易。

长连接很多

可以使用:

Java NIO Selector
Netty
其他事件驱动框架

重点控制:

EventLoop 阻塞
缓冲区
背压
连接状态

阻塞业务较多

现代 Java 可以评估:

阻塞 API
+
虚拟线程

尤其适合大量任务主要在等待:

Socket
数据库
远程服务
队列

的场景。

极致性能场景

不能只根据“BIO、NIO、epoll”几个名称判断。

必须在真实负载下测量:

连接数
活跃比例
消息频率
消息大小
CPU
内存
延迟
带宽
下游容量

最终选择的不是“理论上最快的 API”,而是:

在目标负载下,能够满足吞吐、延迟、资源和维护成本要求的并发模型。


总结

传统 BIO 的基本模型是:

一个连接
   ↓
一个平台线程
   ↓
阻塞等待数据

线程池可以复用线程,但不能消除连接等待对工作线程的占用。

非阻塞 I/O 把:

没有数据就一直等待

变成:

没有数据就立即返回

但手工轮询所有连接,会让应用的工作量随着总连接数增长。

I/O 多路复用把连接交给内核统一等待:

注册很多 fd
   ↓
内核等待事件
   ↓
返回就绪 fd
   ↓
应用处理事件

select() 能等待多个 fd,但在 Linux 上受到传统 FD_SETSIZE 限制,并需要反复准备和扫描集合。

poll() 去掉了固定 fd_set,但仍需要传递和检查整个描述符数组。

epoll 把注册和等待拆开,并通过 Interest List 与 Ready List 管理连接,使应用更适合处理:

连接很多
活跃连接较少

的场景。

Java Selector 将这些底层差异抽象为:

Channel
SelectionKey
Selector

应用注册感兴趣的事件,等待 Channel 就绪,再执行 accept()read()write()

EventLoop 的本质则是:

等待事件
处理事件
执行任务
再次等待

Java 虚拟线程让阻塞式 thread-per-request 模型重新具备了很强的扩展能力,但没有消除文件描述符、Socket 内存、业务状态和下游资源限制。

最后,可以用一句话概括整个演进过程:

BIO 用线程等待每条连接,非阻塞 I/O 让线程主动检查连接,而多路复用让内核报告就绪连接;虚拟线程则从另一个方向降低了“等待一个连接需要一个线程”的成本。

0

评论区