Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 16625] ファイルの概要

このコミットは、Go言語の time パッケージにおけるガベージコレクタ (GC) のエイリアシングバグを修正するものです。具体的には、time.Time 構造体のフィールドのメモリレイアウトが原因で、GCが誤ったポインタを認識し、問題を引き起こす可能性があった点を改善しています。

コミット

commit f21bc7920decb5b6f94d49a9e8eefcdb74960c24
Author: Russ Cox <rsc@golang.org>
Date:   Mon Jun 24 14:49:35 2013 -0400

    time: avoid garbage collector aliasing bug
    
    Time is a tiny struct, so the compiler copies a Time by
    copying each of the three fields.
    
    The layout of a time on amd64 is [ptr int32 gap32 ptr].
    Copying a Time onto a location that formerly held a pointer in the
    second word changes only the low 32 bits, creating a different
    but still plausible pointer. This confuses the garbage collector
    when it appears in argument or result frames.
    
    To avoid this problem, declare nsec as uintptr, so that there is
    no gap on amd64 anymore, and therefore no partial pointers.
    
    Note that rearranging the fields to put the int32 last still leaves
    a gap - [ptr ptr int32 gap32] - because Time must have a total
    size that is ptr-width aligned.
    
    Update #5749
    
    This CL is enough to fix the problem, but we should still do
    the other actions listed in the initial report. We're not too far
    from completely precise collection.
    
    R=golang-dev, dvyukov, r
    CC=golang-dev
    https://golang.org/cl/10504043

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/f21bc7920decb5b6f94d49a9e8eefcdb74960c24

元コミット内容

time: avoid garbage collector aliasing bug

このコミットは、time.Time 構造体のコピー時に発生するガベージコレクタのエイリアシングバグを回避するためのものです。Time 構造体が小さいため、コンパイラは各フィールドをコピーすることで Time をコピーします。

amd64 アーキテクチャにおける Time のメモリレイアウトは [ptr int32 gap32 ptr] となっていました。以前ポインタを保持していた2番目のワードに Time をコピーすると、下位32ビットのみが変更され、異なるがもっともらしいポインタが生成されてしまいます。これが引数や結果フレームに現れると、ガベージコレクタを混乱させます。

この問題を回避するため、nsec フィールドを uintptr として宣言し、amd64 上でギャップがなくなり、部分的なポインタが生成されないようにします。

フィールドを再配置して int32 を最後に配置しても、Time の合計サイズがポインタ幅にアラインされる必要があるため、[ptr ptr int32 gap32] のようにギャップが残ることに注意してください。

この変更は Issue #5749 に関連しています。

この変更は問題を修正するのに十分ですが、初期レポートに記載されている他のアクションもまだ実行する必要があります。完全に正確なコレクションにはまだ遠くありません。

変更の背景

Go言語のガベージコレクタは、プログラムが使用しなくなったメモリを自動的に解放する重要な役割を担っています。GCが正しく機能するためには、メモリ上のどの値がポインタであり、どのポインタが有効なオブジェクトを指しているかを正確に識別する必要があります。しかし、特定の条件下で、GCがポインタではない値をポインタとして誤認識したり、無効なポインタを有効なポインタとして扱ったりする「エイリアシングバグ」が発生することがあります。

このコミットの背景にあるのは、time.Time 構造体のメモリレイアウトと、コンパイラによる構造体のコピー方法に起因するGCの誤動作です。time.Time は、時刻を表すために内部的に秒数 (sec) とナノ秒 (nsec)、そしてタイムゾーン情報 (loc) を保持しています。この構造体が非常に小さいため、Goコンパイラは最適化の一環として、構造体全体を一度にコピーするのではなく、個々のフィールドをコピーして新しい Time 値を生成することがあります。

問題は、amd64 (64ビット) システムにおける time.Time 構造体のメモリレイアウトにありました。元のレイアウトは [ptr int32 gap32 ptr] のような形になっていました。ここで ptr はポインタ、int32 は32ビット整数、gap32 は32ビットのパディング(アラインメントのために挿入される未使用領域)を意味します。この gap32 の部分が、以前にポインタを格納していたメモリ領域と重なる場合、Time 構造体のコピー時に int32 の値がそのギャップに書き込まれると、その int32 の下位32ビットが、以前のポインタの下位32ビットと結合されて、GCが「もっともらしい」が実際には無効なポインタとして認識してしまう可能性がありました。このような「部分的なポインタ」は、GCの正確性を損ない、メモリリークやクラッシュなどの深刻な問題を引き起こす可能性があります。

このバグは、特に引数や結果フレーム(関数呼び出しのスタックフレーム)で time.Time 値が扱われる際に顕在化し、GCがスタック上のポインタをスキャンする際に誤った判断を下す原因となっていました。

前提知識の解説

このコミットを理解するためには、以下のGo言語およびコンピュータサイエンスの基本的な概念を理解しておく必要があります。

  1. Go言語のガベージコレクタ (GC):

    • GoのGCは、主に並行マーク&スイープ方式を採用しています。これは、プログラムの実行と並行してGCが動作し、アプリケーションの一時停止(ストップ・ザ・ワールド)時間を最小限に抑えることを目指しています。
    • GCは、メモリ上のオブジェクトがまだ参照されているかどうかを判断するために、ポインタを追跡します。ルート(グローバル変数、スタック上の変数など)から到達可能なオブジェクトは「生きている」と判断され、それ以外は「死んでいる」と判断されて解放されます。
    • GCがポインタを正確に識別できないと、誤って生きているオブジェクトを解放したり(Use-After-Free)、死んでいるオブジェクトを解放しなかったり(メモリリーク)する問題が発生します。
  2. ポインタとメモリレイアウト:

    • ポインタは、メモリ上の特定のアドレスを指す変数です。64ビットシステムでは、ポインタは通常64ビット(8バイト)のサイズを持ちます。
    • 構造体(struct)は、異なる型のフィールドをまとめた複合データ型です。構造体のフィールドは、メモリ上で連続して配置されますが、CPUの効率的なアクセスを可能にするために「アラインメント」という規則に従います。
    • アラインメント: CPUは、特定のデータ型が特定のメモリアドレスの倍数に配置されている場合に、最も効率的にデータを読み書きできます。例えば、64ビット整数は8バイト境界に、32ビット整数は4バイト境界に配置されることが一般的です。
    • パディング (Padding) / ギャップ (Gap): アラインメントの要件を満たすために、構造体のフィールド間に未使用のバイト(パディング)が挿入されることがあります。これにより、構造体の合計サイズが増加することがあります。例えば、[ptr int32] の後に ptr が続く場合、int32 の後に4バイトのパディングが挿入され、次の ptr が8バイト境界に配置されるように調整されることがあります。これが gap32 の正体です。
  3. uintptr:

    • Go言語の uintptr 型は、ポインタを保持するのに十分な大きさの符号なし整数型です。そのサイズは、システム上のポインタのサイズ(32ビットシステムでは32ビット、64ビットシステムでは64ビット)と同じです。
    • uintptr は、ポインタ演算を行う際や、C言語との相互運用、または低レベルのメモリ操作を行う際に使用されます。
    • 重要なのは、uintptr は「ポインタ」としてGCに認識されないという点です。GCは uintptr の値を単なる数値として扱い、それが指すメモリを追跡しません。これは、GCの正確性を確保するために意図された挙動です。開発者が uintptr をポインタとして使用する場合は、GCにそのポインタを認識させるための特別なメカニズム(例: runtime.KeepAlive)が必要になることがあります。
  4. エイリアシング (Aliasing):

    • プログラミングにおいて、エイリアシングとは、同じメモリ位置が複数の異なる名前や参照によってアクセスされる状況を指します。
    • このコミットの文脈では、gap32 のメモリ領域が、以前は有効なポインタの一部であったが、int32 の値が書き込まれることで、そのポインタの下位ビットが変更され、GCが誤って新しい「もっともらしい」ポインタとして認識してしまう状況を指します。

技術的詳細

このバグの核心は、Goの time.Time 構造体が amd64 アーキテクチャ上でどのようにメモリに配置されるか、そしてGoコンパイラがその構造体をどのようにコピーするかという点にありました。

元の time.Time 構造体は、以下のようなフィールドを持っていました(簡略化):

type Time struct {
    sec int64 // 秒数
    nsec int32 // ナノ秒
    loc *Location // タイムゾーン情報へのポインタ
}

amd64 システムでは、int64 は8バイト、int32 は4バイト、ポインタ (*Location) は8バイトです。アラインメントの要件により、int32 の後にパディングが発生していました。

具体的なメモリレイアウトは以下のようになっていました:

  1. sec (int64): 8バイト
  2. nsec (int32): 4バイト
  3. パディング (gap32): 4バイト (次の loc ポインタを8バイト境界にアラインするため)
  4. loc (*Location): 8バイト

したがって、Time 構造体のメモリレイアウトは概念的に [int64 | int32 | gap32 | *Location] となっていました。

問題は、コンパイラが Time 構造体をコピーする際に、各フィールドを個別にコピーする挙動にありました。例えば、ある Time 変数 t1 を別の Time 変数 t2 に代入する t2 = t1 のような操作が行われると、コンパイラは t1.sect2.sec に、t1.nsect2.nsec に、t1.loct2.loc にそれぞれコピーします。

ここで、もし t2 が以前に別のポインタを保持していたメモリ領域に配置されており、そのポインタの下位32ビットが t2.nsecint32 フィールドが書き込まれる領域と重なっていた場合、t2.nsec のコピーによって、そのポインタの下位32ビットが上書きされてしまいます。これにより、GCがスキャンする際に、元のポインタとは異なるが、依然として「もっともらしい」(つまり、有効なアドレス範囲内にあるように見える)ポインタが生成されてしまう可能性がありました。

GoのGCは、スタックやヒープ上のメモリをスキャンしてポインタを識別し、到達可能性を判断します。もしGCがこのような「部分的に変更されたポインタ」を有効なポインタとして誤認識してしまうと、実際には存在しないオブジェクトを追跡しようとしたり、誤ったメモリ領域を解放しようとしたりする可能性があります。これは、GCの正確性を損ない、プログラムの不安定性やクラッシュにつながります。

この問題を解決するために、コミットでは nsec フィールドの型を int32 から uintptr に変更しました。

type Time struct {
    sec int64
    nsec uintptr // 変更点: int32 から uintptr へ
    loc *Location
}

uintptr は64ビットシステムでは64ビット(8バイト)のサイズを持ちます。これにより、Time 構造体のメモリレイアウトは以下のように変化します:

  1. sec (int64): 8バイト
  2. nsec (uintptr): 8バイト
  3. loc (*Location): 8バイト

新しいレイアウトは [int64 | uintptr | *Location] となり、nsec フィールドがポインタと同じ8バイトのサイズを持つため、int32 の後に発生していた4バイトのパディング (gap32) がなくなります。

パディングがなくなることで、Time 構造体のコピー時に、以前ポインタを保持していたメモリ領域に nsec の値が書き込まれても、その領域全体が uintptr の値で上書きされるため、部分的なポインタが生成される可能性がなくなります。uintptr はGCによってポインタとして扱われないため、GCが誤って追跡しようとすることもありません。これにより、GCの正確性が保たれ、エイリアシングバグが回避されます。

コミットメッセージでは、「フィールドを再配置して int32 を最後に配置してもギャップが残る」と述べられています。これは、[ptr ptr int32 gap32] のように、構造体の合計サイズがポインタ幅(8バイト)にアラインされる必要があるため、最後の int32 の後にパディングが必要になることを意味します。uintptr に変更することで、このアラインメントの問題も同時に解決され、構造体全体がポインタ幅の倍数となり、パディングが不要になります。

この修正は、GoのGCがまだ「完全に正確なコレクション」ではない時期に行われたものであり、GCの進化の過程における重要なステップを示しています。将来的にはGCがより正確になり、このような低レベルのメモリレイアウトの考慮が不要になることが期待されていますが、この時点では uintptr を使用することが最も堅牢な解決策でした。

コアとなるコードの変更箇所

変更は src/pkg/time/time.go ファイルに集中しています。

  1. Time 構造体の nsec フィールドの型変更:

    • type Time struct { ... nsec int32 ... }
    • から
    • type Time struct { ... nsec uintptr ... }
    • へ変更されました。
  2. nsec フィールドへのアクセスと操作の修正:

    • nsecuintptr になったため、int32 として扱っていた箇所で型変換が必要になりました。
    • Add メソッド、Sub メソッド、Now 関数、GobDecode メソッド、Unix 関数、Date 関数、div 関数など、nsec を直接操作するすべての箇所で int32uintptr 間の明示的な型変換が追加されています。

コアとなるコードの解説

以下に、主要な変更箇所のコードと解説を示します。

Time 構造体の定義

--- a/src/pkg/time/time.go
+++ b/src/pkg/time/time.go
@@ -39,7 +39,14 @@ type Time struct {
  	// nsec specifies a non-negative nanosecond
  	// offset within the second named by Seconds.
  	// It must be in the range [0, 999999999].
-	nsec int32
+	//
+	// It is declared as uintptr instead of int32 or uint32
+	// to avoid garbage collector aliasing in the case where
+	// on a 64-bit system the int32 or uint32 field is written
+	// over the low half of a pointer, creating another pointer.
+	// TODO(rsc): When the garbage collector is completely
+	// precise, change back to int32.
+	nsec uintptr
  • 変更点: nsec フィールドの型が int32 から uintptr に変更されました。
  • 解説: これがこのコミットの最も重要な変更点です。uintptr はポインタと同じサイズ(64ビットシステムでは8バイト)を持つため、nsec の後にパディングが発生しなくなります。これにより、Time 構造体のメモリレイアウトが密になり、GCが誤ったポインタを認識する原因となっていた「ギャップ」が解消されます。コメントには、この変更がGCのエイリアシング問題を回避するためであること、そして将来的にGCが完全に正確になれば int32 に戻す可能性があることが明記されています。

Add メソッド

--- a/src/pkg/time/time.go
+++ b/src/pkg/time/time.go
@@ -605,14 +612,15 @@ func (d Duration) Hours() float64 {\
 // Add returns the time t+d.
 func (t Time) Add(d Duration) Time {\
  	t.sec += int64(d / 1e9)\
-	t.nsec += int32(d % 1e9)\
-	if t.nsec >= 1e9 {\
+	nsec := int32(t.nsec) + int32(d%1e9)\
+	if nsec >= 1e9 {\
  	\tt.sec++\
-	\tt.nsec -= 1e9\
-	} else if t.nsec < 0 {\
+	\tnsec -= 1e9\
+	} else if nsec < 0 {\
  	\tt.sec--\
-	\tt.nsec += 1e9\
+	\tnsec += 1e9\
  	}\
+\tt.nsec = uintptr(nsec)\
  	return t\
  }
  • 変更点: t.nsec の直接的な加算が、一時変数 nsec を介した操作に変更され、最後に uintptr に型変換して t.nsec に代入されています。
  • 解説: t.nsecuintptr 型になったため、int32 型の d % 1e9 を直接加算することはできません。そのため、t.nsecint32 に型変換し、d % 1e9 を加算して一時変数 nsec に格納します。ナノ秒の範囲チェックと秒数への繰り上がり/繰り下がりを行った後、最終的な nsec の値を uintptr に型変換して t.nsec に戻しています。これにより、nsec の値が uintptr として正しく保持されつつ、ナノ秒としての算術演算が int32 の精度で行われます。

Sub メソッド

--- a/src/pkg/time/time.go
+++ b/src/pkg/time/time.go
@@ -621,7 +629,7 @@ func (t Time) Add(d Duration) Time {\
 // will be returned.\
 // To compute t-d for a duration d, use t.Add(-d).\
 func (t Time) Sub(u Time) Duration {\
-	d := Duration(t.sec-u.sec)*Second + Duration(t.nsec-u.nsec)\
+	d := Duration(t.sec-u.sec)*Second + Duration(int32(t.nsec)-int32(u.nsec))\
  	// Check for overflow or underflow.\
  	switch {\
  	case u.Add(d).Equal(t):\
  • 変更点: t.nsec - u.nsec の部分が int32(t.nsec) - int32(u.nsec) に変更されました。
  • 解説: nsecuintptr 型であるため、直接減算すると uintptr としての減算が行われてしまいます。ナノ秒の差を計算するためには、両方の nsecint32 に型変換してから減算する必要があります。これにより、ナノ秒の差が正しく計算され、Duration 型に変換されます。

Now 関数

--- a/src/pkg/time/time.go
+++ b/src/pkg/time/time.go
@@ -776,7 +784,7 @@ func now() (sec int64, nsec int32)\
 // Now returns the current local time.\
 func Now() Time {\
  	sec, nsec := now()\
-	return Time{sec + unixToInternal, nsec, Local}\
+	return Time{sec + unixToInternal, uintptr(nsec), Local}\
  }
  • 変更点: Time 構造体の初期化時に、nsec の値が uintptr(nsec) と明示的に型変換されています。
  • 解説: now() 関数は nsecint32 として返しますが、Time 構造体の nsec フィールドは uintptr 型であるため、ここで型変換が必要です。

同様の型変換は、GobDecodeUnixDatediv といった nsec を扱う他の関数やメソッドでも行われています。これらの変更はすべて、nsec フィールドの型が int32 から uintptr に変更されたことに伴う、型安全性を保ちつつ正しい算術演算を行うための修正です。

関連リンク

  • Go Issue #5749: このコミットが修正したバグの元のIssue。詳細な議論や背景情報が含まれている可能性があります。
  • Go CL 10504043: このコミットに対応するGoの変更リスト (Change List)。

参考にした情報源リンク

  • Go言語の公式ドキュメント (特に time パッケージ、uintptr 型、メモリモデルに関する記述)
  • Go言語のガベージコレクタに関する技術記事や論文 (GoのGCの仕組み、ポインタの識別など)
  • amd64 アーキテクチャにおけるデータアラインメントとメモリレイアウトに関する一般的な情報
  • GoのIssueトラッカーやメーリングリストでの関連議論 (特にIssue 5749)
  • Goのソースコード (特に runtime パッケージや src/pkg/time パッケージ)

[インデックス 16625] ファイルの概要

このコミットは、Go言語の time パッケージにおけるガベージコレクタ (GC) のエイリアシングバグを修正するものです。具体的には、time.Time 構造体のフィールドのメモリレイアウトが原因で、GCが誤ったポインタを認識し、問題を引き起こす可能性があった点を改善しています。

コミット

commit f21bc7920decb5b6f94d49a9e8eefcdb74960c24
Author: Russ Cox <rsc@golang.org>
Date:   Mon Jun 24 14:49:35 2013 -0400

    time: avoid garbage collector aliasing bug
    
    Time is a tiny struct, so the compiler copies a Time by
    copying each of the three fields.
    
    The layout of a time on amd64 is [ptr int32 gap32 ptr].
    Copying a Time onto a location that formerly held a pointer in the
    second word changes only the low 32 bits, creating a different
    but still plausible pointer. This confuses the garbage collector
    when it appears in argument or result frames.
    
    To avoid this problem, declare nsec as uintptr, so that there is
    no gap on amd64 anymore, and therefore no partial pointers.
    
    Note that rearranging the fields to put the int32 last still leaves
    a gap - [ptr ptr int32 gap32] - because Time must have a total
    size that is ptr-width aligned.
    
    Update #5749
    
    This CL is enough to fix the problem, but we should still do
    the other actions listed in the initial report. We're not too far
    from completely precise collection.
    
    R=golang-dev, dvyukov, r
    CC=golang-dev
    https://golang.org/cl/10504043

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/f21bc7920decb5b6f94d49a9e8eefcdb74960c24

元コミット内容

time: avoid garbage collector aliasing bug

このコミットは、time.Time 構造体のコピー時に発生するガベージコレクタのエイリアシングバグを回避するためのものです。Time 構造体が小さいため、コンパイラは各フィールドをコピーすることで Time をコピーします。

amd64 アーキテクチャにおける Time のメモリレイアウトは [ptr int32 gap32 ptr] となっていました。以前ポインタを保持していた2番目のワードに Time をコピーすると、下位32ビットのみが変更され、異なるがもっともらしいポインタが生成されてしまいます。これが引数や結果フレームに現れると、ガベージコレクタを混乱させます。

この問題を回避するため、nsec フィールドを uintptr として宣言し、amd64 上でギャップがなくなり、部分的なポインタが生成されないようにします。

フィールドを再配置して int32 を最後に配置しても、Time の合計サイズがポインタ幅にアラインされる必要があるため、[ptr ptr int32 gap32] のようにギャップが残ることに注意してください。

この変更は Issue #5749 に関連しています。

この変更は問題を修正するのに十分ですが、初期レポートに記載されている他のアクションもまだ実行する必要があります。完全に正確なコレクションにはまだ遠くありません。

変更の背景

Go言語のガベージコレクタは、プログラムが使用しなくなったメモリを自動的に解放する重要な役割を担っています。GCが正しく機能するためには、メモリ上のどの値がポインタであり、どのポインタが有効なオブジェクトを指しているかを正確に識別する必要があります。しかし、特定の条件下で、GCがポインタではない値をポインタとして誤認識したり、無効なポインタを有効なポインタとして扱ったりする「エイリアシングバグ」が発生することがあります。

このコミットの背景にあるのは、time.Time 構造体のメモリレイアウトと、コンパイラによる構造体のコピー方法に起因するGCの誤動作です。time.Time は、時刻を表すために内部的に秒数 (sec) とナノ秒 (nsec)、そしてタイムゾーン情報 (loc) を保持しています。この構造体が非常に小さいため、Goコンパイラは最適化の一環として、構造体全体を一度にコピーするのではなく、個々のフィールドをコピーして新しい Time 値を生成することがあります。

問題は、amd64 (64ビット) システムにおける time.Time 構造体のメモリレイアウトにありました。元のレイアウトは [ptr int32 gap32 ptr] のような形になっていました。ここで ptr はポインタ、int32 は32ビット整数、gap32 は32ビットのパディング(アラインメントのために挿入される未使用領域)を意味します。この gap32 の部分が、以前にポインタを格納していたメモリ領域と重なる場合、Time 構造体のコピー時に int32 の値がそのギャップに書き込まれると、その int32 の下位32ビットが、以前のポインタの下位32ビットと結合されて、GCが「もっともらしい」が実際には無効なポインタとして認識してしまう可能性がありました。このような「部分的なポインタ」は、GCの正確性を損ない、メモリリークやクラッシュなどの深刻な問題を引き起こす可能性があります。

このバグは、特に引数や結果フレーム(関数呼び出しのスタックフレーム)で time.Time 値が扱われる際に顕在化し、GCがスタック上のポインタをスキャンする際に誤った判断を下す原因となっていました。

前提知識の解説

このコミットを理解するためには、以下のGo言語およびコンピュータサイエンスの基本的な概念を理解しておく必要があります。

  1. Go言語のガベージコレクタ (GC):

    • GoのGCは、主に並行マーク&スイープ方式を採用しています。これは、プログラムの実行と並行してGCが動作し、アプリケーションの一時停止(ストップ・ザ・ワールド)時間を最小限に抑えることを目指しています。
    • GCは、メモリ上のオブジェクトがまだ参照されているかどうかを判断するために、ポインタを追跡します。ルート(グローバル変数、スタック上の変数など)から到達可能なオブジェクトは「生きている」と判断され、それ以外は「死んでいる」と判断されて解放されます。
    • GCがポインタを正確に識別できないと、誤って生きているオブジェクトを解放したり(Use-After-Free)、死んでいるオブジェクトを解放しなかったり(メモリリーク)する問題が発生します。
  2. ポインタとメモリレイアウト:

    • ポインタは、メモリ上の特定のアドレスを指す変数です。64ビットシステムでは、ポインタは通常64ビット(8バイト)のサイズを持ちます。
    • 構造体(struct)は、異なる型のフィールドをまとめた複合データ型です。構造体のフィールドは、メモリ上で連続して配置されますが、CPUの効率的なアクセスを可能にするために「アラインメント」という規則に従います。
    • アラインメント: CPUは、特定のデータ型が特定のメモリアドレスの倍数に配置されている場合に、最も効率的にデータを読み書きできます。例えば、64ビット整数は8バイト境界に、32ビット整数は4バイト境界に配置されることが一般的です。
    • パディング (Padding) / ギャップ (Gap): アラインメントの要件を満たすために、構造体のフィールド間に未使用のバイト(パディング)が挿入されることがあります。これにより、構造体の合計サイズが増加することがあります。例えば、[ptr int32] の後に ptr が続く場合、int32 の後に4バイトのパディングが挿入され、次の ptr が8バイト境界に配置されるように調整されることがあります。これが gap32 の正体です。
  3. uintptr:

    • Go言語の uintptr 型は、ポインタを保持するのに十分な大きさの符号なし整数型です。そのサイズは、システム上のポインタのサイズ(32ビットシステムでは32ビット、64ビットシステムでは64ビット)と同じです。
    • uintptr は、ポインタ演算を行う際や、C言語との相互運用、または低レベルのメモリ操作を行う際に使用されます。
    • 重要なのは、uintptr は「ポインタ」としてGCに認識されないという点です。GCは uintptr の値を単なる数値として扱い、それが指すメモリを追跡しません。これは、GCの正確性を確保するために意図された挙動です。開発者が uintptr をポインタとして使用する場合は、GCにそのポインタを認識させるための特別なメカニズム(例: runtime.KeepAlive)が必要になることがあります。
  4. エイリアシング (Aliasing):

    • プログラミングにおいて、エイリアシングとは、同じメモリ位置が複数の異なる名前や参照によってアクセスされる状況を指します。
    • このコミットの文脈では、gap32 のメモリ領域が、以前は有効なポインタの一部であったが、int32 の値が書き込まれることで、そのポインタの下位ビットが変更され、GCが誤って新しい「もっともらしい」ポインタとして認識してしまう状況を指します。

技術的詳細

このバグの核心は、Goの time.Time 構造体が amd64 アーキテクチャ上でどのようにメモリに配置されるか、そしてGoコンパイラがその構造体をどのようにコピーするかという点にありました。

元の time.Time 構造体は、以下のようなフィールドを持っていました(簡略化):

type Time struct {
    sec int64 // 秒数
    nsec int32 // ナノ秒
    loc *Location // タイムゾーン情報へのポインタ
}

amd64 システムでは、int64 は8バイト、int32 は4バイト、ポインタ (*Location) は8バイトです。アラインメントの要件により、int32 の後にパディングが発生していました。

具体的なメモリレイアウトは以下のようになっていました:

  1. sec (int64): 8バイト
  2. nsec (int32): 4バイト
  3. パディング (gap32): 4バイト (次の loc ポインタを8バイト境界にアラインするため)
  4. loc (*Location): 8バイト

したがって、Time 構造体のメモリレイアウトは概念的に [int64 | int32 | gap32 | *Location] となっていました。

問題は、コンパイラが Time 構造体をコピーする際に、各フィールドを個別にコピーする挙動にありました。例えば、ある Time 変数 t1 を別の Time 変数 t2 に代入する t2 = t1 のような操作が行われると、コンパイラは t1.sect2.sec に、t1.nsect2.nsec に、t1.loct2.loc にそれぞれコピーします。

ここで、もし t2 が以前に別のポインタを保持していたメモリ領域に配置されており、そのポインタの下位32ビットが t2.nsecint32 フィールドが書き込まれる領域と重なっていた場合、t2.nsec のコピーによって、そのポインタの下位32ビットが上書きされてしまいます。これにより、GCがスキャンする際に、元のポインタとは異なるが、依然として「もっともらしい」(つまり、有効なアドレス範囲内にあるように見える)ポインタが生成されてしまう可能性がありました。

GoのGCは、スタックやヒープ上のメモリをスキャンしてポインタを識別し、到達可能性を判断します。もしGCがこのような「部分的に変更されたポインタ」を有効なポインタとして誤認識してしまうと、実際には存在しないオブジェクトを追跡しようとしたり、誤ったメモリ領域を解放しようとしたりする可能性があります。これは、GCの正確性を損ない、プログラムの不安定性やクラッシュにつながります。

この問題を解決するために、コミットでは nsec フィールドの型を int32 から uintptr に変更しました。

type Time struct {
    sec int64
    nsec uintptr // 変更点: int32 から uintptr へ
    loc *Location
}

uintptr は64ビットシステムでは64ビット(8バイト)のサイズを持ちます。これにより、Time 構造体のメモリレイアウトは以下のように変化します:

  1. sec (int64): 8バイト
  2. nsec (uintptr): 8バイト
  3. loc (*Location): 8バイト

新しいレイアウトは [int64 | uintptr | *Location] となり、nsec フィールドがポインタと同じ8バイトのサイズを持つため、int32 の後に発生していた4バイトのパディング (gap32) がなくなります。

パディングがなくなることで、Time 構造体のコピー時に、以前ポインタを保持していたメモリ領域に nsec の値が書き込まれても、その領域全体が uintptr の値で上書きされるため、部分的なポインタが生成される可能性がなくなります。uintptr はGCによってポインタとして扱われないため、GCが誤って追跡しようとすることもありません。これにより、GCの正確性が保たれ、エイリアシングバグが回避されます。

コミットメッセージでは、「フィールドを再配置して int32 を最後に配置してもギャップが残る」と述べられています。これは、[ptr ptr int32 gap32] のように、構造体の合計サイズがポインタ幅(8バイト)にアラインされる必要があるため、最後の int32 の後にパディングが必要になることを意味します。uintptr に変更することで、このアラインメントの問題も同時に解決され、構造体全体がポインタ幅の倍数となり、パディングが不要になります。

この修正は、GoのGCがまだ「完全に正確なコレクション」ではない時期に行われたものであり、GCの進化の過程における重要なステップを示しています。将来的にはGCがより正確になり、このような低レベルのメモリレイアウトの考慮が不要になることが期待されていますが、この時点では uintptr を使用することが最も堅牢な解決策でした。

コアとなるコードの変更箇所

変更は src/pkg/time/time.go ファイルに集中しています。

  1. Time 構造体の nsec フィールドの型変更:

    • type Time struct { ... nsec int32 ... }
    • から
    • type Time struct { ... nsec uintptr ... }
    • へ変更されました。
  2. nsec フィールドへのアクセスと操作の修正:

    • nsecuintptr になったため、int32 として扱っていた箇所で型変換が必要になりました。
    • Add メソッド、Sub メソッド、Now 関数、GobDecode メソッド、Unix 関数、Date 関数、div 関数など、nsec を直接操作するすべての箇所で int32uintptr 間の明示的な型変換が追加されています。

コアとなるコードの解説

以下に、主要な変更箇所のコードと解説を示します。

Time 構造体の定義

--- a/src/pkg/time/time.go
+++ b/src/pkg/time/time.go
@@ -39,7 +39,14 @@ type Time struct {
  	// nsec specifies a non-negative nanosecond
  	// offset within the second named by Seconds.
  	// It must be in the range [0, 999999999].
-	nsec int32
+	//
+	// It is declared as uintptr instead of int32 or uint32
+	// to avoid garbage collector aliasing in the case where
+	// on a 64-bit system the int32 or uint32 field is written
+	// over the low half of a pointer, creating another pointer.
+	// TODO(rsc): When the garbage collector is completely
+	// precise, change back to int32.
+	nsec uintptr
  • 変更点: nsec フィールドの型が int32 から uintptr に変更されました。
  • 解説: これがこのコミットの最も重要な変更点です。uintptr はポインタと同じサイズ(64ビットシステムでは8バイト)を持つため、nsec の後にパディングが発生しなくなります。これにより、Time 構造体のメモリレイアウトが密になり、GCが誤ったポインタを認識する原因となっていた「ギャップ」が解消されます。コメントには、この変更がGCのエイリアシング問題を回避するためであること、そして将来的にGCが完全に正確になれば int32 に戻す可能性があることが明記されています。

Add メソッド

--- a/src/pkg/time/time.go
+++ b/src/pkg/time/time.go
@@ -605,14 +612,15 @@ func (d Duration) Hours() float64 {\
 // Add returns the time t+d.
 func (t Time) Add(d Duration) Time {\
  	t.sec += int64(d / 1e9)\
-	t.nsec += int32(d % 1e9)\
-	if t.nsec >= 1e9 {\
+	nsec := int32(t.nsec) + int32(d%1e9)\
+	if nsec >= 1e9 {\
  	\tt.sec++\
-	\tt.nsec -= 1e9\
-	} else if t.nsec < 0 {\
+	\tnsec -= 1e9\
+	} else if nsec < 0 {\
  	\tt.sec--\
-	\tt.nsec += 1e9\
+	\tnsec += 1e9\
  	}\
+\tt.nsec = uintptr(nsec)\
  	return t\
  }
  • 変更点: t.nsec の直接的な加算が、一時変数 nsec を介した操作に変更され、最後に uintptr に型変換して t.nsec に代入されています。
  • 解説: t.nsecuintptr 型になったため、int32 型の d % 1e9 を直接加算することはできません。そのため、t.nsecint32 に型変換し、d % 1e9 を加算して一時変数 nsec に格納します。ナノ秒の範囲チェックと秒数への繰り上がり/繰り下がりを行った後、最終的な nsec の値を uintptr に型変換して t.nsec に戻しています。これにより、nsec の値が uintptr として正しく保持されつつ、ナノ秒としての算術演算が int32 の精度で行われます。

Sub メソッド

--- a/src/pkg/time/time.go
+++ b/src/pkg/time/time.go
@@ -621,7 +629,7 @@ func (t Time) Add(d Duration) Time {\
 // will be returned.\
 // To compute t-d for a duration d, use t.Add(-d).\
 func (t Time) Sub(u Time) Duration {\
-	d := Duration(t.sec-u.sec)*Second + Duration(t.nsec-u.nsec)\
+	d := Duration(t.sec-u.sec)*Second + Duration(int32(t.nsec)-int32(u.nsec))\
  	// Check for overflow or underflow.\
  	switch {\
  	case u.Add(d).Equal(t):\
  • 変更点: t.nsec - u.nsec の部分が int32(t.nsec) - int32(u.nsec) に変更されました。
  • 解説: nsecuintptr 型であるため、直接減算すると uintptr としての減算が行われてしまいます。ナノ秒の差を計算するためには、両方の nsecint32 に型変換してから減算する必要があります。これにより、ナノ秒の差が正しく計算され、Duration 型に変換されます。

Now 関数

--- a/src/pkg/time/time.go
+++ b/src/pkg/time/time.go
@@ -776,7 +784,7 @@ func now() (sec int64, nsec int32)\
 // Now returns the current local time.\
 func Now() Time {\
  	sec, nsec := now()\
-	return Time{sec + unixToInternal, nsec, Local}\
+	return Time{sec + unixToInternal, uintptr(nsec), Local}\
  }
  • 変更点: Time 構造体の初期化時に、nsec の値が uintptr(nsec) と明示的に型変換されています。
  • 解説: now() 関数は nsecint32 として返しますが、Time 構造体の nsec フィールドは uintptr 型であるため、ここで型変換が必要です。

同様の型変換は、GobDecodeUnixDatediv といった nsec を扱う他の関数やメソッドでも行われています。これらの変更はすべて、nsec フィールドの型が int32 から uintptr に変更されたことに伴う、型安全性を保ちつつ正しい算術演算を行うための修正です。

関連リンク

  • Go Issue #5749: このコミットが修正したバグの元のIssue。コミットメッセージに記載されている https://code.google.com/p/go/issues/detail?id=5749 は、Goプロジェクトが以前使用していたGoogle CodeのIssueトラッカーへのリンクであり、現在はアーカイブされているか、別のIssueトラッカーに移行している可能性があります。現在の検索結果では、GoLand IDEのIssueトラッカー (JetBrains YouTrack) の GO-5749 がヒットしますが、これは本コミットとは無関係です。
  • Go CL 10504043: このコミットに対応するGoの変更リスト (Change List)。

参考にした情報源リンク

  • Go言語の公式ドキュメント (特に time パッケージ、uintptr 型、メモリモデルに関する記述)
  • Go言語のガベージコレクタに関する技術記事や論文 (GoのGCの仕組み、ポインタの識別など)
  • amd64 アーキテクチャにおけるデータアラインメントとメモリレイアウトに関する一般的な情報
  • GoのIssueトラッカーやメーリングリストでの関連議論 (特にIssue 5749)
  • Goのソースコード (特に runtime パッケージや src/pkg/time パッケージ)