深刻理解LinuxIO复用之epoll

0.概述node

经过本篇文章将了解到如下内容:linux

  • I/O复用的定义和产生背景
  • Linux系统的I/O复用工具
  • epoll设计的基本构成
  • epoll高性能的底层实现
  • epoll的ET模式和LT模式

1.复用技术和I/O复用面试

  • 复用的概念

复用技术(multiplexing)并非新技术而是一种设计思想,在通讯和硬件设计中存在频分复用、时分复用、波分复用、码分复用等,在平常生活中复用的场景也很是多,所以不要被专业术语所迷惑算法

从本质上来讲,复用就是为了解决_有限资源和过多使用者的不平衡问题,且此技术的理论基础是资源的可释放性数据库

  • 资源的可释放性

举个实际生活的例子:
不可释放场景:ICU病房的呼吸机做为有限资源,病人一旦占用且在未脱离危险以前是没法放弃占用的,所以不可能几个状况同样的病人轮流使用。
可释放场景:对于一些其余资源好比医护人员就能够实现对多个病人的同时监护,理论上不存在一个病人占用医护人员资源不释放的场景。编程

  • 理解IO复用

I/O的含义:在计算机领域常说的IO包括磁盘IO和网络IO,咱们所说的IO复用主要是指网络IO,在Linux中一切皆文件,所以网络IO也常常用文件描述符FD来表示。后端

复用的含义:那么这些文件描述符FD要复用什么呢?在网络场景中复用的就是任务处理线程,因此简单理解就是多个IO共用1个线程。api

IO复用的可行性:IO请求的基本操做包括read和write,因为网络交互的本质性,必然存在等待,换言之就是整个网络链接中FD的读写是交替出现的,时而可读可写,时而空闲,因此IO复用是可用实现的。数组

综上认为,IO复用技术就是协调多个可释放资源的FD交替共享任务处理线程完成通讯任务,实现多个fd对应1个任务处理线程安全

现实生活中IO复用就像一只边牧管理几百只绵羊同样

  • IO复用的设计原则和产生背景

高效IO复用机制要知足:协调者消耗最少的系统资源、最小化FD的等待时间、最大化FD的数量、任务处理线程最少的空闲、多快好省完成任务等。

在网络并发量很是小的原始时期,即便per req per process地处理网络请求也能够知足要求,可是随着网络并发量的提升,原始方式必将阻碍进步,因此就刺激了IO复用机制的实现和推广。

2.Linux中IO复用工具

在Linux中前后出现了select、poll、epoll等,FreeBSD的kqueue也是很是优秀的IO复用工具,kqueue的原理和epoll很相似,本文以Linux环境为例,而且不讨论过多select和poll的实现机制和细节。

  • 开拓者select

select大约是2000年初出现的,其对外的接口定义:

/* 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);
void FD_CLR(int fd, fd_set *set);
int  FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);

做为第一个IO复用系统调用,select使用一个宏定义函数按照bitmap原理填充fd,默认大小是1024个,所以对于fd的数值大于1024均可能出现问题,看下官方预警:

Macro: int FD_SETSIZE
The value of this macro is the maximum number of file descriptors
that a fd_set object can hold information about. On systems with a 
fixed maximum number, FD_SETSIZE is at least that number. 
On some systems, including GNU, there is no absolute limit on the 
number of descriptors open, but this macro still has a constant 
value which controls the number of bits in an fd_set; 
if you get a file descriptor with a value as high as FD_SETSIZE, 
you cannot put that descriptor into an fd_set.

也就是说当fd的数值大于1024时在将不可控,官方不建议超过1024,可是咱们也没法控制fd的绝对数值大小,以前针对这个问题作过一些调研,结论是系统对于fd的分配有本身的策略,会大几率分配到1024之内,对此我并无充分理解,只是说起一下这个坑

存在的问题:

  • 可协调fd数量和数值都不超过1024 没法实现高并发
  • 使用O(n)复杂度遍历fd数组查看fd的可读写性 效率低
  • 涉及大量kernel和用户态拷贝 消耗大
  • 每次完成监控须要再次从新传入而且分事件传入 操做冗余

综上可知:
select以朴素的方式实现了IO复用,将并发量提升的最大K级,可是对于完成这个任务的代价和灵活性都有待提升。不管怎么样select做为先驱对IO复用有巨大的推进,而且指明了后续的优化方向,不要无知地指责select

  • 继承者epoll

epoll最初在2.5.44内核版本出现,后续在2.6.x版本中对代码进行了优化使其更加简洁,前后面对外界的质疑在后续增长了一些设置来解决隐藏的问题,因此epoll也已经有十几年的历史了。

在《Unix网络编程》第三版(2003年)尚未介绍epoll,由于那个时代epoll尚未出现,书中只介绍了select和poll,epoll对select中存在的问题都逐一解决,简单来讲epoll的优点包括:

  • 对fd数量没有限制(固然这个在poll也被解决了)
  • 抛弃了bitmap数组实现了新的结构来存储多种事件类型
  • 无需重复拷贝fd 随用随加 随弃随删
  • 采用事件驱动避免轮询查看可读写事件

综上可知
epoll出现以后大大提升了并发量对于C10K问题轻松应对,即便后续出现了真正的异步IO,也并无(暂时没有)撼动epoll的江湖地位,主要是由于epoll能够解决数万数十万的并发量,已经能够解决如今大部分的场景了,异步IO当然优异,可是编程难度比epoll更大,权衡之下epoll仍然富有生命力。

3.epoll的基本实现

  • 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);
  • epoll_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_event是用户态需监控fd的代言人,后续用户程序对fd的操做都是基于此结构的;
  • 通俗描述:

可能上面的描述有些抽象,不过其实很好理解,举个现实中的例子:

  • epoll_create场景
    大学开学第一周,你做为班长须要帮全班同窗领取相关物品,你在学生处告诉工做人员,我是xx学院xx专业xx班的班长,这时工做人员肯定你的身份而且给了你凭证,后面办的事情都须要用到(也就是调用epoll\_create向内核申请了epfd结构,内核返回了epfd句柄给你使用);
  • epoll_ctl场景
    你拿着凭证在办事大厅开始办事,分拣办公室工做人员说班长你把全部须要办理事情的同窗的学生册和须要办理的事情都记录下来吧,因而班长开始在每一个学生手册单独写对应须要办的事情:李明须要开实验室权限、孙大熊须要办游泳卡......就这样班长一股脑写完并交给了工做人员(也就是告诉内核哪些fd须要作哪些操做);
  • epoll_wait场景
    你拿着凭证在领取办公室门前等着,这时候广播喊xx班长大家班孙大熊的游泳卡办好了速来领取、李明实验室权限卡办好了速来取....还有同窗的事情没办好,因此班长只能继续(也就是调用epoll\_wait等待内核反馈的可读写事件发生并处理);
  • 官方DEMO

经过man epoll能够看到官方的demo:

#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;

/* Set up listening socket, 'listen_sock' (socket(),
  bind(), listen()) */

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

ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if(epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
   perror("epoll_ctl: listen_sock");
   exit(EXIT_FAILURE);
}

for(;;) {
   nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
   if (nfds == -1) {
       perror("epoll_pwait");
       exit(EXIT_FAILURE);
   }

   for (n = 0; n < nfds; ++n) {
       if (events[n].data.fd == listen_sock) {
           //主监听socket有新链接
           conn_sock = accept(listen_sock,
                           (struct sockaddr *) &local, &addrlen);
           if (conn_sock == -1) {
               perror("accept");
               exit(EXIT_FAILURE);
           }
           setnonblocking(conn_sock);
           ev.events = EPOLLIN | EPOLLET;
           ev.data.fd = conn_sock;
           if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
                       &ev) == -1) {
               perror("epoll_ctl: conn_sock");
               exit(EXIT_FAILURE);
           }
       } else {
           //已创建链接的可读写句柄
           do_use_fd(events[n].data.fd);
       }
   }
}

4.epoll的底层实现

epoll底层实现最重要的两个数据结构:epitem和eventpoll
能够简单的认为epitem是和每一个用户态监控IO的fd对应的,eventpoll是用户态建立的管理全部被监控fd的结构,详细的定义以下:

#ifndef  _LINUX_RBTREE_H
#define  _LINUX_RBTREE_H
#include <linux/kernel.h>
#include <linux/stddef.h>
#include <linux/rcupdate.h>

struct rb_node {
  unsigned long  __rb_parent_color;
  struct rb_node *rb_right;
  struct rb_node *rb_left;
} __attribute__((aligned(sizeof(long))));
/* The alignment might seem pointless, but allegedly CRIS needs it */
struct rb_root {
  struct rb_node *rb_node;
};
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;
};
  • 底层调用过程

epoll_create会建立一个类型为struct eventpoll的对象,并返回一个与之对应文件描述符,以后应用程序在用户态使用epoll的时候都将依靠这个文件描述符,而在epoll内部也是经过该文件描述符进一步获取到eventpoll类型对象,再进行对应的操做,完成了用户态和内核态的贯穿。

epoll_ctl底层主要调用epoll_insert实现操做:

  • 建立并初始化一个strut epitem类型的对象,完成该对象和被监控事件以及epoll对象eventpoll的关联;
  • 将struct epitem类型的对象加入到epoll对象eventpoll的红黑树中管理起来;
  • 将struct epitem类型的对象加入到被监控事件对应的目标文件的等待列表中,并注册事件就绪时会调用的回调函数,在epoll中该回调函数就是ep_poll_callback();
  • ovflist主要是暂态处理,好比调用ep_poll_callback()回调函数的时候发现eventpoll的ovflist成员不等于EP_UNACTIVE_PTR,说明正在扫描rdllist链表,这时将就绪事件对应的epitem加入到ovflist链表暂存起来,等rdllist链表扫描完再将ovflist链表中的元素移动到rdllist链表中;
  • 如图展现了红黑树、双链表、epitem之间的关系:

注:rbr表示rb_root,rbn表示rb_node 上文给出了其在内核中的定义

  • epoll_wait的数据拷贝
常见错误观点:
epoll_wait返回时,对于就绪的事件,epoll使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,因此就避免了内存拷贝消耗
网上抄来抄去的观点

关于epoll_wait使用共享内存的方式来加速用户态和内核态的数据交互,避免内存拷贝的观点,并无获得2.6内核版本代码的证明,而且关于此次拷贝的实现是这样的:

revents = ep_item_poll(epi, &pt);//获取就绪事件
if (revents) {
  if (__put_user(revents, &uevent->events) ||
  __put_user(epi->event.data, &uevent->data)) {
    list_add(&epi->rdllink, head);//处理失败则从新加入链表
    ep_pm_stay_awake(epi);
    return eventcnt ? eventcnt : -EFAULT;
  }
  eventcnt++;
  uevent++;
  if (epi->event.events & EPOLLONESHOT)
    epi->event.events &= EP_PRIVATE_BITS;//EPOLLONESHOT标记的处理
  else if (!(epi->event.events & EPOLLET)) {
    list_add_tail(&epi->rdllink, &ep->rdllist);//LT模式处理
    ep_pm_stay_awake(epi);
  }
}

5.ET模式和LT模式

  • 简单理解

默认采用LT模式,LT支持阻塞和非阻塞套,ET模式只支持非阻塞套接字,其效率要高于LT模式,而且LT模式更加安全。
LT和ET模式下均可以经过epoll_wait方法来获取事件,LT模式下将事件拷贝给用户程序以后,若是没有被处理或者未处理完,那么在下次调用时还会反馈给用户程序,能够认为数据不会丢失会反复提醒;
ET模式下若是没有被处理或者未处理完,那么下次将再也不通知到用户程序,所以避免了反复被提醒,却增强了对用户程序读写的要求;

  • 深刻理解

上面的简单理解在网上随便找一篇都会讲到,可是LT和ET真正使用起来,仍是存在必定难度的。

  • LT的读写操做

LT对于read操做比较简单,有read事件就读,读多读少都没有问题,可是write就不那么容易了,通常来讲socket在空闲状态时发送缓冲区必定是不满的,假如fd一直在监控中,那么会一直通知写事件,不胜其烦
因此必须保证没有数据要发送的时候,要把fd的写事件监控从epoll列表中删除,须要的时候再加入回去,如此反复。

天下没有免费的午饭,老是无代价地提醒是不可能的,对应write的过分提醒,须要使用者随用随加,不然将一直被提醒可写事件。

  • ET的读写操做

fd可读则返回可读事件,若开发者没有把全部数据读取完毕,epoll不会再次通知read事件,也就是说若是没有所有读取全部数据,那么致使epoll不会再通知该socket的read事件,事实上一直读完很容易作到。

若发送缓冲区未满,epoll通知write事件,直到开发者填满发送缓冲区,epoll才会在下次发送缓冲区由满变成未满时通知write事件。ET模式下只有socket的状态发生变化时才会通知,也就是读取缓冲区由无数据到有数据时通知read事件,发送缓冲区由满变成未满通知write事件。

  • 一道面试题
使用Linux epoll模型的LT水平触发模式,当socket可写时,会不停的触发socket可写的事件,如何处理?
网络流传的腾讯面试题

这道题目对LT和ET考察比较深刻,验证了前文说的LT模式write问题。

普通作法

当须要向socket写数据时,将该socket加入到epoll等待可写事件。接收到socket可写事件后,调用write()或send()发送数据,当数据所有写完后, 将socket描述符移出epoll列表,这种作法须要反复添加和删除。

改进作法:

向socket写数据时直接调用send()发送,当send()返回错误码EAGAIN,才将socket加入到epoll,等待可写事件后再发送数据,所有数据发送完毕,再移出epoll模型,改进的作法至关于认为socket在大部分时候是可写的,不能写了再让epoll帮忙监控。

小结
上面两种作法是对LT模式下write事件频繁通知的修复,本质上ET模式就能够直接搞定,并不须要用户层程序的补丁操做。

  • ET模式的线程饥饿问题

若是某个socket源源不断地收到很是多的数据,在试图读取完全部数据的过程当中,有可能会形成其余的socket得不处处理,从而形成饥饿问题。

解决办法:
为每一个已经准备好的描述符维护一个队列,这样程序就能够知道哪些描述符已经准备好了可是并无被读取完,而后程序定时或定量的读取,若是读完则移除,直到队列为空,这样就保证了每一个fd都被读到而且不会丢失数据,流程如图:

  • EPOLLONESHOT设置

A线程读完某socket上数据后开始处理这些数据,此时该socket上又有新数据可读,B线程被唤醒读新的数据,形成2个线程同时操做一个socket的局面 ,EPOLLONESHOT保证一个socket链接在任一时刻只被一个线程处理。

6.epoll的惊群问题

在2.6.18内核中accept的惊群问题已经被解决了,可是在epoll中仍然存在惊群问题,表现起来就是当多个进程/线程调用epoll_wait时会阻塞等待,当内核触发可读写事件,全部进程/线程都会进行响应,可是实际上只有一个进程/线程真实处理这些事件。

在epoll官方没有正式修复这个问题以前,Nginx做为知名使用者采用全局锁来限制每次可监听fd的进程数量,每次只有1个可监听的进程,后来在Linux 3.9内核中增长了SO\_REUSEPORT选项实现了内核级的负载均衡,Nginx1.9.1版本支持了reuseport这个新特性,从而解决惊群问题。

EPOLLEXCLUSIVE是在2016年Linux 4.5内核新添加的一个 epoll 的标识,Ngnix 在 1.11.3 以后添加了NGX_EXCLUSIVE_EVENT选项对该特性进行支持。EPOLLEXCLUSIVE标识会保证一个事件发生时候只有一个线程会被唤醒,以免多侦听下的惊群问题。

7.参考资料
网络编程之IO模型
一个epoll惊群致使的性能问题
Nignx如何解决惊群问题
LINUX – IO MULTIPLEXING – SELECT VS POLL VS EPOLL

8.一个彩蛋:公号简介
后端技术指南针:专一于分享和探讨后端技术点,涵盖编程语言、数据结构、算法、操做系统、数据库、分布式系统、大数据、中间件等, 追求有深度且具表达力的文字,记录所思所得。

相关文章
相关标签/搜索