BIO 到 NIO 再到 AIO:一篇文章彻底搞懂 I/O 的前世今生

BIO 到 NIO 再到 AIO:一篇文章彻底搞懂 I/O 的前世今生


一、先搞明白:什么是 IO?

IO就是四个字,数据搬运,通俗来讲就是 数据从外部进入计算机 或者 数据从计算机流向外部,网络上就是数据从客户端流向服务端。

例如:

text 复制代码
磁盘读取文件
网络接收数据
数据库查询结果返回
键盘输入

本质都是:

text 复制代码
数据搬运

例如:

java 复制代码
String text = Files.readString(Path.of("test.txt"));

看起来只是:

java 复制代码
读取文件

实际上发生的是:

数据经历了多次复制。


二、为什么 IO 很慢?

看一组经典数据:

操作 耗时
CPU寄存器 1ns
L1缓存 0.5ns
内存 100ns
SSD 100μs
网络 0.1ms~100ms

换算:

text 复制代码
CPU跑100万步

磁盘才动一下

所以:

程序真正慢的不是计算,而是等待IO。


三、一次 read() 到底发生了什么?

例如:

java 复制代码
inputStream.read();

执行后:

这里出现两个问题:


问题1:

text 复制代码
等待数据到来

问题2:

text 复制代码
数据拷贝

Linux所有IO模型都在解决这两个问题。


四、Linux 五种 IO 模型

经典分类来自:

《UNIX Network Programming》

五种模型:

text 复制代码
1 阻塞IO(Blocking IO)
2 非阻塞IO(Non Blocking IO)
3 IO复用(IO Multiplexing)
4 信号驱动IO
5 异步IO(Asynchronous IO)

Java的:

text 复制代码
BIO
NIO
AIO

其实对应:

text 复制代码
BIO → Blocking IO

NIO → IO Multiplexing
     (select/poll/epoll)

AIO → Asynchronous IO

五、BIO:最符合人类思维的模型

代码:

java 复制代码
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        while (true) {
            System.out.println("开始等待客户端的连接。");
            //阻塞方法
            Socket clientSocket = serverSocket.accept();
            System.out.println("客户端连接了");
            new Thread(() -> {
                try {
                    handler(clientSocket);
                } catch (IOException e) {
                    System.out.println("发生IO错误");
                }
            }).start();
        }
    }

    private static void handler(Socket clientSocket) throws IOException {
        byte[] bytes = new byte[1024];
        System.out.println("准备read客户端数据");
        //接收客户端的数据,阻塞方法,没有数据可读时就阻塞
        int read = clientSocket.getInputStream().read(bytes);
        System.out.println("read完毕");
        if (read != -1) {
            System.out.println("接收到客户端的数据:" + new String(bytes, 0, read));
        }
    }
}

accept:

text 复制代码
没有连接
↓
一直等待
↓
线程卡住

这就是:

text 复制代码
Blocking IO

BIO到底阻塞在哪里?

很多人误解:

text 复制代码
线程不工作了

实际上:

线程被挂起。

Linux:

c 复制代码
accept()

执行:

text 复制代码
进入内核
↓
连接没来
↓
线程加入等待队列
↓
CPU切换别的线程

线程状态:

text 复制代码
RUNNING
↓
BLOCKED

BIO的问题

假设:

text 复制代码
10000个连接

传统写法:

java 复制代码
new Thread(() -> handle(socket))

形成:

text 复制代码
1连接 -> 1线程

即:

text 复制代码
C10K问题

10000线程意味着:

text 复制代码
线程栈
上下文切换
CPU调度
内存消耗

全部爆炸。


六、NIO诞生

核心思想:一个线程管理多个连接,也就是非阻塞IO

复制代码
NIO = 同步 + 非阻塞 + 多路复用

意思:线程仍然负责读取数据, 但是线程不等待某一个连接

核心变化:

BIO:

复制代码
线程等连接、事件

NIO:

复制代码
连接等事件

NIO 到底解决什么问题

假设 有100000连接 , 实际只有100连接活跃

BIO : 10万线程,严重浪费。

NIO:16线程 -> 处理10万连接

因为 线程 只处理:活跃连接


**NIO怎么实现活跃连接 **


IO复用

思想:让内核帮忙盯着,应用只问,哪些连接有数据?

内核维护:fd集合

用户:select(...)

流程:

text 复制代码
用户态
 ↓
select
 ↓
内核检查10000连接
 ↓
返回活跃连接

优点:不用轮询不用10000线程

缺点:fd数量有限Linux只有1024个

poll 出现了

改进:

text 复制代码
取消1024限制

但:

text 复制代码
仍然遍历全部fd

复杂度:

text 复制代码
O(n)

epoll革命

Linux 2.6后:

text 复制代码
epoll

真正解决:

text 复制代码
大量连接

核心思想:连接变化时通知,而不是 每次全部检查

结构:

text 复制代码
红黑树
+
就绪链表

创建fd:

c 复制代码
epoll_create()

注册:

c 复制代码
epoll_ctl()

放入:

text 复制代码
RBTree

数据到达:

text 复制代码
网卡中断
↓
内核
↓
fd加入ReadyList

查询:

c 复制代码
epoll_wait()

直接返回:

text 复制代码
ReadyList

复杂度:

text 复制代码
O(1)

NIO底层为什么快

Selector多路复用


java代码执行:

复制代码
while(true){
 fd = select();
 for(fd){
   read();
 }
}

fd = select() 时linux:

text 复制代码
epoll_ctl() // 处理事件
epoll_wait()// 等待事件

之后直接返回有数据的连接


差距:

text 复制代码
O(n)

VS

O(1)

七、Java NIO架构

java 复制代码
public static void main(String[] args) throws IOException {
    // 创建NIO ServerSocketChannel
    ServerSocketChannel serverSocket = ServerSocketChannel.open();
    serverSocket.socket().bind(new InetSocketAddress(8080));
    // 设置ServerSocketChannel为非阻塞
    serverSocket.configureBlocking(false);
    // 打开Selector处理Channel,即创建epoll
    Selector selector = Selector.open();
    // 把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
    SelectionKey selectionKey = serverSocket.register(selector, SelectionKey.OP_ACCEPT);
    System.out.println("服务启动成功");
    while (true) {
        // 阻塞等待需要处理的事件发生
        selector.select();
        // 获取selector中注册的全部事件的 SelectionKey 实例
        Set<SelectionKey> selectionKeys = selector.selectedKeys();
        Iterator<SelectionKey> iterator = selectionKeys.iterator();
        // 遍历SelectionKey对事件进行处理
        while (iterator.hasNext()) {
            SelectionKey key = iterator.next();
            // 如果是OP_ACCEPT事件,则进行连接获取和事件注册
            if (key.isAcceptable()) {
                ServerSocketChannel server = (ServerSocketChannel) key.channel();
                SocketChannel socketChannel = server.accept();
                socketChannel.configureBlocking(false);
                // 这里只注册了读事件,如果需要给客户端发送数据可以注册写事件
                SelectionKey selKey = socketChannel.register(selector, SelectionKey.OP_READ);
                System.out.println("客户端连接成功");
            } else if (key.isReadable()) {  // 如果是OP_READ事件,则进行读取和打印
                SocketChannel socketChannel = (SocketChannel) key.channel();
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(128);
                int len = socketChannel.read(byteBuffer);
                // 如果有数据,把数据打印出来
                if (len > 0) {
                    System.out.println("接收到消息:" + new String(byteBuffer.array()));
                } else if (len == -1) { // 如果客户端断开连接,关闭Socket
                    System.out.println("客户端断开连接");
                    socketChannel.close();
                }
            }
            //从事件集合里删除本次处理的key,防止下次select重复处理
            iterator.remove();
        }
    }
}

核心组件:Channel + Selector


Selector 与 linux:

  1. 打开Selector处理Channel:

java执行

复制代码
Selector selector = Selector.open();

最终会走到

java 复制代码
static native int wait(long h, long pollAddress, int numfds, int timeout)
        throws IOException;

Linux 中进行:

创建fd:

复制代码
epoll_create() // 创建文件描述符

  1. 把ServerSocketChannel注册到selector上

java执行

复制代码
// 把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
SelectionKey selectionKey = serverSocket.register(selector, SelectionKey.OP_ACCEPT);

最终会走到

java 复制代码
implRegister中
pollwrapper.add(fd) -- 也就是把上面创建的fd加到监听中去

  1. 阻塞等待需要处理的事件发生

java执行

复制代码
selector.select();

最终走到

java 复制代码
    @Override
    protected int doSelect(Consumer<SelectionKey> action, long timeout)
        throws IOException
    {
        assert Thread.holdsLock(this);

        // epoll_wait timeout is int
        int to = (int) Math.min(timeout, Integer.MAX_VALUE);
        boolean blocking = (to != 0);

        int numEntries;
        processUpdateQueue(); // 调用 WEPoll.ctl 处理 事件
        processDeregisterQueue();
        try {
            begin(blocking);
            boolean attempted = Blocker.begin(blocking);
            try {
                // 查询 事件
                numEntries = WEPoll.wait(eph, pollArrayAddress, NUM_EPOLLEVENTS, to);
            } finally {
                Blocker.end(attempted);
            }
        } finally {
            end(blocking);
        }
        processDeregisterQueue();
        return processEvents(numEntries, action);
    }

	private void processUpdateQueue() {
        assert Thread.holdsLock(this);

        synchronized (updateLock) {
            SelectionKeyImpl ski;
            while ((ski = updateKeys.pollFirst()) != null) {
                if (ski.isValid()) {
                    int fd = ski.getFDVal();
                    // add to fdToKey if needed
                    SelectionKeyImpl previous = fdToKey.putIfAbsent(fd, ski);
                    assert (previous == null) || (previous == ski);
                    int newOps = ski.translateInterestOps();
                    int registeredOps = ski.registeredEvents();
                    if (newOps != registeredOps) {
                        if (newOps == 0) {
                            // remove from epoll
                            WEPoll.ctl(eph, EPOLL_CTL_DEL, fd, 0);
                        } else {
                            int events = toEPollEvents(newOps);
                            if (registeredOps == 0) {
                                // add to epoll
                                WEPoll.ctl(eph, EPOLL_CTL_ADD, fd, events);
                            } else {
                                // modify events
                                WEPoll.ctl(eph, EPOLL_CTL_MOD, fd, events);
                            }
                        }
                        ski.registeredEvents(newOps);
                    }
                }
            }
        }
    }
java 复制代码
 // 调用 WEPoll.ctl 处理 事件
 static native int ctl(long h, int opcode, long s, int events);
 // 查询 事件
 static native int wait(long h, long pollAddress, int numfds, int timeout)
        throws IOException;

Linux 创建执行:

c 复制代码
epoll_ctl()// 注册、修改、删除监听
c 复制代码
epoll_wait()// 等待事件发生

八、AIO是什么?

NIO:

text 复制代码
你主动问

AIO:

text 复制代码
数据来了通知你

模型:

text 复制代码
应用线程
   │
提交请求
   ▼
Linux Kernel
   │
读取数据
   │
完成通知
   ▼
Callback

数据准备完成:

text 复制代码
操作系统
↓
回调
↓
CompletionHandler(有completed和failed)

AIO与NIO本质区别

NIO:

text 复制代码
read()

↓

线程等待

↓

返回数据

AIO:

text 复制代码
aio_read()

↓

立刻返回

↓

内核处理

↓

回调用户

NIO:

text 复制代码
Reactor

AIO:

text 复制代码
Proactor

为什么 AIO不火

理论:

text 复制代码
AIO > NIO

实际:

text 复制代码
不一定

原因:

Linux 早期 AIO 很弱,没有真正成熟AIO。

传统 Linux AIO:仅文件支持较好、socket支持差


所以:

text 复制代码
Redis、Nginx、Netty 默认仍然使用epoll

几乎没人用传统 AIO。


Linux新时代 -> 真正现代异步:io_uring

Linux 5.1:推出: io_uring

目标:

text 复制代码
彻底异步

核心:

text 复制代码
共享内存队列

结构:

复制代码
Application

SQ
提交队列

↓

Kernel

↓

CQ
完成队列

执行:

复制代码
提交

↓

线程立即返回

↓

内核执行

↓

完成事件

特点:

Shared Ring Buffer

用户态和内核共享队列

复制代码
少系统调用
少上下文切换
真异步
支持网络
支持文件
支持零拷贝

九、Java AIO 底层是什么

Java:

复制代码
AsynchronousSocketChannel

示例:

java 复制代码
   public static void main(String[] args) throws Exception {
        final AsynchronousServerSocketChannel serverChannel =
                AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(9000));
		//  用于消费异步I/O操作结果的处理程序。这个包中定义的异步通道允许指定一个完成处理程序来使用异步操作的结果。 -- 也就是给操作系统准备的回调方法
        serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
            // 这个 完成 方法在I/O操作成功完成时调用
            @Override
            public void completed(AsynchronousSocketChannel socketChannel, Object attachment) {
                try {
                    serverChannel.accept(attachment, this);
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    socketChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                        @Override
                        public void completed(Integer result, ByteBuffer buffer) {
                            buffer.flip();
                            System.out.println(new String(buffer.array(), 0, result));
 							socketChannel.write(ByteBuffer.wrap("HelloClient".getBytes()));
                        }

                        @Override
                        public void failed(Throwable exc, ByteBuffer buffer) {
                            exc.printStackTrace();
                        }
                    });
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
			//这个 失败的 方法在I/O操作失败时调用。这些方法的实现应该及时完成,以避免使调用线程分派给其他完成处理程序。
            @Override
            public void failed(Throwable exc, Object attachment) {
                exc.printStackTrace();
            }
        });
        Thread.sleep(Integer.MAX_VALUE);
    }

完成:

复制代码
completed()

Linux:

复制代码
线程池模拟
+
epoll

不是纯内核 AIO。

所以: Java AIO 在 Linux 上很多时候:

复制代码
AIO ≈ 线程池 + NIO

十、BIO、NIO、AIO终极对比

维度 BIO NIO AIO
模型 阻塞 同步非阻塞 异步
线程 一连接一线程 少量线程 更少线程
核心机制 wait epoll callback
编程复杂度 简单 中等
并发能力 极高
Linux成熟度 极高 极高 一般
适用场景 小系统、调度系统 高并发服务、tomcat、Netty 特殊场景

十一、一张图看懂整个IO演化史

text 复制代码
BIO
│
│ 解决线程阻塞
▼
NIO
│
│ 解决连接爆炸
▼
epoll
│
│ 解决遍历成本
▼
AIO
│
│ 解决等待问题
▼
io_uring

结语

如果只记住一句话:

text 复制代码
BIO、NIO、AIO 本质上是操作系统处理IO的三代思想:

BIO = 等

NIO = 问

AIO = 通知