erlang时间校正机制

原文 2015-04-29 00:34:06 发表于 CSDN,这里对以前写的文章做下收录。

很多人会注意到这个问题,erlang提供了2个时间函数,erlang:now() 和 os:timestamp()。用法一样,都是返回当前的时间。具体时间是从1970年1月1日零时算起,到现在经过的时间,结果为{MegaSecs, Secs, MicroSecs}。

这两个函数有什么区别?
os:timestamp() 获取到的时间为操作系统的时间,不做任何修正;而erlang:now(),每次获取都会确保生成了唯一的时间,就是说,erlang:now()在实现上对时间做了一个校正,每次都生成一个单调向前的唯一值。

erlang:now()的特点:

Monotonic
erlang:now() never jumps backwards - it always moves forward
Interval correct
The interval between two erlang:now() calls is expected to correspond to the correct time in real life (as defined by an atomic clock, or better)
Absolute correctness
The erlang:now/0 value should be possible to convert to an absolute and correct date-time, corresponding to the real world date and time (the wall clock)
System correspondence
The erlang:now/0 value converted to a date-time is expected to correspond to times given by other programs on the system (or by functions like os:timestamp/0)
Unique
No two calls to erlang:now on one Erlang node should return the same value

主要是这3个特点:

特点 说明
单调向前 erlang:now() 获取的时间是单调向前,就算系统时间倒退了,也不会影响这个函数的使用。(时间依旧是向前的,较之前几乎没有偏差)
唯一性 erlang:now() 获取的值都是唯一的,不会重复出现2个相同的值。
间隔修正 两次 erlang:now() 调用的间隔都可以被利用来修正erlang时间。

到这里,可以看出 erlang 内部实现了一套时间校正的机制,当系统时间出错的时候,就会做修正。(关于这块内容,可以看Erlang相关文档 time correction)

erlang 时间校正

时间校正的作用:

在开始这段内容前,讲讲时间校正的作用
1. 时间单调向前:
  举个例子,说明时间倒退问题:
  比如,游戏中会统计今天和昨天杀怪的总数量,跨零点时要把今天杀怪字段的数量写到昨天的字段,然后将今天的置0。跨零点后,如果时间倒退了几秒钟,然后就会重复跨零点。那么,今天的数量会覆盖昨天的数量,导致昨天的数量被清零。
2. 时间平稳:
  同样举个例子,说明时间不平稳问题:
  比如,erlang开发中,经常都会出现一个进程call另一个进程的场景,一般是5秒超时,假如时间突然加快了5秒,就相当于没有等待操作完成,就直接超时了。当然这是很不合理的

erlang时间校正的特点:

Monotonic
The clock should not move backwards
Intervals should be near the truth
We want the actual time (as measured by an atomic clock or an astronomer) that passes between two time stamps, T1 and T2, to be as near to T2 - T1 as possible.
Tight coupling to the wall clock
We want a timer that is to be fired when the wall clock reaches a time in the future, to fire as near to that point in time as possible

假如操作系统时间出现了改变,erlang不会立刻改变内部时间为系统时间,而是将时间轻微加快或减慢,最终和系统时间保持一致。就算系统时间突然倒退到以前的某个时间,但时间总是向前这点是不会改变的,所以,erlang只是预期在将来某个时间和系统时间达成一致,而不会倒退时间。

erlang是怎么校正时间的?

erlang内部时间会和系统挂钟时间保持同步,当系统挂钟时间突然改变时,erlang会比较两个时间的差异,让内部的时间的同步值轻微变大或变小,幅度最大是1%,就是说,VM经历 1s 实际上可能就是 0.99s 或者1.01s。当系统时间改变了1分钟,erlang会花100分钟来慢慢校正,并最终和系统时间保持同步。

哪些函数受到时间校正影响?

erlang:now/0
The infamous erlang:now/0 function uses time correction so that differences between two "now-timestamps" will correspond to other timeouts in the system. erlang:now/0 also holds other properties, discussed later.
receive ... after
Timeouts on receive uses time correction to determine a stable timeout interval.
The timer module
As the timer module uses other built in functions which deliver corrected time, the timer module itself works with corrected time.
erlang:start_timer/3 and erlang:send_after/3
The timer BIF's work with corrected time, so that they will not fire prematurely or too late due to changes in the wall clock time.

不只是 erlang:now() ,以上几个功能都有赖于时间校正的实现。比如 erlang:send_after/3 , 就算系统时间改变了,这个函数发出的消息也会按预定时间期限送达。

源码剖析

erlang:now() 是 bif 实现,代码如下:(以R16B02为例)

/*
 * bif.c now_0函数,实现 erlang:now/0
 * return a timestamp
 */
BIF_RETTYPE now_0(BIF_ALIST_0)
{
    Uint megasec, sec, microsec;
    Eterm* hp;

    get_now(&megasec, &sec, &microsec); // 获取当前时间
    hp = HAlloc(BIF_P, 4);
    BIF_RET(TUPLE3(hp, make_small(megasec), make_small(sec),
		   make_small(microsec))); // 返回{MegaSecs, Secs, MicroSecs}
}

再来看下 get_now() 函数。

/*
 * erl_time_sup.c get_now函数,获取当前时间
 * get a timestamp
 */
void get_now(Uint* megasec, Uint* sec, Uint* microsec)
{
    SysTimeval now;
    
    erts_smp_mtx_lock(&erts_timeofday_mtx);
    
    get_tolerant_timeofday(&now); // 获取当前时间值
    do_erts_deliver_time(&now);  // 记录当前的时间(用于VM内部读取当前时间,如timer)

    /* 确保时间比上次获取的大 */
    if (then.tv_sec > now.tv_sec ||
	(then.tv_sec == now.tv_sec && then.tv_usec >= now.tv_usec)) {
	now = then;
	now.tv_usec++;
    }
    /* Check for carry from above + general reasonability */
    if (now.tv_usec >= 1000000) {
	now.tv_usec = 0;
	now.tv_sec++;
    }
    then = now;
    
    erts_smp_mtx_unlock(&erts_timeofday_mtx);
    
    *megasec = (Uint) (now.tv_sec / 1000000);
    *sec = (Uint) (now.tv_sec % 1000000);
    *microsec = (Uint) (now.tv_usec);

    update_approx_time(&now);//更新「简要」时间(仅用于标记进程启动时间)
}

这里重点看下get_tolerant_timeofday(),实现了时间校正功能。

/*
 * erl_time_sup.c get_tolerant_timeofday函数,获取当前时间
 * 根据系统API不同有两种实现,这里取其中一种做说明
 */
static void get_tolerant_timeofday(SysTimeval *tv)
{
    SysHrTime diff_time, curr;

    if (erts_disable_tolerant_timeofday) {// 时间校正功能被禁用,直接返回系统时间
	sys_gettimeofday(tv);
	return;
    }
    *tv = inittv; // 取VM启动时间
	
	// 计算从VM启动到现在经过的内部时间(正值,单位微秒)
    diff_time = ((curr = sys_gethrtime()) + hr_correction - hr_init_time) / 1000; 

    if (curr < hr_init_time) {
	erl_exit(1,"Unexpected behaviour from operating system high "
		 "resolution timer");
    }

	// 检查是否刚校正过(两次校正最小间隔 1s)
    if ((curr - hr_last_correction_check) / 1000 > 1000000) {
	/* Check the correction need */
	SysHrTime tv_diff, diffdiff;
	SysTimeval tmp;
	int done = 0;

	// 计算从VM启动到现在经过的实际时间(如果系统时间被调整过,可能是负值,单位微秒)
	sys_gettimeofday(&tmp);
	tv_diff = ((SysHrTime) tmp.tv_sec) * 1000000 + tmp.tv_usec;
	tv_diff -= ((SysHrTime) inittv.tv_sec) * 1000000 + inittv.tv_usec;
	diffdiff = diff_time - tv_diff;// 实际时间与内部时间的差值(缩短这个时间差以赶上实际时间)
	if (diffdiff > 10000) { // 内部时间比外部时间快 0.01s 以上
	    SysHrTime corr = (curr - hr_last_time) / 100; //  两次调用经过的实际时间 * 1%
	    if (corr / 1000 >= diffdiff) {
			++done;
			hr_correction -= ((SysHrTime)diffdiff) * 1000; 
			/* 超过diffdiff*1000 * 100,只修正 diffdiff*1000,
			 * 就是1s需要花100s修正,同时标记本次修正完成
			 * 什么情况下会走到这里:就是这个函数很久没调用,超过了时间偏差的100倍
			 * 然后标记修正完成,至此,就没有时间偏差了
			 */
	    } else {
		hr_correction -= corr; // 修正值为两次调用经过的实际时间 * 1%
	    }
		// 重算与VM启动时间的间隔
	    diff_time = (curr + hr_correction - hr_init_time) / 1000; 
	} else if (diffdiff < -10000) { // 内部时间比外部时间慢 0.01s 以上
	    SysHrTime corr = (curr - hr_last_time) / 100;
	    if (corr / 1000 >= -diffdiff) {
		++done;
		hr_correction -= ((SysHrTime)diffdiff) * 1000;
	    } else {
		hr_correction += corr;
	    }
	    diff_time = (curr + hr_correction - hr_init_time) / 1000; 
	} else {
	    /* 内部时间与外部时间偏差在0.01s 内,标记完成,等1s后修正剩下的时间
	     * 这段代码目的是,如果时间偏差在0.01s内,VM特意等1s后修正这个时间
	     * 另外,如果时间没出差错,就都走到这里,减少时间函数调用开销
         */
	    ++done;
	}
	if (done) {
	    hr_last_correction_check = curr;
	}
    }
    tv->tv_sec += (int) (diff_time / ((SysHrTime) 1000000));
    tv->tv_usec += (int) (diff_time % ((SysHrTime) 1000000));
    if (tv->tv_usec >= 1000000) {
	tv->tv_usec -= 1000000;
	tv->tv_sec += 1;
    }
    hr_last_time = curr;
}

这里,erlang利用一个单调递增的时间函数 sys_gethrtime(),作为参照物来判断VM实际经历的真实时间,然后再轻微的向系统挂钟时间倾斜,以致最终和系统挂钟时间保持同步。至于sys_gethrtime(),我也准备了一点资料,放在拓展阅读分享吧。

 

拓展阅读

gethrtime()
前面提到的sys_gethrtime(),实际上是一个宏(暂时只讨论linux下的实现,win下类似)

#define sys_gethrtime() gethrtime()

关于 gethrtime() 可以看下unix官方文档说明man page for gethrtime ,写得很详细。 
gethrtime() 作用是用于实时获取当前时间,精度非常高,以纳秒为单位,但是,两次足够紧连的调用有可能返回相同的结果,毕竟精度单位是纳秒,可以保证的是时间不会倒退。
另外,这里参考了 C ++参考指南 C++ Reference Guide - High Resolution Timers 

也就是这两个特点:
1. 单调向前,不会倒退;
2. 线性增长,不会变快或变慢。每个时间刻度经历的时间都是一样的。
所以,Erlang VM利用基于硬件的单调递增时间,取两个时刻的差值来计算VM运行的时间,然后取操作系统的挂钟时间做比较,通过逐渐缩小这个时间差来实现VM时间始终单调向前,从而平稳地把VM时间纠正到用户的挂钟时间。

时间同步
这里看到《并行与分布仿真系统》的作者写的相关文章Synchronizing Clocks
(Wallclock Time),也很有参考价值

假设时间提前了10毫秒,就每隔30毫秒产生一次中断,每次中断的增量时间为29毫秒,花10次完成修正。

结束语

最后,说下时间校正的副作用。
erlang实现时间校正有计算开销的,而且这个内部校正值是全局变量,不只是所有erlang进程,还是VM所有调度线程都会读写这个时间,所以就要有锁来保证数据安全。为此,erlang内部设定好了 erlang:now/0 调用频率不会超过1微妙1次。
当然,如果获取时间只是用于测试目的,或者打印错误日志时间,完全可以用 os:timestamp/0 来代替。对于一些有大规模进程的项目,还可以设立一些时间管理进程,用于同步时间,而每个进程只要读取自己的进程字典就好。

如果不想使用,还可以禁用这个功能。
Time correction is enabled or disabled by passing the +c [true|false] command line argument to erl.
R18之后,erlang提供了更多时间校正相关的API,对用户暴露底层时间的相关信息。这里暂时就不说明了。链接地址

发表评论

邮箱地址不会被公开。 必填项已用*标注