Linux 的 5 种 IO 模型

2023/2/8

# 关于阻塞非阻塞、异步同步

老张爱喝茶,废话不说,煮开水。 出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。

  1. 老张把水壶放到火上,立等水开。(同步阻塞),老张觉得自己有点傻
  2. 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞) 老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。
  3. 老张把响水壶放到火上,立等水开。(异步阻塞) 老张觉得这样傻等意义不大
  4. 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞) 老张觉得自己聪明了。
  • 阻塞是指老张能不能去干其他事
  • 同步是指老张通过什么方式知道水开了,自己去看就是同步

# Linux 的 5 种 IO 模型

# 阻塞 I/O(blocking IO)

进程会一直阻塞,直到数据拷贝完成 应用程序调用一个IO函数,导致应用程序阻塞,等待数据准备好。数据准备好后,从内核拷贝到用户空间,IO函数返回成功指示。阻塞IO模型图如下所示:

3.png 3.png

# 非阻塞 I/O (nonblocking I/O)

通过进程反复调用IO函数,在数据拷贝过程中,进程是阻塞的。模型图如下所示:

4.png

# I/O 复用 (I/O multiplexing)

使用 select 或者 poll 等待数据,并且可以等待多个套接字中的任何一个变为可读,这一过程会被阻塞,当某一个套接字可读时返回。之后再使用 recvfrom 把数据从内核复制到进程中。它可以让单个进程具有处理多个 I/O 事件的能力。又被称为 Event Driven I/O,即事件驱动 I/O。

如果一个 Web 服务器没有 I/O 复用,那么每一个 Socket 连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程。并且相比于多进程和多线程技术,I/O 复用不需要进程线程创建和切换的开销,系统开销更小。

5.png

nio - new IO可以实现IO非阻塞、select,poll,epoll

# 非阻塞I/O

public static void main(String[] args) throws Exception {
    ServerSocketChannel ss = ServerSocketChannel.open();
    ss.bind(new InetSocketAddress(9090));
    ss.configureBlocking(false);

    List<SocketChannel> clients = new ArrayList<>();
    while (true) {
        Thread.sleep(1000);
        // 不会在这里阻塞
        SocketChannel client = ss.accept();

        if (client == null) {
            System.out.println("null...");
        } else {
            client.configureBlocking(false);
            int port = client.socket().getPort();
            System.out.println("client.port:" + port);
            clients.add(client);
        }

        ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
        for (SocketChannel c : clients) {
            int num = c.read(buffer); // 不会阻塞
            if (num > 0) {
                buffer.flip();
                byte[] aaa = new byte[buffer.limit()];
                buffer.get(aaa);
                
                String b = new String(aaa);
                System.out.println(c.socket().getPort() + ":" + b);
                buffer.clear();
            }
        }
    }
}

可以用一个线程可以处理N个accept和client读写;也可以将clients扔给线程池去处理 缺点:

  1. 无线的循环,无限的系统调用,accept、read
  2. 1000个client连接,但是只有一个发送了数据,但是也会调用1000次read;每次循环,都会O(n)的系统调用

6.png

接车人一直去看每个通道有没有货

# select 和 poll 差别不是很大

应用程序调用内核查询哪些client(文件描述符)有数据了,然后应用程序根据内核返回的文件描述符去对应的client上读(同步)

7.png

public class SelectOrPoll {

    private ServerSocketChannel server = null;
    // selector的实现可以是select、poll、epoll、kqueue
    private Selector selector = null;
    int port = 9090;

    public void initServer() {
        try {
            server = ServerSocketChannel.open();
            server.configureBlocking(false);
            server.bind(new InetSocketAddress(port));

            selector = Selector.open();
            // 需要监听accept
            // selector上可以注册很多很多
            server.register(selector, SelectionKey.OP_ACCEPT);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void start() {
        initServer();
        try {
            while (true) {
                // selector给kernel打电话
                while (selector.select() > 0) {
                    Set<SelectionKey> selectionKeys = selector.selectedKeys();
                    Iterator<SelectionKey> iter = selectionKeys.iterator();
                    while (iter.hasNext()) {
                        SelectionKey key = iter.next();
                        iter.remove();
                        if (key.isAcceptable()) {
                            // 处理连接事件
                            acceptHandler(key);
                        } else if (key.isReadable()) {
                            // 处理read事件
                        }
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void acceptHandler(SelectionKey key) {
        try {
            ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
            SocketChannel client = ssc.accept();
            client.configureBlocking(false);

            ByteBuffer buffer = ByteBuffer.allocateDirect(8192);
            // 需要监听read
            client.register(selector, SelectionKey.OP_READ, buffer);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

缺点:询问内核,需要带上所有参数(例如Server,和要问的client);每次调用都要触发内核遍历

# epoll

做法:开辟一个内核空间,用来记录哪里事件已ready,每一个事件ready了就在空间里记录一下,然后应用程序直接查询这个内核空间就可以了 8.png

# 信号驱动 I/O (signal driven I/O (SIGIO))

应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom 将数据从内核复制到应用进程中。

相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高。

9.png

# 异步 I/O (asynchronous I/O)

进行 aio_read 系统调用会立即返回,应用进程继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。

10.png

# 五种 IO 模型的比较

11.png