美文网首页
IO多路复用-概述篇

IO多路复用-概述篇

作者: 茶还是咖啡 | 来源:发表于2021-06-23 11:16 被阅读0次

    为了讲多路复用,当然还是要跟风,采用鞭尸的思路,先讲讲传统的网络 IO 的弊端,用拉踩的方式捧起多路复用 IO 的优势。
    为了方便理解,以下所有代码都是伪代码,知道其表达的意思即可。

    阻塞IO

    服务端为了处理客户端的连接和请求的数据,写了如下代码。

    listenfd = socket();   // 打开一个网络通信端口
    bind(listenfd);        // 绑定
    listen(listenfd);      // 监听
    while(1) {
      connfd = accept(listenfd);  // 阻塞建立连接
      int n = read(connfd, buf);  // 阻塞读数据
      doSomeThing(buf);  // 利用读到的数据做些什么
      close(connfd);     // 关闭连接,循环等待下一个连接
    }
    

    这段代码会执行得磕磕绊绊,服务端的线程阻塞在了两个地方,一个是 accept 函数,一个是 read 函数。

    如果再把 read 函数的细节展开,我们会发现其阻塞在了两个阶段.

    image image
    1. 网卡将客户端发过来的数据拷贝到内核缓冲区,
    2. 内核缓冲区设置关联的文件描述符为可读,将内核缓冲区中的数据拷贝到用户缓冲区。

    整体流程如下图。

    image.png

    所以,如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接。


    非阻塞 IO

    为了解决上面的问题,其关键在于改造这个 read 函数。

    有一种聪明的办法是,每次都创建一个新的进程或线程,去调用 read 函数,并做业务处理。

    while(1) {
      connfd = accept(listenfd);  // 阻塞建立连接
      pthread_create(doWork);  // 创建一个新的线程
    }
    void doWork() {
      int n = read(connfd, buf);  // 阻塞读数据
      doSomeThing(buf);  // 利用读到的数据做些什么
      close(connfd);     // 关闭连接,循环等待下一个连接
    }
    

    这样,当给一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的 read 请求上。
    不过,这不叫非阻塞 IO,只不过用了多线程的手段使得主线程没有卡在 read 函数上不往下走罢了。操作系统为我们提供的 read 函数仍然是阻塞的。

    所以真正的非阻塞 IO,不能是通过我们用户层的小把戏,而是要恳请操作系统为我们提供一个非阻塞的 read 函数。

    这个 read 函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。

    操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可。

    fcntl(connfd, F_SETFL, O_NONBLOCK);
    int n = read(connfd, buffer) != SUCCESS);
    

    这样,就需要用户线程循环调用 read,直到返回值不为 -1,再开始处理业务。

    这里我们注意到两个问题。

    1. 非阻塞的 read,指的是在数据到达前,即数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的。当数据已到达内核缓冲区,此时调用 read 函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回。
    2. 为每个客户端创建一个线程,服务器端的线程资源很容易被耗光。

    整体流程如下图

    image.png

    IO 多路复用

    当然还有个聪明的办法,我们可以每 accept 一个客户端连接后,将这个文件描述符(connfd)放到一个数组里。
    然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read 方法。

    fdlist.add(connfd);
    
    while(1) {
      for(fd <-- fdlist) {
        if(read(fd) != -1) {
          doSomeThing();
        }
      }
    }
    

    但这和我们用多线程去将阻塞 IO 改造成看起来是非阻塞 IO 一样,这种遍历方式也只是我们用户自己想出的小把戏,每次遍历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用。

    在 while 循环里做系统调用,就好比你做分布式项目时在 while 里做 rpc 请求一样,是不划算的。

    所以,还是得需要操作系统,提供给我们一个有这样效果的函数,我们将一批文件描述符通过一次系统调用传给内核,由内核层去遍历,才能真正解决这个问题。

    select

    select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理:

    image.png

    select系统调用的函数定义如下。

    int select(
        int nfds,
        fd_set *readfds,
        fd_set *writefds,
        fd_set *exceptfds,
        struct timeval *timeout);
    // nfds:监控的文件描述符集里最大文件描述符加1
    // readfds:监控有读数据到达文件描述符集合,传入传出参数
    // writefds:监控写数据到达文件描述符集合,传入传出参数
    // exceptfds:监控异常发生达文件描述符集合, 传入传出参数
    // timeout:定时阻塞监控时间,3种情况
    //  1.NULL,永远等下去
    //  2.设置timeval,等待固定时间
    //  3.设置timeval里时间均为0,检查描述字后立即返回,轮询
    

    首先一个线程不断接受客户端连接,并把 socket 文件描述符放到一个 list 里。

    while(1) {
      connfd = accept(listenfd);
      fcntl(connfd, F_SETFL, O_NONBLOCK);
      fdlist.add(connfd);
    }
    

    然后,另一个线程不再自己遍历,而是调用 select,将这批文件描述符 list 交给操作系统去遍历。

    while(1) {
      // 把一堆文件描述符 list 传给 select 函数
      // 有已就绪的文件描述符就返回,nready 表示有多少个就绪的
      nready = select(list);
      ...
    }
    

    不过,当 select 函数返回后,用户依然需要遍历刚刚提交给操作系统的 list。

    只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。

    while(1) {
      nready = select(list);
      // 用户层依然要遍历,只不过少了很多无效的系统调用
      for(fd <-- fdlist) {
        if(fd != -1) {
          // 只读已就绪的文件描述符
          read(fd, buf);
          // 总共只有 nready 个已就绪描述符,不用过多遍历
          if(--nready == 0) break;
        }
      }
    }
    

    可以看出几个细节:

    1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
    2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
    3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

    整个 select 的流程图如下。

    image

    可以看到,这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)。

    poll

    poll 也是操作系统提供的系统调用函数。

    int poll(struct pollfd *fds, nfds_tnfds, int timeout);
    
    struct pollfd {
      intfd; /*文件描述符*/
      shortevents; /*监控的事件*/
      shortrevents; /*监控事件中满足条件返回的事件*/
    };
    

    它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。

    epoll

    还记得上面说的 select 的三个细节么?

    1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)

    2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)

    3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
      所以 epoll 主要就是针对这三点进行了改进。

    4. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。

    5. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。

    6. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

    具体,操作系统提供了这三个函数。

    第一步,创建一个 epoll 句柄

    int epoll_create(int size);
    

    第二步,向内核添加、修改或删除要监控的文件描述符。

    int epoll_ctl(
      int epfd, int op, int fd, struct epoll_event *event);
    

    第三步,类似发起了 select() 调用

    int epoll_wait(
      int epfd, struct epoll_event *events, int max events, int timeout);
    

    具体流程如下:

    image

    相关文章

      网友评论

          本文标题:IO多路复用-概述篇

          本文链接:https://www.haomeiwen.com/subject/orqbyltx.html