引言
时间看起来只是「一个数字」,实际上是系统编程里最容易出错的一类问题:用墙钟测耗时会在 NTP 校时后得到负数;把 UTC 当本地时间存会在跨时区部署时错乱;手写月份天数表会在闰年二月翻车;把 32 位 time_t 传给 2038 年之后的系统会直接溢出。
Zig 在这件事上的态度和其他语言不同:标准库只提供时间原语,不提供日期库,更不内置时区数据库。std.time 给你秒/毫秒/纳秒时间戳、单调时钟 Instant、计时器 Timer,以及 std.time.epoch 里一套纯计算的日历换算。剩下的格式化、时区、调度,都要你自己按需实现——好处是行为完全可预测,坏处是你必须知道每个原语的语义边界。
前置:系统编程实战、可观测性:日志与指标。
目录
- 1. 时间的三种表示
- 2. std.time 基础 API
- 3. 单调时钟与性能计时
- 4. epoch 与日期换算
- 5. 格式化与解析
- 6. 时区与 UTC 偏移
- 7. 定时器与调度
- 8. 常见陷阱与实践
- 9. 速查表
- 10. 一句话记忆
- 延伸阅读
1. 时间的三种表示
在动手写代码之前,先把三种「时间」分清楚。混用它们是一切时间 bug 的根源:
| 表示 | 语义 | 会跳变吗 | 用途 |
|---|---|---|---|
| 墙钟(wall clock) | 人类日历时间,UTC 基准 | 会(NTP 校时、手动改表) | 时间戳、日志、业务时间 |
| 单调时钟(monotonic) | 从某个任意起点单向递增 | 不会 | 测耗时、超时、调度 |
| 日历分解(calendar) | 年/月/日/时/分/秒 | 不适用 | 展示、报表、跨月计算 |
三条铁律:测耗时只用单调时钟。std.time.nanoTimestamp() 是墙钟,用它算差值在 NTP 回调时可能得到负数。
2. 存储与传输只用 UTC。本地时间只在展示层生成,绝不落库、绝不入协议。
3. 日历计算用整数运算。不要用浮点算天数,也不要手写月份表——std.time.epoch 已经处理了闰年。
2. std.time 基础 API
std.time 提供的原语不多,但每一个都要理解精度与语义:
| API | 返回类型 | 语义 | 精度 |
|---|---|---|---|
std.time.timestamp() | i64 | Unix 秒(UTC) | 秒 |
std.time.milliTimestamp() | i64 | Unix 毫秒 | 毫秒 |
std.time.nanoTimestamp() | i128 | Unix 纳秒 | 纳秒 |
std.time.Instant.now() | ?Instant | 单调时钟起点 | 纳秒 |
std.time.sleep(ns) | void | 阻塞睡眠 | 纳秒 |
test "wall clock" {
const sec = std.time.timestamp(); // 例如 1791000000
const ns = std.time.nanoTimestamp(); // i128
try std.testing.expectEqual(@as(i64, 1_000_000), std.time.ns_per_ms); // 用常量而非魔法数字
_ = .{ sec, ns };
}
单位换算用常量而不是魔法数字:ns_per_us、ns_per_ms、ns_per_s、ns_per_min、ns_per_hour、ns_per_day。它们让 5 * std.time.ns_per_s 这种表达式自解释。
nanoTimestamp 返回 i128,因为纳秒级 Unix 时间戳已超出 i64 的安全表达范围(i64 纳秒只能表示到 2262 年)。精度 ≠ 分辨率:真实分辨率取决于内核时钟源(常见 1 ns~1 ms),不要假设连续两次调用会返回不同的值。
3. 单调时钟与性能计时
std.time.Instant 是单调时钟的封装。它由 Instant.now() 构造,返回可选类型——某些平台(老内核、部分嵌入式目标)不提供单调时钟:
pub fn measure() !u64 {
const start = try std.time.Instant.now(); // error.Unsupported 时无法单调计时
// ... 被测代码 ...
return start.since(try std.time.Instant.now()); // 纳秒差
}
pub fn bench(comptime f: anytype, args: anytype, iters: usize) u64 {
for (0..50) |_| std.mem.doNotOptimizeAway(f(args)); // 预热
var timer = std.time.Timer.start() catch unreachable;
for (0..iters) |_| std.mem.doNotOptimizeAway(f(args));
return timer.read() / iters; // 平均纳秒
}
since 的参数顺序容易记反:earlier.since(later) 得到正数,语义是「从 earlier 到 later 经过了多少纳秒」。写反了会得到一个巨大的 u64(下溢回绕),这是最隐蔽的计时 bug。Timer 则把「开始」固化在构造时,更适合单段计时。
| 特性 | Instant | Timer |
|---|---|---|
| 起点 | 由调用方记录 | 构造时自动记录 |
| 适用 | 跨函数传递时间点 | 单段计时 |
为什么不能用墙钟测耗时:NTP 守护进程会在系统启动后校正时钟,一次 adjtimex 可能让墙钟倒退几十毫秒。如果你用 nanoTimestamp() 测一段 10 ms 的操作,恰好在这期间发生校时,就可能得到负值或荒谬的数值。单调时钟不受影响。
提示:
Timer.read()的返回值是u64纳秒。跨进程或长时间运行(超过 584 年)才会溢出,日常使用无需担心;但把两个不同Instant相减时要确认它们来自同一个时钟源。
4. epoch 与日期换算
std.time.epoch 是一套纯计算的日历工具:输入 Unix 秒,输出年月日时分秒,不涉及任何时区。它的类型链是:
EpochSeconds -> EpochDay -> YearAndDay -> MonthDay
const std = @import("std");
const epoch = std.time.epoch;
pub const DateTime = struct {
year: u16, month: u8, day: u8, // month 1-12,day 1-31
hour: u8, minute: u8, second: u8,
weekday: u8, // 0 = 周日
};
/// 把 Unix 秒(UTC)分解为日历字段
pub fn toDateTime(unix_sec: i64) DateTime {
const es = epoch.EpochSeconds{ .secs = @intCast(unix_sec) };
const day_secs = es.getDaySeconds();
const year_day = es.getEpochDay().calculateYearDay();
const month_day = year_day.calculateMonthDay();
return .{
.year = year_day.year,
.month = month_day.month.numeric(),
.day = month_day.day_index + 1, // day_index 从 0 开始
.hour = day_secs.getHoursIntoDay(),
.minute = day_secs.getMinutesIntoHour(),
.second = day_secs.getSecondsIntoMinute(),
.weekday = @intCast((es.secs / std.time.s_per_day + 4) % 7), // 1970-01-01 是周四
};
}
| 类型 / 方法 | 返回 | 说明 |
|---|---|---|
.getEpochDay() | EpochDay | 自 1970-01-01 起的天数 |
EpochDay.calculateYearDay() | YearAndDay | 年份 + 年内第几天 |
YearAndDay.calculateMonthDay() | MonthDay | 月份 + 月内第几天 |
MonthDay.day_index | u5 | 0 基,需 +1 才是日期 |
DaySeconds.getHoursIntoDay() | u5 | 0-23 |
epoch.isLeapYear(year) | bool | 闰年判断 |
反向换算(日历 → Unix 秒)需要自己写,标准库不提供。核心是「先算年内天数,再累加各月天数,最后乘 86400」:
pub fn daysInMonth(year: u16, month: u8) u8 {
const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 and epoch.isLeapYear(year)) return 29; // 闰年二月
return table[month - 1];
}
pub fn toUnixSeconds(dt: DateTime) i64 {
var days: i64 = 0;
var y: u16 = 1970;
while (y < dt.year) : (y += 1) days += if (epoch.isLeapYear(y)) 366 else 365;
var m: u8 = 1;
while (m < dt.month) : (m += 1) days += daysInMonth(dt.year, m);
days += dt.day - 1;
return days * std.time.s_per_day + @as(i64, dt.hour) * 3600 + @as(i64, dt.minute) * 60 + dt.second;
}
闰年规则是 (year % 4 == 0 and year % 100 != 0) or year % 400 == 0——epoch.isLeapYear 已经实现,不要自己写。1900 不是闰年、2000 是闰年,这是手写日期代码最经典的翻车点。
5. 格式化与解析
Zig 标准库没有内置 ISO 8601 格式化器,用 std.fmt 拼即可。注意补零用 {d:0>4} 这类格式说明符:
const std = @import("std");
/// 输出 RFC 3339:2026-10-05T15:00:00Z
pub fn formatRfc3339(buf: []u8, dt: DateTime) ![]const u8 {
return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second,
});
}
/// 带偏移:2026-10-05T23:00:00+08:00
pub fn formatWithOffset(buf: []u8, dt: DateTime, offset_min: i16) ![]const u8 {
const sign: u8 = if (offset_min < 0) '-' else '+';
const abs: u16 = @intCast(if (offset_min < 0) -offset_min else offset_min);
return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}{c}{d:0>2}:{d:0>2}", .{
dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, sign, abs / 60, abs % 60,
});
}
解析(RFC 3339 → Unix 秒)要处理四种偏移写法:Z、+08:00、-05:00、以及无偏移的「本地时间」。用 std.mem 手动切片比正则更快也更可控:
pub fn parseRfc3339(s: []const u8) !i64 {
if (s.len < 19) return error.TooShort;
const year = try std.fmt.parseInt(u16, s[0..4], 10);
const month = try std.fmt.parseInt(u8, s[5..7], 10);
const day = try std.fmt.parseInt(u8, s[8..10], 10);
const hour = try std.fmt.parseInt(u8, s[11..13], 10);
const minute = try std.fmt.parseInt(u8, s[14..16], 10);
const second = try std.fmt.parseInt(u8, s[17..19], 10); // 无校验:生产代码需拒绝 month>12
var offset_min: i32 = 0;
if (s.len > 19 and s[19] != 'Z') { // Z 表示 UTC,其余为 ±HH:MM
if (s[19] != '+' and s[19] != '-') return error.BadFormat;
const oh = try std.fmt.parseInt(i32, s[20..22], 10);
const om = try std.fmt.parseInt(i32, s[23..25], 10);
offset_min = oh * 60 + om;
if (s[19] == '-') offset_min = -offset_min;
}
const local = toUnixSeconds(.{ .year = year, .month = month, .day = day,
.hour = hour, .minute = minute, .second = second, .weekday = 0 });
return local - offset_min * 60; // 本地时间 → UTC 需要减去偏移
}
| 格式 | 示例 | 用途 |
|---|---|---|
| RFC 3339 | 2026-10-05T15:00:00Z | 网络协议、日志 |
| 仅日期 | 2026-10-05 | 报表、分区键 |
| Unix 秒 | 1791000000 | 存储、比较 |
| Unix 毫秒 | 1791000000000 | 前端、JavaScript 互操作 |
注意:解析必须严格校验。月份 13、日期 32、
2026-02-30这类输入要在解析阶段拒绝,否则后续换算会给出一个「看起来正常但完全错误」的时间戳。
6. 时区与 UTC 偏移
这是 Zig 最需要自己动手的部分:标准库不提供时区数据库(tzdata),也不解析 /etc/localtime。原因很合理——时区规则是一份持续更新的数据文件(政治决策随时可能改),把它塞进标准库意味着每次规则变更都要发新版本。
三条可选路径,按推荐度排序:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 全 UTC + 展示层转换 | 存储与计算全用 UTC,前端按用户时区渲染 | 无依赖、零歧义 | 服务端无法直接生成本地时间文本 |
| 链接 libc | linkLibC() 后调用 localtime_r/tzset | 复用系统 tzdata | 引入 libc 依赖、线程安全需注意 |
固定偏移的实现(覆盖绝大多数中国、日本、印度等无夏令时地区):
pub const Offset = struct {
minutes: i16, // 例如 +08:00 -> 480
pub fn apply(self: Offset, utc_sec: i64) i64 { return utc_sec + @as(i64, self.minutes) * 60; }
pub fn remove(self: Offset, local_sec: i64) i64 { return local_sec - @as(i64, self.minutes) * 60; }
};
需要完整时区支持时走 libc:
const c = @cImport({
@cInclude("time.h");
});
pub fn localParts(unix_sec: i64) ?DateTime {
var t: c.time_t = @intCast(unix_sec); // 32 位平台上 time_t 只有 32 位
var tm: c.struct_tm = undefined;
if (c.localtime_r(&t, &tm) == null) return null; // localtime_r 是线程安全版本
return .{
.year = @intCast(tm.tm_year + 1900), // tm_year 从 1900 起算
.month = @intCast(tm.tm_mon + 1), // tm_mon 从 0 起算
.day = @intCast(tm.tm_mday), .hour = @intCast(tm.tm_hour),
.minute = @intCast(tm.tm_min), .second = @intCast(tm.tm_sec), .weekday = @intCast(tm.tm_wday),
};
}
注意 localtime 返回指向静态缓冲的指针,多线程下必须用 localtime_r;tm_year 从 1900 起算、tm_mon 从 0 起算,这两个「历史包袱」是最常见的错位来源。
夏令时(DST)的两个真陷阱:
- 不存在的时刻。春季拨快一小时,本地时间
02:30可能根本不存在。 - 重复的时刻。秋季拨慢一小时,本地时间
01:30会出现两次。
只要内部一律用 UTC,这两个问题就只影响「展示」而不影响「计算」——这正是推荐全 UTC 的根本原因。
7. 定时器与调度
std.time.sleep 只保证「至少睡这么久」,实际可能更长(调度延迟、系统负载)。需要精确周期时,用单调时钟 + 绝对截止时间,避免累积漂移:
/// 每 interval_ns 执行一次 tick,长时间运行不累积漂移
pub fn runLoop(interval_ns: u64, iterations: usize, tick: anytype) !void {
var next = try std.time.Instant.now();
for (0..iterations) |i| {
tick(i);
// 下一个截止时间 = 上一个截止时间 + 间隔(而不是 now + 间隔)
const deadline = next.timestamp + interval_ns;
const now = try std.time.Instant.now();
if (deadline > now.timestamp) std.time.sleep(deadline - now.timestamp)
else std.log.warn("loop behind by {d} ns", .{now.timestamp - deadline}); // 落后时不补跑
next = .{ .timestamp = deadline };
}
}
| 调度需求 | 做法 |
|---|---|
| 单次延迟 | std.time.sleep(delay_ns) |
| 多个并发定时器 | 最小堆(按截止时间排序)+ 单线程事件循环 |
周期任务的关键是「用截止时间递推」。写成 sleep(interval) 会让每次的执行时间累加到下一次,一小时后就偏出好几秒。写成「下一个截止时间 = 上一个截止时间 + 间隔」,误差不会累积。
多定时器场景不要为每个定时器开一个线程。用最小堆维护截止时间、单线程等待最近的那个,是定时器轮(timer wheel)与 epoll 超时参数的共同思路——详见 异步网络编程 里的事件循环写法。
提示:
std.time.sleep在部分平台上会被信号中断并提前返回(EINTR)。需要严格等待时,应当循环检查实际经过的时间并补睡剩余部分。
8. 常见陷阱与实践
陷阱清单,按踩坑频率排序:
| 陷阱 | 表现 | 正确做法 |
|---|---|---|
| 墙钟测耗时 | 出现负数或荒谬数值 | 用 Instant/Timer |
since 参数写反 | 得到一个巨大的 u64 | earlier.since(later) |
| 本地时间落库 | 跨时区部署后时间错乱 | 存储一律 UTC |
month/day 基址混淆 | 日期差 1 | day_index + 1、month.numeric() |
2038 问题值得单独说:32 位有符号 time_t 在 2038-01-19 溢出。Zig 的 std.time.timestamp() 返回 i64,天然安全;风险来自与 C 库交互时——c.time_t 在 32 位平台上是 32 位,传值前要确认目标平台。
一个可直接用的时间工具模块骨架:
pub const TimeUtil = struct {
pub fn nowUtc() DateTime { // 当前 UTC 日历时间
return toDateTime(std.time.timestamp());
}
pub fn nowMs() i64 { // 当前时间戳(毫秒)
return std.time.milliTimestamp();
}
pub fn rfc3339(buf: []u8) ![]const u8 { // 格式化为 RFC 3339(UTC)
return formatRfc3339(buf, nowUtc());
}
/// 把两个时间点之间的耗时格式化为人类可读
pub fn humanDuration(ns: u64) struct { value: u64, unit: []const u8 } {
if (ns >= std.time.ns_per_s) return .{ .value = ns / std.time.ns_per_s, .unit = "s" };
if (ns >= std.time.ns_per_ms) return .{ .value = ns / std.time.ns_per_ms, .unit = "ms" };
return .{ .value = ns / std.time.ns_per_us, .unit = "us" };
}
};
工程上的四条建议:
- 把时间源做成可注入的。
nowFn: *const fn () i64作为参数传入,测试里就能精确控制时间,不必sleep等待。 - 日志时间戳用 UTC + 毫秒。RFC 3339 格式,方便机器解析;展示层再转本地。
- 所有时间比较在 UTC 下进行。跨时区比较本地时间字符串是纯粹的自找麻烦。
- 暴露时区偏移为配置项。写死
+08:00的服务在海外部署时会全线错乱。
心法:时间的正确性来自「只有一个真源」——内部一律 UTC、一律
i64秒或毫秒、一律单调时钟测耗时,本地时间只在最后一刻渲染出来。
9. 速查表
| 需求 | 手段 |
|---|---|
| Unix 秒 | std.time.timestamp()(i64) |
| 毫秒 / 微秒 | std.time.milliTimestamp() / microTimestamp() |
| 纳秒 | std.time.nanoTimestamp()(i128) |
| 单调时钟 | std.time.Instant.now() + earlier.since(later) |
| 计时器 | std.time.Timer.start() + timer.read() |
| 睡眠 | std.time.sleep(ns) |
| 单位常量 | ns_per_us / ns_per_ms / ns_per_s / ns_per_day |
| 日期分解 | epoch.EpochSeconds → EpochDay → YearAndDay → MonthDay |
| 闰年 | std.time.epoch.isLeapYear(year) |
| 月内天数 | 自己建表 + 闰年二月特判 29 |
| 格式化 | std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-...", ...) |
| 解析 | std.fmt.parseInt 按固定偏移切片 |
| 时区 | 全 UTC 存储 + 固定偏移展示,或 linkLibC + localtime_r |
| 周期调度 | 单调时钟 + 绝对截止时间递推 |
10. 一句话记忆
Zig 的时间处理只给你原语:std.time 管时间戳与单调时钟、epoch 管日历换算、格式化与时区自己写——记住三条铁律:测耗时用 Instant、存储一律 UTC、日历计算全整数,时间 bug 就消失大半。
延伸阅读
- 系统编程实战:文件、进程与信号处理
- 可观测性:日志时间戳与指标采集
- 异步网络编程:事件循环与超时管理
- 测试与代码质量:注入时间源让测试可确定
- 并发与原子操作:多线程下的时间与同步
- 文本处理:Unicode、格式化与解析技巧
- Zig 专题 — Zig 系统编程专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。