目录

go-中-time.Since-失效,手动修改系统时间后不生效time-包的单调时钟设计

go 中 time.Since 失效,手动修改系统时间后不生效?(time 包的单调时钟设计)

前言

最近在做一个时间相关的任务,判断当前时间和记录之间差值大于某个时间时,则触发操作。但是测试人员在测试过程中却报了 bug,修改了系统时间后,预想的操作没有触发,挺简单的一个功能啊,为啥呢?

go 中使用了单调时钟(monotonic clock)设计,用于精确测量时间间隔,避免系统时间被修改导致计算错误。所以本地时间无论怎么修改,time.Since 得到的都是时间差。

问题回溯

又过了一遍代码,看了逻辑没啥问题,写个小测试看下

package main

import (
	"fmt"
	"time"
)

func main() {
	// 先获取一个时间
	t0 := time.Now()
	fmt.Println("当前时间:", t0)

	delay := time.Hour

	ticker := time.NewTicker(time.Second)
	for range ticker.C {
		if time.Since(t0) > delay {
			fmt.Println("time.Since(t0) > delay")
			break
		}

		if (time.Now().Unix() - t0.Unix()) > int64(delay.Seconds()) {
			fmt.Println("(time.Now().Unix() - t0.Unix()) > delay.Seconds()")
			break
		}
	}
}

测试过程中需手动修改系统时间,从程序启动时间向后调整 2 小时,预想的通过 time.Since 跳出循环,但是并没有,最终是通过 time.Now().Unix() - t0.Unix() 跳出循环的,time 包提供的方法都不起作用,为什么会这样?

源码分析

使用 go 版本 1.25.1,发现 gotime 包使用了单调时钟设计。

time.Since 的实现:

// time/time.go
func Since(t Time) Duration {
	// 如果原始时间包含单调时钟信息
	if t.wall&hasMonotonic != 0 && !runtimeIsBubbled() {
		return subMono(runtimeNano()-startNano, t.ext)
	}
	return Now().Sub(t)
}

关键点在于 t.wall&hasMonotonic != 0 这个判断。当使用 time.Now() 获取时间时,go 会同时记录系统时间和单调时钟时间。

// time/time.go
func Now() Time {
    sec, nsec, mono := runtimeNow()
	if mono == 0 {
		return Time{uint64(nsec), sec + unixToInternal, Local}
	}
	mono -= startNano
	sec += unixToInternal - minWall
	if uint64(sec)>>33 != 0 {
		return Time{uint64(nsec), sec + minWall, Local}
	}
	return Time{hasMonotonic | uint64(sec)<<nsecShift | uint64(nsec), mono, Local}
}

当系统时间被修改后,time.Now() 返回的新时间仍然包含单调时钟信息,但单调时钟是基于系统启动时间的,不会因为系统时间修改而改变。

time.Now()
   ├─ walltime()  ── CLOCK_REALTIME   ──> 给人看的时间戳
   └─ nanotime()  ── CLOCK_MONOTONIC  ──> 给程序算间隔
        ↓
打包成 1 个 Time 结构体(wall+ext)
        ↓
业务代码 Sub/Add/After/Before
   ├─ 两边都有 mono ➜ 用 mono,绝对可靠
   └─ 任一边缺 mono ➜ 用 wall,保证语义一致

单调时钟

  • 单调时钟是一个从某个固定点(如系统启动)开始递增的时钟,不会被系统时间修改影响
  • 它只用于计算时间差,不用于显示时间。
  • 这种设计是为了避免系统时间调整(如NTP同步、手动修改等)对时间计算造成的影响。

解决方案

上述分析已经提供了一种方案,只要把这个单调时钟时间去掉就行了。

func t1() {
	delay := time.Hour
	// 直接将 t0 构建为不带单调时钟的时间
	t0 := time.Unix(time.Now().Unix(), 0)
	fmt.Println("当前时间:", t0)
	ticker := time.NewTicker(time.Second)
	for range ticker.C {
		if time.Since(t0) > delay {
			fmt.Println("time.Since(t0.Round(0)) > delay")
			break
		}
	}
}

func t2() {
	delay := time.Hour
	t0 := time.Now()
	fmt.Println("当前时间:", t0)
	ticker := time.NewTicker(time.Second)
	for range ticker.C {
		if time.Since(t0.Truncate(0)) > delay {
			fmt.Println("time.Since(t0.Truncate(0)) > delay")
			break
		}

		if time.Since(t0.Round(0)) > delay {
			fmt.Println("time.Since(t0.Round(0)) > delay")
			break
		}

		if (time.Now().Unix() - t0.Unix()) > int64(delay.Seconds()) {
			fmt.Println("(time.Now().Unix() - t0.Unix()) > delay.Seconds()")
			break
		}
	}
}

总结

Go 的 time.Since 使用单调时钟设计,这是为了提供稳定可靠的时间差计算。当系统时间被修改时,单调时钟不会受到影响,这是 go 时间包的设计特性,而不是 bug