查看原文
其他

深度长文 | 循序渐进解读计算机中的时间—系统&硬件篇(下)

焦点-武俍俍 搜狐技术产品 2021-07-27


本文字数:6225

预计阅读时间:25分钟


导读

前两篇从日常代码出发,讨论了Java、MySQL等应用层中日期时间的表示和存储等操作。深入了 System.currentTimeMillis() 方法是如何获取当前时间点的,以及在时间源的层次考虑不同硬件及其抽象出的数据结构之间的差异。


本篇将尽量深入底层,看看我们在用及“日期时间”时,计算机中发生了什么


1  Linux计时体系结构

本节将从体系结构的角度,选取博大精深的Linux系统,介绍系统对于时间的管理。所谓“体系结构”的概念是指一组部件和部件之间的联系,具体到操作系统我们可以理解为定义的数据结构和操作硬件赋能这些数据结构的方法


Linux对于时间的管理主要还是那两方面:时间点和时间段,即维护系统时间和管理定时器。下面依次介绍,有些前文中提过的将会简略些。


1.1

系统时间

这里的系统时间就是前文中 gettimeofday() 方法所获取的时间,由于有了 vDSO 不用再进行用户态和内核态的切换,直接读取系统时间。


但是这个系统时间是怎么维护的呢?Linux中定义了一个xtime对象,用来存放当前的时间和日期,它是一个timestamp类型的数据结构,该数据结构在 linux/v3.10/source/include/uapi/linux/time.h#L9中定义如下:


1struct timespec {
2  __kernel_time_t    tv_sec;
3  long     tv_nsec;
4};


其中的两个变量:

    ●tv_sec 存放自1970年1月1日(UTC)00:00以来经过的秒数

    ●tv_nsec 存放自上一秒开始经过的纳秒数是不是十分熟悉?下面我们抛开vDSO来介绍系统对于时间的操作。


1.1.1 系统时间的初始化


内核初始化期间,在 linux/v3.10/source/init/main.c 中调用了timekeeping_init() 方法来初始化“墙上时间”。


向下追溯,在 linux/v3.10/source/kernel/time/timekeeping.c#L770中的 timekeeping_init() 方法≥linux/v3.10/source/arch/x86/kernel/rtc.c#L142中的 read_persistent_clock() 方法:


1void read_persistent_clock(struct timespec *ts)
2
{
3  unsigned long retval;
4
5  retval = x86_platform.get_wallclock();
6
7  ts->tv_sec = retval;
8  ts->tv_nsec = 0;
9}


linux/v3.10/source/arch/x86/kernel/rtc.c#L61中的 get_wallclock() => mach_get_cmos_time() 方法(截取部分):


1unsigned long mach_get_cmos_time(void)
2{
3  unsigned int status, year, mon, day, hour, min, sec, century = 0;
4  unsigned long flags;
5
6  spin_lock_irqsave(&rtc_lock, flags);
7
8  while ((CMOS_READ(RTC_FREQ_SELECT) & RTC_UIP))
9    cpu_relax();
10
11  sec = CMOS_READ(RTC_SECONDS);
12  min = CMOS_READ(RTC_MINUTES);
13  hour = CMOS_READ(RTC_HOURS);
14  day = CMOS_READ(RTC_DAY_OF_MONTH);
15  mon = CMOS_READ(RTC_MONTH);
16  year = CMOS_READ(RTC_YEAR);
17
18  return mktime(year, mon, day, hour, min, sec);
19}


而后 linux/v3.10/source/kernel/time.c#L322 中 mktime()方法对年月日等进行了转换:


1unsigned long mktime(const unsigned int year0, const unsigned int mon0,
2                     const unsigned int day, const unsigned int hour,
3                     const unsigned int min, const unsigned int sec)
4
{
5  unsigned int mon = mon0, year = year0;
6
7  /* 1..12 -> 11,12,1..10 */
8  if (0 >= (int) (mon -= 2)) {
9    mon += 12;  /* Puts Feb last since it has leap day */
10    year -= 1;
11  }
12
13  return ((((unsigned long)
14      (year/4 - year/100 + year/400 + 367*mon/12 + day) +
15      year*365 - 719499
16      )*24 + hour /* now have hours */
17    )*60 + min /* now have minutes */
18  )*60 + sec; /* finally seconds */
19}


可见,系统初始化时会进行时钟初始化,读取RTC时间源上的UTC毫秒时间,赋值给xtime变量的 tv_sec 字段,并将xtime变量的 tv_nsec 字段赋值为0。初始化过程完成。


这样初始化出的系统时间值其实是不精确的,因为并没有地方获取准确的纳秒数。计算机会用到NTP等服务进行时间的精确同步,有兴趣的同学可以进一步了解。


但是话说回来,不联网的单机用户对于当前绝对时间点的精确度要求其实并没有那么高,给手枪加上狙击镜的意义不大。反而是对于时间段的精确度很高,那留到下一节定时器去介绍吧。


linux/v3.10/source/init/main.c的start_kernel() 方法中不仅有上述初始化“墙上时间”的过程,还会调用 tick_init()、init_timers()、hrtimers_init()、time_init() 等方法来建立计时体系结构,分别注册通知链、初始化软件时钟相关数据结构、初始化高精度定时器、初始化各种时间源。


各个方法都有独特的用途,以及严丝合缝的配合,有兴趣的同学可以阅读源码,在此不进行展开(实在是太多了==)。


1.1.2 系统时间的读取


系统时间是通过 getnstimeofday() 方法读取的,在上文中已经详细介绍了vDSO的读取方法,其中还加入了高精度定时器的读取。


linux/v3.10/source/kernel/time/timekeeping.c中可以看到单纯系统中 getnstimeofday() 的实现:


1int __getnstimeofday(struct timespec *ts)
2{
3  struct timekeeper *tk = &timekeeper;
4  unsigned long seq;
5  s64 nsecs = 0;
6
7  do {
8    seq = read_seqcount_begin(&timekeeper_seq);
9
10    ts->tv_sec = tk->xtime_sec;
11    nsecs = timekeeping_get_ns(tk);
12
13  } while (read_seqcount_retry(&timekeeper_seq, seq));
14
15  ts->tv_nsec = 0;
16  timespec_add_ns(ts, nsecs);
17
18  return 0;
19}


源码已经很清楚了,不过多解释。值得一提的是 __getnstimeofday() 是顺序锁的典型应用,“写请求并发相对较少,写锁必须优先于读锁”,这样的特点使用顺序锁大大提高了效率。


1.1.3 系统时间的更新


所谓“前人栽树后人乘凉”,读取效率高是因为有后台线程一直在更新xtime变量。更新过程是怎样的?

linux/v3.10/source/kernel/time/timekeeping.c#L489中的 do_settimeofday() 方法(部分):


1int do_settimeofday(const struct timespec *tv)
2
{
3  struct timekeeper *tk = &timekeeper;
4  struct timespec ts_delta, xt;
5  unsigned long flags;
6
7    // 获取写锁
8  raw_spin_lock_irqsave(&timekeeper_lock, flags);
9  write_seqcount_begin(&timekeeper_seq);
10
11    // 更新时间
12  timekeeping_forward_now(tk);
13  xt = tk_xtime(tk);
14  ts_delta.tv_sec = tv->tv_sec - xt.tv_sec;
15  ts_delta.tv_nsec = tv->tv_nsec - xt.tv_nsec;
16  tk_set_wall_to_mono(tk, timespec_sub(tk->wall_to_monotonic, ts_delta));
17  tk_set_xtime(tk, tv);
18  timekeeping_update(tk, truetrue);
19
20    // 释放写锁
21  write_seqcount_end(&timekeeper_seq);
22  raw_spin_unlock_irqrestore(&timekeeper_lock, flags);
23
24  return 0;
25}


每一个时钟中断(节拍)便会更新一次,很清晰的 “获取写锁 -> 更新时间 -> 释放写锁” 的过程。


1.2

定时器

定时器顾名思义,是一种软件功能,即允许在将来的某个时刻,函数在给定的时间间隔用完时被调用。这是一个真正的重头戏,各种系统中的事件都依赖于定时器去完成。


定时器分为静态定时器和动态定时器,静态定时器一般执行一些周期性的固定工作,如更新系统运行时间、更新实际时间、平衡各个处理器上的运行队列、检查进程时间片、更新各种统计值等。


动态定时器被动态地创建和撤销。由于硬件定时器的有限性,动态定时器应用更为广泛,故我们着重对动态定时器进行展开。


1.2.1 相关数据结构


1.2.1.1. HZ


节拍率(HZ)是时钟中断的频率,表示一秒内时钟中断的次数。HZ值一般与体系结构有关,常设置为100。HZ值高则时钟中断程序运行的更加频繁,依赖时间执行的程序更加精确,对资源消耗和系统运行时间的统计更加精确;同时,时钟中断执行的频繁会占用的CPU时间过多,增加系统负担。


1.2.1.2. jiffies


jiffies变量是一个计数器,用来记录自系统启动以来产生的节拍总数。

linux/v3.10/source/include/linux/jiffies.h中查看定义:


1/*
2 * The 64-bit value is not atomic - you MUST NOT read it
3 * without sampling the sequence number in jiffies_lock.
4 * get_jiffies_64() will do this for you as appropriate.
5 */

6extern u64 __jiffy_data jiffies_64;
7extern unsigned long volatile __jiffy_data jiffies;


每次时钟中断(节拍)发生它便加一,32位的 jiffies 最大值为 (2^32 - 1),因此每隔大约50天便会回绕一次。为了避免此问题,jiffies在启动时并不是被指定为0,而是指定为 (-300*HZ) 。


因此,计数器将会在系统启动后的5分钟内处于溢出状态,使得没有做溢出检测的内核代码在开发阶段被及时地发现。


linux/v3.10/source/include/linux/jiffies.h#L162中可以找到该定义:


1/*
2 * Have the 32 bit jiffies value wrap 5 minutes after boot
3 * so jiffies wrap bugs show up earlier.
4 */

5#define INITIAL_JIFFIES ((unsigned long)(unsigned int) (-300*HZ))


不仅如此,Linux还定义了time_after、time_after_eq、time_before、time_before_eq等宏处理回绕问题。其实可以类比为将 unsigned long 类型转换为 long 类型,在1ms为一个节拍的情况下,jiffies_64需要数十亿年才会发生回绕,巧妙地解决了此问题。


1.2.1.3. timer_list

动态定时器用 timer_list 数据结构表示,该数据结构在 /linux/v3.10/source/include/linux/timer.h#L12中定义如下(部分):


1 struct timer_list {
2  struct list_head entry;
3  unsigned long expires;
4  struct tvec_base *base;
5  void (*function)(unsigned long);
6  unsigned long data;
7};


其中,entry字段用于将软定时器插入链表中,后文将详细介绍相关算法;expire字段指定定时器的到期时间,用节拍数表示,其值为系统启动以来所经过的节拍数,当expire的值小于或等于jiffies的值时,就说明定时器到期或终止;function字段包含定时器到期执行函数的地址;data字段指定传递给定时器函数的参数。


1.2.2 动态定时器算法


讲过动态定时器的表示方法,接下来讲动态定时器的工作算法。定时器的三个操作:添加 (add_timer)、删除 (del_timer) 以及到期处理(tick 中断)都对精度和延迟有巨大影响,而其精度和延迟又对应用有巨大影响。Linux在定时器的处理上经历了几个阶段:


1.2.2.1. “原始”算法

简单考虑,动态定时器被定义为一个带指针的数据结构,那么只需要一个全局的链表即可存储所有动态定时器。每次需要新的timer时,只需要向这个链表中添加一个 timer_list 元素,每个节拍到来时遍历该链表。


但是显然,一个无序链表元素数量增加时遍历的成本会线性增加,增、删、到期的时间复杂度分别为O(1)、O(n)、O(n),计算机中会存在极大数量的定时器,不理想。



1.2.2.2. 排序后的算法

自然而然想到在插入链表时排序,增、删、到期的时间复杂度分别为O(n)、O(1)、O(1),虽然到期处理请求快了一些,但是整体还不理想。



1.2.2.3. 最小堆算法

最小堆是我们熟悉的数据结构,利用最小堆可以在链表排序的基础上进一步减小新增定时器的操作复杂度。增、删、到期的时间复杂度分别为O(logn)、O(1)、O(1)。



1.2.2.4. 时间轮算法

时间轮算法并不会遍历每一个定时器节点,而是将定时器按照到期时间分配到各个节拍上,随着时钟的增加,如果发现该节拍上存在定时器则该定时器到期。这样形成一个类似轮状结构,轮上每一个节点对应一个链表或数组,节点的间隔就是定时器最小时间区分。示意图如下:



如图所示,轮中共有N个bucket,每个bucket代表一秒(只作为示意,实际精度很高),每个bucket对应一个链表,链表中为当前bucket将要到期的定时器对象。中间的指针被称为cursor,cursor指向bucket时则bucket对应的链表全部做到期处理。这样一来,增、删、到期的时间复杂度全部为O(1)。


该算法的图中圆轮可以通过数组实现。



时间复杂度上达到预期,可惜这个算法有一个致命的缺陷,当时间线被拉长,精度提高,图中的N将会非常大,数组需要巨大的内存消耗,这显然是不现实的。


1.2.2.5. 分层时间轮算法

分层时间轮(Hierarchical Timing Wheels)算法是在时间轮的基础上做了改进,将单一的 bucket 数组分成了几个不同的数组,每个数组表示不同的时间精度。示意图如下所示:



上图的一个分层时间轮有三级,分别表示小时、分钟和秒。在小时数组中,每个 bucket 代表一个小时。采用原始的时间轮如果我们要表示一天,且 bucket 精度为一秒时,我们需要 24 * 60 * 60 = 86400 个 bucket;而采用分层时间轮,我们只需要 24 + 60 + 60 = 144 个 bucket。极大地减小了空间复杂度,同时增、删、到期的时间复杂度全部为O(1)。


看上去像一个完美的算法,实际并不然。当每次秒钟数组复位时,下一分钟的定时器需要重排列到各个秒钟bucket中,每次分钟数组复位时同样,这个操作被称为“cascades”。


举例说明,比如当前时间是早上08:00:00,要添加一个早上09:05:58触发的定时器,则该定时器刚添加时指针是挂在小时数组中09的位置的;当08:59:59过去后,将挂在小时数组中09位置的所有定时器按照到期的分钟数排列到分钟数组中;当09:04:59秒过去后,将挂在分钟数组中05位置的所有定时器按照到期的秒钟数排列到秒钟数组中。则该09:05:58触发的定时器被分配到了秒钟数组的58位置。


当cursor经过该位置时,定时器被触发。每次cascades操作的时间复杂度是O(m),该m为每个bucket中定时器的个数,多数情况下 m 远小于系统中所有定时器个数。


1.2.2.6. 红黑树算法

实时应用、多媒体软件对时钟和定时器的精度要求不断提高,在早期 Linux 内核中,定时器所能支持的最高精度是一个 tick。


为了提高时钟精度,人们只能提高内核的 HZ 值,更高的 HZ 值意味着时钟中断更加频繁,内核要花更多的时间进行时钟处理。同时,高精度时钟硬件的出现对于定时器算法也提出了更高的要求。


虽然时间轮是一种有效的管理数据结构,但其 cascades 操作有不可预料的延迟。它更适于被称为“timeout”类型的低精度定时器,即不等触发便被取消的 Timer。这种情况下,cascades 可能造成的时钟到期延误不会有任何不利影响,因为根本等不到 cascades,换句话说,多数 Timer 都不会触发 cascades 操作。


而高精度定时器的用户往往是需要等待其精确地被触发,执行对时间敏感的任务,因此 cascades 操作带来的延迟是无法接受的。所以内核开发人员不得不放弃时间轮算法,转而寻求其他的高精度时钟算法,最终开发人员选择了内核中最常用的高性能查找算法:红黑树来实现 hrtimer。


所有的 hrtimer 实例都被保存在红黑树中,添加 Timer 就是在红黑树中添加新的节点,删除 Timer 就是删除树节点,红黑树的值为到期时间。Timer 的触发和设置管理不在定期的 tick 中断中进行,而是动态调整:当前 Timer 触发后,在中断处理的时候,将高精度时钟硬件的下次中断触发时间设置为红黑树中最早到期的 Timer 的时间。时钟到期后从红黑树中得到下一个 Timer 的到期时间,并设置硬件,如此循环反复。


4.2.2.7. 延迟函数

严格来讲延迟函数并不应该归在这一小节,因为并不属于动态定时器的演化范围。但是它也是一种定时器的实现方式,故在此简单讲解。


当内核需要等待一个较短的时间间隔,如不超过毫秒时,就无需使用动态定时器,且动态定时器由于相对较大的设置开销也是不精确的。在这些情况下,内核使用 udelay() 或 ndelay() 函数,接收一个微秒或纳秒级别的参数,并在延迟结束后返回。


如果可以利用TSC或HPET硬件,则该方法使用它们来获得精确的时间测量,否则该方法会执行一个紧凑指令循环的n次循环,至到达指定时间。


 时钟振荡器硬件

上文中所有都还停留在软件和集成硬件层面,我们常说“节拍到来时触发何种操作”,可是节拍是如何产生的?芯片本身通常并不具备时钟信号源,因此须由专门的振荡电路提供时钟信号。


说起振荡,我们脑海里第一反应可能是高中学习的LC振荡器,通过电场和磁场的周期性变化产生固定的频率:



根据这个简单的原理产生了EE专业传世经典:NE555定时器芯片。



当然这是经典的实现方式,但是如今计算机中常用的时钟振荡源是晶体振荡器,也就是我们常说的“晶振”。


石英晶体振荡器(Quartz Crystal OSC)是一种最常用的时钟信号振荡源。石英晶体就是纯净的二氧化硅,是二氧化硅的单晶体。从一块晶体上按一定的方位角切下薄片(称为"晶片"),在晶片的两个表面上涂覆一层薄薄的银层后接上一对金属板,焊接引脚,并用金属外壳封装,就构成了石英晶体振荡器。


石英晶片之所以能当为振荡器使用,是基于它的压电效应:在晶片的两个极上加电场,会使晶体产生机械变形;在石英晶片上加上交变电压,晶体就会产生机械振动,同时机械变形振动又会产生交变电场,虽然这种交变电场的电压极其微弱,但其振动频率是十分稳定的。


为什么石英晶片会有压电效应?下面是唐僧念经可以不看==


石英是日常最常见的非线性晶体,来源于硅氧四面体不能完美密堆积而产生的各种晶胞构象。由有潜在极性的硅氧四面体规则堆积成晶体,所以在某些晶面上,石英整体会呈现极性,并因为电荷的规则分层排布成为电介质。


当外加电压的时候,石英晶体会发挥电介质的特性,让硅氧四面体变形来极化自己,从而抵消外加的电场,在此过程中产生力学特性。


相反,当石英晶体受力的时候,晶胞参数就会改变,使其极性变化,从而硅氧四面体和石英本身的介电常数改变,这样一来再受电场的时候,因为其介电常数变化,就会使得电容变化,可以测得电压变化,形变的弹性也会改变。这就是压电晶体的正压电和反压电效应。


当固定的晶体受力的时候,当然会回弹产生振动。如果外加电场也周期性变化,使得晶体周期性受力,和石英晶体的固有频率相同,那么晶体就会受到电场而反复振动,反过来增强电场,形成谐振。


由于我们的主攻方向是软件层面,辛勤的EE工程师为我们提供了有力的硬件保障,所以关于实际底层硬件就不再多讲,感兴趣的同学可以自行继续研究模电数电等知识。


3  总结与思考

3.1

文章总结

本篇文章接续上篇和中篇,从系统和硬件的角度着重讲解了在高级语言之下的层次,时间和日期的相关问题。


以Linux为例,介绍了系统的计时体系结构,主要包括系统时间和定时器


系统时间包括时间点的初始化、获取、更新方法定时器介绍了相关的概念和数据结构,着重讲解了动态定时器算法的演进过程,从演进过程中可以看出Linux对于系统的一步步优化。简要介绍了时钟的最底层来源——时钟振荡器硬件,以及它的原理压电效应,从而基本打通了从应用层到硬件层的时间相关原理。


3.2

反思回顾

全文(包含三个部分)是从实际开发的角度出发的,按照从顶层向底层的角度进行介绍。但是实际的历史是自下而上发展的,先有了基础的物理学支撑和电子硬件支撑,才能发展出上层至高级语言和数据库技术的应用,应用的需求进而倒逼硬件的发展。技术发展进步是迅速的,一个个框架更新换代,一个个新技术不断涌现,但是很多底层原理鲜有改变,这也是我们努力的方向,厚积才能薄发,掌握基本原理才能更好更快地学习从而跟上时代技术进步。


本文Linux版本是v3.10,每个版本的实现方式都有一定差异,此次深入探索只是讲出了大致的思想方法,具体版本还需要读者自己查看对应源码。


时间和节拍是计算机系统运行的根基,相关概念、原理、源码等车载斗量。本篇文章虽篇幅较长,但是并不能详尽的解释系统所有关于日期时间的问题,只是捡拾了笔者认为需要关注的一些点,更多的问题欢迎大家一同探讨。


在写文章过程中,越向底层探究越发现参考资料变少,很多问题想搞清楚要通过阅读源码来解决。而自己阅读源码的功力不足,加之本身知识经验有限,所以可能某些问题上理解不恰当或表述不清楚,欢迎大家留言指正。




参考资料:

[1]https://elixir.bootlin.com/linux/v3.10/source/include/uapi/linux/time.h#L9

[2]https://elixir.bootlin.com/linux/v3.10/source/init/main.c

[3]https://elixir.bootlin.com/linux/v3.10/source/kernel/time/timekeeping.c#L770

[4]https://elixir.bootlin.com/linux/v3.10/source/arch/x86/kernel/rtc.c#L142

[5]https://elixir.bootlin.com/linux/v3.10/source/arch/x86/kernel/rtc.c#L61

[6]https://elixir.bootlin.com/linux/v3.10/source/kernel/time.c#L322

[7]https://elixir.bootlin.com/linux/v3.10/source/init/main.c

[8]https://elixir.bootlin.com/linux/v3.10/source/kernel/time/timekeeping.c

[9]https://elixir.bootlin.com/linux/v3.10/source/kernel/time/timekeeping.c#L489

[10]https://elixir.bootlin.com/linux/v3.10/source/include/linux/jiffies.h

[11]https://elixir.bootlin.com/linux/v3.10/source/include/linux/jiffies.h#L162

[12]https://elixir.bootlin.com/linux/v3.10/source/include/linux/timer.h#L12

[13]《深入理解Linux内核》第六章-定时测量

[14]https://www.ibm.com/developerworks/cn/linux/1308_liuming_linuxtime3/index.html?ca=drs-

[15]http://blog.chinaunix.net/uid-24774106-id-3909829.html

[16]https://zhuanlan.zhihu.com/p/30289225



也许你还想看

(▼点击文章标题或封面查看)

深度长文|循序渐进解读计算机中的时间—应用篇(上)

2019-11-14

深度长文|循序渐进解读计算机中的时间—应用篇(下)

2019-11-14

如何在Android中完成一个APT项目的开发?

2019-07-11


加入搜狐技术作者天团

千元稿费等你来!

戳这里!☛


    

       看完啦?留言支持一下再走吧~~~

 ▼▼▼


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存