定时器和时间管理
时间管理在内核中占有非常重要的地位。相对于事件驱动而言,内核中有大量的函数都是基于时间驱动的。其中有些函数是周期执行的,像对调度程序中的运行队列进行平衡调整或对屏幕进行刷新这样的函数,都需要定期执行,比如说,每秒执行100次;而另外一些函数,比如需要推后执行的磁盘I/O操作等,则需要等待一个相对时间后才运行—-比如说,内核会在500ms后再执行某个任务。除了上述两种函数需要内核提供时间外,内核还必须管理系统的运行时间以及当前日期和时间。
请注意相对时间和绝对时间之间的差别。如果某个事件在5s后被调度执行,那么系统所需要的不是绝对时间,而是相对时间;相反,如果要求管理当前日期和当前时间,则内核不但要计算流逝的时间而且还要计算绝对时间。所以这两种时间概念对内核时间管理来说都至关重要。
另外,还请注意周期性产生的事件与内核调度程序推迟到某个确定点执行的事件之间的差别。周期性产生的事件—-比如每10ms一次—-都是由系统定时器决定的。系统定时器是一种可编程硬件芯片,它能以固定频率产生中断。该中断就是所谓的定时器中断,它所对应中断处理程序负责更新系统时间,也负责执行需要周期性运行的任务。系统定时器和时钟中断处理程序是Linux系统内核管理机制中的中枢,本章将着重讨论它们。
本章关注的另外一个焦点是动态定时器—-一种用来推迟执行程序的工具。比如说,如果软驱马达在一定时间内都未活动,那么软盘驱动程序会使用动态定时器关闭软驱马达。内核可以动态创建或撤销动态定时器。本章将介绍动态定时器在内核中的实现,同时给出在内核代码中可供使用的定时器接口。
内核中的时间概念
时间概念对计算机来说有点模糊,事实上内核必须在硬件的帮助下才能计算和管理时间。硬件为内核提供了一个系统定时器用以计算流逝的时间,该时钟在内核中可看成一个电子时间资源,比如数字时钟或处理器频率等。系统定时器以某种频率自行触发时钟中断,该频率可以通过编程预定,称作节拍率。当时钟中断发生时,内核就通过一种特殊的中断处理程序对其进行处理。
因为预编的节拍率对内核来说是可知的,所以内核知道连续两次时钟中断的时间间隔。这个间隔时间就称为节拍,它等于节拍率分之一秒。正如你所看到的,内核就是靠这种已知的时钟中断间隔来计算墙上时间和系统运行时间的。墙上时间对用户空间的应用程序来说是最重要的。内核通过控制时钟中断维护实际时间,另外内核也为用户空间提供了一组系统调用以获取实际日期和实际时间。系统运行时间(自系统运行开始所经过的时间)对用户空间和内核都很有用,因为许多程序必须清楚流逝的时间。通过两次(现在和以后)读取运行时间再计算它们的差,就可以得到相对的流逝的时间了。
- 时钟中断对于管理操作系统尤为重要,大量内核函数的生命周期都离不开流逝的时间的控制。下面给出一些利用时间中断周期执行的工作。
- 更新系统运行时间。
- 更新实际时间。
- 在smp系统上,均衡调度程序中各处理器上的运行队列。如果运行队列负载不均衡的话,尽量使它们均衡。
- 检查当前进程是否用尽了自己的时间片。如果用尽,就重新进行调度。
- 运行超时的动态定时器。
- 更新资源消耗和处理器时间的统计值。
这其中有些工作在每次的时钟中断处理程序中都要被处理—-也就是说,这些工作随时钟的频率反复运行。另一些也是周期性地执行,但只需要每n次时钟中断运行一次,也就是说,这些函数在累计一定数量的时钟节拍数才能被执行。在“定时器中断处理程序”这一小节中,我们将详细讨论时钟中断处理程序。
节拍率:HZ
系统定时器频率(节拍率)是通过静态预处理定义的,也就是HZ(赫兹),在系统启动时按照HZ值对也就进行设置。体系结构不同,HZ的值也不同,实际上,对于某些体系结构来说,甚至是机器的不同,它的值也会不一样。
内核在<asm/param.h>文件中定义了这个值。节拍率有一个HZ频率,一个周期为1/HZ秒。例如,在x86体系结构中,系统定时器频率默认100。因此,x86时钟中断的频率就为100HZ,也就是说在i386处理器上的每秒钟时钟中断100次(百分之一秒,即10ms产生一次)。但其他体系结构的节拍率为250和1000,分别对应4ms和1ms。下表给出了各种体系结构与各自对应节拍率的完整列表。
编写内核代码时,不要认为HZ值是一个固定不变的值。这不是一个常见的错误,因为大多数体系结构的节拍率都是可调的。但是在过去,只有Alpha一种机型的节拍率不等于100,所以,很多本该使用HZ的地方,都错误地在代码中直接硬编码(hard-code)成100这个值。稍后,我们会给出内核代码中使用HZ的例子。
正如我们所看到的,时钟中断能处理许多内核事务,所以它对内核来说极为重要。事实上,内核中的全部时间概念都来源于周期运行的系统时钟。所以选择一个合适的频率,就如同在人际交往中建立和谐关系一样,必须取得各方面的折中。
理想的HZ值
自Linux问世以来,i386体系结构中时钟中断频率就设定为100HZ,但是在2.5开发版内核中,中断频率被提高到1000HZ。当然,是否应该提高频率是饱受争议的。由于内核中众多子系统都必须依赖于时钟中断工作,所以改变中断频率必然会对这个系统造成很大的冲击。但是,任何事情总是有两面性的,我们接下来就来分析系统定时器使用高频率与使用低频率各有哪些优劣。
提高节拍率意味着时钟中断产生得更加频繁,所以中断处理程序也会更频繁地执行。如此一来会给整个系统带来如下好处:
- 更高的时钟中断解析度可提高时间驱动事件的解析度。
- 提高了时间驱动事件的准确度。
提高节拍率等同于提高中断解析度。比如HZ=100的时钟的执行粒度为10ms,即系统中的周期事件最快为每10ms运行一次,而不可能由更高的精度,但是当HZ=100时,解析度就为1ms—-精细了10倍。虽然内核可以提高额度为1ms的时钟,但是并没有证据显示对系统中所有程序而言,频率为1000Hz的时钟频率相比频率为100Hz的时钟都更合适。
另外,提高解析度的同时也提高了准确度。假定内核在某个随机时刻触发定时器,而它可能在任何时间超时,但由于只有在时钟中断到来时才可能执行它,所以平均误差大约为半个时钟中断周期。比如说,如果时钟周期为HZ=100,那么事件平均在设定时刻的+/-5ms内发生,所以误差为5ms。如果HZ=1000,那么平均误差可降低到0.5ms—-准确度提高了10倍。
高HZ的优势
更高的时钟中断频度和更高的准确度又会带来如下优点:
- 内核定时器能够以更高的频度和更高的准确度运行。
- 依赖定时值执行的系统调用,比如poll()和select(),能够以更高的精度运行。
- 对诸如资源消耗和系统运行时间等的测量会有更精细的解析度。
- 提高进程抢占的准确度。
对poll()和select()超时精度的提高会给系统性能带来极大的好处。提高精度可以大幅度提高系统性能。频繁使用上述两种系统调用的应用程序,往往在等待时钟中断上浪费大量的时间,而事实上,定时值可能早就超时了。回忆一下,平均误差可是时钟周期的一半。
更高的准确率也使进程抢占更准确,同时还会加快调度响应时间。时钟中断处理程序负责减少当前进程的时间片计数。当时间片计数跌到0时,而又设置了need_resched标志的话,内核便立刻重新运行调度程序。假定有一个正在运行的进程,它的时间片只剩下2ms了,此时调度程序又要求抢占该进程,然后去运行另一个新进程;然而,该抢占行为不会在下一个时钟中断到来前发生,也就是说,在这2ms内不可能进行抢占。实际上,对于频率为100HZ的时钟来说,最坏要在10ms后,当下一个时钟中断到来时才能进行抢占,所以新进程也就可能要求的晚10ms才能执行。当然,进程之间也是平等的,因为所有的进程都是一视同仁的待遇,调度起来都不是很明确—-但关键不在于此。问题在于由于耽误了抢占,所以对于类似于填充音频缓冲区这样有严格时间要求的任务来说,结果是无法接受的。如果将节拍率提高到1000HZ,在最坏情况下,也能将调度延误时间降低到1ms,而在平均情况下,只能降低到0.5ms左右。
高HZ的劣势
提高节拍率会产生副作用。事实上,把节拍率提高到1000HZ,甚至更高,会带来一个大问题:节拍率越高,意味着时钟中断频率越高,也就意味着系统负担越重。 因为处理器必须花时间来执行时钟中断处理程序,所以节拍率越高,中断处理程序占用的处理器的时间越多。这样不但减少了处理器处理其他工作的时间,而且还会更频繁地打乱处理器高速缓存并增加耗电。负载造成的影响值得进一步探讨。将时钟频率从100HZ提高到1000HZ必然会是时钟中断的负载增加10倍。可是增加前的系统负载又是多少呢?最后的结论是:至少在现代计算机系统上,时钟频率为1000HZ不会导致难以接受的负担,并且不会对系统性能造成较大的影响。尽管如此,在2.6版本内核中还是运行在编译内核时选定不同的HZ值。
无节拍的OS?
也许你疑惑操作系统是否一定要有固定时钟。尽管40年来,几乎所有的通用操作系统都使用与本章所描述的系统类似的时钟中断,但Linux内核支持“无节拍操作”这样的选项。当编译内核时设置了CONFIG_HZ配置选项,系统就根据这个选项动态调度时钟中断。并不是每隔固定的时间间隔触发时钟中断,而是按需动态调度和重新设置。如果下一个时钟频率设置为3ms,就每3ms触发一次时钟中断。之后,如果50ms内都无事可做,内核以50ms重新调度时钟中断。
减少开销总是受欢迎的,但是实质性收益还是省电,特别是在系统空闲时。在基于节拍的标准系统中,即使在系统空闲期间,内核也需要为时钟中断提供服务。对于无节拍的系统而言,空闲档期不会被不必要的时钟中断所打断,于是减少了系统的能耗。且不论空闲期是200ms还是200s,随着时间的推移,所省的电是实实在在的。
jiffies
全局变量jiffies用来记录自系统启动以来产生的节拍的总数。启动时,内核将该变量初始化为0,此后,每次时钟中断处理程序就会增加该变量的值。因为一秒内时钟中断的次数等于HZ,所以jiffies一秒内增加的值也就为HZ。系统运行时间以秒为单位计算,就等于jiffies/HZ。实际出现的情况可能稍微复杂些:内核给jiffies赋一个特殊的初值,引起这个变量不断地溢出,由此捕捉bug。当找到实际的jiffies值后,就首先把这个“偏差”减去。
Jiffy的语源
术语jiffy起源是未知的。据说这个短语起源于18世纪的英国。最初,jiffy所指含义不明确,但简单地表示时间周期。
在科学应用中,jiffy表示各种时间间隔,通常指10ms。在物理中,jiffy有时表示光传播某一特定距离所花的时间。
在计算机工程中,jiffy常常是两次连续的时钟周期之间的时间。在电机工程中,jiffy是完成一次AC(交流电)周期的时间。
在操作系统中,尤其是Unix中,jiffy是两次连续的时钟节拍之间的时间。历史上,这是10ms。但是,我们在本章中已经看到,jiffy在Linux中已经有所变化。
jiffies定义于文件<linux/jiffies.h>中:
1 | extern unsigned long volatile jiffies; |
现在我们先来看一些用到jiffies的内核代码。下面表达式将以秒为单位的时间转化为jiffies:
1 | (seconds * HZ) |
相反,下面表达式将jiffies转换为以秒为单位的时间:
1 | (jiffies/HZ) |
比较而言,内核中将秒转换为jiffies用得多一些,比如代码经常需要设置一些将来的时间:
1 | unsigned long time_stamp = jiffies; //现在 |
把时钟转化为秒经常会用在内核和用户空间进行交互的时候,而内核本身很少用到绝对时间。
注意,jiffies类型为无符号长整型,用其他任何类型存放它都不正确。
jiffies的内部表示
jiffies变量总是无符号长整数,因此,在32位体系结构上是32位,在64位体系结构上是64位。32位的jiffies变量,在时钟频率为100HZ情况下,497天后回溢出。如果频率为100HZ,49.7天后回溢出。而如果使用64位的jiffies变量,任何人都别指望会看到它溢出。
由于性能和历史的原因,主要还考虑到与现有内核代码的兼容性,内核开发者希望jiffies依然为unsigned long。有一些巧妙的思想和少数神奇的链接程序扭转了这一局面。
前面已经看到,jiffies定义为 unsigned long:
1 | extern unsigned long volatile jiffies; |
第二个变量也定义在<linux/jiffies.h>中:
1 | extern u64 jiffies_64; |
ld脚本用于连接主内核映像(在x86位于arch/x86/kernel/vmlinux.lds.S),然后用jiffies_64变量的初值覆盖jiffies变量:
1 | jiffies = jiffies_64; |
因此,jiffies取整个64位jiffies_64变量的低32位。代码可以完全像以前一样继续访问jiffies。因为大多数代码只不过是使用jiffies存放流失的事件,因此,也就只关心低32位。不过,时间管理代码使用整个64位,以此来避免整个64位的溢出。下图呈现了jiffies和jiffies_64的划分。
访问jiffies的代码仅会读取jiffies_64的低32位。通过get_jiffies_64()函数,就可以读取整个64位数值。但是这种需求很少,多数代码仍然只要能通过jiffies变量读取低32位就够了。
在这64位体系结构上,jiffies_64和jiffies指的是同一个变量,代码既可以直接读取jiffies也可以调用get_jiffies_64()函数,它们的作用相同。
jiffies的回绕
和任何C整形一样,当jiffies变量的值超过它的最大存放范围后就会发生溢出。对于32位无符号长整形,最大取值为2³²-1。所以在溢出前,定时器节拍数最大为4294967295.如果节拍计数达到了最大值后还要继续增加的话,它的值会回绕到0。
请看下面一个回绕的例子:
1 | unsigned long timeout = jiffies + HZ/2; //0.5s后超时 |
上面这一小段代码是希望设置一个准确的超时时间—-本例中从现在开始计时,时间为半秒。然后再去处理一些工作,比如探测硬件然后等待它的响应。如果处理这些工作的时间超过了设定的超时时间,代码就要做出相应的出错处理。
这里有很多种发生溢出的可能,我们之分析其中之一:考虑如果在设置完timeout变量后,jiffies重新回绕为0将会发生什么?此时,第一个判断将返回假,因为尽管实际上用去的时间可能比timeout值要大,但是由于溢出后回绕为0,所以jiffies这时肯定会小于timeout的值。jiffies本该是个非常大的数值—-大于timeout,但是因为超过了它的最大值,所以反而变成了一个很小的值—-也许仅仅只有几个节拍计数。由于发生了回绕,所以if判断语句的结果刚好相反。
幸好,内核提供了四个宏来帮助比较节拍计数,它们能正确地处理节拍计数回绕情况。这些宏定义在文件<linux/jiffies.h>中,这里列出的宏是简化版:
1 |
其中unknown参数通常是jiffies,known参数是需要比对的值。
宏time_after(unknown,known),当时间unknown超过指定的known时,返回真,否则返回假;宏time_before(unknown,known),当时间unknown没超过指定的know时,返回真,否则返回假。后面两个宏作用和前面两个宏一样,只有当两个参数相等时,它们才返回真。
所以前面的例子可以改造成时钟–回绕–安全的版本,形式如下:
1 | unsigned long timeout = jiffies + HZ/2; //0.5秒后超时 |
如果你对这些宏能避免因为回绕产生的错误感到好奇的话,你可以试试对这两个参数取不同的值。然后设定一个参数回绕到0值,看看会发生什么。
用户空间和HZ
在2.6版以前的内核中,如果改变内核中HZ的值,会给用户空间中某些程序造成异常结果。这是因为内核是以节拍数/秒的形式给用户空间导出这个值的,在这个接口稳定了很长一段时间之后,应用程序便逐渐依赖于这个特定的HZ值了。所以如果在内核中更改了HZ的定义值,就打破了用户空间的常量关系—-用户空间并不知道新的HZ值。所以用户空间可能认为系统运行时间已经是打破20小时了,但实际上系统仅仅是启动了两个小时。
要想避免上面的错误,内核必须更改所有导出的jiffies值。因而内核定义了USER_HZ来代表用户空间看到的HZ值。在x86体系结构上,由于HZ值原来一直是100,所以USER_HZ值就定义为100。内核可以使用函数jiffies_to_clock_t()
将一个由HZ表示的节拍计数转换为一个由USER_HZ表示的节拍计数。所采用的表达式取决于USER_HZ和HZ是否互为整数倍,而且USER_HZ是否小于等于HZ。如果这两个条件都满足,对大多数系统来说通常也能够满足,则表达式相当简单:
1 | return x/(HZ/USER_HZ); |
如果不是整数倍关系,那么该宏就得用到更为复杂的算法了。
最后还要说明,内核使用函数jiffies_64_to_clock_t()将64位的jiffies值的单位从HZ转换为USER_HZ。
在需要把节拍数/秒为单位的值导出到用户空间时,需要使用上面这几个函数。比如:
1 | unsigned long start; |
用户空间期望HZ=USER_HZ,但是如果它们不相等,则由宏完成转换,这样的结果自然是皆大欢喜。
硬时钟和定时器
体系结构提供了两种设备进行计时—-一种是我们前面讨论过的系统定时器;另一种是实时时钟。虽然在不同机器上这两种时钟的实现并不相同,但是它们有着相同的作用和设计思路。
实时时钟
实时时钟(RTC)是用来持久存放系统时间的设备,即便系统关闭之后,它也可以靠主板上的微型电池提供的电力保持系统的计时。在PC体系结构中,RTC和CMOS集成在一起,而且RTC的运行和BIOS的保存设置都是通过同一个电池供电的。
当系统启动时,内核通过读取RTC来初始化墙上时间,该时间存放在xtime变量中。虽然内核通常不会在系统启动后在读取xtime变量,但是有些体系结构会周期性地将时间值存回RTC中。尽管如此,实时时钟最主要的功能仍是在启动时初始化xtime变量。
系统定时器
系统定时器是内核定时机制中最为重要的角色。尽管不同体系结构中的定时器实现不尽相同,但是系统定时器的根本思想并没有区别—-提供一种周期性触发中断机制。有些体系结构是通过对电子晶振进行分频来实现系统定时器,还有些体系结构则提供了一个衰减测量器—-衰减测量器设置一个初始值,该值以固定频率递减,当减到零时,触发一个中断。无论哪种情况,其效果都一样。
在x86体系结构中,主要采用可编程中断时钟(PIT)。PIT在PC机器中普遍存在,而且从DOS时代,就开始以它作为时钟中断源了。内核在启动时对PIT进行编程初始化,使其能够以HZ/秒的频率产生时钟中断。虽然PIT设备很简单,但功能也有限,但它足以满足我们的需要。x86体系结构中的其他的时钟资源还包括本地APIC时钟和时间戳计数(TSC)等。
时钟中断处理程序
现在我们已经理解了HZ、jiffies等概念以及系统定时器的功能。下面将分析时钟中断处理程序是如何实现的。时钟中断处理程序可以划分为两个部分:体系结构相关部分和体系结构无关部分。
与体系结构相关的例程作为系统定时器的中断处理程序而注册到内核中,以便在产生时钟中断时,它能够相应地运行。虽然处理程序的具体工作依赖于特定的体系结构,但绝大多数处理程序最低限度也都要执行如下工作:
- 获得xtime_lock锁,以便对访问jiffies_64和墙上时间xtime进行保护。
- 需要时应答或重新设置系统时钟。
- 周期地使用墙上时钟更新实时时钟。
- 调用体系结构无关的时钟例程:tick_periodic()。
中断服务程序主要通过调用与体系结构无关的例程,tick_periodic()执行下面更多的工作:
- 给jiffies_64变量增加1(这个操作即使是在32位体系结构上也是安全的,因为前面已经获得了xtime_lock锁)。
- 更新资源消耗的统计值,比如当前进程所消耗的系统时间和用户时间。
- 执行已经到期的动态定时器动态定时器。
- 执行scheduler_tick()函数。
- 更新墙上时间,该时间存放在xtime变量中。
- 计算平均负载值。
因为上述工作分别都由单独的函数负责完成,所以tick_periodic()例程的代码看起来非常简单。
1 | static void tick_periodic(int cpu) |
很多重要的操作都在do_timer()和update_process_times()函数中进行。前者承担着对jiffies_64的实际增加操作:
1 | void do_timer(unsigned long ticks) |
函数update_wall_time(),顾名思义,根据所流逝的时间更新墙上的时钟,而calc_global_load()更新系统的平均负载统计值。当do_timer()最终返回时,调用update_process_times()更新所耗费的各种节拍数。注意,通过user_tick区别是花费在用户空间还是内核空间。
1 | void update_process_times(int user_tick) |
回想一下tick_periodic(),user_tick的值是通过查看系统寄存器来设置的:
1 | update_process_times(user_mode(get_irq_regs())); |
account_process_tick()函数对进程的时间进行实质性更新:
1 | void account_process_tick(struct task_struct *p,int user_tick) |
也许你已经发现了,这样做意味着内核对进程进行时间计数时,是根据中断发生时处理器所处的模式进行分类统计的,它把上一个节拍全部算给了进程。但是事实上进程在上一个节拍期间可能多次进入和退出内核模式,而且在上一个节拍期间,该进程也不一定是唯一一个运行进程。很不幸,这种粒度的进程统计方式是传统的Unix所具有的,现在还没有更加精密的统计算法的支持,内核现在只能做到这个程度。这也是内核应该采用更高频率的另一个原因。
接下来的run_local_timers()函数标记了一个软中断去处理所有到期的定时器。
最后,scheduler_tick()函数负责减少当前运行进程的时间片计数值并且在需要时设置need_resched标志。在SMP机器中,该函数还要负责平衡每个处理器上的运行队列。
tick_periodic()函数执行完毕后返回与体系结构相关的中断处理程序,继续执行后面的工作,释放xtime_lock锁,然后退出。
以上全部工作每1/HZ秒都要发生一次,也就是说在x86机器上时钟中断处理程序每秒执行100次或1000次。
实际时间
当前实际时间定义在文件kernel/time/timekeeping.c
中:
1 | struct timespec xtime; |
timespec数据结构定义在文件<linux/time.h>中,形式如下:
1 | struct timespec { |
xtime.tv_sec以秒为单位,存放着自1970年1月1日(UTC)以来经过的时间,1970年1月1日被称为纪元,多数Unix系统的墙上时间都是基于该纪元而言的。xtime.tv_nsec记录自上一秒开始经过的ns数。
读写xtime变量需要使用xtime_lock锁,该锁不是普通自旋锁而是一个seqlock锁。
更新xtime首先要申请一个seqlock锁:
1 | write_seqlock(&xtime_lock); |
读取xtime时也要使用read_seqbegin()和read_seqretry()函数:
1 | unsigned long seq; |
该循环不断重复,直到读者确认读取数据时没有写操作介入。如果发现循环期间有时钟中断处理程序更新xtime,那么read_seqretry()就返回无效序列号,继续循环等待。
从用户空间取得墙上时间的主要接口是gettimeofday(),在内核中对应系统调用为sys_gettimeofday(),定义于kernel/time.c
:
1 | asmlinkage long sys_gettimeofday(struct timeval *tv,struct timezone *tz) |
如果用户提供的tv参数非空,那么与体系结构相关的do_gettimeofday()函数将被调用。该函数执行的就是上面提到的循环读取xtime的操作。如果tz参数为空,该函数将把系统时区(存放在sys_tz中)返回用户。如果在给用户空间拷贝墙上时间或时区时发生错误,该函数返回-EFAULT;如果成功,则返回0。
虽然内核也实现了time()系统调用,但是gettimeofday()几乎完全取代了它。另外C库函数也提供了一些墙上时间的相关的库调用,比如ftime()和ctime()。
另外,系统调用settimeofday()来设置当前时间,它需要具有CAP_SYS_TIME权能。
除了更新xtime时间之外,内核不会像用户空间程序那样频繁使用xtime。但也有需要注意的情况,那就是在文件系统的实现代码中存放访问时间戳时需要使用xtime。
定时器
定时器是管理内核时间流逝的基础。内核经常需要推后执行某些代码,比如以前章节提到的下半部机制就是为了将工作放到以后执行,但不幸的是,之后这个概念很模糊,下半部的本意并非是放到以后的某个时间去执行任务,而仅仅是不在当前时间执行就可以了。我们所需要的是一种工具,能够使工作在指定时间点上执行—-不长不短。正好在希望的时间点上。内核定时器正是解决这个问题的理想工具。
定时器的使用很简单。你只需要执行一些初始化工作,设置一个超时时间,指定超时发生后执行的函数,然后激活定时器就可以了。指定的函数将在定时器到期时自动执行。注意定时器并不周期执行,它在超时之后就会自行撤销,这也正是这种定时器被称为动态定时器的一个原因;动态定时器不断地创建和撤销,而且它的运行次数也不受限制。定时器在内核中应用得非常普遍。
使用定时器
定时器由结构timer_list表示,定义在文件<linux/timer.h>中。
1 | struct timer_list { |
幸运的是,使用定时器并不需要深入了解该数据结构。事实上,过深地陷入该结构,反而会使你的代码不能保证对可能发生的变化提供支持。内核提供了一组与定时器相关的接口用来简化管理定时器的操作。所有这些接口都声明在文件<linux/timer.h>中,大多数接口在文件kernel/timer.c
中实现。
创建定时器时需要先定义它:
1 | struct timer_list my_timer; |
接着需要通过一个辅助函数来初始化定时器数据结构的内部值,初始化必须在使用其他定时器管理函数对定时器进行操作前完成。
1 | init_timer(&my_timer); |
现在你可以填充结构中需要的值了:
1 | my_timer.expires = jiffies + delay; //定时器超时时的节拍数 |
my_timer.expires表示超时时间,它是以节拍为单位的绝对计数值,如果当前jiffies计数等于或大于my_timer.expires,那么my_timer.function指向的处理函数就会开始执行,另外该函数还要使用长整型参数my_timer.data。所以正如我们从time_list结构看到的形式,处理函数必须符合下面的函数原型:
1 | void my_timer_function(unsigned long data); |
data参数使你可以利用同一个处理函数注册多个定时器,只需通过该参数就能区别对待它们。如果你不需要这个参数,就可以简单地传递0给处理函数。
最后,你必须激活定时器:
1 | add_timer(&my_timer); |
大功告成,定时器可以工作了!但请注意定时值的重要性。当前节拍计数等于或大于指定的超时时,内核就开始执行定时器处理函数。虽然内核可以保证不会在超时时间到期前运行定时器处理函数,但是有可能延误定时器的执行。一般来说,定时器都在超时后马上执行,但是也有可能推迟到下一次时钟节拍才能运行,所以不能用定时器来实现任何硬实时任务
。
有时可能需要更改已经激活的定时器超时时间,所以内核通过函数mod_timer()来实现该功能,该函数可以改变指定的定时器超时时间:
1 | mod_timer(&my_tiemr,jiffies+new_delay); //新的定时值 |
mod_timer()函数也可操作那些已经初始化,但还没被激活的定时器,如果定时器未被激活,mod_timer()会激活它。如果调用时定时器未被激活,该函数返回0;否则返回1。但不论哪种情况,一旦从mod_timer()函数返回,定时器都将被激活而且设置了新的定时值。
如果需要在定时器超时前停止定时器,可以使用del_timer()函数:
1 | del_timer(&my_timer); |
被激活或未被激活的定时器都可以使用该函数,如果定时器还未被激活,该函数返回0;否则返回1。注意,不需要为已经超时的定时器调用该函数,因为它们会自动删除。
当删除定时器时,必须注意一个潜在的竞争条件。当del_timer()返回后,可以保证的只是:定时器不会再被激活,但是在多处理器机器上定时器中断可能已经在其他处理器上运行了,所以删除定时器时需要等待可能在其他处理器上运行的定时器处理程序都退出,这是就要使用del_timer_sync()函数执行删除工作:
1 | del_timer_sync(&my_timer); |
和del_timer()函数不同,del_timer_sync()函数不能在中断上下文中使用。
定时器竞争条件
因为定时器与当前执行代码是异步的,因此就有可能存在潜在的竞争条件。所以,首先,绝不能用如下所示的代码替代mod_timer()函数,来改变定时器的超时时间。这样的代码在多处理器机器上是不安全的:
1 | del_timer(my_timer); |
其次,一般情况下应该使用del_tiemr_sync()函数取代dele_timer()函数,因为无法确定在删除定时器时,它是否正在其他处理器上运行。为了防止这种情况的发生,应该调用del_timer_sync()函数,而不是del_timer()函数。否则,对定时器执行删除操作之后,代码会继续执行,但它有可能会去操作在其他处理器上运行的定时器正在使用的资源,因而造成并发访问,所以请优先使用删除定时器的同步方法。
最后,因为内核异步执行中断处理程序,所以应该重点保护定时器中断处理程序中的共享数据。
实现定时器
内核在时钟中断发生后执行定时器,定时器作为软中断在下半部上下文中执行。具体来说,时钟中断处理程序会执行updaye_process_times()函数,该函数随机调用run_local_timers()函数:
1 | void run_local_timers(void) |
run_timer_softirq()函数处理软中断TIMER_SOFTIRQ,从而在当前处理器上运行所有的超时定时器。
虽然所有定时器都以链表形式存放在一起,但是让内核经常为了寻找超时定时器而遍历整个链表是不明智的。同样,将链表以超时时间进行排序也是很不明智的做法,因为这样一来在链表中插入和删除定时器都会很费时。为了提高搜索效率,内核将定时器按它们的超时时间划分为五组。当定时器超时时间接近时,定时器将随组一起下移。采用分组定时器的方法可以在执行软中断的多数情况下,确保内核尽可能减少搜索超时定时器所带来的负担。因此定时器管理代码是非常高效的。
延迟执行
内核代码(尤其是驱动程序)除了使用定时器或下半部机制以外,还需要其他方法来推迟执行任务。这种推迟通常发生在等待硬件完成某些工作时,而且等待的时间往往非常短,比如,重新设置网卡的以太模式需要花费2ms,所以在设定网卡速度后,驱动程序必须等待至少2ms才能继续运行。
内核提供了许多延迟方法处理各种延迟请求。不同的方法有不同的处理特点,有些是在延迟任务时挂起处理器,防止处理器执行任何实际工作;另一些不会挂起处理器,所以也不能确保被延迟的代码能够在指定的延迟时间运行。
忙等待
最简单的延迟方法是忙等待。但要注意该方法仅仅在想要延迟的时间是节拍的整数倍,或者精确率要求不高时才可以使用。
忙循环实现起来很简单—-在循环中不断旋转直到希望的时钟节拍数耗尽,比如:
1 | unsigned long timeout = jiffies + 10; //10个节拍 |
循环不断执行,直到jiffies大于delay为止,总共的循环时间为10个节拍。在HZ值等于1000的x86体系结构上,耗时为10ms。类似地:
1 | unsigned long delay = jiffies + 2*HZ; //2秒 |
程序循环要等待2*HZ个时钟节拍,也就说无论节拍率如何,都将等待2s。
对于系统的其他部分,忙循环方法算不上一个好办法。因为当代码等待时,处理器只能在原地选择等待—-它不会去处理其他任何任务!事实上,你几乎不会用到这种低效率的办法,这里介绍它仅仅是因为它是最简单最直接的延迟方法。当然你也可能在那些蹩脚的代码中发现它的身影。
更好的办法应该是在代码等待时,允许内核重新调度执行其他任务:
1 | unsigned long delay = jiffies + 5*HZ; |
cond_resched()函数将调度一个新程序投入运行,但它只有在设置完need_resched()标志后才能生效。换句话说,该方法有效的条件是系统中存在更重要的任务需要运行。注意,因为该方法需要调用调度程序,所以它不能在中断上下文中使用—-只能在进程上下文中使用。事实上,所有延迟方法在进程上下文中使用得很好,因为中断处理程序都应该尽可能地快执行。另外,延迟执行不管在哪种情况下,都不应该在持有锁时或禁止中断时发生。
C语言的推崇者都会问:什么能保证前面的循环已经执行了。C编译器通常只将变量装载一次。一般情况下不能保证循环中的jiffies变量在每次循环中被读取时都重新被载入。但是我们要求jiffies在每次循环时都必须重新装载,因为在后台jiffies值会随时钟中断的发生而不断增加。为了解决这个问题,<linux/jiffies.h>中jiffies变量被标记为关键字volatile。关键字volatile指示编译器在每次访问变量时都重新从主内存中获得,而不是通过寄存器中的变量别名来访问,从而确保前面的循环能按预期的方式执行。
短延迟
有时内核代码(通常也是驱动代码)不但需要很短暂的延迟(比时钟节拍还短),而且还要求延迟的时间很精确。这种情况多发生在和硬件同步时,也就是说需要短暂等待某个动作的完成(等待时间往往小于1ms),所以不可能使用像前面例子中那种基于jiffies的延迟方法。对于时钟频率为100HZ的时钟中断,它的节拍间隔时钟会超过10ms。即使频率为1000HZ的时钟中断,节拍间隔也只能到1ms,所以我们必须寻找其他方法满足更短、更精确的延迟要求。
幸运的是,内核提供了三个处理us、ns和ms级别的延迟函数,它们定义在文件<linux/delay.h>和<asm/delay.h>中,我们可以看到它们并不使用jiffies:
1 | void udelay(unsigned long usecs); |
前一个函数利用忙循环将任务延迟指定的ms数后运行,后者指定延迟的ms数。众所周知,1s等于1000ms,等于10000000μs。这个函数用起来很简单:
1 | udelay(150); //延迟150μs |
udelay()函数依靠执行数次循环达到延迟效果,而mdelay()函数又是通过udelay()函数实现的。因为内核知道处理器在1s内能执行多少次循环,所以udelay()函数仅仅需要根据指定的延迟时间在1秒内占的比例。就能决定需要进行多少次循环即可达到要求的推迟时间。
我的BogoMIPS比你的大
BogoMIPS值总让人觉得糊涂,也让人觉得很有意思。其实,计算BogoMIPS并不是为了表现你的机器性能,它主要被udelay()函数和mdelay()函数使用。它的名字取自bogus和MIPS(每秒处理百万天指令)。
BogoMIPS值记录处理器在给定时间内忙循环执行的次数。其实,BogoMIPS记录处理器在空闲时速度有多快。该值存放在变量loops_per_jiffy中,可以从文件/proc/cpuinfo
中读到它。延迟循环函数使用loops_per_jiffy值来计算为提供精确延迟而需要进行多少次循环。
内核在启动时利用calibrate_delay()计算loops_per_jiffy值,该函数在文件init/main.c
中。
udelay()函数应当只在小延迟中调用,因为在快速机器上的大延迟可能导致溢出。通常,超过1ms的范围不要使用udelay()进行延迟。对于较长的延迟,mdelay()工作良好。想其他忙等而延迟执行的方案,除非绝对必要,这两个函数都不应当使用。记住,持锁忙等或禁止中断是一种粗鲁的做法,因为系统响应时间和性能都会大受影响。不过,如果你需要精确的延迟,这些调用是最好的办法。这些忙等函数主要用在延迟小的地方,通常在μs范围内。
schedule_timeout()
更理想的延迟执行方法是使用schedule_timeout()函数
,该方法会让需要延迟执行的任务睡眠到指定的延迟时间耗尽后再重新运行。但该方法也不能保证睡眠时间正好等于指定的延迟时间,只能尽量使睡眠时间接近指定的延迟时间。当指定的时间到期后,内核唤醒被延迟的任务并将其重新放回任务队列,用法如下:
1 | //将任务设置为可中断睡眠状态 |
唯一的参数是延迟的相对时间,单位为jiffies,上例中将相应的任务推入可中断睡眠队列,睡眠s秒。因为任务处于可中断状态,所以如果任务收到信号将被唤醒。如果睡眠任务不想接收信号,可以将任务状态设置为TASK_UNINTERRUPTIBLE,然后睡眠。注意,在调用schedule_timeout()函数之前必须首先将任务设置成上面的两种状态之一,否则任务不会睡眠
。
注意,由于schedule_timeout()需要调用调度程序,所以调用它的代码必须保证能够睡眠。简而言之,调用代码必须处于进程上下文中,并且不能持有锁。
schedule_timeout()的实现
schedule_timeout()函数的用法相当简单、直接。其实,它是内核定时器的一个简单应用。请看下面的代码:
1 | signed long schedule_timeout(signed long timeout) |
该函数用原始的名字timer创建了一个定时器timer;然后设置它的超时时间timeout;设置超时执行函数process_timeout();接着激活定时器而且调用schedule()。因为任务被标识为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE,所以调度程序不会再选择该任务投入运行,而会选择其他新任务运行。
当定时器超时时,process_timeout()函数会被调用:
1 | void process_timeout(unsigned long data) |
该函数将任务设置为TASK_RUNNING状态,然后将其放入运行队列。
当任务重新被调度时,将返回代码进入睡眠前的位置继续执行(正好在调用schedule()后)。如果任务被提前唤醒,那么定时器被撤销,process_timeout()函数返回剩余的时间。
在switch()括号中的代码是为处理特殊情况而写的,正常情况下不会用到它们。MAX_SCHEDULE_TIMEOUT是用来检查任务是否无限期的睡眠,如果那样的话,函数不会为它设置定时器,而这时调度程序会立刻被调用。如果你需要无限期地让任务睡眠,最好使用其他方法来唤醒任务。
设置超时时间,在等待队列上睡眠
第4章我们已经看到进程上下文中的代码为了等待特定事件的发生,可以将自己放入等待队列,然后调用调度程序去执行新任务。一旦事件发生后,内核调用wake_up()函数唤醒在睡眠队列上的任务,使其重新投入运行。
有时,等待队列上的某个任务可能既在等待一个特定事件到来,又在等待一个特定时间到期—-就看谁来的快。这种情况下,代码可以简单地使用schedule_timeout()函数代替schedule()函数,这样一来,当希望的指定时间到期,任务都会被唤醒。当然,代码需要检查被唤醒的原因(有可能是被事件唤醒,也有可能是因为延迟的时间到期,还可能是因为接收到了信号),然后执行相应的操作。
小结
在本章中,我们考察了时间的概念,并知道了墙上时钟与计算机的正常运行时间如何管理。我们对比了相对时间和绝对时间以及绝对事件与周期事件。我们还涵盖了诸如时钟中断、时钟节拍、HZ以及jiffies等概念。
我们考察了定时器的实现,了解了如何把这些用到自己的内核代码中。本章最后,我们浏览了开发者用于延迟的其他方法。
你写的大多数内核代码都需要对时间及其走过的时间有一些了解。而最大的可能是,只要你编写驱动程序,就需要处理内核定时器。与其让时间悄悄溜走,还不如阅读本章。