多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++20 tai_clock深度解析:闰秒、时间尺度与chrono时钟转换

C++20 tai_clock深度解析:闰秒、时间尺度与chrono时钟转换 如果你维护过跨年夜的日志系统很可能见过这种诡异的行2016-12-31 23:59:60。我第一次看到时以为是业务系统拼错了字符串后来查了半天才发现这是真实的闰秒。那一秒在UTC时标上确实存在但Unix时间戳、数据库自增ID、日志排序规则统统不认它。也就是从那时候开始我才认真琢磨C里各种时钟到底是什么关系以及为什么C20一口气引入了std::chrono::tai_clock这种“冷门”时钟。这篇文章就从tai_clock出发把国际原子时时钟的底层结构、和system_clock、utc_clock、gps_clock之间的转换关系以及闰秒边界到底会发生什么一次性讲清楚。适合写过基础chrono程序、想知道时间戳背后真相、或者正在设计跨闰秒时间系统的读者。1. 为什么系统里已经有时钟还要再加一个tai_clock1.1 既有三个时钟各自“不靠谱”的地方老牌三个时钟分别是system_clock、steady_clock、high_resolution_clock。它们的分工很多文章讲过但很少有人把“不靠谱”说到位。system_clock是挂钟本质是UTC时间戳。它的问题是可以被NTP回拨也会被运维手动修改同时它不区分“挂钟秒”和“真实物理秒”。steady_clock是单调时钟适合测耗时但它没有日历语义你不能从它拿到“现在是2025年几月几号”。high_resolution_clock则更尴尬标准允许它只是前面某个时钟的别名你不能假设它一定单调、一定精确、一定和日历有关。这三个时钟有一个共同盲区它们都不认识闰秒。system_clock的时间戳不包含第60秒跨过闰秒时它的“秒计数”会发生跳变或停滞这对绝大多数应用无所谓但对需要对事件精确排序、对跨节点的时序做因果判断的系统来说是个隐患。1.2 时间标准的真相UTC不是一条均匀时间线要理解tai_clock为什么存在先得接受一个反直觉的事实我们平时用的UTC并不是“一秒一秒均匀累加”的时间线。UTC在1972年之后采用“SI秒”作为基本单位。SI秒由铯原子钟定义非常稳定。但地球自转不均匀如果完全按原子秒走UTC和天文时间会越差越多。为了不让挂钟和时间测量脱节UTC会不定期插入闰秒通常在6月30日或12月31日的最后一分钟那一分钟会变成61秒。问题来了插入闰秒后“挂钟时间”不再是连续的均匀时间线。相邻两天之间可能多出1秒而这1秒在Unix时间戳里没有对应的整数表示。于是system_clock跨闰秒时要么出现重复秒要么直接跳过某些取值具体取决于操作系统内核怎么处理。而国际原子时TAI是另一条时间线它严格按SI秒累加没有闰秒没有重复秒没有跳秒。TAI从1958年1月1日00:00:00开始起算是许多科学计量系统的实际参考时间线。1.3 C20的回应把“时间尺度”建模进标准库C20在chrono里新增了四种时钟utc_clock、tai_clock、gps_clock、file_clock配合原有的三个构成一个完整的时间尺度家族。其中utc_clock专门表示带闰秒的UTC时间线它可以描述23:59:60这种时刻tai_clock表示不带闰秒的国际原子时gps_clock表示GPS系统内部使用的时标file_clock用来表示文件系统时间戳具体起点由操作系统决定。这些时钟不是噱头。它们解决了一个很实际的问题以前你用system_clock拿到的time_point既可能是UTC也可能被理解成其他东西类型系统无法区分。现在不同时钟产生不同类型的time_point混用会在编译期报错这层类型安全正是C20 chrono改进的核心价值。2. tai_clock底层拆解起点、精度与内部时标2.1 epoch为什么是1958年1月1日tai_clock的time_point基于“从TAI纪元开始经过的时间”来计算。这个纪元是1958年1月1日00:00:00 TAI。为什么选这个时间点因为TAI本身就是从这一刻正式定义的。1958年之前时间计量主要靠天文观测原子钟还不够统一。1958年多台原子钟的综合结果被用来建立国际原子时尺度后来的时间标准体系都围绕这个起点展开。所以C标准把tai_clock的epoch定在这里和TAI的历史定义保持一致。这里要特别注意1958年这个epoch和Unix时间戳的epoch1970年1月1日UTC之间隔着12年。这12年里并不是简单的365乘以12天因为包含几个闰日。具体秒数是378691200秒。这个数会直接参与tai_clock的底层换算。2.2 精度与duration和system_clock高度相关tai_clock没有自己独立的计时器。它的duration类型和system_clock一样通常由实现定义。在Linux上system_clock::period一般是纳秒Windows上一般是100纳秒。tai_clock::period也会跟随系统时钟。这意味着tai_clock::now()拿到的time_point精度并不会因为“原子时”三个字就自动变得比系统时钟更准。它依赖的还是系统时钟源只是把同一个瞬时时刻重新按照TAI时标来编码。auto sys_now std::chrono::system_clock::now(); auto tai_now std::chrono::tai_clock::now();这两个now()本质上来自同一个底层时钟源一个解释成UTC计数一个解释成TAI计数。所以tai_clock::is_steady一定是false因为它随着系统时间一起可以被NTP调整或用户修改。2.3 最容易踩的坑不同epoch的time_point不能直接相减很多人拿到tai_clock::now().time_since_epoch()第一反应是用它和system_clock::now().time_since_epoch()做差想算出“TAI比UTC快多少秒”。这个操作的结果会让你怀疑人生。auto sys_now std::chrono::system_clock::now(); auto tai_now std::chrono::tai_clock::now(); // 错误两个time_since_epoch的参考起点不同 auto diff tai_now.time_since_epoch() - sys_now.time_since_epoch();这个差大约会是三亿七千八百多万秒约等于12年。原因很简单system_clock的计数从1970年开始tai_clock的计数从1958年开始直接相减相当于把两个不同起点的长度混在一起。正确做法是先把其中一个转换到另一个时钟体系或者获取同一个瞬时在TAI和UTC下的表示再比较。// 正确把TAI时间点转回sys_time再和系统时钟比较 auto sys_from_tai std::chrono::tai_clock::to_sys(tai_now); auto same_instant_diff sys_from_tai - sys_now;两边的time_point如果精度不同会通过common_type自动提升。这个操作才是合法的结果也不会是12年。2.4 now()到底经历了什么标准库里实现tai_clock::now()通常就是取system_clock::now()然后调用tai_clock::from_sys()转换。转换过程需要知道当前时刻之前已经发生了多少次闰秒。这个表通常由标准库内置或者依赖于操作系统提供的时间峰值信息。因此tai_clock::now()并不是“原子钟硬件直接读数”它是在软件层面重新解释系统时间。这一点必须清楚否则你会误以为用了tai_clock就获得了原子钟级别的精度。3. UTC与TAI的转换关系37秒的来龙去脉3.1 1972年之前的10秒基础现代UTC体系在1972年1月1日定型。从那天起UTC开始采用SI秒并通过闰秒和TAI保持整秒关系。1972年1月1日00:00:00 UTC对应TAI的时刻是00:00:10也就是说起点就是10秒的偏移。之前的历史更复杂UTC和TAI之间不仅有偏移还有频率调整秒长都不是严格SI秒。C标准明确用了简化模型1972年1月1日之前直接按10秒固定偏移处理。这对现代工程应用几乎没影响但如果你在做上世纪六七十年代的天文历史数据还原不能直接用C标准库的这个简化结果。3.2 闰秒表与累计偏移从1972年开始每插入一次闰秒TAI和UTC的偏移就增加1秒。截止到目前2025年最后一次闰秒发生在2017年1月1日TAI和UTC的差固定在37秒。下面这张表展示了几个关键时间点的偏移值时间点TAI - UTC1972-01-0110秒1980-01-0119秒1990-01-0125秒2000-01-0132秒2010-01-0134秒2020-01-0137秒所以当别人问你“TAI和UTC差多少秒”答案不是一个常数而是“截至当前发生了什么闰秒”现在恰好是37秒。3.3 换算公式和C接口换算关系用公式写出来就是TAI日历时间 UTC日历时间 10秒 已发生的闰秒总数反过来UTC日历时间 TAI日历时间 - 10秒 - 已发生的闰秒总数在C里tai_clock::to_sys和tai_clock::from_sys就是这两个公式的封装。to_sys把TAI时间点转换成sys_timefrom_sys把sys_time转换成TAI时间点。#include chrono #include iostream int main() { auto now_sys std::chrono::system_clock::now(); auto now_tai std::chrono::tai_clock::now(); // 打印当前UTC和TAI的日历显示 std::cout UTC: now_sys \n; std::cout TAI: now_tai \n; // 正确计算TAI读数比UTC读数快多少 auto tai_of_sys std::chrono::tai_clock::from_sys(now_sys); auto offset std::chrono::duration_caststd::chrono::seconds( now_tai - tai_of_sys); std::cout offset: offset.count() s\n; // 把TAI转回同一瞬时的UTC auto back_sys std::chrono::tai_clock::to_sys(now_tai); auto roundtrip std::chrono::duration_caststd::chrono::microseconds( back_sys - now_sys); std::cout roundtrip diff: roundtrip.count() us\n; }这段代码里now_tai - tai_of_sys是同一个时钟体系下的两个时间点做差结果就是“TAI读数比UTC快多少秒”目前输出37。to_sys再转回去和now_sys的差应该只在微秒级因为两次now()调用本身就存在先后间隔。3.4 为什么不直接给个常量37也许你会想既然当前差37秒那我直接在系统时间戳上加37秒不就行了短时间看确实可以但一旦未来有新闰秒这个常数就作废了。更麻烦的是历史数据如果你有一批2020年的日志和一批2010年的日志当年TAI和UTC的偏移分别是37和34用同一个常数去处理其中必然有一批算错。闰秒表本质上是一张“累积偏移变化表”。只有把这张表放进代码里才能对任意历史时刻做正确换算。C20的utc_clock和tai_clock正是为此设计的。4. 闰秒边界为什么这个转换没法用常量搞定4.1 23:59:60 在sys_time里根本不存在UTC日历中闰秒当天的最后一分钟有61秒。真实世界的时间线是这样的23:59:58 23:59:59 23:59:60 00:00:00而在sys_time对应的Unix时间戳里没有23:59:60这个值。内核在闰秒期间通常会重复23:59:59或者在某些实现里时间戳会表现出奇怪的停滞。也就是说sys_time的时间线在闰秒处被“压扁”了真实世界经历了一秒但ISO时间戳的秒计数没有增加。4.2 标准库怎么处理“不存在的时间点”utc_clock专门引入.time_since_epoch()的计数包含闰秒所以它能表示23:59:60。但当utc_clock时间点转换成sys_time时这一秒只能被压缩掉。标准并没有要求所有实现按完全相同的方式压缩。有的实现会把闰秒内的时间点映射到00:00:00之后的对应亚秒有的会直接舍入。因此当你的时间点恰好落在闰秒那一秒里utc_clock::to_sys的结果不应该被当作高精度物理时刻来依赖。实际工程里这种时刻一辈子可能碰不到几次但一旦碰到日志排序错乱、数据库主键冲突、消息对账失败都会冒出来。这也是我建议“核心系统内部统一走TAI”的原因TAI没有第60秒没有重复秒天然避免这类边界歧义。4.3 为什么不能写死37这个数再深一层即使未来几年不变历史数据也需要按当年的闰秒表计算。如果程序里只写死一个37面对以下需求就会出问题查询一条2016年12月31日的日志那一时刻TAI和UTC差36秒不是37。计算两次事件之间的真实物理时长其中一次在2016年12月31日23:59:59另一次在2017年1月1日00:00:00真实差是2秒但如果拿sys_time相减可能只有1秒。金融、审计系统要求时间戳可校验、可回放差1秒会让对账系统产生大量错误告警。C20的库把“闰秒累计表”放在实现里标准库升级时会更新。你不需要自己维护这张表也不用在业务代码里判断“现在差几秒”。4.4 编译期转换与闰秒表更新问题tai_clock::from_sys和to_sys是constexpr函数可以在编译期完成时间点换算。这听起来很酷但也带来一个运维视角的问题闰秒表是标准库实现的一部分升级编译器或标准库版本可能改变对历史时刻的映射结果。大多数情况下这是好事因为新版本会补充新的闰秒信息。但如果你在构建系统里固定了老旧的GCC而业务要处理未来新增的闰秒那就得重新评估。我遇到过的实际情况是一些长期运行的嵌入式项目编译器版本多年不变等到真正遇到闰秒日才发现标准库里的闰秒表停留在几年前。一个可行的缓解方案是业务层不要直接依赖“当前偏移秒数”而是通过utc_clock或tai_clock的转换接口来做这样标准库一旦更新行为自动修正。5. tai_clock真正的用武之地5.1 跨闰秒的日志排序与事件关联假设你有100台服务器日志统一写到中心。平时一切正常但到了闰秒夜可能出现“A服务器处理完请求B服务器还没收到”这种时序倒挂。用system_clock打时间戳时闰秒期间某些机器会给出重复秒导致日志里的时间序与实际因果序不一致。改成TAI之后时间戳本身是一条连续递增的线排序只依赖数值大小不会出现重复秒。auto tai std::chrono::tai_clock::now(); auto message std::format([{}] request handled, tai);不过要注意tai_clock::now()依赖系统时钟如果某台机器的系统时间被NTP大范围调整TAI时间戳也会跟着跳。TAI解决的是“闰秒歧义”不是“NTP抖动的总和”。5.2 计量、天文与科学数据的时间标记天文观测、卫星导航、深空通信这些领域对“真实物理时刻”的要求很高。这些行业实际就是用TAI或TT地球时作为参考线。C的tai_clock至少提供了一个标准化的入口让上层程序不需要自己拼凑偏移量。比如曝光时间戳、激光测距数据、地震波记录如果都以TAI记录不同站点之间的数据对齐就变得直接不用再考虑各地UTC时区和闰秒。5.3 和GPS时钟互转GPS用的时标和TAI有固定关系GPS时比TAI慢19秒。因为GPS在1980年1月6日启动时TAI比UTC快19秒而GPS系统没有再插入闰秒。所以当前GPS和UTC的差等于TAI和UTC的差37秒减去19秒也就是18秒。这个关系几十年不变非常适合作为跨系统换算的桥梁。C20提供了gps_clock可以这样和tai_clock互转auto tai_now std::chrono::tai_clock::now(); auto utc_now std::chrono::utc_clock::from_tai(tai_now); auto gps_now std::chrono::gps_clock::from_utc(utc_now);反过来也行auto gps_now std::chrono::gps_clock::now(); auto utc_from_gps std::chrono::gps_clock::to_utc(gps_now); auto tai_from_gps std::chrono::utc_clock::to_tai(utc_from_gps);这类转换在导航、通信、车辆网联系统里很常见。以前这些系统各自维护一套“GPS周、GPS秒、闰秒表”的代码现在标准库提供了统一模型至少少写不少易错代码。5.4 一个完整示例把当前时间以三种时标显示下面的代码展示同一瞬时在UTC、TAI、GPS三个时标下的表现。注意格式化输出时三种时钟直接打印看到的日历时间会不一致这正是时标之间的读数差。#include chrono #include iostream int main() { using namespace std::chrono; auto now_sys system_clock::now(); auto now_utc utc_clock::from_sys(now_sys); auto now_tai tai_clock::now(); auto now_gps gps_clock::from_utc(now_utc); std::cout sys: now_sys \n; std::cout utc: now_utc \n; std::cout tai: now_tai \n; std::cout gps: now_gps \n; }输出大致是sys: 2025-01-15 10:30:00.123456 utc: 2025-01-15 10:30:00.123456 tai: 2025-01-15 10:30:37.123456 gps: 2025-01-15 10:30:18.123456sys和utc显示相同是因为system_clock和utc_clock在绝大多数运行时刻使用同一个底层时标源只是后者额外区分闰秒边界。要真正看到“第60秒”得等闰秒日平时它俩输出一致。6. 实际使用中的注意事项与个人体会6.1 别拿tai_clock直接做日历显示tai_clock时间点直接格式化得到的是TAI日历比UTC快37秒。大多数业务展示希望能看到UTC或本地时间而不是一个“快了半分钟多”的时间。更要小心的是日历日边界。UTC晚上23:59:23到24:00:00这段时间TAI日历已经进入第二天。如果你直接拿tai_time转换成年月日当天的日志会被错误地归类到“明天”。正确做法是把TAI时间点先转成sys_time再通过sys_days或zoned_time做日历和时区展示。auto tai_now std::chrono::tai_clock::now(); auto sys_of_tai std::chrono::tai_clock::to_sys(tai_now); auto ymd std::chrono::year_month_day{ std::chrono::floorstd::chrono::days(sys_of_tai)};这样得到的日历日才是那个TAI瞬时对应的UTC日历日。6.2 别混用不同时钟的time_pointC20的强类型体系会在编译期阻止直接比较两种不同时钟的time_point但很多人还是会写auto d tai_now - gps_now编译器报错后又改成time_since_epoch()相减重新踩进“参考点不同”的坑。比较和计算应该使用转换接口clock_castTargetClock(src_time_point)或者各个时钟自带的to_sys、from_sys、to_utc、from_utc。我不会说clock_cast在所有实现里都好用但它确实是标准提供的通用入口。在团队协作时建议在项目里封装一个TimeScaleConverter之类的小工具只暴露to_utc_string、to_tai_string、gps_to_tai等几个方法。这样业务代码不会有机会直接操作底层time_since_epoch。6.3 zoned_time、format与闰秒的隐藏关系std::chrono::zoned_time处理的是时区偏移它默认假设每个本地时间点都可以映射到唯一的UTC时间点。但闰秒出现时这个假设会被打破。标准库在具体实现里对闰秒内的字符串解析通常采取“拒绝”或“压缩”策略不会真的让你构造出一个符合物理现实的23:59:60显示。如果你真的需要格式化闰秒建议直接用utc_clock的时间点用std::format的%S可能能输出60但跨编译器的支持程度参差不齐。日常项目里把这一秒当成“系统时间的灰色地带”处理比强行显示更稳妥。6.4 工具链和部署环境的选择tai_clock是C20特性主流编译器在较新版本才完整支持。GCC建议12以上Clang建议配libc14以上MSVC在VS2022 17.4之后才比较完整。早期实现里格式化输出和时区支持经常缺胳膊少腿。如果你在C17项目里可以先用Howard Hinnant的date库它有对应的tai_clock实现API和C20基本一致。后续迁移到C20时改动量很小。6.5 一条更实际的建议TAI做内部UTC做边界我个人在项目里采用的原则是系统内部计算、排序、幂等键生成全部用TAI或者单调时间系统对外展示、存储到传统数据库、和第三方API对接时再转成UTC。理由很简单内部逻辑需要的是连续、无歧义、可排序的时间线TAI和单调时钟都满足而外部世界习惯用UTC和时区强行让外部系统理解TAI只会徒增沟通成本。如果你已经上了C20建议抽时间写一个小测试把tai_clock::now()、utc_clock::now()、system_clock::now()、gps_clock::now()同时打到日志里跑一段日子。等到看数据时你会对“同一瞬时在不同时标下的读数差”有更直观的感受。再说一个我踩过的坑之前做定时任务调度拿tai_clock当单调时钟来用结果发现它会随着system_clock跳变任务休眠时间被NTP一拉直接睡过头。后来才意识到TAI解决的是“时标语义”问题不是“单调来源”问题。要保证不跳变还是得看steady_clock。综上tai_clock的定位不是让普通用户扔掉system_clock而是给需要精确时标、跨闰秒计算、多时钟互转的系统提供一个可靠底座。认真看一遍它的epoch、闰秒表和转换接口以后遇到任何“时间少了1秒”的诡异Bug你至少能快速判断问题出在时标定义还是出在业务逻辑。
返回列表