Linux_IO复用

IO复用 — I/O Multiplexing


定义流的概念,一个流可以是文件,socket,pipe等等可以进行I/O操作的内核对象。

我们对一个流可读可写,一方读,一方写,读写的位置一般是缓冲区(user space)或内核缓冲区(kernel space)。之所以会有缓存区,是为了减少频繁I/O操作而引起频繁的系统调用。

Linux_IO复用_第1张图片

假设万物和谐,应用程序要读,有数据,就读了。快读完的时候,又收到新的数据有写入到缓冲区。一直这样,那就可以永动,就不会出问题了。然而并非如此,往往进程需要的数据是kernel还没准备好的(针对read操作),需要等待kernel准备好数据,拷贝到缓存区后,应用程序才可以读取。

针对一个流有两个(还有别的,先提这两个)基本解决方案:

  • 阻塞:应用程序A要读取数据,但是kernel还没准备好数据,那应用程序A就要被阻塞在那里,等到kernel准备好数据,拷贝到缓存区,应用程序才读到数据;(阻塞I/O模型见IO_MODEL.md)
  • 非阻塞:应用程序A要读取数据,但是kernel还没准备好数据,我又不想等,有就有,没有就是没有。非阻塞与阻塞的区别就在于,应用程序A发起读的请求,你要立马告诉我有没有准备好的数据,没有我就继续问,问到你有为止,这样的方法也成为轮询,polling。

相比于非阻塞,阻塞消耗成本比较小,而且当应用程序阻塞的时候,会被移出CPU的工作队列,从而不会占用太多CPU的效能。非阻塞,当然也有它的应用场景,这边就不说了。


network I/O流的读写例子

接下来,先看下一个流的读写的底层实现是如何的,用network I/O socket read来当做例子。

场景就是服务端起一个socket server,然后监听请求这个socket的client。

//创建socket
int s = socket(AF_INET, SOCK_STREAM, 0);   
//绑定
bind(s, ...)
//监听
listen(s, ...)
//接受客户端连接
int c = accept(s, ...)
//接收客户端数据
recv(c, ...);
//将数据打印出来
printf(...)

在接收数据前,看一下代码recv前做了什么

创建socket

回到代码里面的创建socket,操作系统在文件列表(fd)创建一个socket对象,这个对象里面包含发送缓冲区,接收缓冲区,等待列表等。

Linux_IO复用_第2张图片

准备接收客户端数据recv

在一系列的bind,listen,accept之后,recv函数等待client发送数据,这时由于kernel没有socket server要的数据,所以会把该应用程序(进程)阻塞。如下图,工作队列为内核的进程度列,进程A就是我们的socket server应用程序。阻塞会造成什么?进程A被阻塞后,会被移出kernel的工作队列,会被移到socket对象里面的等待队列。由于工作队列只剩下了进程B和C,依据进程调度,cpu会轮流执行这两个进程的程序,不会执行进程A的程序。进程A被阻塞,不会往下执行代码,也不会占用cpu资源

进程阻塞不会占用系统资源

操作系统添加等待队列只是添加了对这个“等待中”进程的引用,以便在接收到数据时获取进程对象、将其唤醒,而非直接将进程管理纳入自己之下。下图为了方便说明,直接将进程挂到等待队列之下。

Linux_IO复用_第3张图片

开始接收数据

Linux_IO复用_第4张图片

数据从哪来

网卡接收网线传来的数据,上图1.

数据存到哪

网卡收到数据后,为了防止数据丢失,会立马写到内存里,上图2.做的事

怎么告诉操作系统有数据来

中断信号。网卡收到数据后,网卡向CPU发出一个中断信号,操作系统就知道有数据来了,CPU再通过网卡的中断程序去处理数据。上图3.和4。。

唤起进程

当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有了数据,recv可以返回接收到的数据。上图5.和6.,5是在把数据写到socket的接收缓冲区。

Linux_IO复用_第5张图片


I/O Multiplexing

在上面的例子,我们看到一个流的完整处理。现在设想,我们现在要处理多个流,有两种方法去实现:

  • 传统的有多进程并发模型,需要n个进程分别去管理n个流。
  • I/O复用技术就被提出来了,单线程,通过记录跟踪每个I/O流的状态,来同时管理多个I/O流

第一个设计在连接少的时候可以,但是量一大,就不行了。多进程并发模型,进程的创建和销毁过程需要消耗较多的计算机资源。在需要频繁创建和删除较多进程的情况下,资源消耗过多。

Multiplexing

在单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个I/O流。发明它的原因,是尽量多的提高服务器的吞吐能力。

如下图,一个线程会监听很多socket等待message,当监听到其中一个socket有message,就会拨动到有message的socket,接受message,处理message。

I/O复用,复用的是线程

Linux_IO复用_第6张图片

I/O复用主要的技术实现有:select,poll,epoll,kqueue。接下去我们也主要讲这四个。

现在要监听多个socket,以监听一个socket的思路,我们现在要让一个进程里的一个线程去监听多个socket。假设全部socket都还没有message,那所有监听的socket都会被挂起,都在等待message,直到监听到 一个socket收到message。这个思路也是select的基本思路。

Select

select是第一个I/O复用实现 (1983 左右在BSD里面实现的)。

实现流程:

int s = socket(AF_INET, SOCK_STREAM, 0);  
bind(s, ...)
listen(s, ...)

int fds[] =  存放需要监听的socket

while(1){
     
    int n = select(..., fds, ...)
    for(int i=0; i < fds.count; i++){
     
        if(FD_ISSET(fds[i], ...)){
     
            //fds[i]的数据处理
        }
    }
}
  1. 准备一个数组,存放需要监听的socket;

  2. 调用select,挂起所有要监听的socket,把进程加入多有socket对象的等待队列;

    select 会将需要监控的文件描述符集合拷贝到内核空间

Linux_IO复用_第7张图片

  1. 中断程序,唤起进程,把进程移出所有socket对象的等待队列,把进程加回工作队列;
    select 需要将监控的文件描述符集合拷贝到内核空间

Linux_IO复用_第8张图片

  1. 进程唤醒后,遍历socket数组,就可以遍历到有收到message的socket,代码块第9~13行。

select的做法很直白,也很简单,但也有它的问题。

  • 加入和移出进程到等待队列需要遍历,并且这个遍历需要先把fd拷贝到内核空间,开销很大,所以默认文件描述符集合的大小为1024(x86);
  • 在进程被唤醒后,需要遍历监听的socket列表,才可以知道有收到data的socket;
  • select非线程安全;
  • select如果要修改文件描述符集合需要重新启动进程,不能删除或修改;

Poll

在select发现问题后,针对select的改进,1997年提出。

主要改进文件描述符集合只能1024大小的问题,poll能没有1024限制的原因是因为,select用数组保存文件描述符集合,而poll用链表存储文件描述符集合。

poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些fd是否就绪,它的开销随着文件描述符数量的增加而线性增大。


Epoll

在2002, Davide Libenzi 实现了epoll。

epoll修复了select和poll的大部分遗留问题,与select/poll有完全不同的机制。其中最主要的是将功能分离,把维护队列阻塞进程分开了,这也是为什么epoll能实现高并发的其中一个理由。除了功能分离,还有一个最主要的功能,就绪列表,解决了select/poll每次需要遍历所有文件描述符在找到就绪文件描述符的操作。

接下来还是通过一个例子来看epoll是如何实现的,也和select做个区别。

实现流程:

int s = socket(AF_INET, SOCK_STREAM, 0);   
bind(s, ...)
listen(s, ...)

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中

while(1){
     
    int n = epoll_wait(...)
    for(接收到数据的socket){
     
        //处理
    }
}
  1. 在内核文件系统建立epoll对象及socket对象,第1行和第5行;

  2. 把要监听的socket添加到epoll对象中,第6行;

Linux_IO复用_第9张图片

  1. 代码执行到epoll_wait后,进程阻塞,把进程加到epoll对象的等待队列中,第9行;

Linux_IO复用_第10张图片

  1. 当socket收到数据后,中断程序会影响epoll对象而不是进程对象;

  2. 中断程序会将收到数据的socket对象引用添加到epoll对象的rdlist中,即就绪列表;

Linux_IO复用_第11张图片

  1. 中断程序会唤醒epoll对象内等待队列里的进程,因为有rdlist的存在,进程知道哪些socket收到了数据。
数据结构
  • 就绪列表

    epoll使用双向链表来实现就绪队列,因为就绪列表引用着就绪的socket,所以它应能够快速地操作数据

  • 索引结构

    epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket。至少要方便的添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。epoll使用了红黑树作为索引结构。

工作模式
  • LT 水平触发 (level trigger)

    当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。该模式为默认模式

  • ET 边缘触发 (Edge trigger)

    当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。

缺点

只能在linux上用


Kqueue

和epoll机制类似,只不过可以用在BSD系统上。


比较
\ select poll epoll
操作方式 遍历 遍历 回调
底层实现 数组 链表 红黑树实现的哈希表
IO效率 每次调用都进行线性遍历,时间复杂度为O(n) 每次调用都进行线性遍历,时间复杂度为O(n) 事件通知方式,每当文件描述符就绪,系统注册的回调就会被调用,将fd放到rdlist里,时间复杂度为O(1)
最大连接数 1024(x86)和2048(x64) 无上限 无上限
fd拷贝 每次调用select都要把fd拷贝到内核空间 每次调用poll都要把fd拷贝到内核空间 调用epoll_ctl时拷贝到内核空间,之后每次epoll_wait不拷贝
工作模式 LT LT 默认LT,也支持ET

资料来源

https://zhuanlan.zhihu.com/p/63179839

https://blog.csdn.net/weixin_34217711/article/details/91427239

https://blog.csdn.net/u011671986/article/details/79449853

你可能感兴趣的:(Linux,内核,epoll,linux)