谈谈对不同I/O模型的理解 (阻塞/非阻塞IO,同步/异步IO)

一、关于I/O模型的问题

  最近通过对ucore操作系统的学习,让我打开了操作系统内核这一黑盒子,与之前所学知识结合起来,解答了长久以来困扰我的关于I/O的一些问题。

  1. 为什么redis能以单工作线程处理高达几万的并发请求?

  2. 什么是I/O多路复用?为什么redis、nginx、nodeJS以及netty等以高性能著称的服务器其底层都利用了I/O多路复用技术?

  3. 非阻塞I/O为什么会流行起来,在许多场景下取代了传统的阻塞I/O?

  4. 非阻塞I/O真的是银弹吗?为什么即使在为海量用户提供服务的,追求高性能的互联网公司中依然有那么多的服务器在传统的阻塞IO模型下工作?

  5. 什么是协程?为什么Go语言这么受欢迎?

  在这篇博客中,将介绍不同层面、不同I/O模型的原理,并尝试着给出我对上述问题的回答。如果你也或多或少的对上述问题感到疑惑,希望这篇博客能为你提供帮助。

  I/O模型和硬件、操作系统内核息息相关,博客中会涉及到诸如保护模式、中断、特权级、进程/线程、上下文切换、系统调用等关于操作系统、硬件相关的概念。由于计算机中的知识是按照层次组织起来的,如果对这些相对底层的概念不是很了解的话可能会影响对整体内容的理解。可以参考一下我关于操作系统、硬件学习相关的博客:x86汇编学习操作系统学习(持续更新中)

二、硬件I/O模型

  软件的功能总是构建在硬件上的,计算机中的I/O本质上是CPU/内存与外设(网卡、磁盘等)进行数据的单向或双向传输。

  从外设读入数据到CPU/内存称作Input输入,从CPU/内存中写出数据到外设称作Output输出。

  要想理解软件层次上的不同I/O模型,必须先对其基于的硬件I/O模型有一个基本的认识。硬件I/O模型大致可以分为三种:程序控制I/O、中断驱动I/O、使用DMA的I/O

程序控制I/O:

  程序控制I/O模型中,通过指令控制CPU不断的轮询外设是否就绪,当硬件就绪时一点一点的反复读/写数据。

  从CPU的角度来说,程序控制I/O模型是同步、阻塞的(同步指的是I/O操作依然是处于程序指令控制,由CPU主导的;阻塞指的是在发起I/O后CPU必须持续轮询完成状态,无法执行别的指令)。

程序控制I/O的优点:

  硬件结构简单,编写对应程序也简单。

程序控制I/O的缺点:

  十分消耗CPU,持续的轮训令宝贵的CPU资源无谓的浪费在了等待I/O完成的过程中,导致CPU利用率不高。

中断驱动I/O:

  为了解决上述程序控制I/O模型对CPU资源利用率不高的问题,计算机硬件的设计者令CPU拥有了处理中断的功能。

  在中断驱动I/O模型中,CPU发起对外设的I/O请求后,就直接去执行别的指令了。当硬件处理完I/O请求后,通过中断异步的通知CPU。接到读取完成中断通知后,CPU负责将数据从外设缓冲区中写入内存;接到写出完成中断通知后,CPU需要将内存中后续的数据接着写出交给外设处理。

  从CPU的角度来说,中断驱动I/O模型是同步、非阻塞的(同步指的是I/O操作依然是处于程序指令控制,由CPU主导的;非阻塞指的是在发起I/O后CPU不会停下等待,而是可以执行别的指令)。

中断驱动I/O的优点:

  由于I/O总是相对耗时的,比起通过程序控制I/O模型下CPU不停的轮训。在等待硬件I/O完成的过程中CPU可以解放出来执行另外的命令,大大提高了I/O密集程序的CPU利用率。

中断驱动I/O的缺点:

  受制于硬件缓冲区的大小,一次硬件I/O可以处理的数据是相对有限的。在处理一次大数据的I/O请求中,CPU需要被反复的中断,而处理读写中断事件本身也是有一定开销的。

使用DMA的I/O:

  为了解决中断驱动I/O模型中,大数据量的I/O传输使得CPU需要反复处理中断的缺陷,计算机硬件的设计者提出了基于DMA模式的I/O(DMA Direct Memory Access 直接存储器访问)。DMA也是一种处理器芯片,和CPU一样也可以访问内存和外设,但DMA芯片是被设计来专门处理I/O数据传输的,因此其成本相对CPU较低。

  在使用DMA的I/O模型中,CPU与DMA芯片交互,指定需要读/写的数据块大小和需要进行I/O数据的目的内存地址后,便异步的处理别的指令了。由DMA与外设硬件进行交互,一次大数据量的I/O需要DMA反复的与外设进行交互,当DMA完成了整体数据块的I/O后(完整的将数据读入到内存或是完整的将某一内存块的数据写出到外设),再发起DMA中断通知CPU。

  从CPU的角度来说,使用DMA的I/O模型是异步、非阻塞的(异步指的是整个I/O操作并不是由CPU主导,而是由DMA芯片与外设交互完成的;非阻塞指的是在发起I/O后CPU不会停下等待,而是可以执行别的指令)。

使用DMA的I/O优点:

  比起外设硬件中断通知,对于一次完整的大数据内存与外设间的I/O,CPU只需要处理一次中断。CPU的利用效率相对来说是最高的。

使用DMA的I/O缺点:

  1. 引入DMA芯片令硬件结构变复杂,成本较高。

  2. 由于DMA芯片的引入,使得DMA和CPU并发的对内存进行操作,在拥有高速缓存的CPU中,引入了高速缓存与内存不一致的问题

  总的来说,自DMA技术被发明以来,由于其极大减少了CPU在I/O时的性能损耗,已经成为了绝大多数通用计算机的硬件标配。随着技术的发展又出现了更先进的通道I/O方式,相当于并发的DMA,允许并发的处理涉及多个不同内存区域、外设硬件的I/O操作。

三、操作系统I/O模型

  介绍完硬件的I/O模型后,下面介绍这篇博客的重点:操作系统I/O模型。

  操作系统帮我们屏蔽了诸多硬件外设的差异,为应用程序的开发者提供了友好、统一的服务。为了避免应用程序破坏操作系统内核,CPU提供了保护模式机制,使得应用程序无法直接访问被操作系统管理起来的外设,而必须通过内核提供的系统调用间接的访问外设。关于操作系统I/O模型的讨论针对的就是应用程序与内核之间进行I/O交互的系统调用模型。

'  操作系统内核提供的I/O模型大致可以分为几种:同步阻塞I/O、同步非阻塞I/O、同步I/O多路复用、异步非阻塞I/O(信号驱动I/O用的比较少,就不在这里展开了)。

同步阻塞I/O(Blocking I/O BIO)

  我们已经知道,高效的硬件层面I/O模型对于CPU来说是异步的,但应用程序开发者总是希望在执行完I/O系统调用后能同步的返回,线性的执行后续逻辑(例如当磁盘读取的系统调用返回后,下一行代码中就能直接访问到所读出的数据)。但这与硬件层面耗时、异步的I/O模型相违背(程序控制I/O过于浪费CPU),因此操作系统内核提供了基于同步、阻塞I/O的系统调用(BIO)来解决这一问题。

  举个例子:当线程通过基于BIO的系统调用进行磁盘读取时,内核会令当前线程进入阻塞态,让出CPU资源给其它并发的就绪态线程,以便更有效率的利用CPU。当DMA完成读取,异步的I/O中断到来时,内核会找到先前被阻塞的对应线程,将其唤醒进入就绪态。当这个就绪态的线程被内核CPU调度器选中再度获得CPU时,便能从对应的缓冲区结构中得到读取到的磁盘数据,程序同步的执行流便能顺利的向下执行了。(感觉好像线程卡在了那里不动,过了一会才执行下一行,且指定的缓冲区中已经有了所需的数据)

  下面的伪代码示例中参考linux的设计,将不同的外设统一抽象为文件,通过文件描述符(file descriptor)来统一的访问。

BIO伪代码实例 :

// 创建TCP套接字并绑定端口8888,进行服务监听
listenfd = serverSocket(8888,"tcp");
while(true){
    // accept同步阻塞调用
    newfd = accept(listenfd);

    // read会阻塞,因此使用线程异步处理,避免阻塞accpet(一般使用线程池)
    new thread(()->{
        // 同步阻塞读取数据
        xxx = read(newfd);
        ... dosomething
        // 关闭连接
        close(newfd);
    });
}

BIO模型的优点:

  BIO的I/O模型由于同步、阻塞的特性,屏蔽了底层实质上异步的硬件交互方式,令程序员可以编写出简单易懂的线性程序逻辑。

BIO模型的缺点:

  1. BIO的同步、阻塞特性在简单易用的同时,也存在一些性能上的缺陷。由于BIO在等待I/O完成的时间中,线程虽然被阻塞不消耗CPU,但内核维护一个系统级线程本身也有一定的开销(维护线程控制块、内核线程栈空间等等)。

  2. 不同线程在调度时的上下文切换CPU开销较大,在如今大量用户、高并发的互联网时代越来越成为web服务器性能的瓶颈。线程上下文切换本身需要需要保存、恢复现场,同时还会清空CPU指令流水线,以及令高速缓存大量失效。对于一个web服务器,如果使用BIO模型,服务器将至少需要1:1的维护同等数量的系统级线程(内核线程),由于持续并发的网络数据交互,导致不同线程由于网络I/O的完成事件被内核反复的调度。

  在著名的C10K问题的语境下,一台服务器需要同时维护1W个并发的tcp连接和对等的1W个系统级线程。量变引起质变,1W个系统级线程调度引起的上下文切换和100个系统级线程的调度开销完全不同,其将耗尽CPU资源,令整个系统卡死,崩溃。

BIO交互流程示意图:

  

同步非阻塞I/O(NonBlocking I/O NIO)

  BIO模型简单易用,但其阻塞内核线程的特性使得其已经不适用于需要处理大量(1K以上)并发网络连接场景的web服务器了。为此,操作系统内核提供了非阻塞特性的I/O系统调用,即NIO(NonBlocking-IO)

  针对BIO模型的缺陷,NIO模型的系统调用不会阻塞当前调用线程。但由于I/O本质上的耗时特性,无法立即得到I/O处理的结果,NIO的系统调用在I/O未完成时会返回特定标识,代表对应的I/O事件还未完成。因此需要应用程序按照一定的频率反复调用,以获取最新的IO状态。

NIO伪代码实例 :

// 创建TCP套接字并绑定端口8888,进行服务监听
listenfd = serverSocket(8888,"tcp");
clientFdSet = empty_set();
while(true){ // 开启事件监听循环
    // accept同步非阻塞调用,判断是否接收了新的连接
    newfd = acceptNonBlock(listenfd);

    if(newfd != EMPTY){
        // 如果存在新连接将其加入监听连接集合
        clientFdSet.add(newfd);
    }
    // 申请一个1024字节的缓冲区
    buffer = new buffer(1024);
    for(clientfd in clientFdSet){
        // 非阻塞read读
        num = readNonBlock(clientfd,buffer);
        if(num > 0){
            // 读缓冲区存在数据
            data = buffer;
            ... dosomething
            if(needClose(data)){
                // 关闭连接时,移除当前监听的连接
                clientFdSet.remove(clientfd);
            }
        }
        ... dosomething
        // 清空buffer
        buffer.clear();
    }
}

NIO模型的优点:

  NIO因为其非阻塞的特性,使得一个线程可以处理多个并发的网络I/O连接。在C10K问题的语境下,理论上可以通过一个线程处理这1W个并发连接(对于多核CPU,可以创建多个线程在每个CPU核心中分摊负载,提高性能)。

NIO模型的缺点:

  NIO克服了BIO在高并发条件下的缺陷,但原始的NIO系统调用依然有着一定的性能问题。在上述伪代码示例中,每个文件描述符对应的I/O状态查询,都必须通过一次NIO系统调用才能完成。

  由于操作系统内核利用CPU提供的保护模式机制,使内核运行在高特权级,而令用户程序运行在执行、访问受限的低特权级。这样设计的一个好处就是使得应用程序无法直接的访问硬件,而必须由操作系统提供的系统调用间接的访问硬件(网卡、磁盘甚至电源等)。执行系统调用时,需要令应用线程通过系统调用陷入内核(即提高应用程序的当前特权级CPL,使其能够访问受保护的硬件),并在系统调用返回时恢复为低特权级,这样一个过程在硬件上是通过中断实现的。

  通过中断实现系统调用的效率远低于应用程序本地的函数调用,因此原始的NIO模式下通过系统调用循环访问每个文件描述符I/O就绪状态的方式是低效的。

NIO交互流程示意图:

  

同步I/O多路复用(I/O Multiplexing)

  为了解决上述NIO模型的系统调用中,一次事件循环遍历进行N次系统调用的缺陷。操作系统内核在NIO系统调用的基础上提供了I/O多路复用模型的系统调用。

  I/O多路复用相对于NIO模型的一个优化便是允许在一次I/O状态查询的系统调用中,一次传递复数个文件描述符进行批量的I/O状态查询。在一次事件循环中只需要进行一次I/O多路复用的系统调用就能得到所传递文件描述符集合的I/O状态,减少了原始NIO模型中不必要的系统调用开销。

  多路复用I/O模型大致可以分为三种实现(虽然不同操作系统在最终实现上略有不同,但原理是类似的,示例代码以linux内核举例):select、poll、epoll。

select多路复用器介绍

  select I/O多路复用器允许应用程序传递需要监听事件变化的文件描述符集合,监听其读/写,接受连接等I/O事件的状态。

  select系统调用本身是同步、阻塞的,当所传递的文件描述符集合中都没有就绪的I/O事件时,执行select系统调用的线程将会进入阻塞态,直到至少一个文件描述符对应的I/O事件就绪,则唤醒被select阻塞的线程(可以指定超时时间来强制唤醒并返回)。唤醒后获得CPU的线程在select系统调用返回后可以遍历所传入的文件描述符集合,处理完成了I/O事件的文件描述符。

select伪代码示例:

// 创建TCP套接字并绑定端口8888,进行服务监听
listenfd = serverSocket(8888,"tcp");
fdNum = 1;
clientFdSet = empty_set();
clientFdSet.add(listenfd);
while(true){ // 开启事件监听循环
    // man 2 select(查看linux系统文档)
    // int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
    // 参数nfds:一共需要监听的readfds、writefds、exceptfds中文件描述符个数+1
    // 参数readfds/writefds/exceptfds: 需要监听读、写、异常事件的文件描述符集合
    // 参数timeout:select是同步阻塞的,当timeout时间内都没有任何I/O事件就绪,则调用线程被唤醒并返回(ret=0)
    //         timeout为null代表永久阻塞,直到有I/O事件完成
    // 返回值ret:
    //  1.返回大于0的整数,代表传入的readfds/writefds/exceptfds中共有ret个被激活(需要应用程序自己遍历),
    //    2.返回0,在阻塞超时前没有任何I/O事件就绪
    //    3.返回-1,出现错误

    listenReadFd = clientFdSet;
    // select多路复用,一次传入需要监听事件的全量连接集合(超时时间1s)
    result = select(fdNum+1,listenReadFd,null,null,timeval("1s"));
    if(result > 0){
        // 如果服务器监听连接存在读事件
        if(IN_SET(listenfd,listenReadFd)){
            // 接收并建立连接
            newClientFd = accept(listenfd);
            // 加入客户端连接集合
            clientFdSet.add(newClientFd);
       fdNum++; }
// 遍历整个需要监听的客户端连接集合 for(clientFd : clientFdSet){ // 如果当前客户端连接存在读事件 if(IN_SET(clientFd,listenReadFd)){ // 阻塞读取数据 data = read(clientfd); ... dosomething if(needClose(data)){ // 关闭连接时,移除当前监听的连接 clientFdSet.remove(clientfd);
            fdNum--; } } } } }

select的优点:

  1. select多路复用避免了上述原始NIO模型中无谓的多次查询I/O状态的系统调用,将其聚合成集合,批量的进行监听并返回结果集。

  2. select实现相对简单,windows、linux等主流的操作系统都实现了select系统调用,跨平台的兼容性好。

select的缺点:

  1. 在事件循环中,每次select系统调用都需要从用户态全量的传递所需要监听的文件描述符集合,并且select返回后还需要全量遍历之前传入的文件描述符集合的状态。

  2. 出于性能的考量,内核设置了select所监听文件描述符集合元素的最大数量(一般为1024,可在内核启动时指定),使得单次select所能监听的连接数受到了限制。

  3. 抛开性能的考虑,从接口设计的角度来看,select将系统调用的参数与返回值混合到了一起(返回值覆盖了参数),增加了使用者理解的困难度。

I/O多路复用交互示意图:

  

poll多路复用器介绍

  poll I/O多路复用器在使用上和select大同小异,也是通过传入指定的文件描述符集合以及指定内核监听对应文件描述符上的I/O事件集合,但在实现的细节上基于select做了一定的优化。

  和select一样,poll系统调用在没有任何就绪事件发生时也是同步、阻塞的(可以指定超时时间强制唤醒并返回),当返回后要判断是否有就绪事件时,也一样需要全量的遍历整个返回的文件描述符集合。

poll伪代码示例:

/*
// man 2 poll(查看linux系统文档)
// 和select不同将参数events和返回值revents分开了
struct pollfd {
               int   fd;         // file descriptor 对应的文件描述符 
               short events;     // requested events 需要监听的事件
               short revents;    // returned events 返回时,就绪的事件
           };

// 参数fds,要监听的poolfd数组集合
// 参数nfds,传入fds数组中需要监听的元素个数
// 参数timeout,阻塞的超时时间(传入-1代表永久阻塞,直到I/O事件完成)
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

//events/revents是位图表示的
//revents & POLLIN == 1 存在就绪的读事件
//revents & POLLOUT == 1 存在就绪的写事件
//revents & POLLHUP == 1 存在对端断开连接或是通信完成事件
*/

// 创建TCP套接字并绑定端口8888,进行服务监听
listenfd = serverSocket(8888,"tcp");

MAX_LISTEN_SIZE = 100;
struct pollfd fds[MAX_LISTEN_SIZE];
// 设置服务器监听套接字(监听读事件)
fds[0].fd = listenfd;
fds[0].events = POLLIN;
fds[0].revents = 0;
// 客户端连接数一开始为0
int clientCount = 0;

while(true){
    // poll同步阻塞调用(超时时间-1表示永久阻塞直到存在监听的就绪事件)
    int ret = poll(fds, clientCount + 1, -1);
        
    for (int i = 0; i < clientCount + 1; i++){
        if(fds[i].fd == listenfd && fds[i].revents & POLLIN){
            // 服务器监听套接字读事件就绪,建立新连接
            clientCount++;
            fds[clientCount].fd = conn;
            fds[clientCount].events = POLLIN | POLLRDHUP ;
            fds[clientCount].revents = 0;
        }else if(fds[i].revents & POLLIN){
            // 其他链接可读,进行读取
            read(fds[i].fd);
            ... doSomething
        }else if(fds[i].revents & POLLRDHUP){
            // 监听到客户端连接断开,移除该连接
            fds[i] = fds[clientCount];
            i--;
            clientCount--;
            // 关闭该连接
            close(fd);
        }
    }
}

poll的优点:

  1. poll解决了select系统调用受限于内核配置参数的限制问题,可以同时监听更多文件描述符的I/O状态(但不能超过内核限制当前进程所能拥有的最大文件描述符数目限制)。

  2. 优化了接口设计,将参数与返回值进行了分离。

poll的缺点:

  1. poll优化了select,但在处理大量闲置连接时,即使真正产生I/O就绪事件的活跃文件描述符数量很少,依然免不了线性的遍历整个监听的文件描述符集合。每次调用时,需要全量的将整个感兴趣的文件描述符集合从用户态复制到内核态。

  2. 由于select/poll都需要全量的传递参数以及遍历返回值,因此其时间复杂度为O(n),即处理的开销随着并发连接数n的增加而增加,而无论并发连接本身活跃与否。但一般情况下即使并发连接数很多,大量连接都产生I/O就绪事件的情况并不多,更多的情况是1W的并发连接,可能只有几百个是处于活跃状态的,这种情况下select/poll的性能并不理想,还存在优化的空间。

epoll多路复用器:

  epoll是linux系统中独有的,针对select/poll上述缺点进行改进的高性能I/O多路复用器。

  针对select/poll的第一个缺点:在每次事件循环时都需要从用户态全量传递整个需要监听的文件描述符集合

  epoll在内核中分配内存空间用于缓存被监听的文件描述符集合。通过创建epoll的系统调用(epoll_create),在内核中维护了一个epoll结构,而在应用程序中只需要保留epoll结构的句柄就可对其进行访问(也是一个文件描述符)。可以动态的在epoll结构的内核空间中增加/删除/更新所要监听的文件描述符以及不同的监听事件(epoll_ctl),而不必每次都全量的传递需要监听的文件描述符集合。

  针对select/poll的第二个缺点:在系统调用返回后通过修改所监听文件描述符结构的状态,来标识文件描述符对应的I/O事件是否就绪。每次系统调用返回时,都需要全量的遍历整个监听文件描述符集合,而无论是否真的完成了I/O。

  epoll监听事件的系统调用完成后,只会将真正活跃的、完成了I/O事件的文件描述符返回,避免了全量的遍历。在并发的连接数很大,但闲置连接占比很高时,epoll的性能大大优于select/poll这两种I/O多路复用器。epoll的时间复杂度为O(m),即处理的开销不随着并发连接n的增加而增加,而是仅仅和监控的活跃连接m相关;在某些情况下n远大于m,epoll的时间复杂度甚至可以认为近似的达到了O(1)。

  通过epoll_wait系统调用,监听参数中传入对应epoll结构中关联的所有文件描述符的对应I/O状态。epoll_wait本身是同步、阻塞的(可以指定超时时间强制唤醒并返回),当epoll_wait同步返回时,会返回处于活跃状态的完成I/O事件的文件描述符集合,避免了select/poll中的无效遍历。同时epoll使用了mmap机制,将内核中的维护的就绪文件描述符集合所在空间映射到了用户态,令应用程序与内核共享epoll结构对应区域的内存,避免了epoll返回就绪文件描述符集合时的一次内存复制。

epoll伪代码示例:

/**
    epoll比较复杂,使用时大致依赖三个系统调用 (man 7 epoll)
    1. epoll_create 创建一个epoll结构,返回对应epoll的文件描述符 (man 2 epoll_create)
        int epoll_create();
    2. epoll_ctl 控制某一epoll结构(epfd),向其增加/删除/更新(op)某一其它连接(fd),监控其I/O事件(event) (man 2 epoll_ctl)
        op有三种合法值:EPOLL_CTL_ADD代表新增、EPOLL_CTL_MOD代表更新、EPOLL_CTL_DEL代表删除
        int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    3. epoll_wait 令某一epoll同步阻塞的开始监听(epfd),感兴趣的I/O事件(events),所监听fd的最大个数(maxevents),指定阻塞超时时间(timeout) (man 2 epoll_wait)
        int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
*/

// 创建TCP套接字并绑定端口8888,进行服务监听
listenfd = serverSocket(8888,"tcp");
// 创建一个epoll结构
epollfd = epoll_create();

ev = new epoll_event();
ev.events = EPOLLIN; // 读事件
ev.data.fd = listenfd;
// 通过epoll监听服务器端口读事件(新连接建立请求)
epoll_ctl(epollfd,EPOLL_CTL_ADD,listenfd,ev);

// 最大监听1000个连接
MAX_EVENTS = 1000;
listenEvents = new event[MAX_EVENTS];
while(true){
    // 同步阻塞监听事件
    // 最多返回MAX_EVENTS个事件响应结果
    // (超时时间1000ms,标识在超时时间内没有任何事件就绪则当前线程被唤醒,返回值nfd将为0)
    nfds = epoll_wait(epollfd, listenEvents, MAX_EVENTS, 1 * 1000);
        
    for(n = 0; n < nfds; ++n){
        if(events[n].data.fd == listenfd){
            // 当发现服务器监听套接字存在可读事件,建立新的套接字连接
            clientfd = accept(listenfd);

            ev.events = EPOLLIN | EPOLLET;
            ev.data.fd = clientfd;
            // 新建立的套接字连接也加入当前epoll的监听(监听读(EPOLLIN)/写(EPOLLET)事件)
            epoll_ctl(epollfd,EPOLL_CTL_ADD,clientfd,ev);
        } else{
            // 否则是其它连接的I/O事件就绪,进行对应的操作
            ... do_something
        }
    }
}

epoll的优点:

  epoll是目前性能最好的I/O多路复用器之一,具有I/O多路复用优点的情况下很好的解决了select/poll的缺陷。目前linux平台中,像nginx、redis、netty等高性能服务器都是首选epoll作为基础来实现网络I/O功能的。

epoll的缺点:

  1. 常规情况下闲置连接占比很大,epoll的性能表现的很好。但是也有少部分场景中,绝大多数连接都是活跃的,那么其性能与select/poll这种基于位图、数组等简单结构的I/O多路复用器相比,就不那么有优势了。因为select/poll被诟病的一点就是通常情况下进行了无谓的全量检查,而当活跃连接数占比一直超过90%甚至更高时,就不再是浪费了;相反的,由于epoll内部结构比较复杂,在这种情况下其性能比select/poll还要低一点。

  2. epoll是linux操作系统下独有的,使得基于epoll实现的应用程序的跨平台兼容性受到了一定影响。

异步非阻塞I/O(Asynchronous I/O AIO)

  windows和linux都支持了select系统调用,但linux内核在之后又实现了epoll这一更高性能的I/O多路复用器来改进select。

  windows没有模仿linux,而是提供了被称为IOCP(Input/Output Completion Port 输入输出完成端口)的功能解决select性能的问题。IOCP采用异步非阻塞IO(AIO)的模型,其与epoll同步非阻塞IO的最大区别在于,epoll调用完成后,仅仅返回了就绪的文件描述符集合;而IOCP则在内核中自动的完成了epoll中原本应该由应用程序主动发起的I/O操作。

  举个例子,当监听到就绪事件开始读取某一网络连接的请求报文时,epoll依然需要通过程序主动的发起读取请求,将数据从内核中读入用户空间。而windows下的IOCP则是通过注册回调事件的方式工作,由内核自动的将数据放入指定的用户空间,当处理完毕后会调度激活注册的回调事件,被唤醒的线程能直接访问到所需要的数据。

  这也是为什么BIO/NIO/IO多路复用被称为同步I/O,而IOCP被称为异步I/O的原因。

  同步I/O与异步I/O的主要区别就在于站在应用程序的视角看,真正读取/写入数据时是否是由应用程序主导的。如果需要用户程序主动发起最终的I/O请求就被称为同步I/O;而如果是内核自动完成I/O后通知用户程序,则被称为异步I/O。(可以类比在前面硬件I/O模型中,站在CPU视角的同步、异步I/O模型,只不过这里CPU变成了应用程序,而外设/DMA变成了操作系统内核)

AIO的优点:

  AIO作为异步I/O,由内核自动的完成了底层一整套的I/O操作,应用程序在事件回调通知中能直接获取到所需数据。内核中可以实现非常高效的调度、通知框架。拥有前面NIO高性能的优点,又简化了应用程序的开发。

AIO的缺点:

  由内核全盘控制的全自动I/O虽然能够做到足够高效,但是在一些特定场景下性能并不一定能超过由应用程序主导的,经过深度优化的代码。像epoll在支持了和select/poll一样的水平触发I/O的同时,还支持了更加细致的边缘触发I/O,允许用户自主的决定当I/O就绪时,是否需要立即处理或是缓存起来等待稍后再处理。(就像java等支持自动内存垃圾回收的语言,即使其垃圾收集器经过持续的优化,在大多数情况下性能都很不错,但却依然无法达到和经过开发人员反复调优,手动回收内存的C、C++等语言实现的程序一样的性能)

  (截图自《Unix网络编程 卷1》)

操作系统I/O模型小结

  1. 同步I/O包括了同步阻塞I/O和同步非阻塞I/O,而异步I/O中由于异步阻塞I/O模型没有太大价值,因此提到异步I/O(AIO)时,默认指的就是异步非阻塞I/O。

  

  2. 在I/O多路复用器的工作中,当监听到对应文件描述符I/O事件就绪时,后续进行的读/写操作既可以是阻塞的,也可以是非阻塞的。如果是都以阻塞的方式进行读/写,虽然实现简单,但如果某一文件描述符需要读写的数据量很大时将耗时较多,可能会导致事件循环中的其它事件得不到及时处理。因此截图中的阻塞读写数据部分并不准确,需要辩证的看待。

四、非阻塞I/O是银弹吗?

  计算机技术的发展看似日新月异,但本质上有两类目标指引着其前进。一是尽可能的增强、压榨硬件的性能,提高机器效率;二是尽可能的通过持续的抽象、封装简化软件复杂度,提高程序员的开发效率。计算机软件的发展方向必须至少满足其中的一种目标。

  从上面关于操作系统内核I/O模型的发展中可以看到,最初被广泛使用的是易理解、开发简单的BIO模型;但由于互联网时代的到来,web服务器系统面临着C10K问题,需要能支持海量的并发客户端连接,因此出现了包括NIO、I/O多路复用、AIO等技术,利用一个内核线程管理成百上千的并发连接,来解决BIO模型中一个内核线程对应一个网络连接的工作模式中,由于处理大量连接导致内核线程上下文频繁切换,造成CPU资源耗尽的问题。上述的第一条原则指引着内核I/O模型的发展,使得web服务器能够获得更大的连接服务吞吐量,提高了机器效率。

  但非阻塞I/O真的是完美无缺的吗?

  有着非阻塞I/O模型开发经验的程序员都知道,正是由于一个内核线程管理着成百上千个客户端连接,因此在整个线程的执行流中不能出现耗时、阻塞的操作(比如同步阻塞的数据库查询、rpc接口调用等)。如果这种操作不可避免,则需要单独使用另外的线程异步的处理,而不能阻塞当前的整个事件循环,否则将会导致其它连接的请求得不到及时的处理,造成饥饿。

  对于多数互联网分布式架构下处理业务逻辑的应用程序服务器来说,在一个网络请求服务中,可能需要频繁的访问数据库或者通过网络远程调用其它服务的接口。如果使用的是基于NIO模型进行工作的话,则要求rpc库以及数据库、中间件等连接的库是支持异步非阻塞的。如果由于同步阻塞库的存在,在每次接受连接进行服务时依然被迫通过另外的线程处理以避免阻塞,则NIO服务器的性能将退化到和使用传统的BIO模型一样的地步。

  所幸的是随着非阻塞I/O的逐渐流行,上述问题得到了很大的改善,越来越的框架/库都提供了异步非阻塞的api接口。

非阻塞I/O带来的新问题

  异步非阻塞库改变了同步阻塞库下程序员习以为常的,线性的思维方式,在编码时被迫的以事件驱动的方式思考。逻辑上连贯的业务代码为了适应异步非阻塞的库程序,被迫分隔成多个独立片段嵌套在各个不同层次的回调函数中。对于复杂的业务而言,很容易出现嵌套为一层层的回调函数调用链,形成臭名昭著的callback hell(回调地狱)

  最早被callback hell折磨的可能是客户端程序的开发人员,因为客户端程序需要时刻监听着用户操作事件的产生,通常以基于事件驱动的方式组织异步处理代码。

callback hell伪代码示例:

// 由于互相之间有前后的数据依赖,按照顺序异步的调用A、B、C、D
A.dosomething((res)->{
    data = res.xxx;
    B.dosomething(data,(res)->{
        data = res.xxx;
        C.dosomething(data,(res)->{
            data = res.xxx
            D.dosomething(data,(res)->{
                // 。。。 有依赖的同步业务越复杂,层次越深,就像一个无底洞
            })
        })
    })
})

  异步非阻塞库的使用割裂了代码的连贯结构,使得程序变得难以理解、调试,这一缺陷在堆积着复杂晦涩业务逻辑的web应用程序服务器程序中显得难以忍受。这也是为什么如今web服务器仍然有很大一部分依然使用传统的同步阻塞的BIO模型进行开发的主要原因。通过分布式、集群的方式分摊大量并发的连接,而只在业务相对简单的API网关、消息队列等I/O密集型的中间件程序中NIO才被广泛使用(实在不行,业务服务器集群可以加机器,保证开发效率也同样重要)。

  那么就没有什么办法既能够拥有非阻塞I/O支撑海量并发、高吞吐量的性能优势;又能够令程序员以同步方式思考、编写程序,以提高开发效率吗?

  解决办法当然是存在的,且相关技术依然在不断发展。上述计算机技术发展的第二个原则指导着这些技术发展,目的是为了简化代码复杂性,提高程序员的效率。

1. 优化语法、语言库以简化异步编程的难度

  在函数式编程的领域,就一直有着诸多晦涩的“黑科技”(CPS变换、monad等),能够简化callback hell,使得可以以几乎是同步的方式编写实质上是异步执行的代码。例如EcmaScript便在EcmaScript6、EcmaScript7中分别引入了promise和async/await来解决这一问题。

2. 在语言级别支持用户级线程(协程)

  前面提到,传统的基于BIO模型的工作模式最大的优点在于可以同步的编写代码,遇到需要等待的耗时操作时能够被阻塞,使用起来简单易懂。但由于1:1的维护内核线程在处理海量连接时由于频繁的内核线程上下文切换而力不从心,催生了非阻塞I/O。

  而由于上述非阻塞I/O引起的代码复杂度增加的问题,计算机科学家们想到了很早之前就在操作系统概念中提出,但一直没有被广泛使用的另一种线程实现方式:用户级线程。

  用户级线程顾名思义,就是在用户级实现的线程,操作系统内核对其是无感知的。用户级线程在许多方面与大家所熟知的内核级线程相似,都有着自己独立的执行流,和进程中的其它线程共享内存空间。

  用户级线程与内核级线程最大的一个区别就在于由于操作系统对其无感知,因此无法对用户级线程进行基于中断的抢占式调度。要使得同一进程下的不同用户级线程能够协调工作,必须小心的编写执行逻辑,以互相之间主动让渡CPU的形式工作,否则将会导致一个用户级线程持续不断的占用CPU,而令其它用户级线程处于饥饿状态,因此用户级线程也被称为协程,即互相协作的线程。

  用户级线程无论如何是基于至少一个内核线程/进程的,多个用户级线程可以挂载在一个内核线程/进程中被内核统一的调度管理。

  (截图自《现代操作系统》)

  协程可以在遇到I/O等耗时操作时选择主动的让出CPU,以实现同步阻塞的效果,令程序执行流转移到另一个协程中。由于多个协程可以复用一个内核线程,每个协程所占用的开销相对内核级线程来说非常小;且协程上下文切换时由于不需要陷入内核,其切换效率也远比内核线程的上下文切换高(开销近似于一个函数调用)。

  最近很流行的Go语言就是由于其支持语言层面的协程而备受推崇。程序员可以利用一些语言层面提供的协程机制编写高效的web服务器程序(例如在语句中添加控制协程同步的关键字)。通过在编译后的最终代码中加入对应的协程调度指令,由协程调度器接手,控制协程同步时在耗时I/O操作发生时主动的让出CPU,并在处理完毕后能被调度回来接着执行。Go语言通过语言层面上对协程的支持,降低了编写正确、协调工作的协程代码的难度。

  Go编写的高性能web服务器如果运行在多核CPU的linux操作系统中,一般会创建m个内核线程和n个协程(m正比与CPU核心数,n远大于m且正比于并发连接数),底层每个内核线程依然可以利用epoll IO多路复用器处理并发的网络连接,并将业务逻辑处理的任务转交给用户态的协程(gorountine)。每个协程可以在不同的内核线程(CPU核心)中被来回调度,以获得最大的CPU吞吐量。

  使用协程,程序员在开发时能够编写同步阻塞的耗时I/O代码,又不用担心高并发情况下BIO模型中的性能问题。可以说协程兼顾了程序开发效率与机器执行效率,因此越来越多的语言也在语言层面或是在函数库中提供协程机制。顺便一提,上述js中的async/await机制其底层也是协程实现的(区别于有栈协程gorountineasync/await是无栈协程)。

3. 实现用户透明的协程

  在通过虚拟机作为中间媒介,操作系统平台无关的语言中(比如java),虚拟机作为应用程序与操作系统内核的中间层,可以对应用程序进行各方面的优化,令程序员可以轻松编写出高效的代码。

  有大牛在知乎的一篇回答中提到过,其曾经领导团队在阿里巴巴工作时在java中实现了透明的协程。但似乎没有和官方标准达成统一因此并没有对外开放。

  如果能够在虚拟机中提供高效、用户透明的协程机制,使得原本基于BIO多线程的服务器程序无需改造便自动的获得了支持海量并发的能力,那真是太强了Orz。

五、总结

  通过对ucore操作系统源码级的研究学习,加深了我对操作系统原理书中各种抽象概念的理解,也渐渐理解了一些关于各种I/O模型的问题。

  一方面,通过对操作系统I/O模型的总结,使得我对于上层应用程序如java中的nio和netty中的非阻塞的编程风格有了更深的理解,不再像之前只习惯于BIO编程那样感到奇怪,而是觉得非常自然。另一方面,又意识到了自己还有太多的不足。

  站在操作系统I/O模型这一层面,向上看,依然对基于nio的各种中间件不太熟悉,不了解在具体实践中如何利用好NIO这一利器,写出鲁棒、高效的代码;向下看,由于ucore为了尽可能的简化实验课的难度,省略了很多的功能没有实现,导致我对于操作系统底层是如何实现网络协议栈、如何实现nio和io多路复用器的原理知之甚少,暂时只能将其当作黑盒子看待,很多地方可能理解的有偏差。令我在拓宽知识面的同时,感叹知道的越多就越感觉自己无知。但人总是要向前走的,在学习中希望尽量能做到知其然而知其所以然。通过对ucore操作系统的学习,使得我对于操作系统内核的学习不再感到恐惧,在认知学习概念中就是从恐惧区转为了学习区。以后有机会的话,可以通过研究早期的linux内核源码来解答我关于I/O模型底层实现的一系列问题。

  这篇博客是这一段时间来对操作系统学习的一个阶段性总结,直接或间接的回答了博客开头的几个问题,希望能帮到对操作系统、I/O模型感兴趣的人。这篇文章中还存在许多理解不到位的地方,请多多指教。

posted on 2020-11-10 23:47  小熊餐馆  阅读(1545)  评论(1编辑  收藏  举报