(翻译这篇文章时遇到了离散数学的概念,也进行了一些研究,但是有些部分还是没有搞得很明白,所以翻译上有一定误差,但并不影响主要思想)
概要
如果用一般的算法去实现操作系统中的定时模块儿,那么插入一个定时任务或者处理一个到期的定时任务的时间复杂度大概为O(n),n标示的是已经处于等待的定时任务,如果n的值很大,那么消耗也一定很大。这篇文章首先描述了定时算法、分散事件模拟当中的时间流转机制(time flow mechanisms used in discrete event simulations)、排序算法三者之间的联系。接着描述了一个类似于定时轮的算法在逻辑模拟器(logic simulators)中的应用,这个算法是针对短时间间隔的。利用环形缓冲区(circular buffer)或者定时轮,只要定时任务在一个周期内(一个时间间隔内,时间间隔的概念后续有介绍),那么插入定时任务、停止定时任务、处理到期定时任务的时间复杂度只为O(1)。
接着描述了两种用来解决大时间间隔的定时算法。第一种定时轮算法将在某个时间段内的定时任务按照时间进行哈希,然后将定时任务插入到此哈希值所对应的在定时轮上的存储空间。第二种定时轮算法利用层级的定义(天、时、分、秒),能够提供一个跨更大时间间隔的定时算法。我们将对基于这两种理论及其变种的实现的性能进行讨论。1.介绍
在集中控制或者分布式操作系统当中,我们会在某些方面用到定时器:
- 故障修复:有几种故障不能被异步的检测出来。有些故障需要定时的去做检查才能检测出来(比如:内存的错误占用),这个时候就需要定时任务到期以后进行检查。其他的一些故障则是,在某一个给定时间段内,如果相应的动作没有被触发,则需要进行故障修复(比如:消息发出后,需要在指定时间内接收到回执),在这种情况下,如果发生错误次数很少,那么很多定时任务在没有过期前就被取消掉了,根本等不到过期触发。
- 算法当中的时间或者相对时间的概念都是整形的:比如说某些设备的生产频率控制算法(进度控制,通信当中基于频率的数据流控制),定时器算法,网络传输中数据包的生命周期控制等等。这些定时器一般都是会超时的(个人理解,只有到时间后才会发送下一条指令,或者数据包等,基于的时间单位都是整数级的)。
如果要满足下面的条件,那么定时器算法的性能问题将是一个很大的问题:
- 算法的实现是基于处理器的时钟震荡的,这个时候,每一次的时钟震荡都会引起系统中断,大量的系统中断肯定会导致严重过载。
- 对定时器的时间精确度要求很高。
- 同时存在大量的等待过期的定时任务。
举个例子来说,在分布式系统的通信当中。消息在传输的过程当中可能会在网络层丢失,那么就需要有定时任务,在一段时间内如果没有收到回执,触发消息的重传。在分布式环境当中的某一台主机可能会同时有很多的定时任务,比如说,某台服务现在有200个网络连接,每个连接需要3个定时器为其服务。至于高带宽的网络传输(>100Mbit/sec),定时器的到期处理、定时器的新建与停止数量都会大大增加。
如果时钟震荡每次都引起主机的中断操作,并且时间震荡的间隔很小,微秒级别的,那么将会引起大量的中断过载。所以很多操作系统都提供更粗粒度的定时器(毫秒级或者秒级)。相反的,如果在某些系统当中需要晶粒度的定时器,可以提供专门的硬件来做支持。无论哪种情况,定时器算法的性能都是一个很重的问题,因为性能决定了定时器启动、停止的延迟时间,在同一时间内有多少定时器可以并存。
2.体系结构、性能描述
我们的定时器模型一般包含四条命令:
START_TIMER(Interval,Request_ID,Expiry_Action):这条命令在被调用时,将生成一个新的定时任务,参数“Interval”指定这个定时器将在多长时间后到期。参数“Request_ID”用来区别同一客户端生成的不同定时任务。参数“Action”表明指定定时任务到期后的回调函数或方法。
STOP_TIMER(Request_ID):停止由"Request_ID"标识的定时任务。
PER_TICK_BOOKKEEPING: 假如我们每次检查是否有定时任务过期的时间间隔为T个时间单位,每次检测的时候,如果有定时任务到期,这个命令就会调用STOP_TIMER命令去停止定时任务,并执行下一条命令。
EXPIRY_PROCESSING: 这条命令在定时任务到期时,会调用在START_TIMER命令里指定的Action函数。
前两条指令由客户端来调用,后两条指令由时钟振荡器(timer ticks)来调用,时钟振荡器一般由额外的硬件时钟来提供。
下面的两条准则可以用来衡量我们后续描述的各种算法。这两条标准都是以同时存在的定时任务平均个数或者最多个数(n)为基础进行衡量的。
1. 占用的内存空间(SPACE): 定时任务实现用到的内存空间为多少。
2. 指令执行时间(LATENCY): 某条指令的执行时间有多长,我们假如指令执行期间,指令调用者会被阻塞。
举例来说,一个基于TCP/IP第四层的网络程序发现内存足够用,而相应的指令的执行时间则成为了影响性能的主要原因,如图1所示。
这两个衡量标准可以帮助用户在开发程序时选择合适的定时算法。
3.当前正在使用的定时器算法
现在流行的主要有两种算法:
3.1 算法1-直接递减方式
在这个算法当中,每当我们调用START_TIMER指令生成一个定时任务时,都会分配一块儿内存空间用来存储定时任务的过期时间(是单位时间的整数倍),每经过一个T的单位时间,EXPIRY_PROCESSING指令就会将定时任务的过期时间减去T,如果某个定时任务的过期时间变为了零,过期回调指令EXPIRY_PROCESSING就会被调用。
在这种方式中,除了EXPIRY_PROCESSING指令外,其他的执行执行时间都很短。占用空间方面,只有每个定时任务只占用一条记录,才可以占用最小的内存空间。这个算法的性能如下图所示:
这个算法适合的场景可能有:
- 同一时间内只有很少的定时任务存在。
- 很多的定时任务在短时间内就会过期。
- PER_TICK_PROCESSING指令的执行依赖于特定的硬件来实现高速处理。
值得注意的是,我们可以存储定时任务的绝对过期时间,然后和当前时间进行比较,以查看定时任务是否过期。这种方式跟上面的采用时间递减直至为零的方式一样,都可以应用到我们介绍的各种算法。决定我们采用哪种方式的因素包括:定时任务的周期跨度有多长、指令执行时的消耗是多少、硬件的支持怎么样。在这边文章当中,除了下面将要介绍的算法2,我们都采用时间递减的方式。
3.2 算法2-有序的时间队列
在这个算法当中,PER_TICK_BOOKKEEPING指令的执行时延被分摊到了START_TIMER指令上。在这个算法里,定时任务被存储在一个有序序列当中,而且过期时间的存储形式为定时任务的绝对过期时间,而非算法1当中的时间间隔数。
最早过期的定时任务被存储在有序序列的头部,后续的定时任务按照过期时间进行升序排序,如下图所示:
图中,最早的一个定时任务将在10点23分12秒过期。
因为序列是有序的,所以随着时间的推移,PER_TICK_PROCESSING指令只需要每次比较一下当前时间与处在序列头的定时任务的过期时间,如果时间相同或者当前时间已经晚于了定时任务的过期时间,则触发EXPIRY_PROCESSING指令,并将定时任务从序列头的位置删除。这个动作一直循环,直到处在序列头的定时任务的过期时间晚于当前时间了才结束。
START_TIMER在插入一个新的定时任务时,首先要根据过期时间找到插入位置。例如,如果要插入一个在10:24:01过期的定时任务,那么如上图中,就应该插入在第二个与第三个之间。
在最坏的情况下,插入一个定时任务的时间复杂度为O(n)。而平均时延取决于现有定时任务的过期时间的离散情况(个人理解,说的是多个定时任务过期,是集中在很短的时间周期内,还是横跨的时间周期比较长,比如说前一个小时有100个定时任务过期,而一个小时却只有5个定时任务过期),及其到达分布的情况是什么样子(个人理解,比如有一个定时任务插入时,定位过程中比较了100次,而另外一个定时任务插入时,只比较了5次,平均的比较次数大概是50次)。
更有意思的是,如下图所示,可以用一个单一队列服务于大量服务器的模型来标示。这种方式是有效的,因为随着时间的推移,队列里的很多定时任务就过期了,随之就可以从队列里删除了(所以可以同时服务于大量的服务)。我们可以统计出平均时间内,这个队列当中有多少个定时任务存在(比如说平均每小时存在5个定时任务),以及剩余的定时任务的密度情况。
如果定位的时间消耗符合泊松分布(个人理解,定位插入位置时,所做的比较次数比较均匀,比如说,大部分定时任务在插入时,都只需要5次左右,但是有一部分可能只需要比较1次,也有的可能需要比较50次),那么在插入一个定时任务时,就需要从列表的头开始查找,假如读取和写入一个定时任务的时间消耗都为一个单元,那么在指数级情况和平均值情况下的时间复杂度分别为:
2 + 2/3n----指数级情况下
2 + 1/2n----平均值情况下