I/O 多路复用及事件处理模型

概述

什么是 I/O ?

I/O 是指 Input/Output,即输入和输出。以内存为中心:

  • Input 指从外部读入数据到内存,例如,把文件从磁盘读取到内存,从网络读取数据到内存等等。
  • Output 指把数据从内存输出到外部,例如,把数据从内存写入到文件,把数据从内存输出到网络等等。

为什么要把数据读到内存才能处理这些数据?因为代码是在内存中运行的,数据也必须读到内存,最终的表示方式无非是byte数组,字符串等,都必须存放在内存里。

我们讨论的网络 I/O 就是对网卡的读写,网络 I/O 在 Web 应用中十分重要,网络框架的设计离不开 I/O 模型,模型的优劣直接决定了系统的吞吐量、可扩展性、安全性等。

本文主要总结5种 I/O 模型及应对高并发的 I/O 事件处理模型和应用。


网络 IO 过程(TCP)

Untitled

  • accept:从全连接队列中取一个连接;
  • connect:请求进行三次握手,建立连接;
  • read:把数据从接收内核缓冲区复制到进程缓冲区;
  • write:把数据从进程缓冲区复制到内核的发送缓冲区。

关于 accept() 函数的作用:

Linux 实现中 TCP 建立连接时,依赖两个队列,一个 Syn 队列(半连接队列,Hash 表结构),一个 Accept 队列(全连接队列,链表结构),这两个队列是在执行 listen() 时创建的)。

syn/accept 队列

syn/accept 队列

TCP 三次握手要经过:

  1. Client -> Server: SYN
  2. Server -> Client: SYN+ACK
  3. Client -> Server: ACK

在第 1 步 Server 收到 SYN 后,会把消息放到 Syn Queue 中,并回复 SYN+ACK 给 client,当第 3 步 Server 收到 ACK 后,如果这时 Accept Queue 未满,则从 Syn Queue 中取出暂存的信息放入 Accept Queue 中。若满了,则按 net.ipv4.tcp_abort_on_overflow 设置的值执行:

  • net.ipv4.tcp_abort_on_overflow = 0表示如果三次握手第三步的时候 Accept Queue 满了,则 Server 丢弃 Client 发过来的 ACK 。
  • net.ipv4.tcp_abort_on_overflow = 1表示第三步的时候如果全连接队列满了,Server 发送一个 RST 包给 Client ,表示拒绝这个握手过程和这个连接。

那么回到 accept() 的作用,实际上建立连接的过程中不需要accept()参与, 执行 accept() 只是为了从全连接队列里取出一条连接

常见的 IO 模型

BIO(同步阻塞)

ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(8888);
while (1) {
        // 1. 等待连接,同步阻塞
        Socket socket = serverSocket.accept();

        threadPool.execute(() -> {
                // 2. 读取数据
                String someThing = socket.read()
            if(someThing!=null){
            //3. 处理数据
                        bizLogic();
                        // 4. 写数据
                socket.write();
        }
        })
}
  1. 第一步:等待连接(accept)同步阻塞,等待可用连接(例如完成 tcp 三次握手的连接)。
  2. 二、三步:数据传输又可以分成两个阶段,以 read() 为例:
    1. 等待数据就绪:CPU 向网卡发出 I/O 请求,网卡在接收到数据之后,由网卡的 DMA 把的数据写到操作系统的内核缓冲区,完成后通知 CPU。
    2. 将数据从内核拷贝到用户空间:CPU 将数据拷贝到用户空间,消耗 CPU 但性能非常高。

由于是同步阻塞的,所以 read() 会一直阻塞直到数据从内核拷贝到用户空间才返回;write() 也类似,需要等待所有数据都写到发送缓冲区才返回(发送缓冲区一边往外发数据,一边应用往里写,不够写就一部分一部分写等待数据发送后有空出来的空间,直到全部写入)。

BIO 在活动连接数不是特别高(小于单机1000)的情况下是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单。但是缺点也很明显:

  1. 依赖线程,当活动连接数以万计时,需要创建大量线程,此时线程就要占用大量内存;
  2. 线程上下切换影响 CPU load、CPU sys使用率;
  3. 对于长连接,线程的资源一直不会释放,可能造成之后的连接无法处理。

所以出现了各种新 I/O 模型来应对上万甚至上百万连接。

NIO(同步非阻塞)

非阻塞主要体现在用户发起 IO 请求后,不需要等待,无论成功失败,可以直接获取到对应的结果(内核数据是否就绪),当结果是 error时,就再次发送 IO 请求。例如:

  • 非阻塞读:
    • 若 Socket 接收缓冲区中无数据,系统调用立马返回,并带有一个 EWOULDBLOCK 或 EAGAIN 错误,这个阶段用户线程不会阻塞,也不会让出CPU,而是会继续轮训直到 Socket 接收缓冲区中有数据为止;
    • 若 Socket 接收缓冲区中有数据,用户线程在内核态会将内核空间中的数据拷贝到用户空间,注意这个数据拷贝阶段,应用程序是阻塞的,当数据拷贝完成,系统调用返回。
  • 非阻塞写:
    • 不同于阻塞写的苦等数据写完,非阻塞写比较佛系,当发送缓冲区中没有足够的空间容纳全部发送数据时,非阻塞写的特点是能写多少写多少,写不下了,就立即返回。并将写入到发送缓冲区的字节数返回给应用程序,方便用户线程不断的轮询尝试将剩下的数据写入发送缓冲区中。

NIO 最大的问题就是需要不断的发起系统调用去轮询各个 Socket 中的接收缓冲区是否有数据到来,频繁的系统调用随之带来了大量的上下文切换开销。随着并发量的提升,这样会导致非常严重的性能问题, 造成 CPU 的浪费。

IO Multiplexing(IO 多路复用)

// Java NIO 实现多路复用
void serverInit() {
    // 1. 创建 Selector 和 Channel
    try (Selector selector = Selector.open(); ServerSocketChannel serverSocket = ServerSocketChannel.open();) {
        serverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888));
        serverSocket.configureBlocking(false);
        // 2. 注册到 Selector,并声明关注的事件类型(服务端,关注连接事件)
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            // 3. 阻塞,等待就绪的 Channel
            selector.select();
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> iter = selectedKeys.iterator();
            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                if (!key.isValid()) {
                    continue;
                }
                if (key.isAcceptable()) {
                    // 4.获取连接,并注册读事件监听
                    accept(serverSocket, selector);
                } else if (key.isReadable()) {
                    read(key);
                } else if (key.isWritable()) {
                    write(key);
                }
                iter.remove();
            }
        }
    } catch (IOException e) {
..
    }
}

void accept(ServerSocketChannel serverSocket, Selector selector) {
    SocketChannel sc = serverSocket.accept();
    sc.configureBlocking(false);
    sc.register(selector, SelectionKey.OP_READ);
}

void read(SelectionKey key) {
        SocketChannel sc = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);
    sc.read(buffer);
}

void write() {
...
}

上述代码的 select() 方法在 Linux 操作系统内核下最终会调用到 EPollSelectorImpl#doSelect(...) (参考 openJdk),最终由操作系统提供通知机制,当内核数据就绪时才返回。

Linux 通过 select、poll、epoll 这三个函数实现这样的机制。

  • select(1983 Unix 4.2 BSD):当被监听的 fd(文件描述符)就绪后会返回,但是我们无法知道具体是哪些 fd 就绪了,只能遍历所有的 fd。通常来说某一时刻,就绪的 fd 并不会很多,但是使用 select 必须要遍历所有的 fd,这就造成了一定程度上的性能损失。select 最多可监听的 fd 是有限制的,32位操作系统默认 1024 个,64 位默认 2048;
  • poll(1997 Linux 2.1.23):和 select 一样,使用 poll 时也无法知道具体哪些 fd 就绪了,还是需要遍历。poll 最大的改进就是没有了监听数量的限制,但是监听了过多的 fd 会导致性能不佳;
  • epoll(2002 Linux 2.5.44):通常在 Linux 系统中使用 IO 多路复用,都是在使用 epoll 函数。epoll 是 select 和 poll 的增强,可以通知我们哪些 fd 已经就绪了,并且没有监听数量的限制。所以使用 epoll 的性能要远远优于 select 和 poll,目前 linux 2.6 以上都使用 epoll。

    epoll 不同于依赖轮询的 select 和 poll,它包括以下系统调用

    // 创建一个池子监控和管理 fd 
    int epoll_create(int size)
    
    // 负责管理这个池子里的 fd 事件的增、删、改 (红黑树)
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    
    // 就是负责打盹的,让出 CPU 调度,但是只要有“事”,立马会从这里唤醒
    epollwait()
    

    通过 epoll_ctl 将某个 fd 可读/可写等等事件添加到红黑树,这些事件都会与设备 (网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调函数。

    回调函数的任务是把事件就绪的 fd 对应的结构体放到一个特定的队列(就绪队列,ready list),然后把 epoll 从 epollwait 中唤醒。

    因此 epoll 被称为事件驱动。

另外,上面的 read() 实现,我们创建了一个 1024 字节的缓冲区,用于数据的读取。如果连接中的数据,大于 1024 字节怎么办?这涉及两种事件的通知机制:

  • 水平触发(level-triggered) 称作 LT 模式。只要缓冲区有数据,事件就会一直发生
  • 边缘触发(edge-triggered) 称作 ET 模式。缓冲区有数据,仅会触发一次。事件想要再次触发,必须先将 fd 中的数据读完才行

Netty中实现的EpollSocketChannel默认的就是边缘触发模式。JDKNIO默认是水平触发模式。LT 模式频繁环唤醒线程,效率相比较ET模式低。

IO 多路复用即使用一个线程中就可以监听多个 (Linux 中万物皆是文件描述符,网络编程中即是 Socket),一旦某个 FD 就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

多路是指网络连接,复用指的是同一个线程。

Signal Driven IO(信号驱动 IO)

用户线程发起一个 IO 请求操作,会给对应的 socke t注册一个信号函数,之后立即返回,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号后,便在信号函数(callback)中调用 IO 读写操作来进行实际的IO请求操作。

一般用于UDP中,对TCP套接字几乎没用,原因是该信号产生得过于频繁,并且该信号的出现并没有告诉我们发生了什么请求。

Asynchronous IO (异步 IO)

之前的几种 IO 模型实际上都属于同步 IO,只有最后一种是真正的异步 IO,因为无论是多路复用IO 还是信号驱动模型,IO操作的第 2 个阶段都会引起用户线程阻塞,也就是内核进行数据拷贝的过程都会让用户线程阻塞

异步 IO 的主要机制就是在完成 IO ,即数据完成从内核空间复制到用户空间时,才会通知调用方。

与上述的信号 IO 模型区分在于异步是通知我们何时 IO 操作完成,而信号 IO 是通知我们何时可以启动一个 IO 操作。


对比

前面介绍的5中I/O中,I/O 阻塞、I/O非阻塞、I/O复用、SIGIO 都会在不同程度上阻塞应用程序,而只有异步I/O模型在整个操作期间都不会阻塞应用程序。

Untitled


概念区分

同步 vs. 异步

  • 同步:调用 function 的结果必须等待其返回。
  • 异步:调用 function 后立即返回,结果通过回调或者事件等主动通知调用方。

二者主要描述的是通信方式的区别。

阻塞 vs. 非阻塞

  • 阻塞:调用结果返回之前,当前调用方会被挂起。调用线程只有在得到结果之后才会返回。
  • 非阻塞:不能立刻得到结果之前,该调用不会阻塞调用方,而是直接返回有、无、或者有部分结果等。

二者主要描述的是通信过程中调用方状态的区别。

I/O 模型是为了解决内存和外部设备速度差异的问题。我们平时说的阻塞或非阻塞是指应用程序在发起 I/O 操作时,是立即返回还是等待。而同步和异步,是指应用程序在与内核通信时,数据从内核空间到应用空间的拷贝,是由内核主动发起还是由应用程序来触发。


事件处理模型

网络设计模式中,如何处理各种 I/O 事件是其非常重要的一部分,大名鼎鼎的 Java 并发包作者 Doug Lea,在 Scalable I/O in Java 一文中阐述了服务端开发中 I/O 模型的演进过程。Netty 中三种 Reactor 线程模型也来源于这篇经典文章。上面将 I/O 分为同步 I/O 和 异步I/O,可以使用同步 I/O 实现 Reactor 模型,使用异步 I/O 实现 Proactor 模型。

Java NIO SelectionKey 中定义了 4 种事件:

  • OP_ACCEPT:接收连接事件,表示服务端与(至少一个)客户端已完成连接(如三次握手),此时服务端可以获取连接了;
  • OP_CONNECT:连接就绪事件,客户端调用 connect() 尝试进行连接服务端,该操作就绪;
  • OP_READ —— 读就绪事件,当内核的读缓冲区中有数据可读时,该操作就绪;
  • OP_WRITE —— 写就绪事件,内核的写缓冲区中有空闲的空间时(大部分时候都有),该操作就绪。

Reactor

也称为 Dispatcher 模式,核心原理是利用 NIO 对 IO 线程进行不同的分工:

  • 使用前边我们提到的IO多路复用模型比如select,poll,epoll,kqueue,进行IO事件的注册和监听;
  • 将监听到就绪的 IO 事件分发 dispatch 到各个具体的处理 Handler 中进行相应的IO事件处理。

通过 IO 多路复用技术就可以不断的监听 IO 事件,不断的分发 dispatch,就像一个反应堆一样,看起来像不断的产生 IO 事件,因此称这种模式为 Reactor 模型。

注:一般业务处理包含三步:decode、compute、encode

单线程 Reactor

Untitled

这个模型诠释了 Reactor 模式的组成部分:

  • Reactor: 负责分发 I/O 事件,如果是请求建立连接,则由 Acceptor 通过 accept() 处理,如果不是连接建立请求,则 Reactor 会将事件分发给具体的处理逻辑来执行(数据读取、编解码、业务逻辑等);
  • Acceptor:获取连接,注册读事件监听。

单线程 Reactor 线程模型简单,没有引入多线程,自然也就没有多线程并发和竞争的问题。

但其缺点也非常明显,那就是性能瓶颈问题,一个线程只能跑在一个 CPU 上,能处理的连接数是有限的,无法完全发挥多核 CPU 的优势。一旦某个业务逻辑耗时较长,这唯一的线程就会卡在上面,无法处理其他连接的请求,程序进入假死的状态,可用性也就降低了。正是由于这种限制,一般只会在客户端使用这种线程模型。

多线程 Reactor

Untitled

多线程 Reactor 通过引入线程池处理读写请求来解决单线程 Reactor 的性能问题,除开这点其他流程与单线程模型基本一致。这个模型存在的问题是,只有一个线程来处理 Reactor 监听到的所有 I/O 事件,包括连接建立事件以及读写事件,当连接数不断增大的时候,这个唯一的 Reactor 线程也会遇到瓶颈。

主从多线程 Reactor

Untitled

为了解决单 Reactor 多线程模型中的问题,可以引入多个 Reactor。MainReactor 线程负责通过 Acceptor 对象处理其监听到的连接建立事件,当 Acceptor 完成网络连接的建立之后,MainReactor 会将建立好的连接分配给 SubReactor 进行后续读事件监听。当有新的读事件(OP_READ)发生时,SubReactor ,然后分发给 Worker 线程池中的线程进行处理并返回结果

主从 Reactor 多线程的设计模式解决了单一 Reactor 的瓶颈。主从 Reactor 职责明确,主 Reactor 只负责监听连接建立事件,SubReactor只负责监听读写事件。整个主从 Reactor 多线程架构充分利用了多核 CPU 的优势,可以支持扩展,而且与具体的业务逻辑充分解耦,复用性高。但不足的地方是,在交互上略显复杂,需要一定的编程门槛。

Proactor

Proactor 模型采用异步 IO 实现(AIO),事件处理器(或者代由事件分发器发起)直接发起一个异步读写操作(相当于请求),而实际的工作是由操作系统来完成的,事件处理器不再关心操作就绪事件而是关注完成事件,由内核完成 IO 操作后,通知事件分离器,最后呼唤事件处理器调用自定义 handler 进行业务处理。目前 Linux 操作系统内核尚未实现 AIO (Windows 实现了 AIO)。

Untitled

应用

  • Netty: 单线程、多线程、主从 Reactor 模型都有实现(Netty 中的 EventLoop 就是 Reactor);
  • Memcached: 多线程 Reactor 模型;
  • Nginx: 多进程 Reactor 模型;
  • Redis: v6.0 前单线程 Reactor 模型,v6.0 后采用主从 Reactor 模型;
    • 和经典主从 Reactor 不一样的地方在于,decode 和 encode 在从 Reactor 线程组执行,更大的区别是 Redis 命令的运行也是由主 Reactor 线程执行。
  • Tomcat(NIO):主从 Reactor 模型(Acceptor 主 → Poller 从→ BizThread)。
    • 不同于 Netty 的主从 Reactor,Tomcat 监听到读事件时会交给业务线程读数据并处理业务逻辑,之后才继续读下一个请求的数据;不像 Netty I/O 读写由 I/O 线程负责,读完才交给业务线程处理。由于 Tomcat 针对的是 Http 协议,不像 RPC 常用的复用连接(即发请求可以不用等当前请求返回就可以继续发),Tomcat 这样处理也合理。

参考:

彻底理解 IO 多路复用实现机制

Java NIO浅析

Java NIO系列教程(六) Selector

Scalable IO in Java – Doug Lea

Linux I/O Principles and Zero-copy Technology

tcp-listen-a-tale-of-two-queues

网络问题排查笔记

聊聊Netty那些事儿之从内核角度看IO模型

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

Scroll to Top