为什么要用户态的定时器?

首先是为什么要做定时器,定时器的主要说的是我们的应用(业务?功能?总之有这个需求)要做一个定时的任务。其实如果不想为什么,好像是理所当然的。我写这个的时候,知乎有一个问题(Linux内核提供了定时器机制,为什么还要自己动手实现用户态的时间轮或者最小堆定时器? - 知乎 (zhihu.com))没有人回答,其实很容易想到答案,这里当作是一个回答。

游戏业务里(因为之前一直在研究游戏服务器开发的需求,反而对  Web 等的不是很熟悉)服务器里面可能需要定时触发某个事件,可能是任务解锁、人物解锁。

比如种植类游戏(比如某宝里面的农场红包,当然对于需要停留特定时间这种可以客户端实现就行了)、挂机任务;又比如吃鸡游戏里面的毒圈缩圈(对于客户端要不要也做这个属于同步逻辑的问题,之前的文章谈论过了)、更加一般的游戏(比如 RPG)里面的持续加buff、定时恢复、技能冷却等等都是与时间有关的。

而对于网络框架他本身,也是一个定时器的应用,比如对于长时间的连接来说,不应让他永久下去(比如没有用 keepalive),需要超时断开他,这个应用在 ssh、ftp 服务器里面都有应用。

理想的情况是很简单的,因为一般的 OS 都支持定时器,而且这个东西常常是可以不占用资源的,因为我们都知道从硬件开始主板上的晶振提供了时钟的底层实现,因此他当然是异步的硬件中断,比如早期的可编程中断器(PIC)+一个时钟,然后到了 OS 层面,一般 CPU 某个 core 空闲的时候,都会去运行一些 kernel 的任务,本质上应用层的异步定时器比如 sleep 然后特定时间 interrupt 唤醒不过是 kernel 上的 mux 进行对硬件的可编程定时器的多路复用(对于 linux kernel 的时间轮+红黑树的定时器(hrtimer)的演变过程下面有个参考链接)。

然后就是需要在应用层也做一个 MUX,乍一看好像是多此一举因为内核明明已经实现了一套了,但是就是因为抽象分层和隔离导致这些东西必不可少,对于 kernel 的定时器,比如 sleep 这种,应用层根本没法注册多个定时器。linux 2.6 之后支持了 timer,POSIX 早就指定了,只不过 2.6 之前没有实现而已。顺便的基于 Unix everything is file 的思想,timerfd 也一起出来了(timerfd 等于一个做 timer 的 eventfd ,当然可以类比我们用 pipe + epoll 实现不同进程的同步功能),timerfd 是可以注册很多很多个的。

这样做思想实验,对于 kernel 中的数据结构来说,不管他是用的什么实现,红黑树时间轮还是链表什么的,都不重要,关键是他的定时触发,后面我们会有参考资料明白 linux 的高分辨率定时器是怎么巧妙复杂的基于一个硬件定时器来实现的,但是必须注意的是这个异步中断到来的时候,本质上发生了什么以及 kernel 他能做什么。

我们想到,首先是硬件的时钟来了一个中断,马上 CPU 执行 trap 逻辑(跳转中断向量表),进入到 kernel 的代码以及权限控制,然后开始 demux,对比 linux 源码就是在 trap handler 里面检查寄存器,发现,oh ,是定时器中断,然后经过一顿 routing,最终分发到了某数据结构上的一个 node,于是知道要做什么,对于 kernel 来说,我们能做的无非是修改 PCB 上的状态,从而改变进程的下一个方向。到这里我们就很清楚了。当然再插入一些小插曲,对于同一个时间来说,是不是说两个进程同时唤醒会有精度损失(linux CFS 算法时间片的大概范围是是0.75ms 到 6ms左右。)?这种情况是有的,称作 overrun,当然也要处理好。而精度问题本身高精度也可以结合 CFS (对 CFS 的调度逻辑感兴趣的另外看资料,我不涉及)的优先级实现,所以 ns 级别的高精度也是可能实现的。

所以实际我们可以完全委托给 kernel 去做,在 Linux 下,这个问题是一个是 file descriptor 的数量问题(默认开 1024 这个我们都知道,其实本来要想百万并发必然出现 too many open file,ulimit 字节改大他,最大当然是到 int 的最大数量啦),然后可能是说增加了 kernel 的 overhead。我们后面就知道 kernel 的定时器实际的复杂度是后面才提到 O(1) 的,但是对于插入删除来说,红黑树的 logn 可能还是在(实际具体我也不知道,你得看最新的 linux kernel source code)。总之可能有更大的开销。

当然上面这段说的是可能,最大的一个好处是增删改查。对于 select、poll、epoll 来说,他们都是没有 mmap 的,io_uring 才引入了 mmap,意味着我们每次修改,都要 context switch 以及进行虚拟地址翻译+copying,加上现在的 meltdown+spectre 之后的页表隔离(KPTI)补丁之后(、、、、这个说过很多次了,总之开销就是大),做复用可能是比嗯 timer_create 或者 timerfd_create 的那个东西管理快的,比如你要删除吧,比如你 overrun 了吧(对于 timerfd,没有 timer_getoverrun,这个功能通过 read 实现),每次都修改 kernel 的是不现实的。而且定时器的好处就是时间上是线性的,我们一个时间点只需要保证有一个在 kernel 被老大哥看着就行了。


时间轮和多级时间轮

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

时间轮的实现基本是两种经典做法,一种是 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 的开销属于)。

从硬件到 MUX

根据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 另外直接接收硬件中断和原来的 timing wheel 独立开来两层架构。然后其实要实现高精度有一个事情必须做的,就是让最近 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 的实现

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) 查找,现在直接整堆而已)?

用户态的时间轮

注意一个要点,用户态的基于 sleep 的时间轮其实是可能会不高性能的吧,他在用户态频繁 spin + sleep,然后还要计算该 sleep 的时间。。。。而且要频繁查询当前时间,这个不是 syscall 的开销吗?还不如用系统提供的呢。但是他的确搞性能属于。

特别是页表隔离以后。我们现在学习旧的实现以及编写新的实现当然要考虑这个,但是最重要的还是不要臆想臆测,一定要实地 benchmark 才能知道到底好不好。性能问题不能乱猜的。所以我上面这个对时间轮的乱猜实在是大不敬

Libev 的更高性能的 4-heap

而 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

Golang 用的是四叉堆 + 桶。

Nodejs 用的是双向链表