写点什么

浅谈 I/O 多路复用

用户头像
namelij
关注
发布于: 2021 年 03 月 26 日




作为程序员,在日常工作中,都或多或少的接触过网络 I/O 这个概念,接触过网络编程,听说过 socket 等等,但是对于更深层次的理解,多少还是有点欠缺,通过本文,可以了解网络中最重要的模块 I/O,以及对几种网络模型的介绍,在我们日常工作开发过程中,可以针对特定需求,选择特定的网络模型,达到事半功倍的效果。



0


什么是 I/O

通常指数据,在内部存储器和外部存储器或其他周边设备之间的输入和输出。

是信息处理系统(例如计算器)与外部世界(可能是人类或另一信息处理系统)之间的通信。输入是系统接收的信号或数据,输出则是从其发送的信号或数据。该术语也可以用作行动的一部分;到“运行 I/O”是运行输入或输出的操作。

Unix 系统下,不论是标准输入还是借助套接字接受网络输入,都有两个步骤:

  1. 等待数据准备好(Waiting for the data to be ready)

  2. 从内核向进程复制数据(Copying the data from the kernel to the process)



输入/出设备是硬件中由人(或其他系统)使用与计算器进行通信的部件。例如,键盘或鼠标是计算器的输入设备,而监视器和打印机是输出设备。计算器之间的通信设备(如电信调制解调器和网卡)通常运行输入和输出操作。 简单来说,就是用户进程与内核交互,而内核与硬件进行交互



1


阻塞式 I/O 模型


应用程序发起 I/O 系统调用,在获得结果之前,应用程序进程会一直阻塞,直到获得结果(有数据返回或者操作超时)。

默认情况下,Unix 系统上的所有文件描述符都以“阻塞模式”开始。这意味着 read、write 或 connect 之类的 I/O 系统调用在默认情况下,都是阻塞的。

为了理解这一点,我们假如有个程序,在终端上等待标准输入(stdin),此时,假如通过调用 read 函数来实现该功能,那么该程序将被阻塞,直到有实际的数据可用(例如当用户在键盘上敲入字符时)。具体来说,内核将把进程置于“休眠”状态,直到数据在 stdin 上可用。其他类型的文件描述符也是如此。例如,如果您尝试从 TCP 套接字读取数据,那么 read 调用将阻塞,直到连接的另一端实际发送数据为止。

int main(int argc, char *argv[]) {    char buf[ MAX_BUFFER_LENGTH ];    int length = 0;    if( (length = read( 0, buf,  MAX_BUFFER_LENGTH )) < 0 ) {        return -1;    }    buf[length] = '\0';    printf("input: \n%s\n", buf);    return 0;}
复制代码



当在我们执行了上述代码,那么,在该执行代码的进程内,会调用 read 函数,最终会进入 kernel 态,此时,会进入 kernel 态的第一个步骤即 I/O 等待数据状态。

从用户进程的角度来说,会被阻塞。直到超时或者键盘输入了数据,从 kernel 态将数据拷贝到了用户态的内存,此时用户进程才接触阻塞,程序开始执行下面其他步骤。


特点:

用户进程会一直阻塞等待 kernel,直到 kernel 将数据返回


2


非阻塞式 I/O 模型


通常通过将 socket 描述符设置为 O_NONBLOCK 模式。

int flags = fcntl(fd, F_GETFL, 0);fcntl(fd, F_SETFL, flags | O_NONBLOCK);
复制代码

如果一个 socket 描述符被设置为非阻塞的,那么在数据准备好之前,调用 read 函数,会返回-1,而 errno 会被设置为 EWOULDBLOCK。


从上图可以看出,当在用户进程调用 read 系统调用之后,如果 kernel 态没有数据,那么 read 调用会马上返回,而不会阻塞用户进程。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是用户就可以在本次到下次再发起 read 询问的时间间隔内做其他事情,或者直接再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存(这一阶段仍然是阻塞的),然后返回。

整个过程,可以概括为,用户进程不断的调用 read 系统调用,询问 kernel 数据是否准备好,所以,非阻塞式 I/O 模式可以理解为是一个不断循环询问 kernel 的模式。

struct timespec sleep_interval{.tv_sec = 0, .tv_nsec = 1000};ssize_t nbytes;for (;;) {    /* try fd1 */    if ((nbytes = read(fd1, buf, sizeof(buf))) < 0) {        if (errno != EWOULDBLOCK) {            perror("read/fd1");        }    } else {        handle_data(buf, nbytes);    }    /* try fd2 */    if ((nbytes = read(fd2, buf, sizeof(buf))) < 0) {        if (errno != EWOULDBLOCK) {            perror("read/fd2");        }    } else {        handle_data(buf, nbytes);    }    /* 处理其他事情 */    // do other}
复制代码

非阻塞式 I/O 较阻塞式 I/O 来说,性能提升了很多,但仍然存在很多问题,比如:

1、当数据输入非常慢时,程序会频繁而不必要地唤醒,从而浪费 CPU 资源。

2、当数据进入时,如果程序处于睡眠状态或者正在处理其他逻辑,它可能不会立即读取数据,因此程序的延迟将很差。

3、用这种模式处理大量的文件描述符将变得很麻烦。


1、用户进程会不断的询问 kernel 数据是否已经准备好

2、抽象的讲,非阻塞 I/O 与异步 I/O 类似,区别是一个不断的去轮询 kernel,一个是通过被动通知的方式。


3


信号驱动式 I/O 模型


当进程发起一个 IO 操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用 IO 读取数据。



以下条件均会导致对一个 TCP 套接字产生 SIGIO 信号:

  • 监听套接字上某个连接请求已经完成;

  • 某个断连请求已经发起;

  • 某个断连请求已经完成;

  • 某个连接对端已经关闭;

  • 数据到达套接字;

  • 数据已经从套接字发送走;

  • 发生某个异步错误。

当然,我们可以对 TCP 监听套接字可以使用 SIGIO,这样我们就可以在信号处理函数中处理新连接了。

对于 UDP,只有以下两个条件才会产生 SIGIO 信号:

  • 数据报到达套接字;

  • 套接字上发生异步错误。

所以,针对 UDP 套接字产生的 SIGIO 信号,我们只要调用 recvfrom 读入到达的数据,或者获取发生的异步错误就可以了。

void io_handler(int signal) {  int       numbytes;  /* Number of bytes recieved from client */  int       addr_len;  /* Address size of the sender    */  struct sockaddr_in   their_addr;  /* connector's address information  */   if ((numbytes=recvfrom(sock, buf, MAXBUFLEN, 0, \                    (struct sockaddr *)&their_addr, &addr_len)) == -1) {                perror("recvfrom");                exit(1);   }   buf[numbytes]='\0';  printf("got from %s --->%s \n  ",inet_ntoa(their_addr.sin_addr),buf);  return;}int main() {  int length;  struct sockaddr_in server;    sock = socket(AF_INET, SOCK_DGRAM, 0);  if (sock < 0) {    perror("opening datagram socket");    exit(1);  }  server.sin_family = AF_INET;  server.sin_addr.s_addr = INADDR_ANY;  server.sin_port = htons(MYPORT);  if (bind(sock, (struct sockaddr *)&server, sizeof server) <0 ){    perror("binding datagram socket");    exit(1);  }  length = sizeof(server);  if (getsockname(sock, (struct sockaddr *)&server, &length) < 0){    perror("getting socket name");    exit(1);  }  printf("Socket port #%d\n", ntohs(server.sin_port));  // 第一步,注册事件函数  signal(SIGIO,io_handler);  // 第二步 设置要接收的进程id或进程组id,通知其自己的进程id或进程的挂起输入组id  if (fcntl(sock,F_SETOWN, getpid()) < 0){    perror("fcntl F_SETOWN");    exit(1);  }  // 第三步,允许接收异步I/O信号  if (fcntl(sock,F_SETFL,FASYNC) <0 ){    perror("fcntl F_SETFL, FASYNC");    exit(1);  }  for(;;)  ;  // .......  }
复制代码


4


异步 I/O 模型


同步 I/O 意味着当您想读或写某个东西时,可能需要调用一个名为 read()或 write()的函数,函数会阻塞,阻止执行进一步移动,直到读或写完成。这就是普通文件读写的典型工作方式。打开一个文件,然后调用 read(),它用所需的数据填充一个缓冲区,并在完成所有操作后返回,这样就可以用所需的数据填充一个缓冲区。


异步 I/O 恰恰相反。与读写函数等待请求的操作完成后再返回不同,异步 I/O 操作将立即返回到程序,而读写操作将在后台继续。


这有什么好处?这意味着你的程序或游戏可以继续扔东西在屏幕上,更新输入,滚动进度条,无论什么,而所有的硬盘驱动器的数据处理你想要的。您还可以向系统发送多个 IO 请求,这样操作系统就可以找到访问所有所需数据的最有效方法。


用户进程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从 kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。


在异步 IO 中,以下几个概念非常重要:

struct aiocb {  int             aio_fildes;     /* 文件描述符 */  off_t           aio_offset;     /* 文件便宜 */  volatile void  *aio_buf;        /* buffer位置 */  size_t          aio_nbytes;     /* 传输数据大小 */  int             aio_reqprio;    /* 请求优先级 */  struct sigevent aio_sigevent;   /* 通知的方式 */  int             aio_lio_opcode; };
复制代码


aio_read()

函数告诉系统要读取的文件、开始读取的偏移量、要读取的字节数以及要将要读取的字节放在何处。

aio_error()

检查 IO 请求的当前状态。使用这个函数你可以查出请求是否成功。你所要做的就是给它一个地址,地址和你给 aio\u read()的地址相同。如果请求成功完成,则函数返回 0;如果请求仍在工作,则返回 EINPROGRESS;如果发生错误,则返回其他错误代码。

aio_return()

检查 IO 请求的结果,一旦您发现请求已经完成。如果请求成功,此函数返回读取的字节数。如果失败,那么函数返回-1。

下面是异步 I/O 模型的一个简单例子,通过本例,可以简单的了解该模型的大致流程。

int main(){  int file = open("blah.txt", O_RDONLY, 0);   if (file == -1)  {    cout << "Unable to open file!" << endl;    return 1;  }    char* buffer = new char[SIZE_TO_READ];   // 定义控制块变量  aiocb cb;   memset(&cb, 0, sizeof(aiocb));  cb.aio_nbytes = SIZE_TO_READ;  cb.aio_fildes = file;  cb.aio_offset = 0;  cb.aio_buf = buffer;   // 读取数据  if (aio_read(&cb) == -1)  {    cout << "Unable to create request!" << endl;    close(file);  }   cout << "Request enqueued!" << endl;   // 等待,知道请求处理完成  while(aio_error(&cb) == EINPROGRESS)  {    cout << "Working..." << endl;  }   // 判断读取的字节数  int numBytes = aio_return(&cb);   if (numBytes != -1)    cout << "Success!" << endl;  else    cout << "Error!" << endl;   // 释放资源  delete[] buffer;  close(file);   return 0;}
复制代码


1、用户程序告诉 kernel 其要执行某个操作,不等 kernel 回复就立即返回

2、kernel 完成整个操作,包括将获取的数据拷贝到用户的 buffer 之后,再通知用户。


5


I/O 多路复用


I/O 多路复用是这样一种能力,它告诉内核,如果一个或多个 I/O 条件已经就绪,比如输入已经准备好被读取,或者描述符能够获取更多的输出,我们就需要得到通知。


I/O 复用模型使用 select、poll、epoll 函数,这些函数也会阻塞进程,但与阻塞 I/O 不同的是,这两个函数可以同时阻塞多个 I/O 操作。对于多个读操作、多个写操作,可以同时检测 I/O 函数,直到有数据可读或可写时,才实际调用 I/O 操作函数。


当用户进程调用了 select,那么整个进程会被 block,而同时,kernel 会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。

int         maxfdp1, stdineof;fd_set      rset;char        buf[MAXLINE];int     n;stdineof = 0;FD_ZERO(&rset);for ( ; ; ) {    if (stdineof == 0)        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 ( (n = read(sockfd, buf, MAXLINE)) == 0) {            if (stdineof == 1)                return;     /* normal termination */            else                err_quit("str_cli: server terminated prematurely");        }        write(fileno(stdout), buf, n);    }    if (FD_ISSET(fileno(fp), &rset)) {  /* input is readable */        if ( (n = read(fileno(fp), buf, MAXLINE)) == 0) {            stdineof = 1;            shutdown(sockfd, SHUT_WR);  /* send FIN */            FD_CLR(fileno(fp), &rset);            continue;            }        writen(sockfd, buf, n);    }}
复制代码


(1)当客户处理多个描述字时(一般是交互式输入和网络套接口)

(2)当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。

(3)如果一个 TCP 服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到 I/O 复用。

(4)如果一个服务器即要处理 TCP,又要处理 UDP

(5)如果一个服务器要处理多个服务或多个协议


2、现在基本上所有的商用或者大型程序,都是用的多路复用与非阻塞两个模式相结合的方式


https://fwheel.net/aio.html

https://www.itzhai.com/articles/it-seems-not-so-perfect-signal-driven-io.html

https://eklitzke.org/blocking-io-nonblocking-io-and-epoll

https://notes.shichao.io/unp/ch6/


END



高性能架构探索

欢迎关注!

公众号二维码.jpeg



发布于: 2021 年 03 月 26 日阅读数: 13
用户头像

namelij

关注

还未添加个人签名 2021.03.12 加入

还未添加个人简介

评论

发布
暂无评论
浅谈I/O多路复用