C++ 时间日期与时区:std::chrono 日历与时区库

时间处理是分布式系统里最容易出错的一环。本文梳理 std::chrono 的时钟、时长与 C++20 日历类型,讲清 system_clock 与 steady_clock 的分工、duration_cast 的溢出陷阱,并用 zoned_time、locate_zone 演示时区换算与夏令时边界,最后给出格式化与解析的落地方案。

几乎每个服务都要回答「这条记录发生在什么时候」以及「用户本地时间显示成什么」。前者要求单调、无歧义、可比较,后者要求能被人类读懂且随时区变化。C 语言时代我们只有 time_t、struct tm 和 localtime,精度、单位与时区全靠约定,一个 int 到底装秒还是毫秒全凭注释。C++11 引入 std::chrono 把「时长」和「时间点」变成有类型、有单位的量,C++20 又补上了日历与时区,让日期运算不再需要手写闰年判断。

本文沿着「时钟 → 时长 → 日历 → 时区 → 格式化」这条链路,逐层给出可落地的写法与常见陷阱,重点放在那些在线上真正会咬人的地方:精度截断、单调性、夏令时跳变。

从 time_t 到类型安全的时钟与时长

std::chrono 的核心是三个概念:时钟(Clock)、时间点(time_point)、时长(duration)。时钟提供一个 now() 返回时间点,时间点 = 时钟的纪元(epoch)+ 一个时长,时长 = 一个计数值 + 一个单位比率。

#include <chrono>
#include <iostream>

int main() {
    using namespace std::chrono;

    // duration 是「计数 × 单位」,编译期就带单位信息
    seconds s{90};
    milliseconds ms = s;              // 隐式转换:精度提高是安全的
    // milliseconds -> seconds 是精度损失,必须显式
    seconds back = duration_cast<seconds>(ms + milliseconds{500});

    auto start = steady_clock::now();
    // ... 业务代码 ...
    auto cost = steady_clock::now() - start;
    std::cout << duration_cast<microseconds>(cost).count() << " us\n";
}

关键点在于「隐式转换只允许精度变高」。seconds 转 milliseconds 不会丢信息,编译器自动放行;反过来必须写 duration_cast,等于强制你在代码里承认「这里会截断」。

C++20 之后可以给时长起别名,让业务语义进入类型系统:

using Days   = std::chrono::days;      // 24h 的 fixed duration
using Millis = std::chrono::milliseconds;

注意 std::chrono::days 是精确 24 小时的 duration,与「日历日」不是一回事——夏令时切换那天,一个日历日可能是 23 或 25 小时。这个区分后文还会用到。

时钟的选择:system_clock、steady_clock 与它们的近亲

标准库提供三类时钟,选错是很多计时 bug 的根源。

时钟纪元单调典型用途
system_clockUnix epoch(1970-01-01 UTC)否(受 NTP/手动调时影响)时间戳、序列化、日志
steady_clock未指定,通常为开机时刻是耗时测量、超时、退避
high_resolution_clock实现定义实现定义一般不直接用
tai_clock / gps_clock国际原子时 / GPS 时是(无闰秒跳变)需要无闰秒的科学计算

第一条铁律:测耗时永远用 steady_clock。system_clock 会被 NTP 校正,甚至可能在两次读数之间「倒流」,用它算差值会得到负数或巨大值。high_resolution_clock 在 libstdc++ 与 MSVC 里是 system_clock 的别名,在 libc++ 里是 steady_clock 的别名,行为随平台变化,直接避开。

第二条:需要把耗时与真实时间点关联时,要同时取两个时钟:

struct Stamp {
    std::chrono::system_clock::time_point wall;
    std::chrono::steady_clock::time_point mono;
};

Stamp now() {
    return {std::chrono::system_clock::now(), std::chrono::steady_clock::now()};
}

这样既能用 wall 打印和排序,又能用 mono 计算两个事件之间的真实间隔——即使期间发生了调时。数据库与日志系统普遍采用这种「双时间戳」设计。

第三条:跨进程传递 system_clock::time_point 时,不要假设它的内部表示。应先转成明确的时长再序列化:

auto tp = std::chrono::system_clock::now();
auto us = std::chrono::duration_cast<std::chrono::microseconds>(
              tp.time_since_epoch()).count();
// 写入 int64,接收端用 microseconds{us} 还原

这正好呼应了时间戳在存储层的处理方式,可以对照 https://plumephp.com/cpp-serialization-libraries/ 里关于跨语言字段类型的讨论。

时长换算与溢出:duration_cast 的真实成本

duration_cast 做的是整数乘除,截断方向是「向零取整」。这带来两个坑。

第一个坑是负值截断。duration_cast<seconds>(-1500ms) 得到 -1s 而不是 -2s,因为它向零舍入。做「剩余时间」显示时如果想向上取整,必须自己写:

template <class To, class Rep, class Period>
To ceil_cast(std::chrono::duration<Rep, Period> d) {
    auto r = std::chrono::duration_cast<To>(d);
    if (r < d) r += To{1};
    return r;
}

第二个坑是溢出。duration 的计数类型默认是 int64_t(对秒级以上)或更宽类型,但当你把 nanoseconds 直接乘一个大系数、或者混用不同 rep 时,中间结果可能溢出。duration_cast 的常见实现是 (count * Period::num / Period::den) 按顺序做,先乘后除,大数相乘就有风险。

一个典型场景是把纳秒转秒并乘以数量:

// 危险:先转 seconds 再乘,容易丢精度但不易溢出
// 更危险:先乘再转,可能溢出
using namespace std::chrono;
auto ns = nanoseconds{1'500'000'000};
auto s  = duration_cast<seconds>(ns);           // 1s,安全
auto m  = duration_cast<minutes>(ns * 1'000);   // 中间乘 1000 后再除

安全做法是尽量在最终类型上做运算,或者显式使用更宽的 rep:

using WideNs = std::chrono::duration<long double, std::nano>;

浮点 rep 会失去精确性但不会溢出,适合做展示层的比例换算;存储与比较仍应使用整数。

关于精度还有一个常被忽略的事实:system_clock 在 Linux 上通常是纳秒分辨率,在 Windows 上历史上是 100ns,macOS 上可能只有微秒。跨平台代码不要假设「毫秒一定够用」,也不要假设「纳秒一定可用」。

还有一个与「精度」经常被混为一谈的概念是「分辨率(resolution)」与「精度(precision)」。clock::period 给出的是分辨率,即两次读数之间最小可分辨的刻度;精度是读数与真实时间的接近程度。一块分辨率 1ns 的时钟可能精度只有 1ms(未同步),反过来一块同步良好的时钟分辨率也可能只有 1μs。测量耗时关心分辨率,判断「现在是几点」关心精度,二者不能互换。

时长算术中的类型推导

duration 的加减乘除有一套类型推导规则,理解它才能写出不丢精度的表达式。

using namespace std::chrono;

auto a = 1s + 500ms;            // common_type -> milliseconds,结果 1500ms
auto b = 1s + 1min;             // common_type -> seconds,结果 61s
auto c = 100ms * 3;             // 计数类型参与运算,仍是 milliseconds
auto d = 1s / 100ms;            // 注意:duration / duration 得到的是「计数」不是 duration

最后一条最容易踩:两个 duration 相除得到的是无单位的标量(common_type 的 rep),而 duration / 标量 得到的才是 duration。混用会导致编译错误或意外的整数除法。当需要「比例」时,明确写出意图:

double ratio = duration<double>(elapsed) / duration<double>(total);

把两边都转成浮点 duration 再做除法,可以避免整数截断,也避免了「先除再转」的顺序问题。

时钟纪元与不可移植假设

steady_clock 的纪元是「实现定义」的,可能是开机时刻,也可能是某个任意基准。因此下面这段代码是不可移植的:

auto uptime = steady_clock::now().time_since_epoch();  // 语义未知

它只保证「单调递增」,不保证数值有物理意义。要得到进程运行时长,应当记录起点再作差:

static const auto kStart = steady_clock::now();
auto uptime = steady_clock::now() - kStart;   // 这才是可靠的口径

同理,system_clock::time_point 的纪元虽然是 Unix epoch,但标准只保证它是「某个固定时刻」,跨平台序列化时仍应显式转成 duration 再取计数,不要直接 memcpy 时间点对象——它的 rep 与 period 在不同标准库实现上可能不同。

C++20 日历:year_month_day 与 sys_days

C++20 的 <chrono> 引入了日历类型,把「2026 年 10 月 7 日」变成一个可运算的强类型。

#include <chrono>
using namespace std::chrono;

year_month_day ymd{year{2026}, October, 7};
sys_days sd = ymd;              // 转成「自 epoch 起的天数」
sys_seconds ss = sys_days{ymd}; // 隐式补零时刻

// 日期运算:加减天数、月数、年数
auto next_month = ymd + months{1};
auto last_day   = year_month_day{year{2026}, February, last};  // 自动处理闰年

sys_days 是 time_point<system_clock, days> 的别名,把日期变成天数差,天然可比较、可排序、可做差:

auto d1 = sys_days{2026y/October/7};
auto d2 = sys_days{2026y/January/1};
auto diff = (d1 - d2).count();   // 天数差,单位是 days

注意 2026y/October/7 这种字面量语法是 C++20 的 operator/ 重载(y 后缀来自 <chrono>),它让日期字面量接近可读写法,但要注意 2026/10/7 会被解析成整数除法——必须用 2026y。

日历类型之间还有一层「部分信息」的类型:

  • year_month:只精确到月,没有日
  • month_day:没有年
  • weekday:星期几
  • year_month_weekday:如「2026 年 10 月的第二个星期二」

这些类型存在的意义是:当你只有部分信息时,不要瞎填。比如「每月 1 号发账单」,用 year_month 比用 sys_days 更安全,因为后者必须假设一个具体日期。

weekday 还能做「第 n 个星期几」的运算,这在排班与定时任务里很实用:

auto first_monday = year_month_weekday{
    year{2026}, October, Monday[1]};   // 2026 年 10 月第一个周一

闰年与月末在日历类型里是自动处理的,2026y/February/last 会得到 28,2024y/February/last 得到 29。这比手写 is_leap_year() 加天数表可靠得多,也避免了「2 月 30 日」这种非法状态——year_month_day 提供 ok() 判断合法性:

year_month_day bad{year{2026}, February, 30};
if (!bad.ok()) { /* 拒绝非法日期 */ }

时区:tzdb、zoned_time 与夏令时

C++20 时区库依赖 IANA 时区数据库(tzdb),需要 std::chrono::tzdb 与系统时区数据(Linux 上通常在 /usr/share/zoneinfo)。

#include <chrono>
using namespace std::chrono;

const time_zone* tz = locate_zone("Asia/Shanghai");
zoned_time zt{tz, system_clock::now()};
std::cout << zt.get_local_time() << "\n";

核心 API 与语义:

API作用
locate_zone(name)按 IANA 名称查时区,如 Asia/Shanghai
current_zone()取系统当前时区
zoned_time{tz, sys_time}由 UTC 时间点构造带时区的时间
zoned_time{tz, local_time}由本地时间构造(可能歧义/不存在)
zt.get_sys_time()取出 UTC 时间点
tz->to_local(tp) / tz->to_sys(lp)手动转换

夏令时(DST)带来两类病态输入,必须显式处理:

  1. 不存在的时间:春季「拨快一小时」,本地时间 02:30 不存在。
  2. 歧义的时间:秋季「拨慢一小时」,本地时间 01:30 出现两次。

C++20 提供 choose 策略来处理歧义:

auto ambiguous = local_days{2026y/November/1} + 1h + 30min;  // 假设 DST 结束
sys_time<seconds> st = tz->to_sys(ambiguous, choose::earliest);

不指定 choose 时会抛 ambiguous_local_time 或 nonexistent_local_time。很多线上事故源于「用本地时间做定时任务」,夏令时那天任务要么漏跑要么跑两次。定时任务应以 UTC 为基准,只在展示时转本地时区。

时区库还支持按时间点查询偏移与缩写:

auto info = tz->get_info(system_clock::now());
std::cout << info.offset.count() << "s, abbrev=" << info.abbrev << "\n";

get_info 返回的 offset 在 DST 期间会变化,abbrev 是 CST、CEST 这类缩写。注意缩写本身有歧义(CST 既是「中国标准时间」也是「美国中部时间」),不要用它做逻辑判断,只用 time_zone 对象。

时区规则本身是「数据」而非「代码」,它会随各国立法变化(比如某国取消夏令时)。这就是为什么 tzdb 需要定期更新——把时区规则硬编码进程序是最危险的做法。存储侧同样如此,PostgreSQL 的 timestamptz 类型也依赖服务端的时区数据库,具体取舍可以对照 PostgreSQL 时区与时间类型 一文;跨语言的时间处理原则则是通用的,可参考 时间与时区处理 。

闰秒:被大多数系统忽略的一秒

UTC 为了与地球自转对齐,会不定期插入闰秒(leap second)。Unix 时间把一天固定为 86400 秒,无法表示闰秒,于是有两种处理策略:

  • 跳过(smear / 平滑):Google、Amazon 等厂商在闰秒前后把时间「拉长」若干毫秒,让系统永远看不到 23:59:60。
  • 重复/回拨:直接让 system_clock 走 23:59:59 → 23:59:60 → 00:00:00,此时时间戳可能重复或倒退。

这就是为什么「用 system_clock 做耗时」在某些年份的跨年夜会得到荒谬结果。std::chrono 为此提供了 tai_clock(国际原子时)与 gps_clock(GPS 时),它们没有闰秒跳变:

auto tai = std::chrono::tai_clock::now();

auto sys_now = std::chrono::system_clock::now();
auto utc_now = std::chrono::utc_clock::from_sys(sys_now);
auto leap = utc_now - sys_now;   // 累计闰秒数,随 tzdb 更新

utc_clock 是 C++20 新增的「真正的 UTC」,它会考虑闰秒,而 system_clock 在多数实现上等价于 POSIX 时间(不含闰秒)。需要高精度时间间隔的科学与金融系统应当用 tai_clock 或 utc_clock,普通业务系统则用「平滑」方案并接受它与 TAI 的固定偏移。

在跨服务传递时间时,最佳实践是「只传 UTC 时间点,时区随用户配置走」。这条规则与 https://plumephp.com/cpp-modern-17-20-23/ 里对 C++20 新特性的整体梳理一脉相承:标准库已经把过去要引入第三方库(如 Howard Hinnant 的 date 库)的能力内置了。

格式化与解析:std::format 的 chrono 扩展

C++20 的 std::format 支持 chrono 类型,语法接近 Python 的 strftime 但类型安全。

#include <format>
#include <chrono>

auto now = std::chrono::system_clock::now();
std::string s = std::format("{:%Y-%m-%d %H:%M:%S}", now);
std::string z = std::format("{:%Y-%m-%d %H:%M:%S %Z}", zoned_time{locate_zone("Asia/Shanghai"), now});

常用转换说明符:

说明符含义示例
%Y四位年2026
%m两位月10
%d两位日07
%H %M %S时分秒21:00:00
%F等价 %Y-%m-%d2026-10-07
%T等价 %H:%M:%S21:00:00
%Z时区缩写CST
%zUTC 偏移+0800
%j一年中的第几天280
%a %A星期缩写/全称Wed / Wednesday

精度可以用 {:.3} 控制到毫秒:

std::format("{:%H:%M:%S}", std::chrono::floor<std::chrono::milliseconds>(now));
// 21:00:00.123

解析方向用 std::chrono::parse:

std::istringstream in{"2026-10-07 21:00:00"};
std::chrono::sys_seconds tp;
in >> std::chrono::parse("%Y-%m-%d %H:%M:%S", tp);

解析要特别注意:解析出的 sys_seconds 默认是 UTC,如果输入是本地时间,必须再经 local_time 转换,否则会差一个时区偏移。这是时区相关 bug 里最高频的一种。

// 输入是「上海本地时间」,直接 parse 成 sys_seconds 会当成 UTC
std::istringstream in{"2026-10-07 21:00:00"};
std::chrono::local_seconds lp;
in >> std::chrono::parse("%Y-%m-%d %H:%M:%S", lp);
auto tp = locate_zone("Asia/Shanghai")->to_sys(lp);   // 再转 UTC

ISO 8601 与周历

机器间交换时间推荐 ISO 8601(2026-10-07T21:00:00+08:00),它自带时区偏移,解析无歧义。C++20 的 %F、%T、%z 组合即可输出:

std::format("{:%FT%T%Ez}", zoned_time{locate_zone("Asia/Shanghai"), now});
// 2026-10-07T21:00:00+08:00

注意 %Ez 会输出带冒号的偏移(+08:00),%z 输出紧凑形式(+0800),两者在 RFC 3339 与 ISO 8601 中各有约定,跨系统对接时按对方规范选。

周历(ISO week date)是另一套常用口径:一年按「包含周四的那一周」为第一周,%G、%V、%u 分别给出 ISO 年、周号与星期。财报、排班系统常按周聚合,直接用日历来算会踩跨年的边界——2026 年 1 月 1 日可能属于上一年第 53 周。

在性能敏感的热路径上,std::format 的 chrono 格式化比手写 snprintf 慢,因为它要处理任意精度与本地化。日志系统常年在「可读性」与「每秒百万条」之间权衡,这一点在 https://plumephp.com/cpp-performance-optimization/ 中有更系统的讨论。

实践建议

把上面的规则收敛成一张清单,可以在代码评审时直接对照:

  1. 测耗时用 steady_clock,绝不使用 system_clock 或 high_resolution_clock 做差值。
  2. 存储与传输一律 UTC,时区只在渲染层引入;zoned_time 是渲染层的工具,不要让它进入持久化结构。
  3. 精度损失必须显式:任何从高精度到低精度的转换都写 duration_cast,让截断行为在代码里可见。
  4. 定时任务以 UTC 调度,避开夏令时的「不存在」与「歧义」区间;确需本地时间时显式传 choose::earliest 或 choose::latest。
  5. 不要用 time_t 或裸 int64_t 跨模块传时间,用 sys_seconds、sys_milliseconds 这类具名类型,让编译器帮你抓单位错误。
  6. 解析时间串时确认时区语义,std::chrono::parse 默认 UTC,本地时间要先转。
  7. 编译期检查时区数据可用性:std::chrono::get_tzdb() 在容器镜像里可能因缺少 /usr/share/zoneinfo 而抛异常,部署时要带上 tzdata。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. C++ Unicode 与文本处理:编码转换与高性能字符串
  2. C++ 数值计算与线性代数:Eigen 与表达式模板
  3. C++ 静态分析与代码质量工具链:clang-tidy 与 Clang Static Analyzer