Zig 时间、日期与时区处理:std.time 与 epoch 换算

时间处理是系统编程最容易出错的部分:墙钟与单调时钟混用、时区与夏令时、闰年与闰秒、2038 问题。本文讲解 std.time 的完整 API 家族、Instant 与 Timer 的单调计时、std.time.epoch 的日期换算、ISO 8601 与 RFC 3339 的格式化与解析、UTC 偏移与时区处理策略,以及定时器与调度循环的工程写法。

引言

时间看起来只是「一个数字」,实际上是系统编程里最容易出错的一类问题:用墙钟测耗时会在 NTP 校时后得到负数;把 UTC 当本地时间存会在跨时区部署时错乱;手写月份天数表会在闰年二月翻车;把 32 位 time_t 传给 2038 年之后的系统会直接溢出。

Zig 在这件事上的态度和其他语言不同:标准库只提供时间原语,不提供日期库,更不内置时区数据库。std.time 给你秒/毫秒/纳秒时间戳、单调时钟 Instant、计时器 Timer,以及 std.time.epoch 里一套纯计算的日历换算。剩下的格式化、时区、调度,都要你自己按需实现——好处是行为完全可预测,坏处是你必须知道每个原语的语义边界。

前置:系统编程实战、可观测性:日志与指标。


目录


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()i64Unix 秒(UTC)秒
std.time.milliTimestamp()i64Unix 毫秒毫秒
std.time.nanoTimestamp()i128Unix 纳秒纳秒
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 则把「开始」固化在构造时,更适合单段计时。

特性InstantTimer
起点由调用方记录构造时自动记录
适用跨函数传递时间点单段计时

为什么不能用墙钟测耗时: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_indexu50 基,需 +1 才是日期
DaySeconds.getHoursIntoDay()u50-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 33392026-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,前端按用户时区渲染无依赖、零歧义服务端无法直接生成本地时间文本
链接 libclinkLibC() 后调用 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 参数写反得到一个巨大的 u64earlier.since(later)
本地时间落库跨时区部署后时间错乱存储一律 UTC
month/day 基址混淆日期差 1day_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" };
    }
};

工程上的四条建议:

  1. 把时间源做成可注入的。nowFn: *const fn () i64 作为参数传入,测试里就能精确控制时间,不必 sleep 等待。
  2. 日志时间戳用 UTC + 毫秒。RFC 3339 格式,方便机器解析;展示层再转本地。
  3. 所有时间比较在 UTC 下进行。跨时区比较本地时间字符串是纯粹的自找麻烦。
  4. 暴露时区偏移为配置项。写死 +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 就消失大半。


延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「系统编程」更多文章

  1. Zig 插件系统与动态加载:C ABI 契约、热重载与错误隔离
  2. Zig 机器学习推理:张量、GEMM 与 int8 量化
  3. Zig HTTP 客户端与 REST 集成:std.http.Client 实战