ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++20 std::chrono::tai_clock 详解:用原子时规避闰秒风险

C++20 std::chrono::tai_clock 详解:用原子时规避闰秒风险 我们写时间相关代码平时最常碰到的就是system_clock最多再加一个steady_clock。但如果你做过交易系统、卫星数据解析、通信基站同步这类对时间轴严肃程度要求很高的项目就会发现system_clock其实没那么“干净”它背后的 UTC 时间会因为闰秒出现重复秒或者跳秒某些系统甚至会直接把闰秒“吞掉”。这时候就需要一个连续、均匀、不被打断的时间尺度C20 标准库里的std::chrono::tai_clock就是干这个的。tai_clock是国际原子时TAI在 C 标准库里的具体实现。它解决的核心问题是让你在写跨闰秒的时间逻辑时不会因为 UTC 出现 23:59:60 这种秒而崩溃也不会因为闰秒导致两个时间戳的差值变成负数。这篇文章我会从时标系统的底层概念讲起把 TAI、UTC、GPS 时间之间的关系理清楚然后给出可以直接抄走的tai_clock实操代码最后聊聊我在实际项目里踩过的一些坑。适合所有需要精确处理绝对时刻的 C 开发者尤其是做时间同步、金融日志、导航算法和通信协议栈的朋友。1. 时标是什么为什么 C 要给你一个原子钟1.1 TAI、UTC、GPS 时间三兄弟先说结论国际原子时TAI、协调世界时UTC、GPS 时间是三种完全不同性格的时间系统。TAI 由全球几百台原子钟加权平均得出从 1958 年 1 月 1 日 0 点启动它的秒长就是严格的 SI 秒一秒不差连续均匀没有跳秒没有闰秒没有任何人为干预。你可以把 TAI 理解成一把绝对精确的“时间尺子”。UTC 是在 TAI 的基础上通过插入闰秒来逼近地球自转时间UT1的。地球自转越来越慢如果不调整UTC 会和太阳时越差越大。所以国际组织不定期地在 UTC 里加一秒最近一次是 2017 年 1 月 1 日插入之后 TAI 与 UTC 的差值固定为 37 秒。也就是说在当前这个历史阶段真实世界的 TAI 读数 UTC 读数 37 秒。GPS 时间则是一套完全独立的时标。它从 1980 年 1 月 6 日 0 点开始起步星载原子钟维持秒长不插闰秒而且与 TAI 之间存在一个固定偏移TAI GPS 19 秒。由于 GPS 时间不参与闰秒调整所以它和 UTC 的差会随闰秒变化当前是 GPS UTC - 18 秒。这三种时间系统在秒长上都是原子秒区别只在“起点”和“是否插秒”这两个维度上。1.2 为什么时间戳必须区分“连续时标”和“日历时标”很多刚接触这个领域的人会问我平时用system_clock不是好好的吗为什么要折腾 TAI关键在于system_clock表示的是 Unix 时间戳它的计数实际上依赖操作系统维护的 UTC 时间。如果系统通过 NTP 校时在闰秒发生的那一刻NTP 协议往往会通过“重复最后一秒”或者“跳过一秒”的方式把时间拉回正轨。这就导致使用system_clock计算时间差时如果恰好跨过闰秒可能出现b - a等于 0 甚至负数的情况。举个例子你在 2016 年 12 月 31 日 23:59:59.5 记录了一个事件又在 2017 年 1 月 1 日 00:00:00.5 记录了另一个事件。真实世界经过了约 1 秒但有些系统因为要插入闰秒 23:59:60时间戳会表现为从 23:59:59 跳到 23:59:60 再跳到 00:00:00如果你的代码没有处理 60 秒这个边界日志解析、排序、差值计算全都会出问题。普通业务系统可以忽略这个问题因为影响范围只有一秒。但如果你是做高频交易的对账、天文观测设备的数据对齐、卫星通信里的时隙同步这一秒的混乱就可能导致订单错序、数据错位、同步窗口丢失。连续时标TAI、GPS的价值就在这里它把“绝对时刻”和“日历显示”彻底分开。底层时间戳始终是单调递增的你拿它做差值、排序、超时判断绝对安全需要给人看的时候再转换回 UTC 日历时间。1.3 tai_clock 到底适合谁用基于上面的分析tai_clock适合下面几类场景需要生成全局唯一、严格递增的时间戳且不能因为闰秒回拨。需要在不同机器、不同协议之间交换时间戳并且明确约定使用 TAI 时标。需要解析卫星、授时设备、PTP 1588 等硬件提供的时间数据这些设备很多直接输出 TAI 或 GPS 时间。需要一个既能表示日历时刻、又能连续均匀计算间隔的时间类型。如果你的项目只是记录“用户什么时候点了按钮”用system_clock完全没问题。但一旦你意识到“时间戳会倒退”这个隐患就该考虑把核心计时逻辑切到tai_clock上了。2. std::chrono 的时钟家族与 tai_clock 的真实身份2.1 从 system_clock 到 chrono 时钟家族C20 之前chrono库只有三个时钟system_clock、steady_clock、high_resolution_clock。C20 一次性加入了四个新时钟utc_clock、tai_clock、gps_clock以及用于文件系统时间戳的file_clock。这套时钟家族的设计思路很有讲究。system_clock被当成“基准中转站”utc_clock负责表示带闰秒的真实 UTCtai_clock和gps_clock则是连续均匀的时标。标准库通过clock_cast这套转换机制让不同时钟之间可以互相转换。每个时钟都有自己独立的time_point类型比如tai_timeDuration、utc_timeDuration、gps_timeDuration。它们本质上都是time_point的别名但类型不同不能直接做加减运算必须通过clock_cast转换到同一个时钟后才能计算。这种“类型隔离”是好事它让编译器帮你检查你是否在混用不同的时标系统避免把 TAI 的秒数当成 UTC 的秒数直接用。2.2 tai_clock 的本质连续均匀的名义原子时这里必须澄清一个很多人会搞错的点标准库里的tai_clock它的now()返回的日历时间并不会自动比utc_clock::now()快 37 秒。原因在于标准规定tai_clock与system_clock之间的转换是一个固定偏移量sys_time 378691200s tai_time。这个偏移量是编译期常量不查闰秒表不涉及当前 TAI 与 UTC 的真实差值。也就是说tai_clock::now()本质上做的事情是取系统时钟的 Unix 时间戳然后加上一个固定常量。你把它格式化打印出来看到的日期和时间跟system_clock是完全一样的。它的“原子时”身份体现在底层计数是连续均匀的而不是体现在日历读数比 UTC 大 37 秒。如果你想要“真实世界当前 TAI 读数”正确的做法是先把utc_clock::now()转换到tai_clock这个转换会走闰秒表算出真实的偏移。或者在拿到系统 UTC 时间后手动加上当前闰秒偏移目前是 37 秒。这个设计其实是刻意为之。标准委员会希望tai_clock成为一个“干净、稳定、适合做间隔测量”的时标而不是让使用者每次都要去关心当前闰秒是多少。你只需要记住tai_clock的日历显示和 UTC 对齐但它的秒计数是连续递增的。2.3 转换关系一览与 378691200 秒的来历378691200 秒这个常量算一下就能明白它的来源。378691200 除以 86400 等于 4383 天。而从 1958 年 1 月 1 日到 1970 年 1 月 1 日中间跨越了 12 年其中有 1960、1964、1968 三个闰年12 年总天数正好是 4383 天。这意味着标准库把 TAI 的 epoch1958-01-01 00:00:00到 Unix epoch1970-01-01 00:00:00之间的偏移直接定成了纯日历秒数。这个偏移把历史上 TAI 与 UTC 之间那几秒的差值折叠进了常量本身所以从system_clock转出来的 TAI 时间日历上跟 UTC 完全一致。我整理了一个简单的对照表方便你快速理解这几种时钟的区别时钟epoch计数特性与真实世界关系system_clock1970-01-01 00:00:00 UTC依赖系统 UTC可能受闰秒影响未处理闰秒的 UTCutc_clock1970-01-01 00:00:00 UTC计数包含闰秒能表示 23:59:60真实 UTCtai_clock1958-01-01 00:00:00 TAI连续均匀无闰秒名义 TAI从 sys 转或真实 TAI从 utc 转gps_clock1980-01-06 00:00:00 UTC连续均匀无闰秒名义 GPS从 sys 转或真实 GPS从 utc 转3. tai_clock 实操从读取到转换的完整示例3.1 获取当前 TAI 时刻的三种方法先看最直接的用法。需要 C20 编译环境GCC 12 以上、Clang 14 以上、MSVC 2019 16.10 以上都可以。编译时加-stdc20。第一种直接用tai_clock::now()。这也是最简单的方式适合拿来做时间戳计数#include chrono #include iostream int main() { auto tai_now std::chrono::tai_clock::now(); std::cout tai_now \n; return 0; }第二种从系统时钟转换过来。这样得到的依然是名义 TAI 时间但你可以先拿到system_clock的时间再统一转成 TAI 做后续处理auto sys_now std::chrono::system_clock::now(); auto tai_from_sys std::chrono::clock_caststd::chrono::tai_clock(sys_now);第三种获取真实 TAI 读数。代码层面需要先拿utc_clock的时间再转到tai_clockauto utc_now std::chrono::utc_clock::now(); auto tai_real std::chrono::clock_caststd::chrono::tai_clock(utc_now);我在一台 NTP 校时正常的 Linux 机器上实际跑过这三种方式输出大致是这样sys : 2024-03-15 14:23:45.123456 tai_from_sys : 2024-03-15 14:23:45.123456 tai_real : 2024-03-15 14:24:22.123456注意看前两个时间完全一样因为固定偏移不改变日历。第三个比 UTC 多了 37 秒这才是真实世界 TAI 的读数。3.2 时标互转TAI、UTC、GPS、系统时间实际工程里你往往不是只取一个时间就完事而是要在这几套时标之间反复横跳。标准库提供clock_cast底层会通过system_clock做中转把转换链串起来。下面是一个完整的互转示例#include chrono #include iostream using namespace std::chrono; int main() { auto tai tai_clock::now(); auto utc utc_clock::now(); auto sys system_clock::now(); // TAI - UTC - 系统时间 auto utc_from_tai clock_castutc_clock(tai); auto sys_from_tai clock_castsystem_clock(tai); // GPS 时间互转 auto gps_from_tai clock_castgps_clock(tai); auto tai_from_gps clock_casttai_clock(gps_from_tai); // 系统时间 - GPS auto gps_from_sys clock_castgps_clock(sys); std::cout tai : tai \n; std::cout utc : utc \n; std::cout sys : sys \n; std::cout utc_from_tai : utc_from_tai \n; std::cout sys_from_tai : sys_from_tai \n; std::cout gps_from_tai : gps_from_tai \n; std::cout tai_from_gps : tai_from_gps \n; std::cout gps_from_sys : gps_from_sys \n; return 0; }执行结果中不需要刻意去记忆每个偏移值因为标准库已经把转换关系封装好了。但有一个细节值得注意从utc_clock转到其他时钟时内部会查询闰秒表。所以当前环境下utc_from_tai打印出来的日历会比tai慢 37 秒而gps_from_tai打印出来的日历会比tai慢 19 秒。3.3 格式化输出与日历换算tai_clock的time_point支持直接流式输出默认格式是YYYY-MM-DD HH:MM:SS.fffffffff。如果你需要拆出年月日或者按自定义格式输出可以用 C20chrono里的日历类型组合起来做#include chrono #include iostream using namespace std::chrono; int main() { auto tai tai_clock::now(); // 拆出日期部分 auto day floordays(tai); year_month_day ymd{day}; // 拆出时分秒部分 hh_mm_ss hms{tai - day}; std::cout date: ymd \n; std::cout time: hms.hours() : hms.minutes() : hms.seconds() \n; return 0; }这里floordays是把时间点截断到天year_month_day负责解析出年月日hh_mm_ss负责把当天剩下的部分转成时分秒。这套 API 对utc_time、gps_time、sys_time都是通用的。如果你需要在日志里输出一个可排序的字符串我推荐直接流式输出tai本身因为YYYY-MM-DD HH:MM:SS这种格式在字典序上就是时间顺序方便后续用文本工具排查。3.4 一个工程案例跨闰秒测量耗时假设你要写一个模块统计两个事件之间的精确耗时两个事件分别落在闰秒前后。如果直接用系统时间戳可能得到 0 秒或者负值。用tai_clock则完全没这个问题因为它底层的秒计数是连续递增的。#include chrono #include iostream #include thread using namespace std::chrono; void on_event_a() { // 模拟事件 A } void on_event_b() { // 模拟事件 B } int main() { auto t1 tai_clock::now(); on_event_a(); // 在实际中这里可能跨过了闰秒边界 std::this_thread::sleep_for(milliseconds(500)); on_event_b(); auto t2 tai_clock::now(); auto elapsed t2 - t1; std::cout elapsed: duration_castmilliseconds(elapsed).count() ms\n; return 0; }这个例子里t2 - t1的结果一定是一个正值因为tai_clock没有跳秒、没有重复秒。实际项目里这正是把事件排序、超时判断、令牌桶限流的时间源从system_clock换成tai_clock能带来的直接收益。4. 闰秒、边界场景与常见坑4.1 闰秒的脾气闰秒不是按固定周期出现的它由 IERS国际地球自转服务根据地球自转与原子时的差值不定期宣布通常在 6 月 30 日或 12 月 31 日的最后一秒插入。历史上闰秒出现过 27 次最近一次是 2016 年 12 月 31 日插入导致 2017 年 1 月 1 日起 TAI - UTC 37 秒。对程序员来说闰秒最麻烦的地方在于UTC 时间在闰秒时刻会出现 23:59:60 这样的秒值。常规的日期解析库根本处理不了这种格式直接解析会抛异常。有些系统为了省事干脆在闰秒时刻不更新时间让时间“暂停”一秒有些系统则把闰秒直接跳过去导致本地时间比标准 UTC 快一秒。无论哪种处理方式都会破坏时间戳的单调性。这就是为什么严肃系统要改用 TAI 或 GPS 时间做内部计数。你要明白一个原则底层计数用连续时标展示层再转 UTC。顺序一定不能反。4.2 常见问题速查表我把实际开发中经常碰到的问题整理成了表格方便对照排查问题原因解决办法tai_clock::now()为什么跟系统时间一样没有多 37 秒标准规定从system_clock到tai_clock是固定偏移不查闰秒表需要真实 TAI 时先取utc_clock再clock_cast到tai_clock闰秒时system_clock出现了重复秒系统 NTP 或人工调整导致内部计时全部切换到tai_clock或gps_clockutc_time格式化出现 23:59:60解析失败utc_clock忠实记录闰秒使用 C20chrono的流式输出它可以正确表达 60 秒想把 TAI 转成字符串但编译器不支持流式输出GCC 12 之前的版本不支持升级 GCC 到了 12 以上或者用date库过渡跨进程传时间戳后两边结果差 37 秒一方用system_clock一方用utc_clock转换后传输约定统一用 TAI 计数传输展示时再转 UTCclock_cast编译报错说没有匹配的转换两个时钟之间无法通过system_clock中转确认目标时钟支持from_sys/to_sys标准库内置时钟都可以4.3 生产环境经验与避坑清单最后分享几条我在生产环境里总结出来的经验。第一不要在业务代码里到处手动加减闰秒偏移。我见过同事在日志模块里写now 37s这样的代码当时能跑但到了下一次闰秒公告发布后就会出错而且这种错误非常难排查因为你根本不知道 37 这个数字是从哪冒出来的。正确做法是内部统一用tai_clock计数需要展示时用clock_casttai_clock(utc_clock::now())这类标准方式转换。第二注意闰秒表的新鲜度。标准库实现的utc_clock内部维护了一张闰秒表但标准不要求厂商持续更新到未来。也就是说如果你的编译器版本比较老它可能不知道 2017 年之后有没有新闰秒。虽然目前 37 秒这个值已经维持了好几年但保险起见在关键系统里要定期关注编译器版本和标准库更新。更严谨的做法是从外部时间源获取当前闰秒偏移配置到系统里再参与计算。第三区分“名义 TAI”和“真实 TAI”这两个概念在文档和代码注释里写清楚。因为tai_clock::now()返回的是名义 TAI日历显示与 UTC 一致而真实 TAI 比 UTC 快 37 秒。如果共享代码的同事不理解这个区别他很可能拿着一个名义 TAI 时间戳跑到别的系统里去对时间然后发现对不上怀疑代码有问题。第四如果你要从utc_clock转tai_clock注意闰秒那一秒的表示。真实世界的 TAI 是连续的所以在 UTC 的 23:59:60 那一秒对应的 TAI 已经进入了下一秒。转换时标准库会正确地把 23:59:60 累加到 TAI 的计数里但如果你自己手动解析字符串再去加 37很容易在边界上出 bug。第五跨语言、跨系统交换时间戳时尽量传整数计数并带上时钟类型和 epoch 信息。比如用tai_clock的time_since_epoch().count()传输接收方知道这是从 1958-01-01 00:00:00 TAI 开始的纳秒数就能准确还原。如果只传一个格式化好的字符串对方还得猜这是 UTC 还是 TAI精度也可能丢失。我在实际使用中最大的体会是tai_clock最大的价值不是让你多懂一个冷门标准库特性而是逼着你去思考“这个时间戳到底代表什么”。是把时间当成给人看的日历还是当成机器用的连续计数想清楚了这一点很多时间相关的 bug 从一开始就能避免。最后再分享一个小技巧如果你现在用的是 C17等不及 C20可以先引入 Howard Hinnant 的date库它提供了tai_clock、utc_clock、gps_clock的早期实现API 和标准库几乎一致。等编译器升级到 C20 之后代码迁移成本很低主要是把#include date/tz.h换成chrono把date::命名空间去掉即可。
返回列表