使用IO多路复用如何提高并发

710 阅读5分钟

I/O 多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要 select 、 poll 、 epoll 来配合。

一、知识储备

  在介绍IO多路复用的三种实现方式前,需要介绍一下Linux操作系统中的基础概念

1.1 用户空间/内核空间

  现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。 操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。

  针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

1.2 进程切换

  为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的,并且进程切换是非常耗费资源的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  • 保存处理机上下文,包括程序计数器和其他寄存器;
  • 更新PCB信息;
  • 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列;
  • 选择另一个进程执行,并更新其PCB;
  • 更新内存管理的数据结构;
  • 恢复处理机上下文。

1.3 文件描述符

  文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。 文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

1.4 BIO(同步阻塞)、NIO(同步非阻塞)与AIO(异步非阻塞)

  • 同步阻塞(blocking-IO)

数据的读取写入必须阻塞在一个线程内等待其完成。

这里使用那个经典的烧开水例子,这里假设一个烧开水的场景,有一排水壶在烧开水,BIO的工作模式就是, 叫一个线程停留在一个水壶那,直到这个水壶烧开,才去处理下一个水壶。但是实际上线程在等待水壶烧开的时间段什么都没有做。

  • 同步非阻塞(non-blocking-IO)

同时支持阻塞与非阻塞模式,但这里我们以其同步非阻塞I/O模式来说明,那么什么叫做同步非阻塞?如果还拿烧开水来说,NIO的做法是叫一个线程不断的轮询每个水壶的状态,看看是否有水壶的状态发生了改变,从而进行下一步的操作。

  • 异步非阻塞(synchronous-non-blocking-IO)

异步非阻塞与同步非阻塞的区别在哪里?异步非阻塞无需一个线程去轮询所有IO操作的状态改变,在相应的状态改变后,系统会通知对应的线程来处理。对应到烧开水中就是,为每个水壶上面装了一个开关,水烧开之后,水壶会自动通知我水烧开了。

二、Select实现

2.1 代码实现

 sockfd = socket(AF_INET, SOCK_STREAM, 0);
 memset(&addr, 0, sizeof (addr));
 addr.sin_family = AF_INET;
 addr.sin_port = htons(2000);
 addr.sin_addr.s_addr = INADDR_ANY;
 bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
 listen (sockfd, 5);
 
  for (i=0;i<5;i++)
  {
   memset(&client, 0, sizeof (client));
   addrlen = sizeof(client);
   fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);
   if(fds[i] > max)
       max = fds[i];
  }
 
 while(1){
    FD_ZERO(&rset);
    for (i = 0; i< 5; i++ ) {
        FD_SET(fds[i],&rset);
    }
    puts("round again");
    select(max+1, &rset, NULL, NULL, NULL);
    for(i=0;i<5;i++) {
        if (FD_ISSET(fds[i], &rset)){
            memset(buffer,0,MAXBUF);
            read(fds[i], buffer, MAXBUF);
            puts(buffer);
        }
    }  
  }
 return 0;
}

2.2 过程理解

  每次调用都会涉及到用户态/内核态的切换,需要传递待检查的文件系统中对应Socket生成的文件描述符集合,其实也就是fd_set(文件描述符id号);

  当Select被调用时,首先会根据fd_set集合的顺序,去检查内存中的Socket套接字就绪状态,这个复杂度是O(n),

  如果有就绪状态,那么就直接返回,不会阻塞当前调用线程;否则,阻塞当前调用线程,直到有某个Socket有数据之后,才唤醒线程。

  检测到就绪状态的Socket,会将对应的fd文件中设置一个标记位Mask,表示当前fd对应的Socket就绪了;接着唤醒对应的用户级(java、Python)线程

  监听限制:默然最大可监听1024个socket(ps:实际要小于1024);fd_set这个结构它是一个bitmap位图结构,是个长的二进制数,默认长度是1024bit.

三、Poll实现

3.1 代码实现

 for (i=0;i<5;i++)
  {
   memset(&client, 0, sizeof (client));
   addrlen = sizeof(client);
   pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
   pollfds[i].events = POLLIN;
  }
 sleep(1);
 while(1){
    puts("round again");
    poll(pollfds, 5, 50000);
    for(i=0;i<5;i++) {
        if (pollfds[i].revents & POLLIN){
            pollfds[i].revents = 0;
            memset(buffer,0,MAXBUF);
            read(pollfds[i].fd, buffer, MAXBUF);
            puts(buffer);
        }
    }
  }

3.2 过程理解

  Poll是Select的微加强版,与Select的主要区别是使用了数组代替bitmap储存需要检查的Socket集合;解决最大连接数1024问题,及fd_set不可复用的问题

四、epoll实现

4.1 代码实现

struct epoll_event events[5];
 int epfd = epoll_create(10);
  ...
  ...
 for (i=0;i<5;i++)
  {
   static struct epoll_event ev;
   memset(&client, 0, sizeof (client));
   addrlen = sizeof(client);
   ev.data.fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
   ev.events = EPOLLIN;
   epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
  }
 
 while(1){
    puts("round again");
    nfds = epoll_wait(epfd, events, 5, 10000);
   
    for(i=0;i<nfds;i++) {
            memset(buffer,0,MAXBUF);
            read(events[i].data.fd, buffer, MAXBUF);
            puts(buffer);
    }
  }

4.2 过程理解

解决两个缺陷问题:

  1. 函数调用参数拷贝问题;

  2. 系统调用返回后不知道那些Socket就绪问题。

使用epoll_crate系统函数创建一个epfd,储存两块信息,一块是Socket对应的fd(文件操作描述符),一块是Socket的就绪状态 ;
epoll_ctl() 去增加或者修改Socket文件描述符;
epoll_wati()系统调用去监听此次待监听fd_set集合,

  epoll是Linux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select和poll。目前流行的高性能web服务器Nginx正式依赖于epoll提供的高效网络套接字轮询服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。

五、总结

select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。

image.png

select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。

参考链接:devarea.com/linux-io-mu…
juejin.cn/post/688298…