微信公众号【黄小斜】做者是蚂蚁金服 JAVA 工程师,目前在蚂蚁财富负责后端开发工做,专一于 JAVA 后端技术栈,同时也懂点投资理财,坚持学习和写做,用大厂程序员的视角解读技术与互联网,个人世界里不仅有 coding!关注公众号后回复”架构师“便可领取 Java基础、进阶、项目和架构师等免费学习资料,更有数据库、分布式、微服务等热门技术学习视频,内容丰富,兼顾原理和实践,另外也将赠送做者原创的Java学习指南、Java程序员面试指南等干货资源node
在linux 没有实现epoll事件驱动机制以前,咱们通常选择用select或者poll等IO多路复用的方法来实现并发服务程序。在大数据、高并发、集群等一些名词唱得火热之年代,select和poll的用武之地愈来愈有限,风头已经被epoll占尽。linux
本文便来介绍epoll的实现机制,并附带讲解一下select和poll。经过对比其不一样的实现机制,真正理解为什么epoll能实现高并发。程序员
这部分转自https://jeff.wtf/2017/02/IO-multiplexing/面试
当须要从一个叫 r_fd
的描述符不停地读取数据,并把读到的数据写入一个叫 w_fd
的描述符时,咱们能够用循环使用阻塞 I/O :数据库
<pre>123</pre> | <pre>while((n = read(r_fd, buf, BUF_SIZE)) > 0) if(write(w_fd, buf, n) != n) err_sys("write error");</pre> |
---|
可是,若是要从两个地方读取数据呢?这时,不能再使用会把程序阻塞住的 read
函数。由于可能在阻塞地等待 r_fd1
的数据时,来不及处理 r_fd2
,已经到达的 r_fd2
的数据可能会丢失掉。编程
这个状况下须要使用非阻塞 I/O。后端
只要作个标记,把文件描述符标记为非阻塞的,之后再对它使用 read
函数:若是它尚未数据可读,函数会当即返回并把 errorno 这个变量的值设置为 35,因而咱们知道它没有数据可读,而后能够立马去对其余描述符使用 read
;若是它有数据可读,咱们就读取它数据。对全部要读的描述符都调用了一遍 read
以后,咱们能够等一个较长的时间(好比几秒),而后再从第一个文件描述符开始调用 read
。这种循环就叫作轮询(polling)。数组
这样,不会像使用阻塞 I/O 时那样由于一个描述符 read
长时间处于等待数据而使程序阻塞。服务器
轮询的缺点是浪费太多 CPU 时间。大多数时候咱们没有数据可读,可是仍是用了 read
这个系统调用,使用系统调用时会从用户态切换到内核态。而大多数状况下咱们调用 read
,而后陷入内核态,内核发现这个描述符没有准备好,而后切换回用户态而且只获得 EAGAIN (errorno 被设置为 35),作的是无用功。描述符很是多的时候,每次的切换过程就是巨大的浪费。微信
因此,须要 I/O 多路复用。I/O 多路复用经过使用一个系统函数,同时等待多个描述符的可读、可写状态。
为了达到这个目的,咱们须要作的是:创建一个描述符列表,以及咱们分别关心它们的什么事件(可读仍是可写仍是发生例外状况);调用一个系统函数,直到这个描述符列表里有至少一个描述符关联的事件发生时,这个函数才会返回。
select, poll, epoll 就是这样的系统函数。
咱们能够在全部 POSIX 兼容的系统里使用 select 函数来进行 I/O 多路复用。咱们须要经过 select 函数的参数传递给内核的信息有:
select 的返回时,内核会告诉咱们:
<pre>123456</pre> | <pre>#include <sys/select.h>int select(int maxfdp1, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);// 返回值: 已就绪的描述符的个数。超时时为 0 ,错误时为 -1</pre> |
---|
maxfdp1
意思是 “max file descriptor plus 1” ,就是把你要监视的全部文件描述符里最大的那个加上 1 。(它实际上决定了内核要遍历文件描述符的次数,好比你监视了文件描述符 5 和 20 并把 maxfdp1
设置为 21 ,内核每次都会从描述符 0 依次检查到 20。)
中间的三个参数是你想监视的文件描述符的集合。能够把 fd_set 类型视为 1024 位的二进制数,这意味着 select 只能监视小于 1024 的文件描述符(1024 是由 Linux 的 sys/select.h 里 FD_SETSIZE
宏设置的值)。在 select 返回后咱们经过 FD_ISSET
来判断表明该位的描述符是不是已准备好的状态。
最后一个参数是等待超时的时长:到达这个时长可是没有任一描述符可用时,函数会返回 0 。
用一个代码片断来展现 select 的用法:
<pre>12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455</pre> | <pre>// 这个例子要监控文件描述符 3, 4 的可读状态,以及 4, 5 的可写状态// 初始化两个 fd_set 以及 timevalfd_set read_set, write_set;FD_ZERO(read_set);FD_ZERO(write_set);timeval t;t.tv_sec = 5; // 超时为 5 秒t.tv_usec = 0; // 加 0 微秒// 设置好两个 fd_setint fd1 = 3;int fd2 = 4;int fd3 = 5;int maxfdp1 = 5 + 1;FD_SET(fd1, &read_set);FD_SET(fd2, &read_set);FD_SET(fd2, &write_set);FD_SET(fd3, &write_set);// 准备备用的 fd_setfd_set r_temp = read_set;fd_set w_temp = write_set;while(true){ // 每次都要从新设置放入 select 的 fd_set read_set = r_temp; write_set = w_temp; // 使用 select int n = select(maxfdp1, &read_set, &write_set, NULL, &t); // 上面的 select 函数会一直阻塞,直到 // 3, 4 可读以及 4, 5 可写这四件事中至少一项发生 // 或者等待时间到达 5 秒,返回 0 for(int i=0; i<maxfdp1 && n>0; i++){ if(FD_ISSET(i, &read_set)){ n--; if(i==fd1) prinf("描述符 3 可读"); if(i==fd2) prinf("描述符 4 可读"); } if(FD_ISSET(i, &write_set)){ n--; if(i==fd2) prinf("描述符 3 可写"); if(i==fd3) prinf("描述符 4 可写"); } } // 上面的 printf 语句换成对应的 read 或者 write 函数就 // 能够当即读取或者写入相应的描述符而不用等待}</pre> |
---|
能够看到,select 的缺点有:
FD_SETSIZE
值,但因为 select 是每次都会线性扫描整个fd_set,集合越大速度越慢,因此性能会比较差。FD_ISSET
来检查,当未准备好的描述符不少而准备好的不多时,效率比较低。还有一个问题是在代码的写法上给我一些困扰的,就是每次调用 select 前必须从新设置三个 fd_set。 fd_set 类型只是 1024 位的二进制数(实际上结构体里是几个 long 变量的数组;好比 64 位机器上 long 是 64 bit,那么 fd_set 里就是 16 个 long 变量的数组),由一位的 1 和 0 表明一个文件描述符的状态,可是其实调用 select 先后位的 1/0 状态意义是不同的。
先讲一下几个对 fd_set 操做的函数的做用:FD_ZERO
把 fd_set 全部位设置为 0 ;FD_SET
把一个位设置为 1 ;FD_ISSET
判断一个位是否为 1 。
调用 select 前:咱们用 FD_ZERO
把 fd_set 先所有初始化,而后用 FD_SET
把咱们关心的表明描述符的位设置为 1 。咱们这时能够用 FD_ISSET
判断这个位是否被咱们设置,这时的含义是咱们想要监视的描述符是否被设置为被监视的状态。
调用 select 时:内核判断 fd_set 里的位并把各个 fd_set 里全部值为 1 的位记录下来,而后把 fd_set 所有设置成 0 ;一个描述符上有对应的事件发生时,把对应 fd_set 里表明这个描述符的位设置为 1 。
在 select 返回以后:咱们一样用 FD_ISSET
判断各个咱们关心的位是 0 仍是 1 ,这时的含义是,这个位是不是发生了咱们关心的事件。
因此,在下一次调用 select 前,咱们不得不把已经被内核改掉的 fd_set 所有从新设置一下。
select 在监视大量描述符尤为是更多的描述符未准备好的状况时性能不好。《Unix 高级编程》里写,用 select 的程序一般只使用 3 到 10 个描述符。
poll 和 select 是类似的,只是给的接口不一样。
<pre>1234</pre> | <pre>#include <poll.h>int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);// 返回值: 已就绪的描述符的个数。超时时为 0 ,错误时为 -1</pre> |
---|
fdarray
是 pollfd
的数组。pollfd
结构体是这样的:
<pre>12345</pre> | <pre>struct pollfd { int fd; // 文件描述符 short events; // 我期待的事件 short revents; // 实际发生的事件:我期待的事件中发生的;或者异常状况};</pre> |
---|
nfds
是 fdarray
的长度,也就是 pollfd 的个数。
timeout
表明等待超时的毫秒数。
相比 select ,poll 有这些优势:因为 poll 在 pollfd 里用 int fd
来表示文件描述符而不像 select 里用的 fd_set 来分别表示描述符,因此没有必须小于 1024 的限制,也没有数量限制;因为 poll 用 events
表示期待的事件,经过修改 revents
来表示发生的事件,因此不须要像 select 在每次调用前从新设置描述符和期待的事件。
除此以外,poll 和 select 几乎相同。在 poll 返回后,须要遍历 fdarray
来检查各个 pollfd
里的 revents
是否发生了期待的事件;每次调用 poll 时,把 fdarray
复制到内核空间。在描述符太多而每次准备好的较少时,poll 有一样的性能问题。
epoll 是在 Linux 2.5.44 中首度登场的。不像 select 和 poll ,它提供了三个系统函数而不是一个。
<pre>1234</pre> | <pre>#include <sys/epoll.h>int epoll_create(int size);// 返回值:epoll 描述符</pre> |
---|
size
用来告诉内核你想监视的文件描述符的数目,可是它并非限制了能监视的描述符的最大个数,而是给内核最初分配的空间一个建议。而后系统会在内核中分配一个空间来存放事件表,并返回一个 epoll 描述符,用来操做这个事件表。
<pre>123</pre> | <pre>int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);// 返回值:成功时返回 0 ,失败时返回 -1</pre> |
---|
epfd
是 epoll 描述符。
op
是操做类型(增长/删除/修改)。
fd
是但愿监视的文件描述符。
event
是一个 epoll_event 结构体的指针。epoll_event 的定义是这样的:
<pre>1234567891011</pre> | <pre>typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64;} epoll_data_t;struct epoll_event { uint32_t events; // 我期待的事件 epoll_data_t data; // 用户数据变量};</pre> |
---|
这个结构体里,除了期待的事件外,还有一个 data
,是一个 union,它是用来让咱们在获得下面第三个函数的返回值之后方便的定位文件描述符的。
<pre>1234</pre> | <pre>int epoll_wait(int epfd, struct epoll_event *result_events, int maxevents, int timeout);// 返回值:已就绪的描述符个数。超时时为 0 ,错误时为 -1</pre> |
---|
epfd
是 epoll 描述符。
result_events
是 epoll_event 结构体的指针,它将指向的是全部已经准备好的事件描述符相关联的 epoll_event(在上个步骤里调用 epoll_ctl 时关联起来的)。下面的例子可让你知道这个参数的意义。
maxevents
是返回的最大事件个数,也就是你能经过 result_events 指针遍历到的最大的次数。
timeout
是等待超时的毫秒数。
用一个代码片断来展现 epoll 的用法:
<pre>123456789101112131415161718192021222324252627282930313233343536373839404142434445</pre> | <pre>// 这个例子要监控文件描述符 3, 4 的可读状态,以及 4, 5 的可写状态/* 经过 epoll_create 建立 epoll 描述符 /int epfd = epoll_create(4);int fd1 = 3;int fd2 = 4;int fd3 = 5;/ 经过 epoll_ctl 注册好四个事件 /struct epoll_event ev1;ev1.events = EPOLLIN; // 期待它的可读事件发生ev1.data = fd1; // 咱们一般就把 data 设置为 fd ,方便之后查看epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev1); // 添加到事件表struct epoll_event ev2;ev2.events = EPOLLIN;ev2.data = fd2;epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &ev2);struct epoll_event ev3;ev3.events = EPOLLOUT; // 期待它的可写事件发生ev3.data = fd2;epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &ev3);struct epoll_event ev4;ev4.events = EPOLLOUT;ev4.data = fd3;epoll_ctl(epfd, EPOLL_CTL_ADD, fd3, &ev4);/ 经过 epoll_wait 等待事件 */# DEFINE MAXEVENTS 4struct epoll_event result_events[MAXEVENTS];while(true){ int n = epoll_wait(epfd, &result_events, MAXEVENTS, 5000); for(int i=0; i<n; n--){ // result_events[i] 必定是 ev1 到 ev4 中的一个 if(result_events[i].events&EPOLLIN) printf("描述符 %d 可读", result_events[i].fd); else if(result_events[i].events&EPOLLOUT) printf("描述符 %d 可写", result_events[i].fd) }}</pre> |
---|
因此 epoll 解决了 poll 和 select 的问题:
只在 epoll_ctl 的时候把数据复制到内核空间,这保证了每一个描述符和事件必定只会被复制到内核空间一次;每次调用 epoll_wait 都不会复制新数据到内核空间。相比之下,select 每次调用都会把三个 fd_set 复制一遍;poll 每次调用都会把 fdarray
复制一遍。
epoll_wait 返回 n ,那么只须要作 n 次循环,能够保证遍历的每一次都是有意义的。相比之下,select 须要作至少 n 次至多 maxfdp1
次循环;poll 须要遍历完 fdarray 即作 nfds
次循环。
在内部实现上,epoll 使用了回调的方法。调用 epoll_ctl 时,就是注册了一个事件:在集合中放入文件描述符以及事件数据,而且加上一个回调函数。一旦文件描述符上的对应事件发生,就会调用回调函数,这个函数会把这个文件描述符加入到就绪队列上。当你调用 epoll_wait 时,它只是在查看就绪队列上是否有内容,有的话就返回给你的程序。select()
poll()``epoll_wait()
三个函数在操做系统看来,都是睡眠一下子而后判断一下子的循环,可是 select 和 poll 在醒着的时候要遍历整个文件描述符集合,而 epoll_wait 只是看看就绪队列是否为空而已。这是 epoll 高性能的理由,使得其 I/O 的效率不会像使用轮询的 select/poll 随着描述符增长而大大下降。
注 1 :select/poll/epoll_wait 三个函数的等待超时时间都有同样的特性:等待时间设置为 0 时函数不阻塞而是当即返回,不管是否有文件描述符已准备好;poll/epoll_wait 中的 timeout 为 -1,select 中的 timeout 为 NULL 时,则无限等待,直到有描述符已准备好才会返回。
注 2 :有的新手会把文件描述符是否标记为阻塞 I/O 等同于 I/O 多路复用函数是否阻塞。其实文件描述符是否标记为阻塞,决定了你
read
或write
它时若是它未准备好是阻塞等待,仍是当即返回 EAGAIN ;而 I/O 多路复用函数除非你把 timeout 设置为 0 ,不然它老是会阻塞住你的程序。注 3 :上面的例子只是入门,多是不许确或不全面的:一是数据要当即处理防止丢失;二是 EPOLLIN/EPOLLOUT 不彻底等同于可读可写事件,具体要去搜索 poll/epoll 的事件具体有哪些;三是大多数实际例子里,好比一个 tcp server ,都会在运行中不断增长/删除的文件描述符而不是记住固定的 3 4 5 几个描述符(用这种例子更能看出 epoll 的优点);四是 epoll 的优点更多的体如今处理大量闲链接的状况,若是场景是处理少许短链接,用 select 反而更好,并且用 select 的代码能运行在全部平台上。
select的缺点:
相比select模型,poll使用链表保存文件描述符,所以没有了监视文件数量的限制,但其余三个缺点依然存在。
拿select模型为例,假设咱们的服务器须要支持100万的并发链接,则在__FD_SETSIZE 为1024的状况下,则咱们至少须要开辟1k个进程才能实现100万的并发链接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的无脑内存拷贝、数组轮询等,是系统难以承受的。所以,基于select模型的服务器程序,要达到10万级别的并发访问,是一个很难完成的任务。
所以,该epoll上场了。
因为epoll的实现机制与select/poll机制彻底不一样,上面所说的 select的缺点在epoll上不复存在。
设想一下以下场景:有100万个客户端同时与一个服务器进程保持着TCP链接。而每一时刻,一般只有几百上千个TCP链接是活跃的(事实上大部分场景都是这种状况)。如何实现这样的高并发?
在select/poll时代,服务器进程每次都把这100万个链接告诉操做系统(从用户态复制句柄数据结构到内核态),让操做系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,所以,select/poll通常只能处理几千的并发链接。
epoll的设计和实现与select彻底不一样。epoll经过在Linux内核中申请一个简易的文件系统(文件系统通常用什么数据结构实现?B+树)。把原先的select/poll调用分红了3个部分:
1)调用epoll_create()创建一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
2)调用epoll_ctl向epoll对象中添加这100万个链接的套接字
3)调用epoll_wait收集发生的事件的链接
如此一来,要实现上面说是的场景,只须要在进程启动时创建一个epoll对象,而后在须要的时候向这个epoll对象中添加或者删除链接。同时,epoll_wait的效率也很是高,由于调用epoll_wait时,并无一股脑的向操做系统复制这100万个链接的句柄数据,内核也不须要去遍历所有的链接。
下面来看看Linux内核具体的epoll机制实现思路。
当某一进程调用epoll_create方法时,Linux内核会建立一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体以下所示:
[cpp] view plain copy
每个epoll对象都有一个独立的eventpoll结构体,用于存放经过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就能够经过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。
而全部添加到epoll中的事件都会与设备(网卡)驱动程序创建回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。
在epoll中,对于每个事件,都会创建一个epitem结构体,以下所示:
[cpp] view plain copy
当调用epoll_wait检查是否有事件发生时,只须要检查eventpoll对象中的rdlist双链表中是否有epitem元素便可。若是rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
epoll数据结构示意图
从上面的讲解可知:经过红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效。
OK,讲解完了Epoll的机理,咱们便能很容易掌握epoll的用法了。一句话描述就是:三步曲。
第一步:epoll_create()系统调用。此调用返回一个句柄,以后全部的使用都依靠这个句柄来标识。
第二步:epoll_ctl()系统调用。经过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。
第三部:epoll_wait()系统调用。经过此调用收集收集在epoll监控中已经发生的事件。
最后,附上一个epoll编程实例。
几乎全部的epoll程序都使用下面的框架:
[cpp] view plaincopyprint?
[cpp] view plaincopyprint?
微信公众号【Java技术江湖】一位阿里 Java 工程师的技术小站。(关注公众号后回复”Java“便可领取 Java基础、进阶、项目和架构师等免费学习资料,更有数据库、分布式、微服务等热门技术学习视频,内容丰富,兼顾原理和实践,另外也将赠送做者原创的Java学习指南、Java程序员面试指南等干货资源)