[インデックス 17201] ファイルの概要
このコミットは、Goランタイムにおける LockOSThread
の実装に関するバグ修正です。具体的には、runtime.LockOSThread
および runtime.UnlockOSThread
の内部ヘルパー関数がプリエンプション(横取り)される可能性があり、それによって m
(Machine) ポインタが変更され、不正な状態になる問題を解決します。
コミット
このコミットは、Goランタイムの proc.c
ファイルにおける LockOSThread
および UnlockOSThread
の実装を修正し、これらの関数が実行中にプリエンプションされないようにすることで、m
ポインタの整合性を保証します。
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/4961483e7d8e0edf5211ab9f92aa010a6f74b59d
元コミット内容
runtime: fix LockOSThread
Fixes #6100.
R=golang-dev, dave, bradfitz, rsc
CC=golang-dev
https://golang.org/cl/12703045
変更の背景
このコミットは、GoのIssue #6100「runtime: LockOSThread can be preempted」で報告された問題を修正するために行われました。
Goのランタイムには、特定のGoルーチンをOSスレッドに「ロック」する機能 (runtime.LockOSThread
) があります。これは、Goルーチンが常に同じOSスレッドで実行されることを保証するために使用されます。例えば、Cgoを介してOSの特定のスレッドアフィニティを持つAPIを呼び出す場合や、OpenGLなどのGUIライブラリを使用する場合に必要となることがあります。
問題は、runtime.LockOSThread
および runtime.UnlockOSThread
の内部実装が、Goスケジューラによってプリエンプションされる可能性があったことです。これらの関数は、現在の m
(Machine、OSスレッドを表すランタイム構造体) の状態を変更します。もし関数が実行中にプリエンプションされ、別の m
に切り替わってしまうと、m->locked
のような重要なフィールドが誤った m
に対して操作され、ランタイムが不正な状態に陥る可能性がありました。これは、特に m->locked
のようなクリティカルな状態変数を操作する際に、アトミックな操作が保証されないことによる競合状態の一種です。
このバグは、GoルーチンがOSスレッドにロックされているにもかかわらず、そのGoルーチンが別のOSスレッドに移動してしまうという、LockOSThread
の意図に反する動作を引き起こす可能性がありました。
前提知識の解説
Goランタイムスケジューラ (M, P, G)
Goのランタイムは、独自のスケジューラを持っており、Goルーチン(G)、論理プロセッサ(P)、OSスレッド(M)という3つの主要なエンティティで構成されています。
- G (Goroutine): Goプログラム内で実行される軽量な並行処理単位です。ユーザーが
go
キーワードを使って作成するものです。 - P (Processor): 論理プロセッサを表します。Goルーチンを実行するためのコンテキストを提供し、MとGの間の仲介役となります。Pの数は通常、CPUコアの数に設定されます (
GOMAXPROCS
)。 - M (Machine): OSスレッドを表します。Pにアタッチされ、Pが実行するGoルーチンを実際にOS上で実行します。MはOSのスケジューラによって管理されます。
Goスケジューラは、GをPにディスパッチし、PはM上でGを実行します。通常、Goルーチンは特定のMに固定されず、必要に応じて異なるMに移動することができます。これにより、効率的なリソース利用と並行処理が実現されます。
runtime.LockOSThread
と runtime.UnlockOSThread
runtime.LockOSThread()
は、呼び出し元のGoルーチンを現在のOSスレッド (M) にロックします。これにより、そのGoルーチンは、runtime.UnlockOSThread()
が呼び出されるまで、常に同じOSスレッド上で実行されることが保証されます。
この機能は、以下のような特定のシナリオで必要とされます。
- CgoとOSスレッドアフィニティ: Cライブラリの中には、特定のスレッドでしか呼び出せない関数や、スレッドローカルストレージに依存する関数があります。このような場合、GoルーチンがOSスレッド間で移動すると問題が発生するため、
LockOSThread
を使用してGoルーチンをOSスレッドに固定します。 - GUIアプリケーション: OpenGLやDirectXなどのグラフィックスAPIは、通常、特定のOSスレッド(メインスレッドなど)からの呼び出しを要求します。GoでGUIアプリケーションを開発する際に、この制約を満たすために
LockOSThread
が使用されることがあります。
プリエンプション (Preemption)
Goランタイムスケジューラは、Goルーチンが長時間CPUを占有するのを防ぐために、プリエンプションメカニズムを持っています。これは、Goルーチンが一定時間以上実行された場合、または特定のポイント(関数呼び出しなど)で、スケジューラがそのGoルーチンの実行を中断し、別のGoルーチンにCPUを割り当てることを意味します。
プリエンプションは通常、Goルーチンの公平な実行を保証し、レイテンシを低減するために重要ですが、ランタイムの内部状態を操作するようなクリティカルなセクションでは、予期せぬプリエンプションが競合状態やデータ破損を引き起こす可能性があります。
#pragma textflag NOSPLIT
これはGoコンパイラに対するディレクティブで、特定の関数がスタックを分割しないように指示します。Goの関数は通常、必要に応じてスタックを動的に拡張するために、プロローグでスタックのチェックと拡張を行います。しかし、NOSPLIT
が指定された関数は、スタックチェックを行わず、スタックを拡張しません。これは、非常に短い、アトミックな操作を行う関数や、ランタイムの低レベルな部分で、スタックのオーバーヘッドを避けたい場合や、プリエンプションポイントを作りたくない場合に使用されます。
NOSPLIT
は、関数が実行中にプリエンプションされる可能性を減らす効果があります。なぜなら、Goスケジューラは通常、関数呼び出しのプロローグや特定の安全なポイントでプリエンプションを挿入するため、NOSPLIT
はこれらのポイントを排除するからです。
技術的詳細
このコミットの核心は、runtime.LockOSThread
および runtime.UnlockOSThread
の内部ヘルパー関数が、実行中にGoスケジューラによってプリエンプションされることを防ぐことです。
元の実装では、runtime·LockOSThread
と runtime·lockOSThread
が直接 LockOSThread()
を呼び出し、runtime·UnlockOSThread
と runtime·unlockOSThread
が直接 UnlockOSThread()
を呼び出していました。これらの内部関数 LockOSThread()
と UnlockOSThread()
は、m->locked
フィールドを操作していました。
問題は、これらの内部関数がプリエンプションされる可能性があり、その結果、m
ポインタが変更されてしまうと、m->locked
の操作が意図しないOSスレッドに対して行われる可能性があったことです。これは、GoルーチンがOSスレッドにロックされているという前提を崩し、ランタイムの整合性を損なう可能性がありました。
この修正では、以下の変更が行われました。
-
内部ヘルパー関数のリネームと静的化:
LockOSThread
がlockOSThread
にリネームされ、static
キーワードが追加されました。UnlockOSThread
がunlockOSThread
にリネームされ、static
キーワードが追加されました。 これにより、これらの関数はファイルスコープに限定され、外部から直接呼び出されることがなくなりました。
-
#pragma textflag NOSPLIT
の追加:- 新しくリネームされた
lockOSThread
とunlockOSThread
関数に#pragma textflag NOSPLIT
ディレクティブが追加されました。 - このディレクティブにより、これらの関数はスタックの分割を行わず、コンパイラがプリエンプションポイントを挿入するのを防ぎます。これにより、これらの関数が実行されている間は、Goスケジューラによるプリエンプションが発生しなくなり、
m
ポインタの整合性が保証されます。
- 新しくリネームされた
この変更により、runtime.LockOSThread
や runtime.UnlockOSThread
が呼び出された際に、m->locked
の操作がアトミックに、かつ現在の正しい m
に対して行われることが保証されます。これにより、GoルーチンがOSスレッドにロックされているという状態が、ランタイムレベルで確実に維持されるようになります。
コアとなるコードの変更箇所
src/pkg/runtime/proc.c
ファイルが変更されています。
--- a/src/pkg/runtime/proc.c
+++ b/src/pkg/runtime/proc.c
@@ -1871,8 +1871,12 @@ runtime·gomaxprocsfunc(int32 n)
return ret;
}
+// lockOSThread is called by runtime.LockOSThread and runtime.lockOSThread below
+// after they modify m->locked. Do not allow preemption during this call,
+// or else the m might be different in this function than in the caller.
+#pragma textflag NOSPLIT
static void
-LockOSThread(void)
+lockOSThread(void)
{
m->lockedg = g;
g->lockedm = m;
@@ -1882,18 +1886,23 @@ void
runtime·LockOSThread(void)
{
m->locked |= LockExternal;
- LockOSThread();
+ lockOSThread();
}
void
runtime·lockOSThread(void)
{
m->locked += LockInternal;
- LockOSThread();
+ lockOSThread();
}
+// unlockOSThread is called by runtime.UnlockOSThread and runtime.unlockOSThread below
+// after they update m->locked. Do not allow preemption during this call,
+// or else the m might be in different in this function than in the caller.
+#pragma textflag NOSPLIT
static void
-UnlockOSThread(void)
+unlockOSThread(void)
{
if(m->locked != 0)
return;
@@ -1905,7 +1914,7 @@ void
runtime·UnlockOSThread(void)
{
m->locked &= ~LockExternal;
- UnlockOSThread();
+ unlockOSThread();
}
void
@@ -1914,7 +1923,7 @@ runtime·unlockOSThread(void)
if(m->locked < LockInternal)
runtime·throw("runtime: internal error: misuse of lockOSThread/unlockOSThread");
m->locked -= LockInternal;
- UnlockOSThread();
+ unlockOSThread();
}
bool
コアとなるコードの解説
-
LockOSThread
からlockOSThread
への変更:- 元の
LockOSThread
関数がlockOSThread
に名前が変更され、static
キーワードが追加されました。これにより、この関数はproc.c
ファイル内でのみ可視となり、外部からの直接アクセスが不可能になります。 - 重要な変更は、この関数の定義の直前に
#pragma textflag NOSPLIT
が追加されたことです。これは、Goコンパイラに対して、この関数がスタックを分割しないように指示します。結果として、この関数が実行されている間は、Goスケジューラによるプリエンプションが発生しなくなります。これにより、m->lockedg = g;
やg->lockedm = m;
といったm
とg
の間のロック状態を設定する操作が、プリエンプションによって中断されることなく、アトミックに実行されることが保証されます。
- 元の
-
UnlockOSThread
からunlockOSThread
への変更:- 同様に、元の
UnlockOSThread
関数がunlockOSThread
に名前が変更され、static
キーワードが追加されました。 - この関数にも
#pragma textflag NOSPLIT
が追加されています。これにより、m->locked
の状態を解除する操作が、プリエンプションによって中断されることなく、アトミックに実行されることが保証されます。
- 同様に、元の
-
呼び出し元の変更:
runtime·LockOSThread
、runtime·lockOSThread
、runtime·UnlockOSThread
、runtime·unlockOSThread
の各関数内で、以前は直接LockOSThread()
やUnlockOSThread()
を呼び出していた箇所が、それぞれ新しいlockOSThread()
やunlockOSThread()
の呼び出しに修正されています。- これらの
runtime·
プレフィックスを持つ関数は、Go言語からCgoを介して呼び出されるランタイム関数であり、GoルーチンがOSスレッドをロック/アンロックするためのエントリポイントとなります。これらの関数自体はプリエンプションされる可能性がありますが、内部で呼び出すlockOSThread
およびunlockOSThread
がNOSPLIT
によって保護されるため、m->locked
の操作自体は安全に行われます。
この修正により、runtime.LockOSThread
および runtime.UnlockOSThread
の内部処理が、Goスケジューラのプリエンプションから保護され、m
ポインタの整合性が維持されるようになりました。これにより、GoルーチンがOSスレッドにロックされるという保証が、より堅牢なものとなります。
関連リンク
- Go Issue #6100: https://github.com/golang/go/issues/6100
- Go CL 12703045: https://golang.org/cl/12703045
参考にした情報源リンク
- GoのM, P, Gモデルに関するドキュメントや記事
- Goのプリエンプションに関するドキュメントや記事
#pragma textflag NOSPLIT
に関するGoコンパイラのドキュメントや関連する議論- Goのソースコード (
src/pkg/runtime/proc.c
の関連部分) - GoのIssueトラッカー (特に #6100 の議論)
- Goのコードレビューシステム (CL 12703045 の議論)