深入理解 Linux 的 epoll 机制
时间:2021-08-19 16:29:22
手机看文章
扫描二维码
随时随地手机看文章
[导读]坚持思考,就会很酷在Linux系统之中有一个核心武器:epoll池,在高并发的,高吞吐的IO系统中常常见到epoll的身影。IO多路复用在Go里最核心的是Goroutine,也就是所谓的协程,协程最妙的一个实现就是异步的代码长的跟同步代码一样。比如在Go中,网络IO的read,w...
在 Linux 系统之中有一个核心武器:epoll 池,在高并发的,高吞吐的 IO 系统中常常见到 epoll 的身影。
IO 多路复用
在 Go 里最核心的是 Goroutine ,也就是所谓的协程,协程最妙的一个实现就是异步的代码长的跟同步代码一样。比如在 Go 中,网络 IO 的
read
,write
看似都是同步代码,其实底下都是异步调用,一般流程是:write ( /* IO 参数 */ )
请求入队
等待完成
后台 loop 程序
发送网络请求
唤醒业务方
Go 配合协程在网络 IO 上实现了异步流程的代码同步化。核心就是用 epoll 池来管理网络 fd 。实现形式上,后台的程序只需要 1 个就可以负责管理多个 fd 句柄,负责应对所有的业务方的 IO 请求。这种一对多的 IO 模式我们就叫做 IO 多路复用。多路是指?多个业务方(句柄)并发下来的 IO 。复用是指?复用这一个后台处理程序。站在 IO 系统设计人员的角度,业务方咱们没办法提要求,因为业务是上帝,只有你服从的份,他们要创建多个 fd,那么你就需要负责这些 fd 的处理,并且最好还要并发起来。业务方没法提要求,那么只能要求后台 loop 程序了!要求什么呢?快!快!快!这就是最核心的要求,处理一定要快,要给每一个 fd 通道最快的感受,要让每一个 fd 觉得,你只在给他一个人跑腿。那有人又问了,那我一个 IO 请求(比如 write )对应一个线程来处理,这样所有的 IO 不都并发了吗?是可以,但是有瓶颈,线程数一旦多了,性能是反倒会差的。这里不再对比多线程和 IO 多路复用实现高并发之间的区别,详细的可以去了解下 nginx 和 redis 高并发的秘密。
我不用任何其他系统调用,能否实现 IO 多路复用?可以的。那么写个
for
循环,每次都尝试 IO 一下,读/写到了就处理,读/写不到就 sleep
下。这样我们不就实现了 1 对多的 IO 多路复用嘛。while True:
for each 句柄数组 {
read/write(fd, /* 参数 */)
}
sleep(1s)
慢着,有个问题,上面的程序可能会被卡死在第三行,使得整个系统不得运行,为什么?默认情况下,我们 create
出的句柄是阻塞类型的。我们读数据的时候,如果数据还没准备好,是会需要等待的,当我们写数据的时候,如果还没准备好,默认也会卡住等待。所以,在上面伪代码第三行是可能被直接卡死,而导致整个线程都得到不到运行。举个例子,现在有 11,12,13 这 3 个句柄,现在 11 读写都没有准备好,只要 read/write(11, /*参数*/)
就会被卡住,但 12,13 这两个句柄都准备好了,那遍历句柄数组 11,12,13 的时候就会卡死在前面,后面 12,13 则得不到运行。这不符合我们的预期,因为我们 IO 多路复用的 loop 线程是公共服务,不能因为一个 fd 就直接瘫痪。那这个问题怎么解决?只需要把 fd 都设置成非阻塞模式。这样 read/write
的时候,如果数据没准备好,返回 EAGIN
的错误即可,不会卡住线程,从而整个系统就运转起来了。比如上面句柄 11 还未就绪,那么 read/write(11, /*参数*/)
不会阻塞,只会报个 EAGIN
的错误,这种错误需要特殊处理,然后 loop 线程可以继续执行 12,13 的读写。以上就是最朴实的 IO 多路复用的实现了。但好像在生产环境没见过这种 IO 多路复用的实现?为什么?因为还不够高级。for
循环每次要定期 sleep 1s
,这个会导致吞吐能力极差,因为很可能在刚好要 sleep
的时候,所有的 fd 都准备好 IO 数据,而这个时候却要硬生生的等待 1s,可想而知。。。那有同学又要质疑了,那 for
循环里面就不 sleep
嘛,这样不就能及时处理了吗?及时是及时了,但是 CPU 估计要跑飞了。不加 sleep
,那在没有 fd 需要处理的时候,估计 CPU 都要跑到 100% 了。这个也是无法接受的。纠结了,那 sleep
吞吐不行,不 sleep
浪费 cpu,怎么办?这种情况用户态很难有所作为,只能求助内核来提供机制协助来。因为内核才能及时的管理这些事件的通知和调度。我们再梳理下 IO 多路复用的需求和原理。IO 多路复用就是 1 个线程处理 多个 fd 的模式。我们的要求是:这个 “1” 就要尽可能的快,避免一切无效工作,要把所有的时间都用在处理句柄的 IO 上,不能有任何空转,sleep 的时间浪费。有没有一种工具,我们把一箩筐的 fd 放到里面,只要有一个 fd 能够读写数据,后台 loop 线程就要立马唤醒,全部马力跑起来。其他时间要把 cpu 让出去。能做到吗?能,但这种需求只能内核提供机制满足你。2 这事 Linux 内核必须要给个说法?
是的,想要不用 sleep 这种辣眼睛的实现,Linux 内核必须出手了,毕竟 IO 的处理都是内核之中,数据好没好内核最清楚。内核一口气提供了 3 种工具
select
,poll
,epoll
。为什么有 3 种?历史不断改进,矬 -> 较矬 -> 卧槽、高效
的演变而已。Linux 还有其他方式可以实现 IO 多路复用吗?好像没有了!这 3 种到底是做啥的?这 3 种都能够管理 fd 的可读可写事件,在所有 fd 不可读不可写无所事事的时候,可以阻塞线程,切走 cpu 。fd 有情况的时候,都要线程能够要能被唤醒。而这三种方式以 epoll 池的效率最高。为什么效率最高?其实很简单,这里不详说,其实无非就是 epoll 做的无用功最少,select 和 poll 或多或少都要多余的拷贝,盲猜(遍历才知道)fd ,所以效率自然就低了。举个例子,以 select 和 epoll 来对比举例,池子里管理了 1024 个句柄,loop 线程被唤醒的时候,select 都是蒙的,都不知道这 1024 个 fd 里谁 IO 准备好了。这种情况怎么办?只能遍历这 1024 个 fd ,一个个测试。假如只有一个句柄准备好了,那相当于做了 1 千多倍的无效功。epoll 则不同,从 epoll_wait
醒来的时候就能精确的拿到就绪的 fd 数组,不需要任何测试,拿到的就是要处理的。epoll 池原理
下面我们看一下 epoll 池的使用和原理。
1 epoll 涉及的系统调用
epoll 的使用非常简单,只有下面 3 个系统调用。
epoll_create
epollctl
epollwait
就这?是的,就这么简单。epollcreate
负责创建一个池子,一个监控和管理句柄 fd 的池子;epollctl
负责管理这个池子里的 fd 增、删、改;epollwait
就是负责打盹的,让出 CPU 调度,但是只要有“事”,立马会从这里唤醒;
2 epoll 高效的原理
Linux 下,epoll 一直被吹爆,作为高并发 IO 实现的秘密武器。其中原理其实非常朴实:epoll 的实现几乎没有做任何无效功。 我们从使用的角度切入来一步步分析下。首先,epoll 的第一步是创建一个池子。这个使用
epoll_create
来做:原型:int epoll_create(int size);
示例:epollfd = epoll_create(1024);
if (epollfd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
这个池子对我们来说是黑盒,这个黑盒是用来装 fd 的,我们暂不纠结其中细节。我们拿到了一个 epollfd
,这个 epollfd
就能唯一代表这个 epoll 池。注意,这里又有一个细节:用户可以创建多个 epoll 池。然后,我们就要往这个 epoll 池里放 fd 了,这就要用到 epoll_ctl
了原型:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
示例:if (epoll_ctl(epollfd, EPOLL_CTL_ADD, 11,