又到周六了,不过这周有点忙新文章还没有写,为了不跳票,就想着把早期还不错的文章,重新排版修改发一下,因为当时读者很少,现在而言完全可以当作一篇新文章(有种狡辩的意思)...
今天一起来学习一下高并发实现的的重要基础:I/O复用技术 & epoll原理。
通过本文你将了解到以下内容:
温馨提示:技术文章涉及很多细节都会比较晦涩,反复琢磨才能掌握。
在了解epoll之前,我们先看下复用技术的概念和IO复用到底在说什么?
复用技术 multiplexing 并不是新技术而是一种设计思想,在通信和硬件设计中存在频分复用、时分复用、波分复用、码分复用等,在日常生活中复用的场景也非常多,因此不要被专业术语所迷惑。
从本质上来说,复用就是为了解决有限资源和过多使用者的不平衡问题,从而实现最大的利用率,处理更多的问题。
举个例子: 不可释放场景:ICU 病房的呼吸机作为有限资源,病人一旦占用且在未脱离危险之前是无法放弃占用的,因此不可能几个情况一样的病人轮流使用。
可释放场景:对于一些其他资源比如医护人员就可以实现对多个病人的同时监护,理论上不存在一个病人占用医护人员资源不释放的场景。
所以我们可以想一下,多个 IO 共用的资源(处理线程)是否具备可释放性?
I/O的含义:在计算机领域常说的IO包括磁盘 IO 和网络 IO,我们所说的IO复用主要是指网络 IO ,在Linux中一切皆文件,因此网络IO也经常用文件描述符 FD 来表示。
复用的含义:那么这些文件描述符 FD 要复用什么呢?在网络场景中复用的就是任务处理线程,所以简单理解就是多个IO共用1个处理线程。
IO复用的可行性:IO请求的基本操作包括read和write,由于网络交互的本质性,必然存在等待,换言之就是整个网络连接中FD的读写是交替出现的,时而可读可写,时而空闲,所以IO复用是可用实现的。
综上认为:IO复用技术就是协调多个可释放资源的FD交替共享任务处理线程完成通信任务,实现多个fd对应1个任务处理线程的复用场景。
现实生活中IO复用就像一只边牧管理几百只绵羊一样:
图片来自网络:多只绵羊共享1只边牧的管理
业务需求是推动技术演进的源动力。
在网络并发量非常小的原始时期,即使 per req per process 地处理网络请求也可以满足要求,但是随着网络并发量的提高,原始方式必将阻碍进步,所以就刺激了 IO 复用机制的实现和推广。
高效 IO 复用机制要满足:协调者消耗最少的系统资源、最小化FD的等待时间、最大化FD的数量、任务处理线程最少的空闲、多快好省完成任务等。
画外音:上面的一段话可能读起来有些绕,朴素的说法就是让任务处理线程以更小的资源消耗来协调更多的网络请求连接,IO复用工具也是逐渐演进的,经过前后对比就可以发现这个原则一直贯穿其中。
理解了IO复用技术的基本概念,我们接着来看Linux系统中先后出现的各种IO复用工具以及各自的特点,加深理解。
在 Linux 中先后出现了select、poll、epoll等,FreeBSD的 kqueue也是非常优秀的 IO 复用工具,kqueue 的原理和 epoll 很类似,本文以 Linux 环境为例,并且不讨论过多 select 和 poll 的实现机制和细节。
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以内,对此我并没有充分理解,只是提及一下这个坑。
由于底层实现方式的局限性,select 存在一些问题,主要包括:
select 以朴素的方式实现了IO复用,将并发量提高的最大K级,但是对于完成这个任务的代价和灵活性都有待提高。
无论怎么样 select作为先驱对IO复用有巨大的推动,并且指明了后续的优化方向,不要无知地指责 select。
在 epoll出现之前,poll 对 select进行了改进,但是本质上并没有太大变化,因此我们跳过 poll直接看 epoll。
epoll 最初在2.5.44内核版本出现,后续在2.6.x版本中对代码进行了优化使其更加简洁,先后面对外界的质疑在后续增加了一些设置来解决隐藏的问题,所以 epoll也已经有十几年的历史了。
在《Unix网络编程》第三版(2003年)还没有介绍 epoll,因为那个时代epoll还没有出现,书中只介绍了 select和poll,epoll对select中存在的问题都逐一解决,epoll的优势包括:
epoll的应用现状: epoll出现之后大大提高了并发量对于C10K问题轻松应对,即使后续出现了真正的异步IO,也并没有(暂时没有)撼动epoll的江湖地位。
因为epoll可以解决数万数十万的并发量,已经可以解决现在大部分的场景了,异步IO固然优异,但是编程难度比epoll更大,权衡之下epoll仍然富有生命力。
epoll 继承了select 的风格,留给用户的接口非常简洁,可以说是简约而不简单,我们一起来感受一下。
epoll主要包括epoll_data、epoll_event、三个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);
其中三个api中贯穿了一个数据结构:epoll_event,它可以说是用户态需监控fd的代言人,后续用户程序对fd的操作都是基于此结构的;
可能上面的描述有些抽象,举个现实中的例子,来帮助大家理解:
通过man epoll可以看到官方的demo,虽然只有50行,但是干货满满,如下:
#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);
}
}
}
特别注意: 在epoll_wait时需要区分是主监听线程fd的新连接事件还是已连接事件的读写请求,进而单独处理。
epoll底层实现最重要的两个数据结构:epitem和eventpoll。
可以简单的认为epitem是和每个用户态监控IO的fd对应的,eventpoll是用户态创建的管理所有被监控fd的结构,我们从局部到整体,从内到外看一下epoll相关的数据结构。
红黑树节点定义:
#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;
};
epitem定义:
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;
}
eventpoll定义:
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实现:
如图展示了红黑树、双链表、epitem之间的关系:
一种广泛流传的错误观点:
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);
}
}
epoll的两种模式是留给用户的发挥空间,也是个重点问题。
默认采用LT模式,LT支持阻塞和非阻塞套,ET模式只支持非阻塞套接字,其效率要高于LT模式,并且LT模式更加安全。
LT和ET模式下都可以通过epoll_wait方法来获取事件,LT模式下将事件拷贝给用户程序之后,如果没有被处理或者未处理完,那么在下次调用时还会反馈给用户程序,可以认为数据不会丢失会反复提醒;
ET模式下如果没有被处理或者未处理完,那么下次将不再通知到用户程序,因此避免了反复被提醒,却加强了对用户程序读写的要求;
上面的解释在网上随便找一篇都会讲到,但是LT和ET真正使用起来,还是存在难度的。
LT对于read操作比较简单,有read事件就读,读多读少都没有问题,但是write就不那么容易了,一般来说socket在空闲状态时发送缓冲区一定是不满的,假如fd一直在监控中,那么会一直通知写事件,不胜其烦。
所以必须保证没有数据要发送的时候,要把fd的写事件监控从epoll列表中删除,需要的时候再加入回去,如此反复。
天下没有免费的午餐,总是无代价地提醒是不可能的,对应write的过度提醒,需要使用者随用随加,否则将一直被提醒可写事件。
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模式就可以直接搞定,并不需要用户层程序的补丁操作。
如果某个socket源源不断地收到非常多的数据,在试图读取完所有数据的过程中,有可能会造成其他的socket得不到处理,从而造成饥饿问题。
解决办法: 为每个已经准备好的描述符维护一个队列,这样程序就可以知道哪些描述符已经准备好了但是并没有被读取完,然后程序定时或定量的读取,如果读完则移除,直到队列为空,这样就保证了每个fd都被读到并且不会丢失数据。
流程如图:
A线程读完某socket上数据后开始处理这些数据,此时该socket上又有新数据可读,B线程被唤醒读新的数据,造成2个线程同时操作一个socket的局面 ,EPOLLONESHOT保证一个socket连接在任一时刻只被一个线程处理。
通过前面的对比可以看到LT模式比较安全并且代码编写也更清晰,但是ET模式属于高速模式,在处理大高并发场景使用得当效果更好,具体选择什么根据自己实际需要和团队代码能力来选择。
在知乎上有关于ET和LT选择的对比,有很多大牛在其中发表观点,感兴趣可以前往查阅。
如果你不知道什么是惊群效应,想象一下:
你在广场喂鸽子,你只投喂了一份食物,却引来一群鸽子争抢,最终还是只有一只鸽子抢到了食物,对于其他鸽子来说是徒劳的。
这种想象在网络编程中同样存在。
在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标识会保证一个事件发生时候只有一个线程会被唤醒,以避免多侦听下的惊群问题。