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

[インデックス 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.LockOSThreadruntime.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·LockOSThreadruntime·lockOSThread が直接 LockOSThread() を呼び出し、runtime·UnlockOSThreadruntime·unlockOSThread が直接 UnlockOSThread() を呼び出していました。これらの内部関数 LockOSThread()UnlockOSThread() は、m->locked フィールドを操作していました。

問題は、これらの内部関数がプリエンプションされる可能性があり、その結果、m ポインタが変更されてしまうと、m->locked の操作が意図しないOSスレッドに対して行われる可能性があったことです。これは、GoルーチンがOSスレッドにロックされているという前提を崩し、ランタイムの整合性を損なう可能性がありました。

この修正では、以下の変更が行われました。

  1. 内部ヘルパー関数のリネームと静的化:

    • LockOSThreadlockOSThread にリネームされ、static キーワードが追加されました。
    • UnlockOSThreadunlockOSThread にリネームされ、static キーワードが追加されました。 これにより、これらの関数はファイルスコープに限定され、外部から直接呼び出されることがなくなりました。
  2. #pragma textflag NOSPLIT の追加:

    • 新しくリネームされた lockOSThreadunlockOSThread 関数に #pragma textflag NOSPLIT ディレクティブが追加されました。
    • このディレクティブにより、これらの関数はスタックの分割を行わず、コンパイラがプリエンプションポイントを挿入するのを防ぎます。これにより、これらの関数が実行されている間は、Goスケジューラによるプリエンプションが発生しなくなり、m ポインタの整合性が保証されます。

この変更により、runtime.LockOSThreadruntime.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

コアとなるコードの解説

  1. LockOSThread から lockOSThread への変更:

    • 元の LockOSThread 関数が lockOSThread に名前が変更され、static キーワードが追加されました。これにより、この関数は proc.c ファイル内でのみ可視となり、外部からの直接アクセスが不可能になります。
    • 重要な変更は、この関数の定義の直前に #pragma textflag NOSPLIT が追加されたことです。これは、Goコンパイラに対して、この関数がスタックを分割しないように指示します。結果として、この関数が実行されている間は、Goスケジューラによるプリエンプションが発生しなくなります。これにより、m->lockedg = g;g->lockedm = m; といった mg の間のロック状態を設定する操作が、プリエンプションによって中断されることなく、アトミックに実行されることが保証されます。
  2. UnlockOSThread から unlockOSThread への変更:

    • 同様に、元の UnlockOSThread 関数が unlockOSThread に名前が変更され、static キーワードが追加されました。
    • この関数にも #pragma textflag NOSPLIT が追加されています。これにより、m->locked の状態を解除する操作が、プリエンプションによって中断されることなく、アトミックに実行されることが保証されます。
  3. 呼び出し元の変更:

    • runtime·LockOSThreadruntime·lockOSThreadruntime·UnlockOSThreadruntime·unlockOSThread の各関数内で、以前は直接 LockOSThread()UnlockOSThread() を呼び出していた箇所が、それぞれ新しい lockOSThread()unlockOSThread() の呼び出しに修正されています。
    • これらの runtime· プレフィックスを持つ関数は、Go言語からCgoを介して呼び出されるランタイム関数であり、GoルーチンがOSスレッドをロック/アンロックするためのエントリポイントとなります。これらの関数自体はプリエンプションされる可能性がありますが、内部で呼び出す lockOSThread および unlockOSThreadNOSPLIT によって保護されるため、m->locked の操作自体は安全に行われます。

この修正により、runtime.LockOSThread および runtime.UnlockOSThread の内部処理が、Goスケジューラのプリエンプションから保護され、m ポインタの整合性が維持されるようになりました。これにより、GoルーチンがOSスレッドにロックされるという保証が、より堅牢なものとなります。

関連リンク

参考にした情報源リンク

  • GoのM, P, Gモデルに関するドキュメントや記事
  • Goのプリエンプションに関するドキュメントや記事
  • #pragma textflag NOSPLIT に関するGoコンパイラのドキュメントや関連する議論
  • Goのソースコード (src/pkg/runtime/proc.c の関連部分)
  • GoのIssueトラッカー (特に #6100 の議論)
  • Goのコードレビューシステム (CL 12703045 の議論)