几乎每个服务都要回答「这条记录发生在什么时候」以及「用户本地时间显示成什么」。前者要求单调、无歧义、可比较,后者要求能被人类读懂且随时区变化。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_clock | Unix 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)带来两类病态输入,必须显式处理:
- 不存在的时间:春季「拨快一小时」,本地时间
02:30不存在。 - 歧义的时间:秋季「拨慢一小时」,本地时间
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-%d | 2026-10-07 |
%T | 等价 %H:%M:%S | 21:00:00 |
%Z | 时区缩写 | CST |
%z | UTC 偏移 | +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/ 中有更系统的讨论。
实践建议
把上面的规则收敛成一张清单,可以在代码评审时直接对照:
- 测耗时用
steady_clock,绝不使用system_clock或high_resolution_clock做差值。 - 存储与传输一律 UTC,时区只在渲染层引入;
zoned_time是渲染层的工具,不要让它进入持久化结构。 - 精度损失必须显式:任何从高精度到低精度的转换都写
duration_cast,让截断行为在代码里可见。 - 定时任务以 UTC 调度,避开夏令时的「不存在」与「歧义」区间;确需本地时间时显式传
choose::earliest或choose::latest。 - 不要用
time_t或裸int64_t跨模块传时间,用sys_seconds、sys_milliseconds这类具名类型,让编译器帮你抓单位错误。 - 解析时间串时确认时区语义,
std::chrono::parse默认 UTC,本地时间要先转。 - 编译期检查时区数据可用性:
std::chrono::get_tzdb()在容器镜像里可能因缺少/usr/share/zoneinfo而抛异常,部署时要带上 tzdata。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。