select和epoll

参考链接:
https://zhuanlan.zhihu.com/p/63179839
https://zhuanlan.zhihu.com/p/64138532
https://zhuanlan.zhihu.com/p/64746509

在讲解之前,我们需要先知道计算机是如何接受网络数据的。简单来讲,就是当网络数据到达网卡的时候,网卡会通过中断控制器向CPU发送中断信号,CPU接受到中断信号的时候会根据接受到的中断向量号调用提前在中段描述符表中注册好的中断处理程序,中断处理程序会保存当前正在执行的程序的上下文,然后将网卡的中的数据复制到内核缓冲区,在等待空闲的时间将内核缓冲区的数据复制到用户缓冲区中供用户进程处理。

而我们知道,对于服务端建立socket的过程如下:

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

当和客户端成功完成3次握手后,便会调用recv阻塞等待接收客户端发送过来的数据。

实际上,当某个进程A执行到创建socket语句的时候, 操作系统就会创建一个由文件系统管理的socket对象。socket包含了发送缓冲区、接收缓冲区、等待队列等。其中发送缓冲区和接收缓冲区就是我们TCP流量控制中用到的滑动窗口,而TCP本身就是全双工的,既可以发送数据,又可以接收数据,因此会有2个缓冲区。而等待队列指向的是所有等待该socket事件的进程。

image.png

当程序执行到recv的时候,操作系统会将进程A从工作队列移动到该socket的等待队列中(传个引用),进程B、C继续调度执行,A进程被阻塞


image.png

当socket接收到数据,操作系统便会将在该socket的等待队列上的进程重新放回工作队列中,由内核调度器继续调度执行,该进程变成运行状态,继续执行代码,由于socket的接收缓冲区已有了数据,recv便可接收返回的数据。

那么当数据到来的时候,操作系统如何知道是哪一个socket呢?一个进程又是如何监听多个socket的数据呢?

因为一条TCP连接对应一个四元组,TCP首部的信息中含有接收方的端口号信息,而一个socket对应着一个端口号,因此可以根据TCP头部的信息找到对应的socket,将数据复制到socket的接收缓冲区中;而进程正是通过IO多路复用的形式来监听多个socket(select和epoll)。

我们先来看一下select是怎么做的:
如下的代码中,准备一个数组fds,存放需要监视的所有socket,然后调用select,如果fds中所有的socket都没有数据,select会阻塞,直到有一个socket收到数据,select返回,唤醒进程,用户可以遍历fds,通过FD_ISSET判断哪个socket收到了数据,然后做出处理。

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]的数据处理
        }
    }
}

加入进程A同时监视如下图的sock1、sock2、sock3,调用select后操作系统会将进程A分别加入这3个socket的等待队列中。


image.png

当任何一个socket收到数据后,中断处理程序唤醒线程,加入工作队列


image.png

然后A继续执行,只需遍历一遍socket列表,就可以得到就绪的socket。

但是简单的方法往往有缺点,主要是:
其一,每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。
其二,进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。
那么,有没有减少遍历的方法?有没有保存就绪socket的方法?这两个问题便是epoll技术要解决的。

补充说明: 本节只解释了select的一种情形。当程序调用select时,内核会先遍历一遍socket,如果有一个以上的socket接收缓冲区有数据,那么select直接返回,不会阻塞。这也是为什么select的返回值有可能大于1的原因之一。如果没有socket有数据,进程才会阻塞。

epoll解决了上述select低效的问题:
epoll先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。

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){
        //处理
    }
}

内核维护一个“就绪链表”,引用收到数据的socket,防止对socket的遍历,如下所示,收到数据的sock2和sock3会被rdlist(就绪链表所引用),进程被唤醒后,只需要获取rdlist的内容就能够知道哪些socket收到了数据。


image.png

epoll原理和流程:

如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。


image.png

创建eventpoll对象后,可以通过epoll_ctl添加或者删除需要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。

image.png

当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。

当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。


image.png

所以eventpoll相当于一个中间层,位于socket和进程之间,socket的数据通过改变eventpoll的就序列表来改变进程状态

假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。


image.png

当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。

image.png

就绪列表的数据结构

就绪列表引用着就绪的socket,所以它应能够快速的插入数据。
程序可能随时调用epoll_ctl添加监视socket,也可能随时删除。当删除时,若该socket已经存放在就绪列表中,它也应该被移除。
所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll使用双向链表来实现就绪队列(对应上图的rdllist)。

索引结构

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

你可能感兴趣的:(select和epoll)