Linux 高性能服务器开发笔记:Reactor 模型定时器 | 网络编程定时器

本文主要根据游双书本 Linux 高性能服务器开发 学习分析 linux 网络编程常用到的定时器模型,配备详细理解和分析,同时分析了 Linux 内核中定时器的低精度时间轮和高精度定时器实现思路还有 了解 kafka 的时间轮,提供必要的资料进一步理解。sigalarm 方面讲解通过定时器实现心跳包的原理。


定时器基础

socket 选项和 I/O 复用 timeout

  • 首先第一个,这个正如前面讲的要用 setsockopt 而且不同的东西要用不同的结构体传进去,还要传一个 len

  • 然后是如何判断超时还是其他错误,一个是没有得读写才会 -1 ,然后如果是超时就会返回 EAGAIN 或者 EWOULDBLOCK。
  • 然后 connect (这里是 TCP 的)的是 EINPROGRESS 是因为他只是返回了而已,但是他还可能正在等握手包。复习一下:使用 connect 的时候实际是发送第一个 SYN 请求连接, 主要是客户端启用.对客户端而言,只要他收到 SYN+ACK,connect 就会返回。
  • 为什么要设置这个?对于不存在的主机,只要有 ICMP 不可达(一般是路径 router 发送的,没有配置这个 ARP 出口)或者 RST(绑定的端口,但是kernel没有这个四元组信息),马上就能返回错误,但是对于那些存在的但是没有响应的(没有绑定的端口一般会 silent discard),于是就会像 ping 那样等到 request time out,对于 ping 就是设置了一个 1s 的超时,这个超时一个能规避 75s 的 kernel 退避重试,第二个是避免了这个 silent discard 的情况(kernel 75s 之后也会强制放弃也是要处理这个 silent discard 的 case)。
  • IO 复用的 timeout 已经很熟悉了,不过就是传个结构体或者 int 进去就能得到一个非永远阻塞的复用函数调用而已。

SIGALARM 信号使用

  • 一般需要应用定时器的地方包括心跳包,IO 复用流量控制(用户态的 scheduler)。
  • 对于阻塞可以用 sleep 和 usleep,然而需要直接运行等待回调(一大堆定时器)的,基本方法是自己维护一个 multiplexer 而已。
  • 最简单的方案是不断地注册 alarm 或者 ualarm(这两都是一次性的,后者支持 interval 调用,理论上可重复,但是精度太小反而影响性能,根据要求选择吧),然后 event 都注册为 absolute time,醒来之后作为 alarm 的注册回调就是复用器,他随后检查一系列的定时器,然后执行那些回调函数。
  • 注意这里 time slicing 是我们为服务器管理各个连接搞的,所以并不一定会准时触发事件。对于 kernel 的准确性,虽然 manual 也说了依赖 timer 的 granularity 实现会有延迟,但是大体是匹配的,可以实现为 usleep 的 slot 是 ms,sleep 的是 s 就行了,毕竟一秒已经是十亿条指令了(实际有分支和 cache 失效问题并不能达到),对于游戏的实时事件触发需要那个属于应用层上的定时器(或者不需要实时显示的定制一种应用层日志就行了)。
  • 但是实际定时器需要调整,比如延长或者缩短时间。然后还有删除操作。简单的数据结构是类似 kernel 编程的时候用的那种 LRU 的双向链表,不过这次顺序不是 LRU 而是用时间排序而已。双向链表做优先队列的时间复杂度由于有序性无法利用(不能用二分法)所以插入是 O(n), 删除因为是 double linked 所以是 O(1). 
  • kernel 用红黑树实现优先队列。至于为什么,我在讲游戏网络编程的 gameloop 的时候讲过(这下知识点东一块西一块了,全是 DFS ),这里重复贴一下:
  • kernel 的计时器是这样的,对于芯片而言,分频器有正确的基频,硬件给操作系统提供一个时钟中断(RISCV 在 CLINT 里,8086 是用 8253),这样能够更新系统的时间。对于 time sharing 系统,就必须做一个 mux,这个有点麻烦,只能用数据结构来实现!所有的超时回调和阻塞都和这个 mux 有关。mux 的基本实现的无非链表,队列,最小堆,其他高级一点的就是时间轮(Linux 用的就是多级时间轮实现的),红黑树(The high-resolution timer code uses an rbtree to organize outstanding timer requests.)至于为什么用红黑树而不是普通的 priority queue,有两个原因,一个是 heap 用数组内存需要连续的,不然就要用数组指针来搞一套(不过 kernel 当时已经用上了 rbtree,就不另外写新数据结构 ADT 了),然后 heap(pq)的随机删除是必须 O(n) 查找的,say that 某个注册回调不需要了或者某个进程需要 block 掉或者出异常 kill 掉就需要 remove (delete)了,而红黑树全体有序(半个BBST),所以更胜一筹。而且如果都用红黑树,不用维护太多内核数据结构 adt。kernel 进行操作的延迟不会超过几十条指令,对 1Ghz CPU 来说,不过点 ns 而已,这样反而对 nanosleep 的精确度存疑了。
  • 所以我们用 sigalarm 其实已经是 kernel 搞了一层分包商过来的了,之后我们再分包给各路 socket d,一个区域网关层次结构属于

应用实例: SIGALARM 连接保活 keepalive

  • 为了提供必要的上下文复习而不用重新看书,这里也给出游双书本这个例子的 11-3 的源码链接:

11/11-3nonactive_conn.cpp · makerbob/LinuxServerCodes - Gitee

  • 来复习 epoll 了!要修改红黑树里面的内容,都是通过 epoll_ctl 来实现的(比如添加,删除,修改感兴趣的东西)。然后 epoll 的睡觉是用 epoll wait,他会返回特定数量的 fd 数组,which 都是已经触发了感兴趣事件的。对于返回来的事件列表,有 EPOLLIN 可读,EPOLLOUT 可写,EPOLLPRI 带外(UNP 和 RFC 说了新的 application 不要用 TCP 的 URG 功能),EPOOLERR 和 EPOLLHUP 是 RST 什么的(implementation define),额,总之不是 POLLIN 和 POLLOUT / PRI 就删除连接就行了,简单粗暴。
  • 然后还有复习 gameloop 这里 UNP 天天见的信号丢失的问题,考虑我们要捕获一个信号,然后为了避免可重入函数的限制,所以通过一个 boolean 来标识他,之后再处理。这个是基于信号会打断慢系统调用如 select poll 这种的。然而在调用 select poll 的之前,select poll 前面第一次检查这个 boolean 的之后就来了信号就会丢失,所以一般要先 pending signal 而使用 pselect(pselect 支持 us 和 sigmask)。linux 的确提供了 ppoll,而且也提供了 epoll_pwait 和 epoll_pwait2 (2 的升级是不用 int 表示 timeout,用 struct timespec)。不过如果这里的 timeslot 设置得比较大,比如几百ms到几s的时候,这个就不用考虑了。比如下面这样:

而重设也是在 gameloop 的最后,所以基本不用考虑这个问题。这个是 alarm 的 SIGALARM 的可预测性带来的好处。

  • 还记得讲 mq 的时候说到那个 poll 的 workaround (poll 不支持 mq,所以要让 mq notify handler 写 private pipe)吗?这里也用上了。为了统一我们的 gameloop(串台 eventloop 了)内处理事件,这里也不是不可以这样做,毕竟 write 和 read 是可重入安全的而注册的回调比如这里的 epoll_ctl 可不一定是, 再次附上 manual page ,这个的确常备才行 signal-safety(7) - Linux manual page (man7.org)
  • 还有一个要点是,优先级的问题,由于 alarm 触发的回调东西可能会特别多,我们可以先处理他们,也可以先处理新的连接和已有的连接服务,由于 fd 有很多,所以实际的 gameloop 是这样的:while(没有 sigterm) for(d in 触发的 fd ) 事件分类并处理
  • 为了让处理 sigalarm 不影响对服务的影响,应该让这个处理在 while 里面的最后(即完成内层 for 之后)。

高性能定时器数据结构分析

时间轮 Time Wheeling

时间轮这个实现方案这个帖子的 gif 比较好看:

一张图理解Kafka时间轮(TimingWheel),看不懂算我输! - 知乎 (zhihu.com)

时间轮的实现基本是两种经典做法,一种是 Linux 的内核态时间轮,一种是 kafka 的应用态时间轮,通过 DelayQueue 避免稀疏空转。

Linux 里的 timer 基础就是多级时间轮,然后根据这里说的 Red-black Trees (rbtree) in Linux — The Linux Kernel documentation,高分辨率 timer 用的是红黑树(实现的优先队列?)。时间轮应该是很不错的,首先他很多操作都是 O(1),因为直接定位循环数组索引。然后用红黑树的问题是,他不是完全的,所以没办法实现数组存储,结果就是失去了 locality 性能。为什么时间轮只能实现低精度,而堆/红黑树能实现高精度的定时器呢?因为 kernel 的这个时间轮和上面 gif 演示那个不一样(kafka),kafka 的方案是用

cascading down 的方式,即到达的第二层的时候,会把时间插回原来的小时间轮中。然而这个过程也要花时间的(如果处理不好可能会超过延时),kernel 的做法是二层时间轮就直接按大的时间周期走了(水表齿轮),比如一层是 1ms 并且只能容纳 31 间隔,超过 31间隔就要放到二层去,二层可能是 8ms 一个 slot (也是 32 个 slot),然后每走 8 个才执行一个二层槽的,等一层 31ms 结束了 32个槽之后,刚好结束第四个二层槽,32ms 的超时将会放在第五个二层槽,这样就会引发延迟(延迟6ms)。时间轮的复杂度插入删除都是 O1,PPT (High resolution timers and dynamic ticks design notes — The Linux Kernel documentation)说了 higher tick frequencies don't scale due to long lasting timer callbacks and increased recascading。这也是为什么低分辨率的另一个原因回调耗时。

内核高分辨率定时器思原理

linux 的高分辨率计时器前面讲过是用 rbtree 做的,High resolution timers and dynamic ticks design notes — The Linux Kernel documentation,然后 ppt 是这个High resolution timers and dynamic ticks design notes — The Linux Kernel documentation。可以看到一开始最简单的 linux timer 实现就一个双向链表而已,这个迭代思路很重要,你要开发什么东西都先把东西做出来先,优化什么的之后再说!muduo 的思想也是一开始做了一个线性表的 timerqueue 再改成 map (红黑树)的。

而且红黑树做最小堆也不是一定要 logn 复杂度的,因为树的最左边总是可以被维护的,所以可以直接 O(1) 的 peek 是可实现的,但是删除涉及 rotation 操作,所以 deleteMin 的确没有办法降低(起码降低了 findMin 的开销属于)。

根据PPT ,内核的 hrtimer 的实现是这样的:timers inserted into a red­black tree sorted by expiration time(absolute 时间吧应该是),base code is still tick driven (softirq is called in the timer softirq context, 这个软中断其实之前做 e1000 的 xv6 网卡驱动的时候就接触过了,就是 bottom half 。实际的网卡驱动的 bottom half 是通过 softirq 启动一个内核独立线程(进程)来运行的,实际会参与进程调度的,而且优先级不低(比如网卡收发包肯定比 app 要重要))。他最后把这个hrtimer 另外直接接收硬件中断和原来的 time wheeling 独立开来两层架构。然后其实要实现高精度有一个事情必须做的,就是让最近 expire 的那个 absolute time 的 event (which 就是红黑树的最左边那个节点)时刻必须触发一个中断,这个中断会注册到 clock_event_device 里,即可编程定时器(比如8086 的8253),因为你不可能一秒轮询一次的,这个东西必须要硬件的支持的,由于一次只需要注册一个事件,所以简单硬件完全足够胜任了。

当然,有一些情况必须考虑的,比如程序不能动的情况,这些情况有很多,包括 debug,回调耗时(应用层的回调当然是不会耗时的,但是这里我们说的是 kernel 做的事情,比如应用层注册了一个高清事件需要 sigalarm 中断,但是内核可能在某个 critical section 是无法被 preempt 出去的,就算是软中断也要 pending )以及虚拟机(虚拟机的可编程定时器是由软件虚拟的或者直接硬件虚拟化技术的)停机等,这个时候内核应该处理过期事件,接下来的内容其实和我们讲网络编程没有什么关系了,所以就这样点到为止吧(这已经不止点到了吧,感兴趣的读者可以阅读 Linux 官方站点的资料,注意是在 2.6.16 内核版本(PPT 说的)以后就行了,对于源码分析的资料,这里有个2012 的博客我觉得分析得不错 Linux时间子系统之六:高精度定时器(HRTIMER)的原理和实现_DroidPhone的专栏-CSDN博客)!

需要注意了本节标题叫高性能,实际我们做的用户态定时器说的高性能并不是说 high resolution 的,我们的高性能是支持高并发高可用的定时器应该,高清定时器这个东西是硬件的,直接注册 usleep 或者 ualarm 而不要在应用层再搞一个 multiplexer 才行。

红黑树和堆做优先队列的不同点分析

nginx 用的是 rbtree,不用 heap pq 的具体原因前面说过了一个是空间不提前预知(这个有点难讲,因为如果你用连续空间就要预知大内存块,就要均摊这个 reallocation 的 overhead,但是能保证 locality,如果你不用连续空间,就要失去 locality),然后是无法高效随机删除。

然而红黑树做优先队列查找最小值是 logn,删除是 logn,heap pq 是查找 1,删除 n。这个 trade-off 怎么做的呢?而且 heap 还有 locality !这下有点难决策的,特别是删除比较少的时候。(插入都是 logn)。看到 libevent(C语言 reactor 模型异步库) 在1.4后 use a min heap instead of a red-black tree for timeouts; as a result finding the min is a O(1) operation now; from Maxim Yegorushkin. 这个得看实测性能了。另一个思想实验的,如果 timer event 本身自己身上有一个 pointer 指向他在数据结构中的位置,自然就可以在 heap 里面实现 O(logn) 左右的删除了(因为本来要 O(n) 查找,现在直接整堆而已)?

而 libev (一个提供更多功能的 reactor 异步库,并且不使用全局变量更好支持多线程)用的是 4-heap,这个东西性能比 2-heap 块,在添加新元素(从下往上浮,一直浮找第一个比他小的就行了,没有4个节点的比较,所以就是高度)的方面:binary heap:O(log2n) vs d-ary heap: O(log4n) ,log4n < log2n 。但deleteMin(把末尾元素放到堆顶往下沉,下沉的时候必须找到最小的孩子取而代之,所以必须有 4 个比较):binary heap:O(log2n) vs d-ary heap:O((d-1)logdn),当 d > 2 时,(d-1)logdn > log2n ,另外,d-ary heap比binary heap 对缓存更加友好,更多的子结点相邻在一起(其实是整体的高度下降了,倍数关系引发换 cache 少一些)。故在实际运行效率往往会更好一些。

Golang 用的是四叉堆 + 桶。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值