IO 模型之 BIO,NIO 与 AIO

什么是 IO

我们知道在 Unix 系统里面,一切都是文件。文件也就是一串二进制流而已。不管是 Socket,管道,终端,对我们来说,都是文件,都是流。在信息交换的过程中,我们对这些流进行数据的收发操作,简称 I/O 操作(input 和 output)

计算器里面有这么多的流,我们怎么知道要操作哪个流那?就是通过文件描述符,即通常所说的 fd,一个 fd 就是一个整数,所以对于这个整数的操作就是对这个文件(流)的操作。

我们创建一个 socket,通过系统调用会返回一个文件描述符,那么剩下对 socket 的操作就会转化为对这个描述符的操作。

用户空间与内核空间

现在的操作系统都是采用虚拟存储器,所以对于 32 位的系统而言,它的寻址空间(虚拟存储空间)位 4G (2 的 32 次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。

为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。

对 Linux 系统而言,将高 1G 的字节供内核使用,称为内核空间,而将较低的 3G 字节,供各个进程使用,称为用户空间。

同步与异步

同步与异步是针对应用程序与内核的交互而言的。

同步过程中进程触发 IO 操作并等待(阻塞)或者轮询的去查看 IO 操作(非阻塞)是否完成。

异步过程中触发 IO 操作以后,直接返回,做自己的事情,IO 交给内核来处理,完成后内核通知进程 IO 完成。

所以同步有阻塞和非阻塞之分,异步则一定是非阻塞的。

阻塞,非阻塞,多路 IO 复用,都是同步 IO。只有用户线程在操作 IO 的时候根本不去考虑 IO 的执行全部交给 CPU 去完成,而自己只等待一个完成信号的通知,这才是真正的异步 IO。拉一个子线程去轮询,去死循环或者使用 select,poll,epoll 都不是异步。

阻塞与非阻塞

阻塞:进程给 CPU 一个任务之后,一直等待 CPU 处理完成,然后才执行后面的操作。

非阻塞:进程给 CPU 一个任务之后,继续处理后续的操作,隔一段时间再来询问之前的操作是否完成,这个过程其实也叫轮询。

因为一个线程只能处理一个套接字的 I/O 事件,如果想同时处理多个,可以利用非阻塞忙轮询的方式,伪代码如下:

while true  
{  
    for i in stream[]  
    {  
        if i has data  
        read until unavailable  
    }  
}

通过上面的代码,我们只需要把所有流从头到尾读一遍,就可以处理多个流了。但是这样做有一个问题,因为如果所有流都没有 I/O 事件,拜拜浪费 CPU 时间片。

为了避免这里 CPU 空转,我们不让这个线程亲自去检查流中是否有事件,而是引进了一个代理(一开始是 select,后来是 poll),这个代理很厉害,可以同时观察许多流的 I/O 事件。如果没有事件就阻塞,线程就不会挨个去轮询了。

while true  
{  
    select(streams[]) //这一步死在这里,直到有一个流有I/O事件时,才往下执行  
    for i in streams[]  
    {  
        if i has data  
        read until unavailable  
    }  
}

这里还是有问题,我们从 select 那里仅仅是知道了有 I/O 事件发生,却不知道是哪个流,我们还是需要无差别的轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以 select 具有 O(n) 的无差别轮询复杂度。流越多,无差别轮询的事件就越长。

epoll 可以理解为 event poll,不同于忙轮询和无差别轮询,epoll 会把哪个流发生了怎样的 I/O 事件通知我们。所以说 epoll 实际是事件驱动(每个事件都关联上 fd),此时我们对这些流的操作都是由意义的。复杂度降到了 O(1)。

while true  
{  
    active_stream[] = epoll_wait(epollfd)  
    for i in active_stream[]  
    {  
        read or write till  
    }  
}

所以 select 和 epoll 的最大的区别是,select 只是告诉你一定数目的流有事件了,至于是哪个流有事件,还得一个一个得去轮询。而 epoll 会把发生得事件告诉你,通过发生得事件,就能定位到具体得流了。性能好了不是一点半点。

正在执行的进程,由于期待的某些事件未发生,比如请求系统资源失败,等待某种操作的完成,新数据尚未到达或无新工作等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行动态的进程(获得 CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用 CPU 资源的。

I/O 模型

输入操作一般会包含两个步骤:

  • 等待数据准备好,对于一个套接字上得操作,这一步骤关系到数据从网络到达,并将其复制到内核某个缓冲区。
  • 将数据从内核缓冲区复制到进程缓冲区。

阻塞 I/O 模型

最广泛的模型是阻塞 I/O 模型,默认情况下,所有的套接口是阻塞的。进程调用 recvfrom 系统调用,整个过程是阻塞的,直到数据复制到进程缓冲区时才返回(当然,系统调用被中断时也会返回)。

img

非阻塞I/O模型(NIO)

当我们把一个套接口设置为非阻塞时,就是在告诉内核,当请求的 I/O 操作无法完成时,不要将进程休眠,而是返回一个错误。当数据没有准备好时,内核立即返回 EWOULDBLOCK 错误。第四次调用 recvfrom 时,数据已经存在,这时将数据复制到进程缓冲区中。这其中有一个操作时轮询(polling)。

img

I/O复用模型

此模型用到 select 和 poll 函数,这两个函数也会使进程阻塞。但是与阻塞 I/O 不同的是,这两个函数可以同时阻塞多个 I/O 操作,而且可以同时多个读操作,多个写操作的 I/O 函数进行检测,直到有数据可读或可写。

select 被调用后,进程会被阻塞,内核监视所有 select 负责的 socket,当有任何一个 socket 的数据准备好了,select 就会返回套接字可读,我们就可以调用 recvfrom 处理数据。

阻塞 I/O 只能阻塞一个 I/O 操作,而 I/O 复用模型能够阻塞多个 I/O 操作,所以才叫多路复用。

img

异步I/O模型(AIO)

进程发起 read 操作之后,立刻就可以开始去做其他的事情。内核收到了一个 asynchronous read 之后,首先会立即返回,所以不会 block 任何用户进程。Kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切完成后,kernel 会给用户发送一个 singal,告诉它 read 操作完成了。

img

服务端编程经常需要构造高性能的 IO 模型,常见的 IO 模型有四种:

  • 同步阻塞 IO(Blocking IO):即传统的 IO 模型。
  • 异步非阻塞 IO(Non-blocking IO):默认创建的 socket 都是阻塞的,非阻塞 IO 要求 socket 被设置为 NONBLOCK。这里的 NIO 并未 Java 的 NIO(new IO)库。
  • IO 多路复用(IO Multiplexing):即经典的 Reactor 设计模式,Java 中的 Selector 和 Linux 中的 epoll 都是这种模型。
  • 异步 IO(Asynchronous IO):即经典的 Proactor 设计模式,也被称为异步非阻塞 IO。

同步阻塞 IO

同步阻塞 IO 模型是最简单的 IO 模型,用户线程在内核进行 IO 操作时被阻塞。

img

  1. 用户线程通过系统调用 read 发起 IO 读操作。由用户空间转到内核空间。
  2. 内核等数据包到达后,将接收到的数据拷贝到用户空间
{

read(socket, buffer); //一直阻塞等待
process(buffer);

}

用户需要等待 read 将 socket 中的数据读取到 buffer,才能继续处理接收的数据。整个 IO 请求的过程中,用户线程是被阻塞的,导致用户在发起 IO 请求时不能做任何事情,对 CPU 的资源利用率不够。

同步非阻塞 IO

同步非阻塞 IO 是在同步阻塞 IO 的基础上,将 socket 设置为 NONBLOCK。这样做用户线程可以在发起 IO 请求后立即返回。

img

  1. 由于 socket 是非阻塞的方式,因此用户线程发起 IO 请求时立即返回。
  2. 但并未读取到任何数据,用户线程需要不断地发起 IO 请求,直到数据到达后,才读取到真正的数据。
{

while(read(socket, buffer) != SUCCESS); //不断请求

process(buffer);

}
  1. 用户需要不断地调用 read,尝试读取 socket 中的数据,直到读取成功后,才继续处理接收的数据。
  2. 虽然可以立即返回,但是为了等到数据,仍需要不断地轮询,重复请求,消耗了大量的 CPU 资源。一般很少直接使用这种模型,而是在其他 IO 模型中使用非阻塞 IO 这一特性。

IO 多路复用

IO 多路复用模型是建立在内核提供的多路分离函数 select 基础之上的,使用 select 函数可以避免同步非阻塞 IO 模型中轮询等待的问题。

img

  1. 用户首先将需要进行 IO 操作的 socket 添加到 select 中,然后阻塞等待 select 系统调用返回。
  2. 当数据到达时,socket 被激活,select 函数返回。
  3. 用户线程正式发起 read 请求,读取数据并继续执行。

从流程上来看,使用 select 进行 IO 请求和同步阻塞模型并没有太大区别,甚至还加了监视 socket 的操作,效率好像更差。但是使用 select 后最大的优势是用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。在同步阻塞模型中,必须通过多线程才能达到这个目的。

{
    select(socket);
    while(1) {
        sockets = select();
        for(socket in sockets) {
        if(can_read(socket)) {
            read(socket, buffer);
            process(buffer);
        }
    }
    }
}

使用 select 函数的有点不仅限于此。虽然上述方式允许多线程内处理多个 IO 请求,但是每个 IO 请求的过程还是阻塞的(在 select 上阻塞),平局实现比同步阻塞 IO 模型还要长。如果用户线程只注册自己感兴趣的 socket 或者 IO 请求,然后去做自己的事情,这样可以提高 CPU 的利用率。

IO 多路复用模型使用了 Reactor 设计模式实现了这一个机制:

img

EventHandler 抽象类表示 IO 事件处理器,他拥有 IO 文件句柄 handle(通过 get_handle 获取),以及对 handle 的操作 handle_event(读/写等)。继承于 EventHandler 的子类可以对事件处理器的行为进行定制。Reactor 用于管理 EventHandler(注册,删除等),并使用 handle_events 实现事件循环,不断调用同步事件多路分离器的多路分离函数 select,只要某个文件句柄被激活(可读/写等),select 就返回,handle_events 就会调用与文件句柄相关联的事件处理器 handle_event 进行相关操作。

img

通过 Reactor 的方式,可以将用户线程轮询 IO 操作状态的工作统一交给 handle_events 事件循环进行处理。用户线程注册事件处理器之后可以继续执行其他的工作,而 Reactor 线程负责调用内核的 select 函数检查 socket 的状态。当有 socket 被激活时,则通知相应的用户线程(或执行用户线程的回调函数),执行 handle_event 进行数据读取,处理的工作。由于 select 函数是阻塞的,因此多路 IO 复用模型也被称为一步阻塞 IO 模型。这里说的阻塞指的是 select 函数执行的线程被阻塞,而不是 socket。

IO 多路复用模型的伪代码描述为:

void UserEventHandler::handle_event() {
    if(can_read(socket)) {
        read(socket, buffer);
        process(buffer);
    }
}
{
    Reactor.register(new UserEventHandler(socket));
}

用户需要重写 EventHandler 的 handle_event 函数进行读取数据,处理数据的工作,用户线程只需要将自己的 EventHandler 注册到 Reactor 即可。

Reactor 中的 handle_events 事件循环的伪代码如下:

Reactor::handle_events() {
        while(1) {
                sockets = select();
                for(socket in sockets) {
                        get_event_handler(socket).handle_event();
                }
        }
}

事件循环不断地调用 select 获取被激活的 socket,然后根据 socket 对应的 EventHandler,执行 handle_event 函数即可。

异步IO

真正的异步 IO 需要操作系统更强的支持。在 IO 多路复用模型中,事件循环将文件句柄的状态事件通知给用户线程,用户自行读取数据,处理数据。而在异步 IO 模型中,当用户线程收到通知时,数据已经被内核读取完毕,并且放到了用户线程指定的缓冲区内,内核在 IO 完成后通知用户线程直接使用即可。

异步 IO 模型使用了 Proactor 设计模式实现了这一机制。

img

Proactor 模式和 Reactor 模式在结构上比较相似,不过在用户使用方式上差别较大。Reactor 模式中,用户线程通过向 Reactor 对象注册感兴趣的事件监听,然后触发时调用事件处理函数。而 Proactor 模式中,用户线程将 AsynchronousOperation,Proactor 以及操作完成时的 CompeletionHandler 注册到 AsynchronousOperationProcessor。AsynchronousOperationProcessor 使用 Facade 模式提供了一组异步操作 API 供用户使用,当用户线程调用异步 API 后,便继续执行自己的任务。

Proactor 负责回调每一个异步操作的事件完成处理函数 handle_event,虽然 Proactor 模式中每个异步操作都可以绑定一个 Proactor 对象,但是一般在操作系统中,Proactor 被实现为 Singleton 模式,以便集中化分发操作完成事件。

img

  1. 用户线程直接使用内核提供的异步 IO API 发起 read 请求,且发起后立即返回。
  2. 用户线程已经将调用的 AsynchronousOperation 和 CompletionHandler 注册到了内核,然后操作系统开启独立的内核线程去处理 IO 操作。
  3. 当 read 请求的数据到达时,由内核负责读取 socket 中的数据,并写入用户指定的缓冲区。
  4. 内核将 read 的数据和用户线程注册的 CompletionHandler 分发给内部 Proactor,Proactor 将 IO 完成的信息通知给用户线程,完成异步 IO。
void UserCompletionHandler::handle_event(buffer) {
    process(buffer);
}

{
    aio_read(socket, new UserCompletionHandler);
}

异步 IO 并不常用,不少高性能并发服务程序使用 IO 多路复用模型 + 多线程任务处理的架构基本可以满足需求。Java 7 之后已经支持了异步 IO。

猜你喜欢

转载自www.cnblogs.com/paulwang92115/p/12186174.html