从Linux I/O到Reactor模式,多图长文梳理

584 阅读14分钟

1 Linux IO模型

对于一次IO访问(以read为例),会经历两个阶段:

  • 第一阶段:等待数据准备,数据到达内核(等待网络上的数据分组到达,或者从磁盘等拷贝到内核)
  • 第二阶段:将数据从内核拷贝到进程中

基于两阶段不同的处理方式,出现了多种网络IO模型。常见的大致有如下5种

  • 阻塞IO(bloking IO)
  • 非阻塞IO(non-blocking IO)
  • 多路复用IO(multiplexing IO)
  • 信号驱动式IO(signal-driven IO)
  • 异步IO(asynchronous IO

以上,前4种都是同步IO(synchronous IO)模型。 5中模型的演进主要基于这两步进行优化,为了减少占用CPU时间,提供内核系统高并发的能力,让CPU做更多的事情。这5种模型,都是应用程序通过调用内核相关接口函数来实现。

1.1 阻塞I/O(blocking IO)

在Linux中,socket的I/O默认都是阻塞的。

image.png

当应用程序调用recvfrom系统调用,内核进入第一个阶段:等待数据。在用户态这边整个进程都会阻塞。
当内核准备数据之后,还需要将数据拷贝到用户态内存,然后才会return,之后用户进程才会解除阻塞状态。

1.2 非阻塞I/O(nonblocking IO)

Linux可以设置socket为非阻塞的。

image.png

过程 当用户进程发出recvfrom系统调用时,如果内核还没数据,会立即返回一个error结果,不会阻塞用户进程。不会阻塞用户进程。
用户进程收到error时知道数据还没准备好,过一会再调用recvfrom。直到内核数据准备好,拷贝到用户空间内存中。所以非阻塞I/O需要用户进程一直轮询I/O。

1.3 I/O多路复用模型(I/O multiplexing)

image.png

过程

Linux提供select/poll等,进程可以将多个fd传给select/poll系统调用, 阻塞在select操作上,通过 select/poll侦测多个fd是否处于就绪状态。
fd代表的socket中的任一数据准备好了,select就会返回。
之后再用recvfrom系统调用把数据从内核缓存区复制到用户进程。

特点

I/O多路复用最大的特点是让单个线程能同时处理多个IO事件的能力。
select/epoll 的优势并不是对于单个连接能处理得更好,而是不需要更多线程的创建、切换、销毁,系统开销更小,适合高并发的场景。

1.4 信号驱动I/O

image.png

过程

应用进程使用sigaction系统调用,内核立即返回。
当内核数据就绪时会发送一个信号给用户线程。
后用户进程再调用recvfrom,将数据从内核缓冲区拷贝到应用进程。

特点

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

1.5 异步I/O(Asynchronous I/O)

image.png 在数据拷贝完成后,kernel会给用户进程发送一个信号告诉其read操作完成了。

过程 异步I/O,告知内核启动某一个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知应用进程。

特点

前面4种I/O模型实际上都属于同步I/O,I/O操作的第2个阶段都会引起用户线程阻塞。
只有异步I/O把“内核拷贝数据到应用程序” 这个阶段也变成了不阻塞,释放了CPU。
异步I/O在返回的时候所有操作都已经完成。

1.6 各种I/O模型比较

image.png

2 select、poll 和 epoll

I/O多路复用就是通过一种机制,一个线程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

  • 多路: 指的是多个socket网络连接;
  • 复用: 指的是复用一个线程 目前支持I/O多路复用的系统调用有select,pselect,poll,epoll。与多进程/线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建和维护 进程/线程,从而大大减小了系统的开销。

2.1 select

select 函数

int select(
    int nfds, //指定被监听的文件描述符的总数,通为监听的所有文件描述符中最大值+1
    fd_set *readfds, // 指向可读事件对应的文件描述符集合
    fd_set *writefds, //指向可写事件对应的文件描述符集合
    fd_set *exceptfds, // 指向异常事件对应的文件描述符集合
    struct timeval *timeout);
    
 /*timeout:定时阻塞监控时间,3种情况
 1.NULL,永远等下去 
 2.设置timeval,等待固定时间 
 3.设置timeval里时间均为0,检查描述字后立即返回,轮询*/

过程 假设监控的仅仅是socket可读:

  • select会将需要监控的readfds集合拷贝到内核空间
  • 遍历fd集合,检查是否有可读事件
  • 如果遍历完所有fd后没有发现可读事件,则挂起当前线程,直到有可读事件或者主动超时
  • 被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历
  • 如果有就绪就挨个收集可读事件并返回给用户

select函数返回后可以通过轮询fdset来找到就绪的socket。

优点

  • 几乎所有平台都支持,

缺点

  • 能够监听的fd数量有限,Linux系统上一般为1024,是写死在宏定义中的

  • 每次调用select,都需要把被监控的fds集合从用户态空间拷贝到内核态空间,这个操作是比较耗时的。

  • 被监控的fds集合中,只要有一个有数据可读,整个集合就会被遍历一次

2.2 poll

pollselect基本相同,在返回就绪后,通过遍历文件描述符来获取已经就绪的socketpoll描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构。

int poll(struct pollfd *fds, unsigned int nfds, int timeout)


struct pollfd {
  int fd;           /*文件描述符*/
  short events;     /*监控的事件*/
  short revents;    /*监控事件中满足条件返回的事件*/
};

优点

poll没有最大fd数量限制(实际也会受到物理资源的限制,因为系统的fd数量是有限的)

缺点

同select:

  • 大量的fd的数组被复制于用户态和内核地址空间之间,开销大
  • 对socket进行线性扫描,即采用轮询的方法,效率较低,时间复杂度为O(N)

2.3 epoll

epollselectpoll的增强版本,epoll使用事件的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

epoll的接口非常简单,一共就三个函数:

  • epoll_create:创建一个epoll句柄
  • epoll_ctl:向 epoll 对象中添加/修改/删除要管理的连接
  • epoll_wait:等待其管理的连接上的 IO 事件

2.3.1 epoll_create 函数

int epoll_create(int size)

功能: 该函数生成一个 epoll 专用的文件描述符。

参数

  • size: 用来告诉内核这个监听的数目一共有多大,参数 size 并不是限制了 epoll 所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。

返回值:如果成功,返回poll专用的文件描述符,否者失败,返回-1。

2.3.2 epoll_ctl 函数

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

功能: epoll的事件注册函数,用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上,通过回调函数内核会将 I/O 准备好的描述符加入到一个就绪链表中管理。

参数

  • epfd: epoll 专用的文件描述符,epoll_create()的返回值
  • op:  表示动作

EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从 epfd 中删除一个 fd;

  • fd:  需要监听的文件描述符
  • event: 告诉内核要监听什么事件

EPOLLIN :表示对应的文件描述符可以读;
EPOLLOUT:表示对应的文件描述符可以写; EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET :将 EPOLL 设为边缘触发(Edge Trigger)模式,这是相对于水平触发(Level Trigger)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里

返回值 0表示成功,-1表示失败

2.3.3 epoll_wait函数

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); 

功能: 等待事件的产生,收集在 epoll 监控的事件中已经发送的事件,可以从就绪链表中得到事件完成的描述符

参数

  • epfd:  epoll 专用的文件描述符,epoll_create()的返回值
  • events:  分配好的 epoll_event 结构体数组,epoll 将会把发生的事件赋值到events 数组中(events 不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存)
  • maxevents:** maxevents 告之内核这个 events 有多少个
  • timeout:** 超时时间,单位为毫秒,为-1时,函数为阻塞

返回值:

  • 如果成功,表示返回需要处理的事件数目
  • 如果返回0,表示已超时
  • 如果返回-1,表示失败

2.3.4 epoll的优点

  • 没有最大并发连接的限制:能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
  • 效率提升:不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
  • 内存拷贝:利用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,在用户空间和内核空间的copy只需一次。mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销,

3 Reactor模式

3.1 传统的Socket服务设计

通常处理一个网络请求有如下几个步骤:

  • read:从socket读取数据
  • decode:解码,网络上的数据以byte的传输的
  • compute:计算
  • encode:编码
  • send: 发送数据

为了方便, decode-> compute->encode 统称为业务处理

下图是传统处理请求的模式:为每一个请求分配一个线程

image.png BIO代码

  public static void main(String[] args) {
    try {
        ServerSocket serverSocket = new ServerSocket(9696);
        Socket socket = serverSocket.accept();
        new Thread(() -> {
            try {
                byte[] byteRead = new byte[1024];
                socket.getInputStream().read(byteRead);

                String req = new String(byteRead, StandardCharsets.UTF_8);//encode
                // do something

                byte[] byteWrite = "Hello".getBytes(StandardCharsets.UTF_8);//decode
                socket.getOutputStream().write(byteWrite);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }).start();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

缺点

  • 可能存在大量线程处于休眠状态,只是在等待输入或者输出数据就绪
  • 对于每一个线程需要分配栈内存,存在大量线程会造成内存占有过多
  • 线程个数多时,上下文切换带来的开销也比较大

3.1 Reactor模式

Reactor模式的核心便是事件驱动,通过I/O多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个线程(处理资源池)。

The reactor design pattern is an event handling pattern for handling service requests delivered concurrently to a service handler by one or more inputs. The service handler then demultiplexes the incoming requests and dispatches them synchronously to the associated request handlers

Reactor模式主要由ReactorHandler(处理资源池)这两个核心部分组成

  • Reactor:主要负责事件(连接、IO)监听和分发(到处理资源池)、IO事件读写
  • Handler(处理资源池):非阻塞的进行业务处理( decode -> compute -> encode);

好处

  • 使用较少的线程就可以处理很多连接,减少了内存管理和线程上下文切换
  • 当没有I/O操作需要处理时,cpu可以执行其他线程

网络模型演化过程中,将建立连接、IO等待/读写以及事件转发等操作分阶段处理,然后可以对不同阶段采用相应的优化策略来提高性能。常见的Reactor分为三种:
单线程模型
多线程模型: 使用线程池来来提升业务处理的效率
主从多线程模型:将建立连接 和 IO事件监听/读写以及事件分发 两部分用不同的线程处理,特别的,IO事件监听/读写以及事件分发可以用线程池处理。

3.2 单线程模型

image.png

主要对象

  • Reactor对象的作用是监听和分发事件;
  • Acceptor对象的作用是获取连接;
  • Handler对象的作用是处理业务;

处理过程

  • Reactor对象通过 select (IO多路复用) 监听事件,收到事件后,根据事件类型通过dispatch进行分发;
  • 如果是连接建立的事件,则交由 Acceptor对象进行处理,Acceptor对象通过 accept方法获取连接,并创建一个Handler对象来处理后续的响应事件;
  • 如果不是连接建立事件, 则交由对应的Handler对象来进行响应;
  • Handler对象通过调用read -> 业务处理 -> send的流程来完成完整的业务流程。

缺点

  • 只有一个线程,无法充分利用多核CPU的性能
  • Handler对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,造成响应延迟

适用场景

不适用计算机密集型的场景,只适用于业务处理非常快速的场景。
Redis采用的正是单Reactor单进程的方案,因为Redis主要在内存中操作,速度是很快,性能瓶颈不在CPU上。

在Java NIO中流程图

image.png

3.3 多线程模型

image.png

处理过程及演进

Reactor多线程模型将业务处理交给多个线程进行处理。除此之外,多线程模型其他的操作与单线程模型是类似的,比如连接建立、IO事件读写以及事件分发等都是由一个线程来完成。

  • Handler对象不再负责业务处理,只负责数据的接收和发送,Handler对象通过 read 读取到数据后,会将数据放到ThreadPool线程池处理;
  • ThreadPool线程池进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler对象通过send方法将响应结果发送给client;

优点

  • 能够充分利用多核CPU的能力

缺点

  • 需要处理多线程竞争资源的问题(互斥锁)
  • 一个Reactor对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方

在Java NIO中流程图

image.png

3.3 主从Reactor模式

image.png

主从Reactor模式中,分为了mainReactor对象subReactor对象,分别处理 新建立的连接IO读写事件/事件分发

注意,IO事件监听相对新连接处理更加耗时,可以使用使用线程池来处理,因此 subReactor对象可能有多个,即一主多从

  • 主线程中的 mainReactor对象通过Selector对象监听连接建立事件,收到事件后通过 Acceptor对象中的accept获取连接,将新的连接注册到subReactor对象上;
  • subReactor对象将连接加入Selector对象继续进行监听,并创建一个Handler对象用于处理连接的各种事件;
  • 如果有新的事件发生时,subReactor对象会调用对应的 Handler对象来进行响应。
  • Handler对象通过read -> 业务处理 -> send的流程来完成完整的业务流程。

优点

  • 职责明确:mainReactor对象只负责接收新连接,subReactor对象负责完成后续的业务处理。
  • 交互很简单:mainReactor对象只需要把新连接传给subReactor对象subReactor对象无须返回数据,直接就可以在子线程里将处理结果发送给客户端。

在Java NIO中的体现 image.png