本文介绍epoll的原理,以及各种实际的例子。
系统延时对比
- 首先了解下各种操作的延时对比(《性能之巅峰》)
- 以
3.3GHz
的CPU为例,1/3.3G=0.3ns
事件 | 延时 | 相对时间 |
---|---|---|
1个cpu周期 | 0.3ns | 1s |
L1 | 0.9ns | 3s |
L2 | 2.8ns | 9s |
L3 | 12.8ns | 43s |
内存 | 120ns | 6min |
固态 | 50-160ns | 2-6day |
机械 | 1-10ms | 1-12mon |
网络 旧金山->纽约 | 40ms | 4year |
网络 旧金山->英国 | 81ms | 8year |
网络 旧金山->澳大利亚 | 183ms | 19year |
tcp包重传 | 1-3s | 105-317year |
os虚拟化系统重启 | 4s | 423year |
… | ||
物理系统重启 | 5min | 32000year |
- 一级缓存大约3个时钟周期
- 二级缓存大约9个时钟周期
- 内存大约360个时钟周期
- 可见磁盘io,网络io的耗时和内存访问的差距是十分大的
- 直观的感受,一个cpu周期,光只能走0.5米。可能眼睛到书的这段距离,光需要1.7ns,但是cpu已经执行了5个cpu周期了
可见访问不同的存储介质,延时有很大的差别,对于io密集型的应用,比如web服务器,网络rpc,如何让io更高效就成了关键,这里主要探讨的是网络io。
五种io模型
同步io
阻塞io(BIO)
sequenceDiagram participant 进程 participant 内核 opt 整个过程都阻塞 进程 ->> 内核: recvfrom 内核 ->> 内核: 挂起进程,等待数据ok Note over 内核: 数据ok Note over 内核: 拷贝到进程空间 内核 ->> 进程: 读到数据 进程 ->> 进程: 处理数据 end
- 阻塞io,在进程发起read调用后就进入阻塞挂起,直到对端发来数据,并且内核处理完后拷贝到用户进程空间,此时read函数才返回。
- 可见阻塞io的效率很低,如果进程只有一个线程,那么整个过程会导致进程阻塞在read系统调用上,无法响应其他用户请求。
非阻塞io(NIO)
sequenceDiagram participant 进程 participant 内核 loop 轮询检查多个fd 进程 ->> 内核: recvfrom fd=1 内核 ->> 进程: 没有数据 进程 ->> 进程: do other 进程 ->> 内核: recvfrom fd=2 内核 ->> 进程: 没有数据 end 进程 ->> 内核: recvfrom fd=1 Note over 内核: 数据ok opt 阻塞 Note over 内核: 拷贝到进程空间 end 内核 ->> 进程: 读到数据 进程 ->> 进程: 处理数据
- 首先进程会在fd上设置非阻塞的标志,然后调read系统调用,此时和阻塞io不同的是如果数据没准备好,会直接返回错误,这样用户进程就没有阻塞了,可以去做其他的事情,过一段时间再来轮询
- 本质上是轮询,只不过进程读不到数据可以继续做其他事情,比如轮询其他fd,或者给其他连接回包,这样的话不会因为某个连接阻塞而无法处理其他的
io复用
sequenceDiagram participant 进程 participant 内核 进程 ->> 内核: poll 同时监听多个fd opt 阻塞 内核 ->> 内核: 等待多个fd数据准备好 Note over 内核: 数据ok end 内核 ->> 进程: 数据ok 进程 ->> 内核: recvfrom opt 阻塞 Note over 内核: 拷贝到进程空间 end 内核 ->> 进程: 读到数据 进程 ->> 进程: 处理数据
- 似乎这里阻塞的时间又变多了,和阻塞io类似,只不过分成了两段,但实际上这里效率更高了,阻塞io是阻塞在单个fd上,这里是多个fd
- 并且,第一个阻塞的操作是可以设置最长等待时间的,也就是epoll_wait可以指定阻塞最长的时间,到了时间后便会返回,这里往往应用在定时器上,可以实现一个高精度的定时器,比如redis在实现的时候就是在这里加上定时器的逻辑,还有其他的用户态协程库的实现一般也是这样。
信号io
sequenceDiagram participant 进程 participant 内核 进程 ->> 内核: 注册信号 内核 ->> 进程: 返回 进程 ->> 进程: do other Note over 内核: 数据ok 内核 ->> 进程: 通过信号通知进程 进程 ->> 内核: recvfrom opt 阻塞 Note over 内核: 拷贝到进程空间 end 内核 ->> 进程: 读到数据 进程 ->> 进程: 处理数据
- tcp会产生信号的有
- 监听套接字上某个连接请求已经完成;
- 某个断连请求发起
- 某个断连请求完成
- 某个连接已经关闭
- 数据到达套接字
- 数据已经从套接字发送走
- 发生某个异步错误
- udp会产生信号的有
- 数据报到达套接字
- 套接字上发生异步错误
- 可见tcp产生信号的原因太多,判断起来很费资源,不太适合使用信号io
异步io
异步io(AIO)
sequenceDiagram participant 进程 participant 内核 进程 ->> 内核: aio_read 内核 ->> 进程: 返回 进程 ->> 进程: do other opt 内核异步 Note over 内核: 数据ok Note over 内核: 拷贝到进程空间 end 内核 ->> 进程: 信号通知 进程 ->> 进程: 处理数据
- 只有异步io模型是异步io,其他的都是同步io,只不过可以设置非阻塞模式
- 异步io
为什么要用epoll
网络io是非常耗时的
- 从上面的系统延时对比可以看出,网络io是非常耗时的,通常在几十毫秒以上,所以如何处理并发的请求就是关键
不使用epoll也能处理
- 是的,不使用epoll也能处理并发的请求,每个线程处理一个fd也能并发,但是问题在于linux每个线程栈大小默认是8m,成千上万的fd不可能每个都分配一个线程,线程数过多还会导致上下文频繁切换,导致cpu都耗费在了上线文切换上,实际效率很低
epoll的好处
- select有fd上限,默认2048,并且select需要自己判断fd是否活跃,扫描是O(n)的复杂度
- poll链表实现监听fd没上限,但也要扫描全部的fd,复杂度O(n)
- epoll监听fd没有上限,并且epoll是直接返回所有的活跃的fd,用户空间直接拿到fd进行操作就行,epoll内部使用红黑树,内部复杂度是O(logn),用户空间不用轮询,复杂度O(1)
epoll的应用
- epoll的应用基本都是通过事件反应堆来实现的,基本思路就是
- 把fd和对应的handle函数绑定在一起,因为系统的epoll会提供一个指针,这个指针在fd就绪会一起返回,所以一般会使用这个指针来保存私有的数据,比如事件对应的处理handle,这个handle在处理的时候,同时会根据业务逻辑需要来创建另外一个事件,再把这个fd进行注册,直到某一次事件被回调后不再需要注册新的事件,那么这个fd的反应堆就完成了。大致流程是
graph LR fd绑定handle --> 注册到epoll 注册到epoll --> epoll_wait返回 epoll_wait返回 --> 回调handle 回调handle --> c{是否创建新的事件?} c --yes--> fd绑定handle c --no--> 释放fd
- epoll_wait可以支持设置毫秒级别的阻塞时间,因此通常epoll也用在实现精确的定时任务。
redis中的epoll
redis统一的ae接口定义
- redis中把select,epoll,kqueue等系统提供的接口抽象成了统一的接口,在ae.h中可以找到定义。
- redis的实现思路也是和上面的流程一致,基于事件反应堆实现的,并且redis的源码可读性特别高,读起来特别舒服,十分推荐
redis中的场景
- 读写用户请求(好像有点废话了)
- 定时任务
redis的epoll部分实现逻辑
redis一开始都是单线程的,根据上面的流程图,可以想象假如在处理单个用户的fd的handle函数的时候,阻塞太长时间,比如可能回一个很大的包,这就有可能阻塞住,由于是单线程的,阻塞的就是整个进程,导致其他客户端也阻塞了,所以redis在write的时候,会检查阻塞的时间,如果超过某个阈值则会直接break掉训循环,下次再写
redis的设计,cpu往往不是瓶颈,主要可能在内存和网络io上遇到瓶颈,针对网络io,事件堆模型已经能处理大多数的场景,但是redis在6.0后还是进行了优化,采用了多线程的模式进行网络io,这样可以充分利用多核处理器。
总结
- epoll是网络编程中的利器,广泛应用在各种开源软件,各种rpc框架中,熟悉原理很有必要,redis中的实现十分不错,非常值得用来学习epoll的原理。