五类网络IO模型(阻塞、非阻塞、select多路复用、基于信号、异步IO)

POSIX: Portable Operating System Interface, developed by IEEE and adopted as standards by ISO and IEC (ISO/IEC)

1. Unix下I/O模型

  • 阻塞I/O(BIO)模型(默认recvfrom系统调用)
  • 非阻塞I/O模型(基于轮询系统调用recvfrom)
  • 基于I/O多路复用模型(select和poll)
  • 基于信号的I/O模式(SIGIO信号通知)
  • 异步IO模型(POSIX aio_系列函数)

输入操作的两个阶段:

  1. 系统内核等待数据准备(比如等待网络数据达到)。当数据到达,数据被复制到内核缓冲区;
  2. 系统内核将内核缓冲区数据,复制到进程缓存区。将内核缓冲区就绪数据,复制到应用程序缓冲区;

阻塞与非阻塞,是针对进程而言,是否由于I/O问题导致进程阻塞与否

1.1. 阻塞I/O模型

进程调用recvfrom系统调用在数据报到达并复制到应用程序缓冲区或者发生错误(最常见的错误是系统调用被信号中断)之前不会返回;

进程从调用recvfrom到返回的整个时间都被阻止,当recvfrom成功返回时,我们的应用程序处理的数据包。

1.2. 非阻塞I/O模型 - 轮询系统请求

  • sockect: 创建一个套接字,返回一个文件描述符
  • fcntl: 针对文件描述符进行控制,设置文件描述符相关状态,比如文件记录锁、文件IO读写阻塞、异步通知等设定

若套接字通过fcntl被设置为非阻塞O_NOBLOCK,recvfrom在内核数据未就绪情况下,会直接返回-1,以及额外的ERRNO(EAGIN)错误;

对于前三个recvfrom,没有数据要返回,内核立即返回错误EWOULDBLOCK(EAGIN),应用程序不断轮询内核以查看某些操作是否已准备就绪,这通常是浪费CPU时间。

我们第四次调用recvfrom,数据报就绪,它被复制到我们的应用程序缓冲区,并recvfrom成功返回,然后我们处理数据。

1.3. 同步I/O多路复用模型 - select和poll

int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds, struct timeval *restrict timeout);

针对以readfds、writefds和errorfds传递地址的I/O描述符集合,检测查是否他们中有IO准备就绪,返回文件描述符集合中就绪的文件描述符数量或者-1(错误发生)、0(超时),当文件描述符被中断处理时候,select可能无法修改导致调用失败;

利用两个系统调用之一中调用select或poll阻塞,而不是在实际的I/O系统调用中阻塞:

与阻塞I/O模型相比,优点是可以通过select等待多个描述符就绪,缺点是需要使用两个系统调用:select+recvfrom

另外,还有一个模型需要注意,多线程的阻塞IO模型,即不使用select模式,但可以通过多线程,每个线程负责一个系统调用(如recvfrom)阻塞IO操作

1.4. 基于信号I/O模型 - SIGIO

若套接字通过fcntl被设置成异步IO模式O_ASYNC,在内核I/O数据就绪的情况下,通过使SIGIO信号发送到进程组。

基于信号的I/O模式有两个阶段:

  1. 应用程序设置套接字文件描述符允许信号IO驱动模式,并通过sigaction注册一个信号处理函数,这个系统调用是直接返回的,不会阻塞
  2. 当数据报准备在内核准备就绪,SIGIO信号会通知到我们的应用进程,触发注册的信号处理函数,通知进程执行recvfrom操作

与阻塞I/O模型相比,其优点是在内核等待数据报过程中,进程不会被阻塞。

1.5. 异步IO模型

int aio_read(struct aiocb *aiocbp);,POSIX异步I/O (AIO)接口允许应用程序启动一个或多个异步执行的I/O操作,其他类似的有aio_cancelaio_writeaio_returnaio_suspendaio_error

异步I/O由POSIX(Portable Operating System Interface)规范定义,形成了相关标准和规范, 这些aio_*系列函数通过告诉内核启动操作并在整个操作(包括从内核到缓冲区的数据副本)完成时通知我们来工作

在将数据复制到我们的应用程序缓冲区之前,不会生成此信号,这与信号驱动的I/O模型不同:

异步IO模型和信号驱动的I/O模型的主要区别在于,通过信号驱动的I/O,内核告诉我们何时可以启动I/O操作(在内核IO缓冲数据就绪时候触发),但是使用异步I/O,内核告诉我们I/O操作完成时

1.6. 五类I/O模型比较

前四个模型之间的主要区别在于第一个阶段(内核数据就绪处理机制不同),因为前四个模型中的第二个阶段是相同的:recvfrom当数据从内核复制到调用者的缓冲区时,进程被阻塞。

但是,异步I/O处理两个阶段,与前四个不同(直至内核就绪数据被拷贝至应用进程缓冲区)。

2. 同步IO与异步IO

POSIX将这两个术语定义如下:

  • 同步I/O操作会导致请求进程被阻塞,直到I/O操作完成。
  • 异步I/O操作不会导致请求进程被阻止。

使用这些定义,前四个I/O模型(阻塞、非阻塞、I/O多路复用、信号驱动I/O)都是同步的,因为实际的I/O操作(recvfrom)会阻止进程,只有异步I/O模型匹配异步I/O定义。

3. select函数

int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds, struct timeval *restrict timeout);

3.1. select的文件描述符状态检测功能

进程通过Select函数指示内核感兴趣的描述符(用于读取,写入或异常条件)以及等待多长时间(描述符不仅限于套接字,还可以使用select测试任何描述符):

  1. 等待多个事件中的任何一个发生,并仅在发生一个或多个这些事件时唤醒进程;
  2. 经过指定的时间后,唤醒进程;

3.2. Timeout参数

Timeout参数告诉内核,等待内核数据准备的时间,支持秒和微秒的timeval结构,有以下几类值的区别:

  • 永久等待(Timeout被设置为空指针),告知内核仅在数据准备好了再通知进程
  • 等待一个固定时间,设定了等待内核IO操作的超时时间
  • 不做等待(设置为0),检测描述符后,立即返回,不做等待

3.3. readset,writeset和exceptset参数

三个中间参数readset,writeset和exceptset指定了我们希望内核为读取,写入和异常条件测试的描述符,如果我们不感兴趣条件可以指定为一个空指针,都指定为空,类似于sleep的定时器(但精度更高)

3.4. select返回值

select函数在返回时,结果指示哪些描述符已准备好,即在所有描述符集中准备好的总位数。

如果计时器值在任何描述符就绪(读、写、错误操作就绪)之前到期,则返回值0。返回值-1表示错误(例如,如果函数被捕获的信号中断,则可能发生错误)。

3.5. 就绪状态文件描述符

  • 若满足以下四个条件中的任何一个,则套接字已准备好读取:
    • 套接字接收缓冲区数据字节数 >= 低水位标记的当前大小(SO_RCVLOWAT - socket设定的,tcp、udp默认为1个字节)
    • 收到FIN的TCP连接
    • 套接字是侦听套接字,已完成连接的数量非零
    • 套接字错误正在等待读操作处理
  • 若满足以下四个条件中的任何一个,则套接字已准备好写入:
    • 套接字发送缓冲区中可用空间的字节数 >= 发送缓冲区的低水位标记的当前大小(SO_SNDLOWAT设定)
    • 连接的写半部分已关闭,将生成套接字上的写操作SIGPIPE
    • 使用非阻塞连接的套接字已完成连接,或者连接失败
    • 套接字错误正在等待处理 套接字上的写操作不会阻塞
  • 若套接字的带外数据或套接字仍处于带外标记(out-of-band),则套接字具有待处理的异常条件

3.6. 最大描述符数select

select最初设计,操作系统通常对每个进程描述符的最大数的上限,并选择只用该相同的限制。

但是,当前版本的Unix允许每个进程几乎无限数量的描述符(通常仅限于内存量和任何管理限制),这会影响select,从可移植性的角度来看,请注意使用大型描述符集。

3.7. Select处理流程

include    "unp.h"

void
str_cli(FILE *fp, int sockfd)
{
    int         maxfdp1;
    fd_set      rset;
    char        sendline[MAXLINE], recvline[MAXLINE];

    FD_ZERO(&rset);
    for ( ; ; ) {
        FD_SET(fileno(fp), &rset);
        FD_SET(sockfd, &rset);
        maxfdp1 = max(fileno(fp), sockfd) + 1;
        Select(maxfdp1, &rset, NULL, NULL, NULL);

        if (FD_ISSET(sockfd, &rset)) {  /* socket is readable */
            if (Readline(sockfd, recvline, MAXLINE) == 0)
                err_quit("str_cli: server terminated prematurely");
            Fputs(recvline, stdout);
        }

        if (FD_ISSET(fileno(fp), &rset)) {  /* input is readable */
            if (Fgets(sendline, MAXLINE, fp) == NULL)
                return;     /* all done */
            Writen(sockfd, sendline, strlen(sendline));
        }
    }
}
  • Call select:
    • 通过描述符集(rset)来检查可读性
    • 在计算两个描述符的最大值后调用select,在调用中,写集指针和异常集指针都是空指针。
  • Handle readable socket
    • 如果套接字是可读的,则使用readline读取回显的行并由fputs输出
  • Handle readable input
    • 如果标准输入是可读的,则由fgets读取一行并使用writen将其写入套接字

4. 小结

简要概述了Unix的五类I/O模型(阻塞IO、非阻塞轮询I、SELECT IO多路复用、信号IO驱动以及异步AIO模型),前面四类按POSIX描述,IO过程中对应用进程有阻塞,归类为同步IO模型,异步AIO模型归类为异步IO模型

关于IO阻塞与非阻塞,是程序通过fcntl设置套接字文件描述符的状态为O_NOBLOCK,也可以直接基于socket指定SOCK_NONBLOCK类型标识;

关于信号IO驱动,是基于程序通过fcntl设定套接字的文件描述符状态为O_ASYNC,当内核数据准备就绪,会通过SIGIO信号通知应用进程;

最后,内容简要对select函数进行了说明,select函数对文件描述符设定了读、写、异常的检测,同时设定了检测的超时事项,最后,附带了一个Select的处理流程;

5. 参考