epoll的原理和应用

本文介绍epoll的原理,以及各种实际的例子。

系统延时对比

  • 首先了解下各种操作的延时对比(《性能之巅峰》)
  • 3.3GHz 的CPU为例, 1/3.3G=0.3ns
事件延时相对时间
1个cpu周期0.3ns1s
L10.9ns3s
L22.8ns9s
L312.8ns43s
内存120ns6min
固态50-160ns2-6day
机械1-10ms1-12mon
网络 旧金山->纽约40ms4year
网络 旧金山->英国81ms8year
网络 旧金山->澳大利亚183ms19year
tcp包重传1-3s105-317year
os虚拟化系统重启4s423year
物理系统重启5min32000year
  • 一级缓存大约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的原理。
codelover wechat
原创公众号
-----若有不妥,敬请斧正-----