linux时钟浅析
时钟的作用
尽管与CPU指令执行没有什么直接关系,时钟对于操作系统来说还是有着很重要的意义:
1、记录系统时间。很多应用程序需要知道日期和时间、由日期和时间构成的时间戳也会被打在文件上面、等等;
2、统计功能。如top之类的用户程序可以查看一段时间内的系统负载、以及各个进程占用CPU的时间、等等;
3、定时功能。很多用户程序会使用到定时器,比如sleep一段时间后做某件事情、比如给select设置一个超时时间、等等;
关于时钟硬件
为了实现这些功能,计算机硬件需要提供相应的时钟电路。这些电路大致上分两类:
1、RTC(实时时钟),记录着当前的日期和时间(记录的是自1970-01-01起经历的秒数),并且自动随时间增长。
这种硬件是由电池独立供电的,因为它要保持日期时间的更新,不能因为计算机的断电而停止工作。
linux内核只在启动时从RTC获取当前时间,用于设置系统时间。在系统时间被修改时,linux内核会相应地更新RTC。
而系统时间的自动增长则不依赖于RTC,依赖的是另一种时钟电路。
2、时钟振荡器,可以周期性地发出中断。
这种硬件是可编程的,linux内核在启动时可设置它发出中断的周期,一般是1ms。于是每隔1ms,CPU将收到一个时钟中断,这个时间间隔称作一个tick(节拍)。
利用这种周期性的中断,linux内核在处理时钟中断的中断处理程序(参见《linux内核中断处理浅析》)中,实现了系统时间的自动增长、各种统计和定时器功能。
CPU每ms都要处理一次时钟中断,并且还要在中断处理程序中完成很多功能,那它还有时间干别的事吗?考虑一个主频为200MHz的RISC结构的CPU,假设每个CPU周期能处理一条指令,那么1ms的时间这个CPU能执行约20万条指令。假设时钟中断处理程序能在1万条指令之内完成(一般情况下1万条指令应该足够了),CPU还是有大部分的时间能干其他事的。
关于时钟中断
在单处理器系统中,每个tick只发生一次时钟中断。在对应的中断处理程序中完成更新系统时间、统计、定时器、等全部功能;
而在多处理器系统下,时钟中断实际上是分成两个部分:
1、全局时钟中断,系统中每个tick只发生一次。对应的中断处理程序用于更新系统时间和统计系统负载;
2、本地时钟中断,系统中每个tick在每个CPU上发生一次。对应的中断处理程序用于统计对应CPU和运行于该CPU上的进程的时间,以及触发对应CPU上的定时器;
于是,在多处理器系统下,每个tick,每个CPU要处理一次本地时钟中断;另外,其中一个CPU还要处理一次全局时钟中断。
更新系统时间
在linux内核中,全局变量jiffies_64用于记录系统启动以来所经历的tick数。每次进入时钟中断处理程序(多处理器系统下对应的是全局时钟中断)都会更新jiffies_64的值,正常情况下,每次总是给jiffies_64加1。
而时钟中断存在丢失的可能。内核中的某些临界区是不能被中断的,所以进入临界区前需要屏蔽中断。当中断屏蔽取消的时候,硬件只能告诉内核是否曾经发生了时钟中断、却不知道已经发生过多少次。于是,在极端情况下,中断屏蔽时间可能超过1个tick,从而导致时钟中断丢失。
如果计算机上的时钟振荡器有很高的精度,linux内核可以读振荡器中的计数器,通过比较上一次读的值与当前值,以确定中断是否丢失。如果发现中断丢失,则本次中断处理程序会给jiffies_64增加相应的计数。但是如果振荡器硬件不允许(不提供计数器、或者计数器不允许读、或者精度不够),内核也没法知道时钟中断是否丢失了。(不过丢失就丢失吧,也没什么大不了的。)
内核中的全局变量xtime用于记录当前时间(自1970-01-01起经历的秒数、本秒中经历的纳秒数)。xtime的初始值就是内核启动时从RTC读出的。
在时钟中断处理程序更新jiffies_64的值后,便更新xtime的值。通过比较jiffies_64的当前值与上一次的值(上面说到,差值可能大于1),决定xtime应该更新多少。
系统调用gettimeofday(对应库函数time和gettimeofday)就是用来读xtime变量的,从而让用户程序获取系统时间。
统计系统负载
在时钟中断处理程序(多处理器系统下对应的是全局时钟)完成对上述jiffies_64和xtime的更新之后,接下来就会对系统负载进行统计。
所谓系统负载,是指系统中各个CPU的可执行队列中包含的进程数目。值为0表示对应CPU上没有可执行的进程,CPU空闲;值为1表示对应CPU被一个进程独占,CPU占用率为100%;值大于1表示对应CPU被多个进程共享,CPU占用率为100%。
现在,内核要在中断处理程序中统计系统负载,意味着内核每隔一个tick要去查看一下在当前时间点上各个CPU的可执行队列中的进程数目。实际上,孤立地看每个tick对应的时间点上的CPU负载值并没有太大意义,除非内核把从系统启动以来的所有负载值都记录下来,然后再交由关心系统负载的用户进程进行分析。但是这样做需要花费大量的内存空间,并不现实。所以,内核最终保留了3个值:最近1分钟、5分钟、15分钟的平均负载。每次中断处理程序中获取到的实时的负载值只对这3个值产生影响。
在linux系统中,通过top、uptime之类的命令就能看到这3个值,cat /proc/stat也能看到。
统计运行时间
在单处理器系统中,中断处理程序完成上面的操作后,就到了这一步;在多处理器系统中,每个CPU上的本地时钟中断直接进入这一步。
统计运行时间实现起来非常简单。在中断处理函数中,内核查看当前CPU上正在运行的是哪个进程,然后就给该进程的运行时间增加1ms(一个时钟周期),除非当前进程是0号进程。只要当前CPU上正在运行的不是0号进程,就给CPU的运行时间加1ms。因为0号进程是用来让CPU空转的进程,只在CPU的可执行队列中没有进程时,才会被调度执行。所以,0号进程正在运行,表明对应的CPU空闲。
这样的统计方法从局部看是很不准确的,可能出现这样的情况:进程A运行了0.99ms,然后切换到进程B。进程B只运行了0.01ms,然后中断就来了。结果中断处理程序在计算运行时间只把整个1ms都加到了B进程上。
但是从一段比较长的时间来看(比如几十秒、几分钟),误差并不会太大,并且就算有误差也没什么大问题(并不影响内核的稳定性,仅仅是展现不够友好而已)。所以内核使用的这种统计方式基本上可以说是非常简单并且有效的。
在linux系统中,通过cat /proc/$pid/stat就能看到这里的统计值。能看到各个进程运行了多少时间,这个时间还分为用户态下的运行时间和内核态下的运行时间。
linux系统中的top命令能够看到进程的CPU占用率,也是利用了这里的统计结果。当然,top命令还做了很多事情:对所有进程的运行时间进行一遍扫描,然后sleep 1秒(或被用户的按键打断),然后再扫描一遍。计算在两次扫描的间隔时间中,各个进程的运行时间都增加了多少,最后得出各个进程在这段时间内的CPU占用率、以及总的CPU占用率。
除统计外,更新进程运行时间还有激活间隔定时器的作用。用户程序通过settimer系统调用设置的定时器可以选择多种定时策略:ITIMER_REAL(真正的时间)、ITIMER_VIRTUAL(进程在用户态下运行的时间)、ITIMER_PROF(进程运行的时间,包括用户态和内核态)。后两种定时器称为间隔定时器。
在中断处理程序进行进程运行时间统计时会更新进程的运行时间,于是间隔定时器可以在这里被触发。如果条件满足,内核会发送SIGVTALARM或SIGPROF信号给当前进程。(为什么只是当前进程?因为之前1 个tick的时间将全部增加到当前进程上,而其他进程不会得以增加。)
另外,随着当前进程运行时间的增加,当前进程可能由于时间片被耗尽而触发一次调度。也有可能因为运行时间达到限制而被内核通过发送SIGKILL信号杀掉(通过ulimit -a命令可以看到“cpu time”这一限制,不过这项限制一般是“unlimited”)。
处理定时器
时钟中断处理程序完成上面的操作以后(多处理器系统下对应的是本地时钟),就触发一次软中断(参见《linux内核中断处理浅析》),由对应的软中断处理程序来处理定时器。
定时器使用的地方非常多,前面提到的settimer系统调用在选择ITIMER_REAL策略时将设置一个定时器,alarm系统调用也用于设置一个类似的定时器。这两个定时器在触发时,内核都会发送SIGALARM信号给定时的进程。另外,nanosleep系统调用(对应库函数sleep、usleep等)也使用定时器;select、poll、futex、等允许设置超时时间系统调用也使用定时器。这些定时器在触发时,对应的进程将被唤醒,并且退出系统调用。
为实现定时器,每个CPU都维护一个定时器队列。进程在添加定时器时总是添加到正在运行该进程的那个CPU的定时器队列上。定时器有两个要素:什么时候触发?触发后干什么(比如发送XXX信号、唤醒进程、等等)?
定时器队列并不是简单的队列,否则每个时钟中断到来时(多处理器系统下对应的是本地时钟),中断处理程序都必须遍历队列中的所的定时器,以决定需要触发其中的哪些。在定时器数目很多时,这是非常要命的事。
排序的话又如何呢?如果队列中的定时器按触发时间从早到晚排好序,则中断处理程序只需要关心队列头的若干个触发时间与当前时间相同的定时器。这将最大限度的降低中断处理程序对定时器的处理时间。但是添加定时器时就麻烦了,必须花费一定的时间来寻找合适的位置(复杂度为O(logn),二分法)。并且,从寻找到添加完成的过程必须与中断处理程序处理定时器的过程互斥。添加定时器的时间长了,势必影响到中断处理程序。所以这样的队列也并不够理想。
而linux内核使用了一种比较巧妙的队列结构,很好的解决了添加/删除/处理定时器时的效率问题。下面仅对定时器队列模型做一些介绍。
linux内核将定时器队列设计为多个队列,每个队列的时间单位不一样。就像自来水水表一样,水表上有多个指针,每个指针表示着不同的刻度单位。假设水表上有1、10、100三个指针,每个1刻度代表一个tick。然后想象水表转动的情景:1指针逐渐增加到9,然后变成0,同时10指针增加1。
添加定时器时,在0~9个tick之内将要触发的定时器被放在1指针的刻度盘上。随着1指针的移动,指针指向的刻度上对应定时器将被触发;
在10~19个tick之内将要触发的定时器被放在10指针指向的下一个刻度上(在这10个tick之内的定时器都放在同一个刻度上)。以此类推,在20~29个tick之内将要触发的定时器被放在10指针指向的下两个刻度上……当1指针发生进位时,10指针移动一个刻度。这个刻度上对应的定时器将被放入到1指针的刻度盘上(注意,这些定时器的触发时间跨越了10个tick);
以此类推,在100~199个tick之内将要触发的定时器被放在100指针指向的下一个刻度上。并且当10指针进位时,它们被重新放到10指针的刻度盘上……
可见,按照这样的模型,添加定时器时,只需要计算一下该定时器应该放在哪个刻度盘的哪个刻度上即可,复杂度为O(1);而时钟中断处理程序每次也只需要处理1指针指向的一组定时器即可。然后移动指针,如果发生进位,则花费一些时间将10指针刻度盘上被指向的一组定时器重新分派到1指针的刻度盘上。10指针进位只也类似地将100指针刻度盘上被指向的一组定时器重新分派到10指针的刻度盘上。平均复杂度为O(1)。
一些想法
以前接触过一些用户态定时器线程的实现。调用者将一个定时器结构放入定时器队列中,由定时器线程在时间到来的时候调用定时器结构中的回调函数。
定时器线程的实现通常有两种思路:
1、定时线程sleep一小段时间(如1秒),然后处理定时器队列中已到期的那些定时器(可以通过将定时器排序来优化这一处理过程)。
这种方法实现起来非常简单,调用者也只需要将定时器添加到定时器队列即可。
但是效率并不高,定时器线程每隔一定时间轮询一下定时器队列,会造成一些不必要的开销(因为很多时候并没有定时器需要处理)。并且定时器精度不够(取决于睡眠时间的长短),要提高精度只能缩短睡眠时间,从而又造成更大的轮询开销。
2、定时线程维护一个按触发时间排序的定时器队列,然后总是以当前时间到第一个定时器触发时间的间隔作为睡眠时间。
这种方法显然更复杂一些。最麻烦的是,调用者添加新定时器的时候,如果新定时器比原有定时器的触发时间都早,则需要打断定时线程的睡眠,并且让它重新开始一次更短的睡眠。
如果总是不断的有触发时间更早的定时器将定时线程打断,会对性能有一定的影响。但是总的来说,这种方法避免了大量无用的轮询,效率会更高一些。并且精度更高(取决于内核的定时精度)。
对应到linux内核,目前对时钟的处理是以类似第一种方式来实现的。简单,但效率不高、精度不高。
那么今后linux内核是否有可能改用类似第二种方式来处理时钟呢?拭目以待……