IO多路复用 2021-10-03

2021-10-03  本文已影响0人  9_SooHyun

1.同步和异步

同步/异步指的是【消息通信机制】
同步(Synchronous)指的是,主动轮询结果
异步 (Asynchronous)指的是,被动接受通知

2.阻塞和非阻塞

进程进入waiting状态,就是阻塞
阻塞/非阻塞,关注的是调用方的状态,往往是与系统调用(system call)紧密联系在一起的
因为一个进程/线程进入waiting状态,要么是主动wait() or sleep(),要么就是调用了一些涉及IO操作的system call,由于IO不能立即完成,因此内核会先将其挂起,等IO complete之后再将其状态置为ready

3.最原始的单线程同步&阻塞式IO

最原始的同步&阻塞式IO是单线程模式,从始至终都是一个线程负责建立连接+读取交互数据+执行业务逻辑。代码模版如下

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

在用户态执行系统调用accept会发生阻塞,连接建立后accept返回一个【连接文件描述符connfd】
然后,在用户态执行系统调用read会发生阻塞——当网卡将数据拷贝到内核缓冲区时,connfd由【读未就绪】状态变成【读就绪】状态,这时read解除阻塞状态,将内核缓冲区的数据拷贝到用户态的buf

在这个模式中,服务端和客户端之间建立连接是严格串行的,服务端的一个网络通信端口只能与一个客户端进行连接,并且需要等到当前连接关闭后,才可以与新的客户端再次建立连接

4.多线程的同步&阻塞式IO

为了解决上文的痛点,改变一下模版:
对于每个已建立的连接connfd,主线程都另开一个线程去handle,而主线程只负责连接的建立

listenfd = socket();   // 打开一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  thread_create(handler(connfd)); // 新开线程处理连接
}

func handler(connfd) {
  int n = read(connfd, buf);  // 阻塞在read,直到读到数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接
}

这样的改动可以解决一部分问题,但是每个handler线程内进行read系统调用依然是阻塞的;同时,一个连接对应一个线程,当连接量大的时候,服务端线程数量过多,对资源的消耗是比较大的;同时线程的创建、切换和销毁也产生了不少开销

5.同步&非阻塞式IO--调整read()的返回

操作系统为了适应新的需求,将原本是阻塞的系统调用read调整成非阻塞。思路很简单,当connfd处于【读未就绪】状态时,直接返回-1;当connfd处于【读就绪】状态时,将数据从内核缓冲区拷贝至用户态内存空间并返回

6.应用程序中实现IO多路复用

先解释下IO多路复用:
多路指多个文件描述符fd;复用指复用同一个进程/线程同时监听前面提到的多个文件描述符fd

有了非阻塞的read这个基础,我们可以在应用程序中,将多个connfd组织成一个connfd数组,这样一个线程就可以通过轮询connfd数组的方式来handle多个connfd了

// 应用程序connfdlist示意代码
while(1) {
  for fd in connfdlist {
    if (read(fd) != -1) {
      doSomeThing();
    }
  }
}

7.操作系统内核实现IO多路复用

在应用程序中实现IO多路复用的方式中,在一次轮询内,每个connfd都要进行一次read系统调用,遇到 read 返回 -1 时仍然是一次浪费资源的系统调用

于是,我们把connfd数组整体传给操作系统内核,在内核层完成遍历,返回遍历结果。这就得到了第一代IO多路复用模式--select

7.1 select

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

while(1) {
  nready = select(fdlist);  // 阻塞在select,直到有fd可读
  // 用户层依然要遍历,只不过少了很多无效的系统调用
  for fd in fdlist {
    if (fd != -1) {
      // 只会对已就绪的文件描述符执行read系统调用,未就绪的文件描述符被跳过
      read(fd, buf);
      // 总共只有 nready 个已就绪描述符,不用过多遍历
      if(--nready == 0) break;
    }
  }
}
  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
  4. select这个系统调用是阻塞的
  5. select可监听的fd个数在Linux上是1024,因为select模式下的fd是通过数组存储的(poll使用链表存储监听的fd集合,解决了select监听fd集合大小受限的问题)

7.2 epoll

epoll针对select的三个可优化点进行了改进:

  1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分
  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
    具体,操作系统提供了这三个函数。
第一步,创建一个 epoll 句柄
int epoll_create(int size);
第二步,向内核添加、修改或删除要监控的文件描述符,对应改进1
int epoll_ctl(
  int epfd, int op, int fd, struct epoll_event *event);
第三步,类似发起了 select() 调用,会阻塞进程,会返回就绪态的fd
int epoll_wait(
  int epfd, struct epoll_event *events, int max events, int timeout);

注意:
应用程序调用select、poll还是epoll,都是直到有就绪的fd才会返回,那么对应用程序本身来说都是阻塞式的调用
区别仅在于它们内部如何去判断是否有fd就绪:这里面select和poll是非阻塞的,使用了非阻塞read不断轮询地去判断fd的就绪状态;而epoll内部是阻塞式的,它使用了事件唤醒的异步机制
但再次强调,这些系统调用内部的逻辑对应用程序是透明的,对应用程序本身来说都是阻塞式的调用

参考:https://mp.weixin.qq.com/s/YdIdoZ_yusVWza1PU7lWaw

7.2.1 epoll-wq与epoll惊群

ep->wq是一个等待队列,用来保存【对该epoll实例调用epoll_wait()】的所有进程

一个进程调用 epoll_wait()后,如果监视的文件还没有任何事件发生,需要让当前进程挂起等待(放到 ep->wq 里);当 epoll 实例监视的文件上有事件发生后,需要唤醒 ep->wq 上的进程去继续执行用户态的业务逻辑

之所以要用一个等待队列来维护关注这个 epoll 的进程,是因为有时候调用 epoll_wait()的不只一个进程,当多个进程都在关注同一个 epoll 实例时,休眠的进程们通过这个等待队列就可以逐个被唤醒了

这就引出了惊群现象

为了解决 epoll 惊群,内核后续的高版本又提供了 EPOLLEXCLUSIVE option 和 SO_REUSEPORT option,我个人理解两种解决方案思路上的不同点在于:EPOLLEXCLUSIVE 是在唤起进程阶段起作用,只唤起排在队列最前面的 1 个进程;而 SO_REUSEPORT 是在分配连接时起作用,相当于每个进程自己都有一个独立的 epoll 实例,内核来决策把连接分配给哪个 epoll

上一篇下一篇

猜你喜欢

热点阅读