漫谈五种IO模型

76 阅读11分钟

TCP 发送数据的流程

要深入的理解各种 IO 模型,那么必须先了解下产生各种 IO 的原因是什么,要知道这其中的本质问题那么我们就必须要知一条消息是如何从过一个人发送到另外一个人的;

以两个应用程序通讯为例,我们来了解一下当“A”向"B" 发送一条消息,简单来说会经过如下流程:

第一步:应用 A 把消息发送到 TCP 发送缓冲区。

第二步:TCP 发送缓冲区再把消息发送出去,经过网络传递后,消息会发送到 B 服务器的 TCP 接收缓冲区。

第三步:B 再从 TCP 接收缓冲区去读取属于自己的数据。

高性能 IO 模型浅析

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

(1)同步阻塞IO(Blocking IO):即传统的IO模型。

(2)同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为 NONBLOCK。

(3)IO多路复用(IO Multiplexing):即经典的 Reactor 设计模式,有时也称为异步阻塞 IO,Java中的 Selector 和 Linux 中的 epoll 都是这种模型。

(4)异步IO(Asynchronous IO):即经典的 Proactor 设计模式,也称为异步非阻塞IO。

IO 模型举例理解

  1. 阻塞IO, 给女神发一条短信, 说我来找你了, 然后就默默的一直等着女神下楼, 这个期间除了等待你不会做其他事情, 属于备胎做法.
  2. 非阻塞IO, 给女神发短信, 如果不回, 接着再发, 一直发到女神下楼, 这个期间你除了发短信等待不会做其他事情, 属于专一做法.
  3. IO多路复用, 是找一个宿管大妈来帮你监视下楼的女生, 这个期间你可以些其他的事情. 例如可以顺便看看其他妹子,玩玩王者荣耀, 上个厕所等等. IO复用又包括 select, poll, epoll 模式. 那么它们的区别是什么? 3.1 select大妈 每一个女生下楼, select大妈都不知道这个是不是你的女神, 她需要一个一个询问, 并且select大妈能力还有限, 最多一次帮你监视1024个妹子 3.2 poll大妈不限制盯着女生的数量, 只要是经过宿舍楼门口的女生, 都会帮你去问是不是你女神 3.3 epoll大妈不限制盯着女生的数量, 并且也不需要一个一个去问. 那么如何做呢? epoll大妈会为每个进宿舍楼的女生脸上贴上一个大字条,上面写上女生自己的名字, 只要女生下楼了, epoll大妈就知道这个是不是你女神了, 然后大妈再通知你.

上面这些同步IO有一个共同点就是, 当女神走出宿舍门口的时候, 你已经站在宿舍门口等着女神的, 此时你属于同步等待状态

  1. 异步IO的情况。你告诉女神我来了, 然后你就去王者荣耀了, 一直到女神下楼了, 发现找不见你了,女神再给你打电话通知你, 说我下楼了, 你在哪呢? 这时候你才来到宿舍门口. 此时属于逆袭做法。

同步阻塞 IO

同步阻塞IO模型是最简单的IO模型,用户线程在内核进行IO操作时被阻塞。 image.png 如图所示,用户线程通过系统调用 read 发起 IO 读操作,由用户空间转到内核空间。内核等到数据包到达后,然后将接收的数据拷贝到用户空间,完成 read 操作。

用户线程使用同步阻塞IO模型的伪代码描述为:

{
    read(socket, buffer);
    process(buffer);
}

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

同步非阻塞 IO

同步非阻塞 IO 是在同步阻塞 IO 的基础上,将 socket 设置为 NONBLOCK。这样做用户线程可以在发起 IO 请求后可以立即返回。 image.png 如图所示,由于 socket 是非阻塞的方式,因此用户线程发起 IO 请求时立即返回。但并未读取到任何数据,用户线程需要不断地发起 IO 请求,直到数据到达后,才真正读取到数据,继续执行。

用户线程使用同步非阻塞IO模型的伪代码描述为:

{
    while(read(socket, buffer) != SUCCESS);
    process(buffer);
}

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

IO 多路复用

多路分离函数 select

IO 多路复用模型是建立在内核提供的多路分离函数 select 基础之上的,使用 select 函数可以避免同步非阻塞 IO 模型中轮询等待的问题。 image.png 如图所示,用户首先将需要进行 IO 操作的 socket 添加到 select 中,然后阻塞等待 select 系统调用返回。当数据到达时,socket 被激活,select 函数返回。用户线程正式发起 read 请求,读取数据并继续执行。

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

用户线程使用 select 函数的伪代码描述为:

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

其中 while 循环前将 socket 添加到 select 监视中,然后在 while 内一直调用 select 获取被激活的 socket,一旦 socket可读,便调用 read 函数将 socket 中的数据读取出来。

然而,使用 select 函数的优点并不仅限于此。虽然上述方式允许单线程内处理多个 IO 请求,但是每个 IO请 求的过程还是阻塞的(在 select 函数上阻塞),平均时间甚至比同步阻塞 IO 模型还要长。如果用户线程只注册自己感兴趣的 socket 或者 IO 请求,然后去做自己的事情,等到数据到来时再进行处理,则可以提高 CPU 的利用率。

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

IO 多路复用

image.png 如图所示,通过 Reactor 的方式,可以将用户线程轮询 IO 操作状态的工作统一交给 handle_events 事件循环进行处理。用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而 Reactor 线程负责调用内核的 select 函数检查 socket 状态。当有 socket 被激活时,则通知相应的用户线程(或执行用户线程的回调函数),执行 handle_event 进行数据读取、处理的工作。由于 select 函数是阻塞的,因此多路 IO 复用模型也被称为异步阻塞 IO 模型。注意,这里的所说的阻塞是指 select 函数执行时线程被阻塞,而不是指 socket。一般在使用 IO 多路复用模型时, socket 都是设置为 NONBLOCK 的,不过这并不会产生影响,因为用户发起 IO 请求时,数据已经到达了,用户线程一定不会被阻塞。

用户线程使用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 模型,但是其异步程度还不够“彻底”,因为它使用了会阻塞线程的 select 系统调用。因此 IO 多路复用只能称为异步阻塞 IO,而非真正的异步 IO。

信号驱动 IO 模型

复用 IO 模型解决了一个线程可以监控多个 fd 的问题,但是 select 是采用轮询的方式来监控多个 fd 的,通过不断的轮询 fd 的可读状态来知道是否就可读的数据,而无脑的轮询就显得有点暴力,因为大部分情况下的轮询都是无效的,所以有人就想,能不能不要我总是去问你是否数据准备就绪,能不能我发出请求后等你数据准备好了就通知我,所以就衍生了信号驱动 IO 模型。

于是信号驱动 IO 不是用循环请求询问的方式去监控数据就绪状态,而是在调用 sigaction 时候建立一个 SIGIO 的信号联系,当内核数据准备好之后再通过 SIGIO 信号通知线程数据准备好后的可读状态,当线程收到可读状态的信号后,此时再向内核发起 recvfrom 读取数据的请求,因为信号驱动 IO的 模型下应用线程在发出信号监控后即可返回,不会阻塞,所以这样的方式下,一个应用线程也可以同时监控多个 fd。

IO 复用模型里面的 select 虽然可以监控多个 fd 了,但 select 其实现的本质上还是通过不断的轮询 fd 来监控数据状态, 因为大部分轮询请求其实都是无效的,所以信号驱动 IO 意在通过这种建立信号关联的方式,实现了发出请求后只需要等待数据就绪的通知即可,这样就可以避免大量无效的数据状态轮询操作。

异步IO

“真正”的异步 IO 需要操作系统更强的支持。在 IO 多路复用模型中,事件循环将文件句柄的状态事件通知给用户线程,由用户线程自行读取数据、处理数据。而在异步 IO 模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在 IO 完成后通知用户线程直接使用即可。异步 IO 模型使用了 Proactor 设计模式实现了这一机制。 image.png 如图所示,异步 IO 模型中,用户线程直接使用内核提供的异步 IO API 发起 read 请求,且发起后立即返回,继续执行用户线程代码。不过此时用户线程已经将调用的 AsynchronousOperation 和 CompletionHandler 注册到内核,然后操作系统开启独立的内核线程去处理 IO 操作。当 read 请求的数据到达时,由内核负责读取 socket 中的数据,并写入用户指定的缓冲区中。最后内核将 read 的数据和用户线程注册的 CompletionHandler 分发给内部 Proactor, Proactor 将 IO 完成的信息通知给用户线程(一般通过调用用户线程注册的完成事件处理函数),完成异步 IO。

用户线程使用异步IO模型的伪代码描述为:

void UserCompletionHandler::handle_event(buffer) {
    process(buffer);
}

{
    aio_read(socket, new UserCompletionHandler);
}

用户需要重写 CompletionHandler 的 handle_event 函数进行处理数据的工作,参数 buffer 表示 Proactor 已经准备好的数据,用户线程直接调用内核提供的异步 IO API,并将重写的 CompletionHandler 注册即可。

相比于 IO 多路复用模型,异步 IO 并不十分常用,不少高性能并发服务程序使用 IO 多路复用模型+多线程任务处理的架构基本可以满足需求。况且目前操作系统对异步 IO 的支持并非特别完善,更多的是采用 IO 多路复用模型模拟异步 IO 的方式( IO 事件触发时不直接通知用户线程,而是将数据读写完毕后放到用户指定的缓冲区中)。