这两天看到BurntSushi新发布了jiff时间库, 不禁回想起之前的Java项目中, 老态龙钟的Date依然大行其道的局面, 尽管在JDK1.8就包含了新的时间API. 这其实也从侧面说明, 设计好用的时间库并非易事. 这篇文章主要讲有关计算机时间的内容, 为什么时间很复杂.

时间是怎么度量的

时间是从哪里开始的, 流逝的方向又是什么, 这些既是科学, 又是哲学, 还是神学的问题, 我们不清楚答案. 聪明的"我们"虽然搞不清时间的本质, 但这并不妨碍我们度量时间, 而其中的关键就在于找到度量的单位.

image 404

回想一下质量是怎么被度量的, 我记得之前教科书上有张"国际千克原器"的图, 就是放在钟形容器的那块砝码. 这块砝码是质量的度量单位"千克", 在要称量其他物品时, 只要和这块砝码做比较, 知道有几个砝码那么重就可以了.

那时间的度量也应是如此, 我们要找到度量的单位, 然后其他的时间段的"长度", 只要和这个时间单位比较一下就好了. 我们找到的这个时间单位, 就是一些规律的, 周期性的事件, 从细沙的流逝到机械的嘀嗒, 从星球的转动到原子的跃迁.

地球公转一周, 就有一年; 地球自转一圈, 就有一天. 为了更精确地表达时间, 再把这些度量单位进一步地细化切分, 1天分成24小时, 1小时再分成60分钟, 1分钟再分成60秒, 从秒再分毫秒, 还有微妙, 纳秒, 皮秒.

我们使用的时间

2024-01-01 02:03:09
\__/ \/ \/ \_____/
 年  月  日  时分秒 

我们日常使用的是上面这样的格式, 在全球范围内都是如此. 虽年月日顺序可能有所区别, 但总体上差不太多. 这种 日期+时间 的格式被称为本地时间, PlainTime, CivilTime, NativeTime, LocalTime等等, 在代码会遇到不同的名称, 但大差不差, 都是指这种时间格式.

本地时间是偏向于自然规律, 偏向于生活作息的时间, 差不多06:00该日出, 而18:00的时候应该要日落了, 中午的时间是12:00, 午夜的时间是00:00. 大家(当然有些地区比较特别, 比如高纬度区域)都是这么认为的, 不管是东半球的人们, 还是西半球的人们.

计算机里的时间

image 404

来到计算机世界, 一方面很多计算机都会配有硬件时钟. 硬件时钟的原理在于, 其晶体振荡一次相当于多少秒是确定的, 所以以这种周期性的振荡为时间单位, 乘上振荡过的次数可以换算到秒了. 而且晶体振荡的频率很高, 所以硬件时钟的精度远高于秒.

=> sudo hwclock -v
hwclock from util-linux 2.40.2
System Time: 1721962835.492649
... 
2024-07-26 11:00:35.466318+08:00

Linux下, 可以用hwclock来查看或配置硬件时钟. 硬件时钟通常有单独的电池供电, 所以计算机关机或者拔掉电源后, 也还能继续工作. 下次开机的时候, 系统的时间依然是正常的.

另一方面计算机还配有"网络时钟", 能通过网络时间协议NTP(Network Time Protocol)来校正自己的时间. NTP的时间源头是全世界几百台原子钟的共识, 这里的周期性事件是原子的跃迁, 共同决定了秒的"长度", wiki Atimic Clock上给出的近代原子钟的精度可以达到2*10-16.

如此广泛的共识, 加上如此高的精度, 确实厉害. Arch开启NTP可以参考Network_Time_Protocol. windows的话在日期时间配置面板上, 勾选与Internet时间服务器同步就可以了.

NTP通过网络把"标准"时间传递出去, 五花八门的计算机们就可以校准自己的时钟到一个"准确"的时间了. 当然, 由于网络延迟等问题的存在, 虽然NTP协议有考虑到这些因素, 但最终校准出来的时间肯定是会丢失很多精度的.

int clock_gettime(clockid_t clockid, struct timespec *tp);

struct timespec {
  time_t   tv_sec;   // 秒
  long     tv_nsec;  // 纳秒
};

Linux提供了clock_gettime这个系统调用来获取当前的系统时间, 返回的timespec里的字段, 指的是从Epoch(1970-01-01 00:00:00Z)开始到现在经历的秒和纳秒数. 计算机提供给我们的就是这个时间, 也就是大名鼎鼎的时间戳, 上面hwclock的执行结果里有这个值.

// jiff::Timestamp::now()
pub fn now() -> Timestamp { Timestamp::try_from(std::time::SystemTime::now()) }

// std::Instant::now()
pub fn now() -> Instant {
// https://www.manpagez.com/man/3/clock_gettime/
  const clock_id: libc::clockid_t = libc::CLOCK_MONOTONIC;
  Instant { t: Timespec::now(clock_id) }
}

// std::SystemTime::now()
pub fn now() -> SystemTime { SystemTime { t: Timespec::now(libc::CLOCK_REALTIME) } }

jiff库里获取当前时间的Timestamp::now, 调用的是标准库里的SystemTime::now. 而标准库里的Instant::nowSystemTime::now, 底层正是调用了clock_gettime.

时间戳到本地时间

时间戳被定义为从Epoch开始到某个时刻经历的秒数, 因为起点Epoch是被广泛认可的, 秒的"长度"也是原子钟协商出来的, 所以时间戳是我们所有人的共识, 对所有人来说都是一样的, 可以看作是"绝对"时间.

尽管操作系统和, 程语言都为我们提供了获取时间戳的方法, 但毕竟计算机还要给人用的, 而本地时间的格式是更符合我们的使用习惯的, 所以需要能将时间戳转换到本地时间的方法, 这不, 问题就来了.

因为本地时间是偏向自然规律的, 比如中午是12:00, 中国人是这样认为的, 美国人也是这样认为的. 但对于某个具体的时刻, 例如中国中午(12:00), 美国却是午夜(00:00), 尽管时间戳都是一样的.

显然, 时间戳没办法直接转换到本地时间, 这边的主要问题在于地球是绕着地轴转的, 不同地区看到日出日落时间不一样, 同一个时间戳对不同地区的人, 有了不同的本地时间.

既然问题在于地区的差异, 那解决的办法也就有了. 只要地球还是绕着地轴转的, 同一经线上的本地时间就是相同的, 而地球自转也是"均匀的", 所以不同经线上的时间偏差是可以计算出来的.

加上地区信息, 时间戳和本地时间之间就可以换算了, 时间戳没什么大 问题了, 那关键就在于地区信息了, 下面是我们有可能会遇到的三种时区格式.

固定偏移时区

image 404

上面我们提到Epoch的时候, 给出的是时间1970-01-01 00:00:00Z. 这其实是一个带时区的时间, 最后的字母Z, 表示的上面图最中间这条黑色的经线, Z就是这里的时区信息.

地球自转360°是1天, 也就是24小时, 也就是1440分钟. 那很容易计算, 经线相隔15°就会差1个小时, 相隔1/4°就会差1分钟, 这就是地区之间的时间偏移量.

我们以上面的这条黑色经线为"原点", 往东(右)为正, 往西(左)为负, 就可以表达出全球所有地区之间的时间偏移关系. Z的偏移量是0, 也被成为UTC时区(TimeZone::UTC).

1970-01-01T01:00:00+00:00  3600

1970-01-01T02:30:00+01:30  3600
\________/ \______/ \___/
    |         |       |
   日期       时间   偏移(时区)

如上是两个固定偏移时区的时间, 都对应3600这个时间戳. 第一个是UTC时区的时间, 偏移量是0. 第二个时间的偏移量是01:30, (偏移量 + UTC时区时间 = 当前时间), 所以 (01:00 + 01:30 = 02:30).

值得一提的是. 偏移量的范围是-12:00 ~ +14:00, 区间长度是26, 不是理所当然的24小时, 加上夏令时还可能是27, 感兴趣的同学可以看一下UTC+14:00.

POSIX时区

POSIX格式的时区用的并不多(基本没见过..), 不过这种格式包含了夏令时的规则, 所以也列在这边了, 顺便也能了解下夏令时是什么概念.

STD offset [ DST [ dstoffset ] [ , rule ] ]

STD:        夏令时没开启时采用哪个时区
offset:     夏令时没开启时UTC偏移
DST:        夏令时开启后采用哪个时区(可选)
dstoffset:  夏令时没开启后UTC偏移(可选)
rule:       夏令时规则(可选)

夏令时, Daylight Saving Time, 简称DST. 一般在春夏季开启, 秋冬季退出. 在夏令时开启时, 会把时钟调快, 比如向前拨1个小时, 这样本来的07:00, 就变成了08:00. 这样做的原因是, 夏季天亮的更早, 对于日出而作的我们, 能更多地利用太阳光照, 降低能源消耗.

初看到DST时, 我还以为是Rust的动态尺寸类型, Dynamically Sized Types. 时间处理怎么和类型系统扯上关系了... 原来还有这个DST, Daylight Saving Time, 夏令时

EST05:00EDT,M3.2.0/2:00:00,M11.1.0/2:00:00
\_/\___/\_/ \____________/ \_____________/
 |   |   |        |               |
时区 偏移 时区  夏令时开启时间   夏令时结束时间

上面是纽约的POSIX时区的示例, 没开启夏令时, 采用时区EST时区(UTC-05:00, 和UTC偏移相反); 开启夏令时后, 采用EDT时区. 夏令时开启的时间是3月份第2个星期日的02:00, 结束的时间是11月份第1个星期日的02:00.

let d1 = DateTime::constant(2024, 3, 10, 1, 59, 59, 0)
    .intz("America/New_York").unwrap();
let d2 = d1.add(1.second());

// d1: 2024-03-10T01:59:59-05:00[America/New_York]
// d2: 2024-03-10T03:00:00-04:00[America/New_York]  

夏令时增加了时间换算的复杂度, 给程序带来的麻烦可不少, 比如上面的例子, d1是进入夏令时的前一刻, d1加上一秒到了d2. 明明只过了1秒, 时间上却差了1个小时, 不了解夏令时的同学肯定会非常困惑(例如我~).

而且对于纽约来说, 2024-03-10 02:00:00 ~ 2024-03-10 03:00:00这段时间成了"空白", 没有时间戳会解析在这个时间段里, 这被称为gap.

退出夏令时时, 比如2024-11-03 01:00:00 ~ 2024-11-03 02:00:00, 这段时间则会"重放"一遍, 所以其中任意时间点, 都会对应到两个时间戳, 这被称为fold.

我国在1986也实行过夏令时制, 实行了6年, 到1992年暂停了, 因为节电效果并不好, 而且给工业生产和日常生活带来了很多麻烦, U_U.

TZIF格式时区

固定偏移的时区是理论上的划分, 简洁, 容易理解, 但现实总不那么简单. 一方面地区很少是按经线划分的, 国家或州省的形状并不规整; 另一方面, 时区和夏令时规则是随时会改变的, 而且从实际看这种改变并不罕见.

image 404

按15°一个时区的划分方式, 我国的领土横跨5个时区, 也就是说东西地区的时差会达到5个小时. 但我国是统一使用北京时间的, 即东八区(UTC+08:00)的时间, 所以在直播里, 经常会看到都晚上10点了, 藏区的天还是白亮的.

这些时间规则不是预先可以确定的逻辑, 而是随时可能会变化更新的, 所以更像是一个数据库. 因此Time Zone Database时区信息数据库诞生了, 里面包含国家和地区的时间规则, 当国家和地区发生变更, 或者政府宣布变更时间规则时, 这些变更记录也会被更新进去.

// /usr/share/zoneinfo/America/New_York     
Sun Mar 10 06:59:59 2024 UT = Sun Mar 10 01:59:59 2024 EST isdst=0 gmtoff=-18000
Sun Mar 10 07:00:00 2024 UT = Sun Mar 10 03:00:00 2024 EDT isdst=1 gmtoff=-14400
Sun Nov  3 05:59:59 2024 UT = Sun Nov  3 01:59:59 2024 EDT isdst=1 gmtoff=-14400
Sun Nov  3 06:00:00 2024 UT = Sun Nov  3 01:00:00 2024 EST isdst=0 gmtoff=-18000

如上是纽约地区2024年的时间规则, Linux发行版通常自带时区数据库, 比如Arch系统在/usr/share/zoneinfo/目录下有很多地区时间文件. 系统当前的时间规则文件是/etc/localtime, 我们可以用命令zdump -v /etc/localtime查看配置的时间规则.

JDK也带有时区数据库, 比如JDK1.8的jre/lib/tzdb.dat文件. Rust的时间处理库chrone, 搭配第三方库可以处理时区数据库, 例如tzfile会读取系统里的数据库文件, 而chrone-tz是读取预先打包进二进制包里文件.

闰年和闰秒

地球公转一周是1年, 地球自转一圈是1天, 1年"等于"365天. 这个365的倍数关系是经验所得, 也是为了方便使用, 不是定理证明的结果. 事实上, 1年会比365天稍微长一点, 比如365.25天, 为了让这点误差不长年累月的积累下去, 就引入了闰年, 每隔4年额外加一天.

闰秒的引入也是为了消除误差, UTC里的秒是由原子钟来定义的, 和我们日常使用的秒是1天的86400秒, 这两个秒之间存在误差. 与闰年不同的是, 闰秒什么时候出现是没有规律的, 通常是由IERS提前6个月通知大家要加1秒了, 这导致了计算机很难处理闰秒.

闰秒和闰年存在的原因, 是因为我们给不同的周期性时间, 也就是时间单位之间, 赋予了倍数关系, 而这些单位之间并不像大小齿轮那样是紧紧咬合, 一丝不差的, 闰秒和闰年就负责消除误差.

小结

这篇文章主要讲述了计算机时间相关的内容, 起因是BurntSushi新发布的jiff这个时间库, 项目里的文档DESIGN.mdCOMPARE.md非常值得一读, 设计和开发好用的时间库并不容易, 了解下我们的时间也非常有意思.


-> 如果文章有不足之处或者有改进的建议,可以在这边告诉我,也可以发送给我的邮箱