Bryan

Bryan

twitter
medium

あなたは本当に time.Now() を理解していますか?

本文は Go1.20.4 のソースコードに基づいて分析を行っており、より高いまたは低いバージョンでは異なる場合があります。

概要:time.Time#

余計なことは言わずに、まずはソースコードを見てみましょう。

// 記事の長さを短くし、重要な点を強調するために、コメント部分を削除・変更しています。

// A Time represents an instant in time with nanosecond precision.
// 
// The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC.
//
// In addition to the required “wall clock” reading, a Time may contain an optional
// reading of the current process's monotonic clock, to provide additional precision
// for comparison or subtraction.
type Time struct {
	// wall and ext encode the wall time seconds, wall time nanoseconds,
	// and optional monotonic clock reading in nanoseconds.
	//
	// From high to low bit position, wall encodes a 1-bit flag (hasMonotonic),
	// a 33-bit seconds field, and a 30-bit wall time nanoseconds field.
	// The nanoseconds field is in the range [0, 999999999].
	// If the hasMonotonic bit is 0, then the 33-bit field must be zero
	// and the full signed 64-bit wall seconds since Jan 1 year 1 is stored in ext.
	// If the hasMonotonic bit is 1, then the 33-bit field holds a 33-bit
	// unsigned wall seconds since Jan 1 year 1885, and ext holds a
	// signed 64-bit monotonic clock reading, nanoseconds since process start.
	wall uint64
	ext  int64

	// 本文の焦点はタイムゾーンにはなく、関連するトピックについては別の記事を書く予定です。
	loc *Location
}

「時間」という概念について話すとき、あるいはより正確に言えば、時点(instant)について話すとき、私たちは通常疑問を持ちません。しかし、私たちの生活の中での時点が唯一であるのとは異なり、現代のコンピュータには実際に二種類の時計が存在します:カレンダー時計(time-of-day clock /wall clock)単調時計(monotonic clock)

私たちが通常目にする時間(タイムスタンプ、年月日、時分秒の表示など)は一般的にカレンダー時計ですが、時間間隔を計算したいとき、カレンダー時計の差は負の数になる可能性があります —— 二回の計算の間に「時間を調整」することができるからです。したがって、私たちは二つの時点間の ** 経過時間(elapsed time)** を安定して取得する方法が必要であり、これが単調時計の由来です。単調時計という名前はその単調増加の特性に由来しており、通常は実際の時間値ではなく、単調時計の値を考慮することは無意味であり、その唯一の目的は時間差を安定して計算するために使用されます。

カレンダー時計と単調時計についてのさらなる議論はこの記事の範囲を超えていますので、《データ密集型アプリケーションシステム設計》第 8 章の信頼できない時計の部分を読むことをお勧めします。しかし、最初に戻って Time のソースコードを見てみると、カレンダー時計と単調時計の両方が含まれています。

wall の 64 ビットは 1+33+30 の 3 つの部分に分かれており、最初の部分(最上位ビット)は hasMonotonic と呼ばれ、カレンダー時計と単調時計の保存方法を決定します。

  • hasMonotonic = 1 の場合
    • wall の第 2 部分(33 ビット)は 1885 年 1 月 1 日からの秒数(符号なし)を保存します。
    • wall の第 3 部分(30 ビット)はナノ秒部分(範囲 [0, 10^9-1])を保存します。
    • ext はプロセス開始からのナノ秒数(符号付き)を保存します。
  • hasMonotonic = 0 の場合
    • wall の第 2 部分(33 ビット)は全て 0 です。
    • wall の第 3 部分(30 ビット)はナノ秒部分(範囲 [0, 10^9-1])を保存します。
    • ext は西暦 1 年 1 月 1 日からの秒数(符号付き)を保存します。

ここで、いくつかの極限分析を行います。

  1. ナノ秒部分の 10 進数の最大値 10^9-1 に対応する 2 進数は 30 ビットであり、wall の第 3 部分がオーバーフローしないことを保証します。
  2. wall の第 2 部分(33 ビット)は秒に対応し、最大値は 8589934591 秒で、約 272.4 年、1885 年 1 月 1 日から 2157 年まで使用可能です。
  3. 64 ビット符号付きナノ秒の最大値は約 292.47 年(プログラムが一度にそれほど長く実行されることはないでしょう)。
    • 実際には、Golang の内部実装により、最大限界はシステムが返す単調時計に制限され、Linux の場合はシステムの稼働時間が最大で 292.47 年です。
  4. 64 ビット符号付き西暦 1 年 1 月 1 日からの秒数の最大値は 2924.7 億年であり、私たちの生涯ではオーバーフローを見ることはありません。

時間を取得する#

time.Now()#

// Now returns the current local time.
func Now() Time {
	sec, nsec, mono := now()
	mono -= startNano
	sec += unixToInternal - minWall
	if uint64(sec)>>33 != 0 {
		// Seconds field overflowed the 33 bits available when
		// storing a monotonic time. This will be true after
		// March 16, 2157.
		return Time{uint64(nsec), sec + minWall, Local}
	}
	return Time{hasMonotonic | uint64(sec)<<nsecShift | uint64(nsec), mono, Local}
}

// Provided by package runtime.
func now() (sec int64, nsec int32, mono int64)

time.Timeについて十分に理解した後、このコードの設計ロジックを簡単に理解できるようになります —— できるだけ monotonic を保持します。

nowメソッドは現在の時間を返すために使用され、具体的な実装はシステムに依存します。その 3 つの戻り値はそれぞれ次の通りです。

  • sec - Unix タイムスタンプ(1970 年 1 月 1 日からの秒数)
  • nsec - ナノ秒オフセット [0, 10^9-1]
  • mono - システムレベルの単調時計の値

まずは 2 つの調整があります。

  • mono -= startNano

    • 前述の通り、Time に保存されている単調時計の値はシステムが返すものではなく、プロセス開始からのナノ秒数であるため、ここで一度減算を行います(システム起動時のシステム単調時計の値と減算します)。
    // Monotonic times are reported as offsets from startNano.
    // We initialize startNano to runtimeNano() - 1 so that on systems where
    // monotonic time resolution is fairly low (e.g. Windows 2008
    // which appears to have a default resolution of 15ms),
    // we avoid ever reporting a monotonic time of 0.
    // (Callers may want to use 0 as "time not set".)
    var startNano int64 = runtimeNano() - 1
    
  • sec += unixToInternal - minWall

    • hasMonotonic=1 の場合、Time に保存されているのは 1885 年 1 月 1 日からの秒数であり、システムが返すのは 1970 年 1 月 1 日からの秒数であるため、ここで sec からこの 85 年の差を引く必要があります。
    const (
    	unixToInternal int64 = (1969*365 + 1969/4 - 1969/100 + 1969/400) * secondsPerDay
    )
    
    const (
    	wallToInternal int64 = (1884*365 + 1884/4 - 1884/100 + 1884/400) * secondsPerDay
    	minWall      = wallToInternal               // year 1885
    )
    

その後の判断ロジックは非常にシンプルです:可能であれば hasMonotonic=1 を使用して保存し、そうでない場合(時間が 1885 年から 2157 年の範囲に満たない場合)は hasMonotonic=0 を使用して保存します。その後の判断分岐…… この記事を読んでいる人はおそらく遭遇しないでしょう。

time.Unix()#

// Unix returns the local Time corresponding to the given Unix time,
// sec seconds and nsec nanoseconds since January 1, 1970 UTC.
// It is valid to pass nsec outside the range [0, 999999999].
// Not all sec values have a corresponding time value. One such
// value is 1<<63-1 (the largest int64 value).
func Unix(sec int64, nsec int64) Time {
	if nsec < 0 || nsec >= 1e9 {
		n := nsec / 1e9
		sec += n
		nsec -= n * 1e9
		if nsec < 0 {
			nsec += 1e9
			sec--
		}
	}
	return unixTime(sec, int32(nsec))
}

func unixTime(sec int64, nsec int32) Time {
	return Time{uint64(nsec), sec + unixToInternal, Local}
}

const (
	unixToInternal int64 = (1969*365 + 1969/4 - 1969/100 + 1969/400) * secondsPerDay
)

Unix は主に Unix タイムスタンプ(エポック)を使用してhasMonotonic=0 の時間を生成することを行います。

unixTime 呼び出し前の if ロジックは、ビジネスコードの記述を簡素化するために使用されます —— nsec 部分が [0, 10^9-1] の制限範囲外にある場合、修正を行います。

ああ、そういえば、最も一般的な秒単位のタイムスタンプの他に、ミリ秒単位のタイムスタンプやマイクロ秒単位のタイムスタンプにも出会う可能性がありますが、Go は簡略化された呼び出しを提供しています。

// UnixMilli returns the local Time corresponding to the given Unix time,
// msec milliseconds since January 1, 1970 UTC.
func UnixMilli(msec int64) Time {
	return Unix(msec/1e3, (msec%1e3)*1e6)
}

// UnixMicro returns the local Time corresponding to the given Unix time,
// usec microseconds since January 1, 1970 UTC.
func UnixMicro(usec int64) Time {
	return Unix(usec/1e6, (usec%1e6)*1e3)
}

うん…… もしナノ秒単位のタイムスタンプが必要なら…… 直接 time.Unix(0, nanoseconds) を呼び出せばいいんですよ😂

time.Date()#

// 記事の長さを短くし、重要な点を強調するために、コードとコメント部分を削除・変更しています。

// Date returns the Time corresponding to
//
//	yyyy-mm-dd hh:mm:ss + nsec nanoseconds
//
// The month, day, hour, min, sec, and nsec values may be outside
// their usual ranges and will be normalized during the conversion.
// For example, October 32 converts to November 1.
func Date(year int, month Month, day, hour, min, sec, nsec int, loc *Location) Time {
	...

	// Normalize overflow
	...

	// Compute days since the absolute epoch.
	d := daysSinceEpoch(year)

	// Add in days before this month.
	d += uint64(daysBefore[month-1])
	if isLeap(year) && month >= March {
		d++ // February 29
	}

	// Add in days before today.
	d += uint64(day - 1)

	// Add in time elapsed today.
	abs := d * secondsPerDay
	abs += uint64(hour*secondsPerHour + min*secondsPerMinute + sec)

	unix := int64(abs) + (absoluteToInternal + internalToUnix)

	// Look for zone offset for expected time, so we can adjust to UTC.
	...

	t := unixTime(unix, int32(nsec))
	t.setLoc(loc)
	return t
}

ほとんどのロジックは省略しましたが、簡単に言うと、Date は以下のことを行います。

  1. 「オーバーフロー」を処理します:10 月 32 日などの日付を提供することができ、Golang は正しく修正(11 月 1 日として)してくれます —— AddDate のソースコードを見れば、関連する値を単純に操作しただけです。
  2. 閏年を処理します。
  3. タイムゾーンを処理します。
  4. Unix タイムスタンプを使用してhasMonotonic=0 の時間を生成します。

time.Parse()#

// 記事の長さを短くし、重要な点を強調するために、コードとコメント部分を削除・変更しています。

func Parse(layout, value string) (Time, error) {
	return parse(layout, value, UTC, Local)
}

func parse(layout, value string, defaultLocation, local *Location) (Time, error) {
	...
	return Date(year, Month(month), day, hour, min, sec, nsec, defaultLocation), nil
}

この部分はさらに削除されており、本質的にはフォーマットを解析した後に Date を呼び出すだけなので、生成されるのもhasMonotonic=0 の時間です。

小結#

私たちが最もよく使用する 4 つの時間構築方法(Now、Date、Unix、Parse)の中で、Now だけが単調時計の情報を保存しています(hasMonotonic=1)。

この保存の最も重要な利点は、time.Now() - time.Now()を使用して二回の実行間の経過時間を計算できることであり、時間の逆流が発生することを考慮する必要がないことです。

時間差#

時間の減法#

実際には、time.Time の構造体を理解すれば、すべての難点は解決されます。構造体を設計した後、自分で Sub を書くことになれば、あなたも同じように書くでしょう。

余計なことは言わずに、Sub のコードを見てみましょう。

// Sub returns the duration t-u. If the result exceeds the maximum (or minimum)
// value that can be stored in a Duration, the maximum (or minimum) duration
// will be returned.
// To compute t-d for a duration d, use t.Add(-d).
func (t Time) Sub(u Time) Duration {
	if t.wall&u.wall&hasMonotonic != 0 {
		te := t.ext
		ue := u.ext
		d := Duration(te - ue)
		if d < 0 && te > ue {
			return maxDuration // t - u is positive out of range
		}
		if d > 0 && te < ue {
			return minDuration // t - u is negative out of range
		}
		return d
	}
	d := Duration(t.sec()-u.sec())*Second + Duration(t.nsec()-u.nsec())
	// Check for overflow or underflow.
	switch {
	case u.Add(d).Equal(t):
		return d // d is correct
	case t.Before(u):
		return minDuration // t - u is negative out of range
	default:
		return maxDuration // t - u is positive out of range
	}
}

核心ロジックはあなたが考えている通りです:もし二つの時間が両方とも hasMonotonic=1 であれば、単調時計の差を計算するだけで済みます —— 二つの ext の減算です。以前の極限分析を覚えていますか?極端な場合、hasMonotonic のどの分岐に進んでも、オーバーフローの可能性があります —— 二つの時間間隔が大きすぎて、int64 ナノ秒の最大許容値 292.47 年を超えてしまうのです。

上記のコードをよく読み、特に二つの状況でオーバーフローを検出する方法が異なる理由を考えてみることをお勧めします

比較#

ゼロ値 —— IsZero#

直接 Time を初期化すると、その値は 0 です。

var t time.Time

Time の初期化には魔法はなく、Go の他の構造体の初期化の 0 値と同じです —— すべてのフィールドが 0 に設定されます。したがって、ルールに従って、hasMonotonic=0 となり、ext に秒を保存し、wall の第 3 部分にナノ秒を保存しますが、これらも 0 です。したがって、0 値の時間は January 1, year 1, 00:00:00.000000000 UTC です。

この時間は実際にはあまり一般的ではないため、ゼロ値は通常「未初期化の時間」と見なされます。Go はIsZeroメソッドを提供してこれを検出します。

// 記事の長さを短くし、重要な点を強調するために、部分的にコメントを追加しました。

// IsZero reports whether t represents the zero time instant,
// January 1, year 1, 00:00:00 UTC.
func (t Time) IsZero() bool {
	return t.sec() == 0 && t.nsec() == 0
}

// sec returns the time's seconds since Jan 1 year 1.
func (t *Time) sec() int64 {
	if t.wall&hasMonotonic != 0 { // 0値の時はこの分岐には進まない
		return wallToInternal + int64(t.wall<<1>>(nsecShift+1))
	}
	return t.ext
}

// nsec returns the time's nanoseconds.
// これは単にwallの第3部分(30ビットのナノ秒時間オフセット)を返すだけです。
func (t *Time) nsec() int32 {
	return int32(t.wall & nsecMask)
}

const nsecMask = 1<<30 - 1

その実装は少し複雑に見えますが、実際には、hasMonotonic=1 のときに値が 0 であることはあり得ないため、この Time がポインタを使って強制的に破壊されていない限り、wall の最初と第三部分が 0 で ext が 0 であれば、全体として 0 になります。

等しい —— Equal#

まず明確にしておくべきことは、構造体を直接比較する場合、構造体が等しい場合は必ず時間も等しいが、構造体が等しくないからといって時間が等しくないとは限らないということです。したがって、Go で時間を比較する際には、できるだけ==を使用するのではなく、Equalメソッドを使用するべきです。

構造体が等しくないが時間が等しい理由には、同じ値に対応する二つの表現方法(hasMonotonic=0/1)がある可能性や、同じ値に対応する二つのタイムゾーンがある可能性があります。

本文の焦点はタイムゾーンにはなく、タイムゾーンは Time の wall と ext には影響を与えません(どちらも UTC 値で保存されています)、loc フィールドにのみ影響を与えます。

// Equal reports whether t and u represent the same time instant.
// Two times can be equal even if they are in different locations.
// For example, 6:00 +0200 and 4:00 UTC are Equal.
// See the documentation on the Time type for the pitfalls of using == with
// Time values; most code should use Equal instead.
func (t Time) Equal(u Time) bool {
	if t.wall&u.wall&hasMonotonic != 0 {
		return t.ext == u.ext
	}
	return t.sec() == u.sec() && t.nsec() == u.nsec()
}

Go における Equal の実装も「単調時計優先の原則」に従っています。

加餐#

上記の内容で、この記事の核心である time.Time については説明しました(うん、タイムゾーンを除いて)。このセクションでは、使用時にはあまり必要ないが、技術的に知っておくと良いことをいくつか書きたいと思います。

ᕦ(・́~・̀) 加餐の内容量は実際の本文よりも多いです。

time.Now は実際にどのように実装されているのか#

上記で time.Now の実装について説明しましたが、気づきましたか……

// Provided by package runtime.
func now() (sec int64, nsec int32, mono int64)

この now は直接省略されており😂一行だけが残されています。

そうです、これは runtime によって実装されており、実際の実装は runtime の time_now 関数にあり、さらに進んで、Linux Amd64 と他のシステムの実装は少し異なります。

//go:build !faketime && (windows || (linux && amd64))

//go:linkname time_now time.now
func time_now() (sec int64, nsec int32, mono int64)
//go:build !faketime && !windows && !(linux && amd64)

//go:linkname time_now time.now
func time_now() (sec int64, nsec int32, mono int64) {
	sec, nsec = walltime()
	return sec, nsec, nanotime()
}

Linux Amd64 では、全体の time_now はアセンブリで実装されており、以下のコードは私がコメントを追加したバージョンです。元のバージョンはこのリンクから読むことができます。

// 記事の長さを短くし、重要な点を強調するために、コードとコメント部分を削除・変更しています。

// func time.now() (sec int64, nsec int32, mono int64)
// $16-24は関数が16バイトのスタックスペースと24バイトの戻り値スペースを必要とすることを示します。
TEXT time·now<ABIInternal>(SB),NOSPLIT,$16-24
	// vDSOの準備
    ...

	// (g0でない場合)g0に切り替え
	...

noswitch: // 時間取得のロジック(優先的にvDSOを利用)
	SUBQ	$32, SP		// 二つの時間結果のためのスペース
	ANDQ	$~15, SP	// Cコード用に整列

	MOVL	$0, DI // CLOCK_REALTIME(カレンダー時計を取得)
	LEAQ	16(SP), SI
	MOVQ	runtime·vdsoClockgettimeSym(SB), AX
	CMPQ	AX, $0
	JEQ	fallback // 失敗時のジャンプ
	CALL	AX

	MOVL	$1, DI // CLOCK_MONOTONIC(単調時計を取得)
	LEAQ	0(SP), SI
	MOVQ	runtime·vdsoClockgettimeSym(SB), AX
	CALL	AX

ret: // 取得成功、結果はスタックに保存されています
	MOVQ	16(SP), AX	// リアルタイムの秒
	MOVQ	24(SP), DI	// リアルタイムのナノ秒(以下でBXに移動)
	MOVQ	0(SP), CX	// 単調の秒
	IMULQ	$1000000000, CX
	MOVQ	8(SP), DX	// 単調のナノ秒

	// 状態を復元
    ...

	// 結果を返す
	// 結果レジスタを設定;AXはすでに正しい
	MOVQ	DI, BX
	ADDQ	DX, CX // 単調ナノ秒を計算
	RET

fallback: // CLOCK_REALTIMEの取得に失敗した場合、vDSOの失敗を示し、システムコールを利用
	MOVQ	$SYS_clock_gettime, AX
	SYSCALL

	MOVL	$1, DI // CLOCK_MONOTONIC
	LEAQ	0(SP), SI
	MOVQ	$SYS_clock_gettime, AX
	SYSCALL

	JMP	ret

簡単に言うと、Linux Amd64 では、Go は可能な限り vDSO を利用して時間情報(カレンダー時計と単調時計の時間)を取得し、エラーが発生した場合にのみシステムコールにフォールバックします。vDSO の主な目的は、システムコールの時間を短縮することであり、具体的にはLinux マニュアルを読んで詳細を確認できます。Go では、実際には時間に関連するシステムコールのみが vDSO を使用しています:vdso_linux_amd64.go

Linux Amd64 以外では、walltime()nanotime()を使用してそれぞれカレンダー時計と単調時計を取得します。

以下のコードは Linux Arm64 の例です。

func walltime() (sec int64, nsec int32)

//go:nosplit
func nanotime() int64 {
	return nanotime1()
}

func nanotime1() int64

このようにボディがないことから、アセンブリに進むことがわかります😂上記の Linux Amd64 の内容を理解すれば、Arm64 も大差ありません(vDSO + syscall fallback を使用しているだけで、walltime と nanotime を一つの関数で取得していないだけです)。関連するソースコードはこちらをクリックして読むことができます

システム単調時計を取得するには?#

time パッケージにはシステム単調時計を取得する手段は提供されていませんが、runtime の nanotime は実際にはシステム単調時計です。以下の内容をヘルパー関数としてラップして使用することができます。

import (
	_ "unsafe"
)

//go:noescape
//go:linkname Monotonic runtime.nanotime
func Monotonic() int64

あるいは…… 私の extime.Monotonic を使用することもできます👀

Round —— 四捨五入 + 単調時計情報の削除#

Round は四捨五入を行います。

// Round returns the result of rounding t to the nearest multiple of d (since the zero time).
// The rounding behavior for halfway values is to round up.
// If d <= 0, Round returns t stripped of any monotonic clock reading but otherwise unchanged.
//
// Round operates on the time as an absolute duration since the
// zero time; it does not operate on the presentation form of the
// time. Thus, Round(Hour) may return a time with a non-zero
// minute, depending on the time's Location.
func (t Time) Round(d Duration) Time {
	t.stripMono()
	if d <= 0 {
		return t
	}
	_, r := div(t, d)
	if lessThanHalf(r, d) {
		return t.Add(-r)
	}
	return t.Add(d - r)
}

// stripMono strips the monotonic clock reading in t.
func (t *Time) stripMono() {
	if t.wall&hasMonotonic != 0 {
		t.ext = t.sec()
		t.wall &= nsecMask
	}
}

Duration を使用して精度を提供することができます(例えば t.Round(time.Second))。

さらに、「副作用」として、前述の Time 内の単調時計の情報が削除されます(最初の行の stripMono)。したがって、四捨五入を行いたい場合や、単調時計の内容を削除したい場合は、t.Round(0)を使用できます。

閏秒#

閏秒について知らない場合は、まずウィキペディアを訪れてください。閏秒は存在し続け、無数の事故を引き起こし、そして今後廃止される予定のものです。先人が踏んだ落とし穴のため、私たちはもはやそれほど関心を持たないかもしれませんが、少なくとも知っておくべきです。

閏秒はある程度この記事の議論の範囲を超えています —— なぜなら Go は閏秒を考慮していないからです。Go では「23:59:60」のような時間を得ることはなく、時間差を計算する際にはすでに単調時計が導入されているため、事故が発生することもありません。

ちょっと待って、再?ぜひ2017 年の Cloudflare の事故を訪れてください。その時、Go の time.Now はまだ単調時計を使用していませんでした。

参考#

go/time.go at go1.20.4 · golang/go

https://github.com/golang/go/issues/12914

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。