epoll & Select

1、同步异步与阻塞非阻塞

1、用户空间和内核空间

操作系统为了支持多个应用同时运行,需要保证不同进程之间相对独立(一个进程的崩溃不会影响其他的进程 , 恶意进程不能直接读取和修改其他进程运行时的代码和数据)。 因此操作系统内核需要拥有高于普通进程的权限, 以此来调度和管理用户的应用程序。
于是内存空间被划分为两部分,一部分为内核空间,一部分为用户空间,内核空间存储的代码和数据具有更高级别的权限。内存访问的相关硬件在程序执行期间会进行访问控制( Access Control),使得用户空间的程序不能直接读写内核空间的内存。

2、进程转换

image.png

上图展示了进程切换中几个最重要的步骤:

  1. 当一个程序正在执行的过程中, 中断(interrupt) 或 系统调用(system call) 发生可以使得 CPU 的控制权会从当前进程转移到操作系统内核。
  2. 操作系统内核负责保存进程 i 在 CPU 中的上下文(程序计数器, 寄存器)到 PCBi (操作系统分配给进程的一个内存块)中。
  3. 从 PCBj 取出进程 j 的CPU 上下文, 将 CPU 控制权转移给进程 j , 开始执行进程 j 的指令。

可以看出来, 操作系统在进行进切换时,需要进行一系列的内存读写操作, 这带来了一定的开销

3、进程阻塞

image.png
上图展示了一个进程的不同状态:

  • New:进程正在被创建。
  • Running:进程的指令正在被执行。
  • Waiting:进程正在等待一些事件的发生(例如 I/O 的完成或者收到某个信号)。
  • Ready:进程在等待被操作系统调度。
  • Terminated:进程执行完毕(可能是被强行终止的)。

我们所说的 “阻塞”是指进程在发起了一个系统调用(System Call) 后, 由于该系统调用的操作不能立即完成,需要等待一段时间,于是内核将进程挂起为**等待 (waiting)**状态, 以确保它不会被调度执行, 占用 CPU 资源。

3.1、阻塞原理

image.png
分时进程队列
对于Socket来说:
当发生阻塞时候,调用阻塞程序,而阻塞程序最重要的一个操作就是将进程从工作队列移除,并且将其加到等待队列。
image.png
阻塞
当发生中断时候,调用中断程序,而中断程序最重要的一个操作就是将等待队列中的进程重新移回工作队列,继续分配系统的CPU资源。
image.png

4、文件描述符

我们最熟悉的句柄是0、1、2三个,0是标准输入,1是标准输出,2是标准错误输出。0、1、2是整数表示的,对应的FILE *结构的表示就是stdin、stdout、stderr,0就是stdin,1就是stdout,2就是stderr。

#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char **argv)
{
    char buf[10] = "";
    read(0, buf, 9);              /* 从标准输入 0 读入字符 */
    // fprintf(stdout, "%s\n", buf); /* 向标准输出 stdout 写字符 */
    write(1, buf, strlen(buf));
    return 0;
}

6、同步&异步

同步

同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。也就是说,调用会等待返回结果计算完成才能继续执行。

异步

异步是不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。也就是说,其实异步调用会直接返回,但是这个结果不是计算的结果,当结果计算出来之后,才通知被调用的程序。

同步阻塞IO的优缺点

优点:

  • 开发简单,由于accept()、recv()都是阻塞的,为了服务于多个客户端请求,新的连接创建一个线程去处理即可
  • 阻塞的时候,线程挂起,不消耗CPU资源

缺点:

  • 每新来一个IO请求,都需要新建一个线程对应,高并发下系统开销大,多线程上下文切换频繁
  • 创建线程太多,内存消耗大

7、阻塞&非阻塞

阻塞

阻塞调用是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务。

非阻塞

不管可不可以读写,它都会立即返回,返回成功说明读写操作完成了,返回失败会设置相应error状态码,根据这个error可以进一步执行其他处理。它不会像阻塞IO那样,卡在那里不动。
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。
可以这么理解么?阻塞和非阻塞,应该描述的是一种状态,同步与非同步描述的是行为方式。

2、多路复用

IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程
在处理 IO 的时候,阻塞和非阻塞都是同步 IO。
只有使用了特殊的 API 才是异步 IO。
image.png

select、poll、epoll之间的区别

\selectpollepoll
操作方式遍历遍历回调
底层实现数组链表哈希表
IO效率每次调用都进行线性遍历,时间复杂度为O(n)每次调用都进行线性遍历,时间复杂度为O(n)事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到rdllist里面。时间复杂度O(1)
最大连接数1024(x86)或 2048(x64)无上限无上限
fd拷贝每次调用select,都需要把fd集合从用户态拷贝到内核态每次调用poll,都需要把fd集合从用户态拷贝到内核态调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝

epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))
select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现

2.1、Select

基于select调用的I/O复用模型如下:
image.png

2.2.1、流程

image.png
传统select/poll的另一个致命弱点就是当你拥有一个很大的socket集合,由于网络得延时,使得任一时间只有部分的socket是"活跃" 的,而select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。
但是epoll不存在这个问题,它只会对"活跃"的socket进 行操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。于是,只有"活跃"的socket才会主动去调用 callback函数,其他idle状态的socket则不会,在这点上,epoll实现了一个 "伪"AIO,因为这时候推动力在os内核。

2.1.2、过程

当进程A调用select语句的时候,会将进程A添加到多个监听socket的等待队列中
image.png
当网卡接收到数据,然后网卡通过中断信号通知cpu有数据到达,执行中断程序,中断程序主要做了两件事:

  1. 将网络数据写入到对应socket的接收缓冲区里面
  2. 唤醒队列中的等待进程(A),重新将进程A放入工作队列中.

如下图,将所有等待队列的进程移除,并且添加到工作队列中。
image.png

上面只是一种情况,当程序调用 Select 时,内核会先遍历一遍 Socket,如果有一个以上的 Socket 接收缓冲区有数据,那么
Select 直接返回,不会阻塞。

问题:

  • 每次调用 Select 都需要将进程加入到所有监视 Socket 的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个 FDS 列表传递给内核,有一定的开销。
  • 进程被唤醒后,程序并不知道哪些 Socket 收到数据,还需要遍历一次
    :::info
    select和poll在内部机制方面并没有太大的差异。相比于select机制,poll只是取消了最大监控文件描述符数限制,并没有从根本上解决select存在的问题。
    :::
2.1.3、Select API

轮询所有的句柄,找到有处理状态的句柄并且进行操作。
主要函数:

/* According to POSIX.1-2001 */
#include <sys/select.h>

/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

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,检查描述字后立即返回,轮询
struct timeval {
    long tv_sec; // seconds 
    long tv_usec; // microseconds 
};
*/
void FD_CLR(int fd, fd_set *set);   // 把文件描述符集合里fd清0
int  FD_ISSET(int fd, fd_set *set); // 测试文件描述符集合里fd是否置1
void FD_SET(int fd, fd_set *set);   // 把文件描述符集合里fd位置1
void FD_ZERO(fd_set *set);         //把文件描述符集合里所有位清0

说明:

  • 监听socket也由select来轮询,不需要单独的线程;
  • working_set每次都要重新设置,因为select调用后它所检测的集合working_set会被修改;
  • 接收很长一段数据时,需要循环多次recv。但是recv函数会阻塞,可以通过自定义包头(保存数据长度)
2.1.4、select 缺点
  1. 单个进程可监视的fd数量被限制,即能监听端口的大小有限。一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.
  2. 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低:当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。
    1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
    2. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  3. 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

2.2、Poll

poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大
相关的函数:

#include <poll.h>
int poll(struct pollfd fds[], nfds_t nfds, int timeout)

参数描述:

  1. 该poll()函数返回fds集合中就绪的读、写,或出错的描述符数量,返回0表示超时,返回-1表示出错;
  2. fds是一个struct pollfd类型的数组,用于存放需要检测其状态的socket描述符,并且调用poll函数之后fds数组不会被清空;
  3. nfds:记录数组fds中描述符的总数量;
  4. timeout:调用poll函数阻塞的超时时间,单位毫秒;

其中pollfd结构体定义如下:

typedef struct pollfd {
        int fd;                         /* 需要被检测或选择的文件描述符*/
        short events;                   /* 对文件描述符fd上感兴趣的事件 */
        short revents;                  /* 文件描述符fd上当前实际发生的事件*/
} pollfd_t;

一个pollfd结构体表示一个被监视的文件描述符,通过传递fds[]指示 poll() 监视多个文件描述符,其中:

  • 结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域。
  • 结构体的revents域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。

events域中请求的任何事件都可能在revents域中返回。合法的事件如下:

常量说明
POLLIN普通或优先级带数据可读
POLLRDNORM普通数据可读
POLLRDBAND优先级带数据可读
POLLPRI高优先级数据可读
POLLOUT普通数据可写
POLLWRNORM普通数据可写
POLLWRBAND优先级带数据可写
POLLERR发生错误
POLLHUP发生挂起
POLLNVAL描述字不是一个打开的文件

当需要监听多个事件时,使用POLLIN | POLLRDNORM设置 events 域;当poll调用之后检测某事件是否就绪时,fds[i].revents & POLLIN进行判断。
缺点

  1. 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
  2. poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

2.3、Epoll

epoll可以理解为event poll(基于事件的轮询)。

2.3.1、使用场合
  1. 当客户处理多个描述符时(一般是交互式输入和网络套接口),必须使用I/O复用。
  2. 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
  3. 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
  4. 如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
  5. 如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。

I/O多路复用有很多种实现。在linux上,2.4内核前主要是select和poll,自Linux 2.6内核正式引入epoll以来,epoll已经成为了目前实现高性能网络服务器的必备技术。尽管他们的使用方法不尽相同,但是本质上却没有什么区别。

2.3.2、Epoll原理

不同于select/poll,Epoll正是保存了那些收到数据的Socket到一个双向链表中,这样一来,就少了一次遍历。epoll = 减少遍历 + 保存就绪Socket

  1. 减少遍历

将控制与阻塞分离。

  1. 保存就绪Socket

维护一个rdlist以及rb_tree,类似于双向链表操作
通过 epoll_ctl 添加 Socket1、Socket2 和 Socket3 的监视,内核会将 eventpoll的引用 添加到这三个 Socket 的等待队列中。
epoll 在 Linux 内核中申请了一个简易的文件系统,用于存储相关的对象,每一个 epoll 对象都有一个独立的 eventpoll 结构体,这个结构体会在内核空间中创造独立的内存,用于存储使用epoll_ctl 方法向 epoll 对象中添加进来的事件。这些事件都会挂到 rbr 红黑树中,这样,重复添加的事件就可以通过红黑树而高效地识别出来。
image.png
epoll底层实现最重要的两个数据结构:**epitem **和 eventpoll。可以简单的认为 epitem 是和每个用户态监控IO的fd对应的,eventpoll是用户态创建的管理所有被监控fd的结构,详细的定义如下:

struct epitem {
  struct rb_node  rbn;      
  struct list_head  rdllink; 
  struct epitem  *next;      
  struct epoll_filefd  ffd;  
  int  nwait;                 
  struct list_head  pwqlist;  
  struct eventpoll  *ep;      
  struct list_head  fllink;   
  struct epoll_event  event;  
};

struct eventpoll {
  spin_lock_t       lock; 
  struct mutex      mtx;  
  wait_queue_head_t     wq; 
  wait_queue_head_t   poll_wait; 
  struct list_head    rdllist;   //就绪链表
  struct rb_root      rbr;      //红黑树根节点 
  struct epitem      *ovflist;
};
2.3.3、epoll过程

image.png

  • epoll_create

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

eventpoll 这个结构体中的几个成员的含义如下:

  • wq: 等待队列链表。软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。
  • rbr: 一棵红黑树。为了支持对海量连接的高效查找、插入和删除,eventpoll 内部使用了一棵红黑树。通过这棵树来管理用户进程下添加进来的所有 socket 连接。
  • rdllist: 就绪的描述符的链表。当有的连接就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程只需要判断链表就能找出就绪进程,而不用去遍历整棵树。

当epollevent被创建之后需要初始化等待队列头、初始化就绪列表、初始化红黑树指针

epollfd = epoll_create(1024);
if (epollfd == -1) {
    perror("epoll_create");
    exit(EXIT_FAILURE);
}

这个池子对我们来说是黑盒,这个黑盒是用来装 fd 的,我们暂不纠结其中细节。我们拿到了一个 epollfd ,这个 epollfd 就能唯一代表这个 epoll 池。注意,这里又有一个细节:用户可以创建多个 epoll 池。
然后,我们就要往这个 epoll 池里放 fd 了,这就要用到 epoll_ctl 了

  • epoll_ctl

image.png
通过 epoll_ctl 添加 Socket1、Socket2 和 Socket3 的监视,内核会将 eventpoll的引用 添加到这三个 Socket 的等待队列中。
假设我们现在和客户端们的多个连接的 socket 都创建好了,也创建好了 epoll 内核对象。在使用 epoll_ctl 注册每一个 socket 的时候,内核会做如下三件事情

  1. 分配一个红黑树节点对象 epitem,
  2. 添加等待事件到 socket 的等待队列中,其回调函数是 ep_poll_callback
  3. 将 epitem 插入到 epoll 对象的红黑树里

在 epoll_ctl 中首先根据传入 fd 找到 eventpoll、socket相关的内核对象 。对于 EPOLL_CTL_ADD 操作来说,会然后执行到 ep_insert 函数。所有的注册都是在这个函数中完成的。
image.png
对于每一个 socket,调用 epoll_ctl 的时候,都会为之分配一个 epitem。
当Socket收到数据之后,中断程序会执行将Socket的引用添加到eventpoll对象的rdlist就绪列表中。
分配完 epitem 对象后,紧接着并把它插入到红黑树中。
image.png
假设计算机中正在运行进程 A 和进程 B、C,在某时刻进程 A 运行到了 epoll_wait 语句,会将进程A添加到eventpoll的等待队列中。

  • epoll_wait

epoll_wait 做的事情不复杂,当它被调用时它观察 eventpoll->rdllist 链表里有没有数据即可。有数据就返回,没有数据就创建一个等待队列项,将其添加到 eventpoll 的等待队列上,然后把自己阻塞掉就完事。

  1. 判断就绪队列上有没有事件就绪
  2. 定义等待时间并关联当前进程
    1. 假设确实没有就绪的连接,那接着会进入 init_waitqueue_entry 中定义等待任务,并把 current (当前进程)添加到 waitqueue 上。
  3. 添加到等待队列中
  4. 让出CPU主动进入睡眠状态

在前面 epoll_ctl 执行的时候,内核为每一个 socket 上都添加了一个等待队列项。在 epoll_wait 运行完的时候,又在 event poll 对象上添加了等待队列元素。
image.png
假设我们现在和客户端们的多个连接的 socket 都创建好了,也创建好了 epoll 内核对象。在使用 epoll_ctl 注册每一个 socket 的时候,内核会做如下三件事情

  1. 分配一个红黑树节点对象 epitem,
  2. 添加等待事件到 socket 的等待队列中,其回调函数是 ep_poll_callback
  3. 将 epitem 插入到 epoll 对象的红黑树里

image.png
当 Socket 接收到数据,中断程序一方面修改 rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程 A 再次进入运行状态。因为Soket包含eventpoll对象的引用,因此可以直接操作eventpoll对象.
image.png
那有简单高效的数据结构吗?
有,红黑树。Linux 内核对于 epoll 池的内部实现就是用红黑树的结构体来管理这些注册进程来的句柄 fd。红黑树是一种平衡二叉树,时间复杂度为 O(log n),就算这个池子就算不断的增删改,也能保持非常稳定的查找性能。
现在思考第二个高效的秘密:怎么才能保证数据准备好之后,立马感知呢?
epoll_ctl 这里会涉及到一点。秘密就是:回调的设置。在 epoll_ctl 的内部实现中,除了把句柄结构用红黑树管理,另一个核心步骤就是设置 poll 回调。

  1. 内部管理 fd 使用了高效的红黑树结构管理,做到了增删改之后性能的优化和平衡;
  2. epoll 池添加 fd 的时候,调用 file_operations->poll ,把这个 fd 就绪之后的回调路径安排好。通过事件通知的形式,做到最高效的运行;
  3. epoll 池核心的两个数据结构:红黑树和就绪列表。红黑树是为了应对用户的增删改需求,就绪列表是 fd 事件就绪之后放置的特殊地点,epoll 池只需要遍历这个就绪链表,就能给用户返回所有已经就绪的 fd 数组;

image.png

2.3.4、epoll API

epoll的api定义:

// 用户数据载体
typedef union epoll_data {
   void    *ptr;
   int      fd;
   uint32_t u32;
   uint64_t u64;
} epoll_data_t;
// fd装载入内核的载体
 struct epoll_event {
     uint32_t     events;    /* Epoll events */
     epoll_data_t data;      /* User data variable */
 };
 // 三板斧api
int epoll_create(int size); 
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
int epoll_wait(int epfd, struct epoll_event *events,
                 int maxevents, int timeout);
  • poll_create是在内核区创建一个epoll相关的一些列结构,并且将一个句柄fd返回给用户态,后续的操作都是基于此fd的,参数size是告诉内核这个结构的元素的大小,类似于stl的vector动态数组,如果size不合适会涉及复制扩容,不过貌似4.1.2内核之后size已经没有太大用途了;
  • epoll_ctl是将fd添加/删除于epoll_create返回的epfd中,其中epoll_event是用户态和内核态交互的结构,定义了用户态关心的事件类型和触发时数据的载体epoll_data;
  • epoll_wait*是阻塞等待内核返回的可读写事件,epfd还是epoll_create的返回值,events是个结构体数组指针存储epoll_event,也就是将内核返回的待处理epoll_event结构都存储下来,maxevents告诉内核本次返回的最大fd数量,这个和events指向的数组是相关的;
  • epoll_wait是用户态需监控fd的代言人,后续用户程序对fd的操作都是基于此结构的;

总结:
每次调用poll/select系统调用,操作系统都要把current(当前进程)挂到fd对应的所有设备的等待队列上,可以想象,fd多到上千的时候,这样“挂”法很费事;而每次调用epoll_wait则没有这么罗嗦,epoll只在epoll_ctl时把current挂一遍(这第一遍是免不了的)并给每个fd一个命令“好了就调回调函数”,如果设备有事件了,通过回调函数,会把fd放入rdllist,而每次调用epoll_wait就只是收集rdllist里的fd就可以了——epoll巧妙的利用回调函数,实现了更高效的事件驱动模型。

2.3.5、Epoll优点
  1. 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
  2. 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
  3. 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。

3、epoll工作模式

3.1、LT模式(默认)

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。
LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作,而在ET(边缘触发)模式中,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无 论fd中是否还有数据可读。

3.2、ET模式

ET (edge-triggered) 是高速工作方式,只支持no-block socket(非阻塞)。 在这种模式下,当描述符从未就绪变为就绪时,内核就通过epoll告诉你,然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的 就绪通知,直到你做了某些操作而导致那个文件描述符不再是就绪状态(比如 你在发送,接收或是接受请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核就不会发送更多的通知(only once)。不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认。

3.3、epoll为什么要有EPOLL ET触发模式?

如果采用EPOLL LT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率.。而采用EPOLL ET这种边沿触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

4、epoll 优化select 和 poll方案?

epoll既然是对select和poll的改进,就应该能避免上述缺点。那epoll都是怎么解决的呢?
在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctlepoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。
  对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
  对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。
  epoll没有FD个数这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

5、场景题

5.1、选择技术

1、表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
2、select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善

5.2、百万用户方案

设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?
select / poll 方案:
在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。
epoll方案:
epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树)。把原先的select/poll调用分成了3个部分:

  1. 调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
  2. 调用epoll_ctl向epoll对象中添加这100万个连接的套接字
  3. 调用epoll_wait收集发生的事件的连接

6、问答

6.1、问题:单核 CPU 能实现并行吗?

不行。

6.2、问题:单线程能实现高并发吗?

可以。

6.3、问题:那并发和并行的区别是?

一个看的是时间段内的执行情况,一个看的是时间时刻的执行情况。

6.4、问题:单线程如何做到高并发?

IO 多路复用呗,今天讲的 epoll 池就是了。

6.5、问题:单线程实现并发的有开源的例子吗?

redis,nginx 都是非常好的学习例子。当然还有我们 Golang 的 runtime 实现也尽显高并发的设计思想。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值