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

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

このコミットは、Goランタイムにおけるデッドロック検出の誤検知(false deadlock crash)を修正するものです。具体的には、runtime/proc.c 内のスケジューラ関連のロジックが変更され、デッドロックの判定基準がより正確になるように調整されています。また、この修正を検証するための新しいTCPストレス テストが net/tcp_test.go に追加されています。

コミット

commit 1da1030b5dd9d9610ccada4413a23e77b21c7f3b
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Tue Aug 13 22:07:42 2013 +0400

    runtime: fix false deadlock crash
    Fixes #6070.
    Update #6055.
    
    R=golang-dev, nightlyone, rsc
    CC=golang-dev
    https://golang.org/cl/12602043

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

https://github.com/golang/go/commit/1da1030b5dd9d9610ccada4413a23e77b21c7f3b

元コミット内容

runtime: fix false deadlock crash
Fixes #6070.
Update #6055.

R=golang-dev, nightlyone, rsc
CC=golang-dev
https://golang.org/cl/12602043

変更の背景

このコミットは、Goランタイムが特定の条件下で誤ってデッドロックを検出し、プログラムをクラッシュさせてしまう問題("false deadlock crash")を修正するために行われました。コミットメッセージに記載されている #6070#6055 は、この問題に関連するGoのIssueトラッカーのエントリです。

  • Issue #6070: runtime: false deadlock detection with netpoll このIssueは、netpoll(ネットワークI/Oを処理するためのポーリングメカニズム)がアイドル状態のM(OSスレッド)を再利用しようとした際に、デッドロック検出ロジックが誤動作し、実際にはデッドロックが発生していないにもかかわらずプログラムがクラッシュするという問題が報告されています。特に、netpollがG(ゴルーチン)を注入する直前または直後に、別のMがシステムコールから戻り、作業がないと判断してデッドロックを報告してしまうシナリオが指摘されていました。

  • Issue #6055: runtime: deadlock detection is too aggressive このIssueは、より広範なデッドロック検出の積極性に関する議論を含んでおり、#6070はその具体的な症状の一つとして挙げられています。Goのランタイムは、すべてのゴルーチンがブロックされ、実行可能なゴルーチンが存在しない場合にデッドロックと判断してプログラムを終了させます。しかし、この検出ロジックが、一時的なアイドル状態や、システムコールからの復帰タイミングなど、特定の競合状態において誤った判断を下すことがありました。

これらの問題は、特にネットワークI/Oを多用するアプリケーションや、多数のゴルーチンが頻繁にシステムコールに出入りするような状況で顕在化し、Goプログラムの安定性に影響を与えていました。このコミットは、これらの誤検知を排除し、ランタイムの堅牢性を向上させることを目的としています。

前提知識の解説

このコミットの変更内容を理解するためには、Goランタイムのスケジューラとデッドロック検出に関する以下の概念を理解しておく必要があります。

Goランタイムスケジューラ (M, P, G)

Goのランタイムスケジューラは、ゴルーチン(G)、論理プロセッサ(P)、OSスレッド(M)という3つの主要なエンティティで構成されるGPMモデルを採用しています。

  • G (Goroutine): Goにおける軽量な実行単位です。関数呼び出しごとに作成され、スタックサイズが小さく、数百万個のゴルーチンを同時に実行できます。
  • P (Processor): 論理プロセッサであり、Goコードを実行するためのコンテキストを提供します。PはMにアタッチされ、Gを実行します。Pの数は通常、CPUコア数に設定され、並列実行の度合いを制御します。
  • M (Machine/OS Thread): オペレーティングシステムのスレッドです。MはPにアタッチされ、Pが持つ実行可能なGをOS上で実行します。Mはシステムコール(ネットワークI/O、ファイルI/Oなど)の実行中にブロックされることがあります。

スケジューリングの基本的な流れ:

  1. MはPからGを取得し、実行します。
  2. Gがシステムコール(例: ネットワークI/O)を実行すると、Mはブロックされます。このとき、Pは別のMに引き渡されるか、新しいMが作成されてPにアタッチされ、他のGの実行を継続します。
  3. システムコールが完了すると、Mはブロック状態から解放され、実行可能な状態に戻ります。このMは、利用可能なPにアタッチされ、Gの実行を再開するか、アイドル状態になります。

デッドロック検出

Goランタイムは、プログラムがデッドロック状態に陥ったことを検出するメカニズムを持っています。デッドロックとは、すべてのゴルーチンがブロックされており、かつ、どのゴルーチンも今後実行可能になる見込みがない状態を指します。このような状態を検出すると、Goランタイムはプログラムをクラッシュさせ、デッドロックが発生したことをユーザーに通知します。

デッドロック検出の基本的なロジックは、以下の要素を監視することに基づいています。

  • 実行中のMの数: 実際にGoコードを実行しているOSスレッドの数。
  • アイドル状態のMの数: 作業がなく、PにアタッチされていないOSスレッドの数。
  • ロックされたMの数: 特定のゴルーチンに紐付けられ、システムコールなどでブロックされているOSスレッドの数。
  • 実行可能なGの数: 実行キューに存在する、または実行中のゴルーチンの数。

ランタイムは、これらのカウンタを基に、すべてのGがブロックされ、かつ、実行可能なGが存在しない状態を「デッドロック」と判断します。しかし、システムコールからの復帰やネットワークポーリングによるGの注入など、一時的にすべてのMがアイドル状態に見えるような状況で、誤ってデッドロックと判断してしまう「誤検知」が発生することがありました。

netpoll

netpollは、GoのネットワークI/Oを効率的に処理するためのメカニズムです。これは、OSの提供する非同期I/O機能(Linuxのepoll、macOSのkqueueなど)を利用して、複数のネットワーク接続からのイベントを効率的に監視します。netpollは、ネットワークイベントが発生した際に、対応するゴルーチンを「実行可能」状態に戻し、スケジューラに注入する役割を担います。

技術的詳細

このコミットの主要な変更点は、Goランタイムのデッドロック検出ロジックにおける「アイドル状態のロックされたM(OSスレッド)の数」の管理方法の改善です。

以前のデッドロック検出ロジックでは、runtime·sched.mlocked というカウンタが使用されていました。このカウンタは、特定のゴルーチンに紐付けられ、システムコールなどでブロックされているMの数を追跡していました。しかし、このカウンタの更新タイミングが不適切であったため、特にnetpollがゴルーチンを注入する際や、Mがシステムコールから復帰する際に、一時的にすべてのMがアイドル状態であると誤認され、デッドロックが誤検知される可能性がありました。

このコミットでは、以下の変更が行われています。

  1. mlocked から nmidlelocked への名称変更と意味の明確化:

    • runtime·sched.mlocked というフィールドが runtime·sched.nmidlelocked に変更されました。
    • これに伴い、関連する関数 inclockedincidlelocked に変更されました。
    • この名称変更は、このカウンタが「ロックされたMの総数」ではなく、「アイドル状態のロックされたMの数」を追跡していることをより明確にするためのものです。つまり、システムコールなどでブロックされているが、まだ作業を再開していないMの数を数えるようになりました。
  2. incidlelocked の呼び出しタイミングの調整:

    • stoplockedm (ロックされたMを停止させる関数) や startlockedm (ロックされたMを開始させる関数) における incidlelocked の呼び出しは、Mがアイドル状態になったり、アイドル状態から復帰したりするタイミングで適切にカウンタを増減するように調整されました。
    • 特に重要なのは、sysmon (システムモニタゴルーチン) 内の netpoll 呼び出し後の処理です。netpollが実行可能なゴルーチンを返した場合、そのゴルーチンがスケジューラに注入される前に incidlelocked(-1) が呼び出され、一時的にアイドル状態のロックされたMの数を減らします。これにより、netpollがゴルーチンを注入し、Mがそのゴルーチンを実行するまでの短い期間に、デッドロック検出が誤って発動するのを防ぎます。ゴルーチンが注入された後、incidlelocked(1) が呼び出され、カウンタが元に戻されます。これは、netpollがGを注入した直後に、別のMがシステムコールから戻り、作業がないと判断してデッドロックを報告する状況を回避するためのものです。
    • 同様に、retake (システムコールでブロックされているPを再取得する関数) においても、Pを再取得する直前に incidlelocked(-1) を呼び出し、再取得後に incidlelocked(1) を呼び出すことで、デッドロック検出の誤検知を防いでいます。これは、Pを再取得するMが、システムコールから戻り、nmidle をインクリメントしてデッドロックを報告する可能性を考慮したものです。
  3. デッドロック検出ロジックの修正:

    • checkdead 関数内のデッドロック判定ロジックが、runtime·sched.mlocked の代わりに runtime·sched.nmidlelocked を使用するように変更されました。これにより、デッドロックの判断がより正確な「アイドル状態のロックされたM」の数に基づいて行われるようになります。

これらの変更により、Goランタイムは、一時的なMのアイドル状態や、netpollによるゴルーチンの注入、システムコールからのMの復帰といった正常な動作中に、誤ってデッドロックと判断してクラッシュする可能性が大幅に低減されました。

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

src/pkg/net/tcp_test.go

--- a/src/pkg/net/tcp_test.go
+++ b/src/pkg/net/tcp_test.go
@@ -494,3 +494,78 @@ func TestTCPReadWriteMallocs(t *testing.T) {
 		t.Fatalf("Got %v allocs, want %v", mallocs, maxMallocs)
 	}
 }
+
+func TestTCPStress(t *testing.T) {
+	const conns = 2
+	const msgs = 1e4
+	const msgLen = 512
+
+	sendMsg := func(c Conn, buf []byte) bool {
+		n, err := c.Write(buf)
+		if n != len(buf) || err != nil {
+			t.Logf("Write failed: %v", err)
+			return false
+		}
+		return true
+	}
+	recvMsg := func(c Conn, buf []byte) bool {
+		for read := 0; read != len(buf); {
+			n, err := c.Read(buf)
+			read += n
+			if err != nil {
+				t.Logf("Read failed: %v", err)
+				return false
+			}
+		}
+		return true
+	}
+
+	ln, err := Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Listen failed: %v", err)
+	}
+	defer ln.Close()
+	// Acceptor.
+	go func() {
+		for {
+			c, err := ln.Accept()
+			if err != nil {
+				break
+			}
+			// Server connection.
+			go func(c Conn) {
+				defer c.Close()
+				var buf [msgLen]byte
+				for m := 0; m < msgs; m++ {
+					if !recvMsg(c, buf[:]) || !sendMsg(c, buf[:]) {
+						break
+					}
+				}
+			}(c)
+		}
+	}()
+	done := make(chan bool)
+	for i := 0; i < conns; i++ {
+		// Client connection.
+		go func() {
+			defer func() {
+				done <- true
+			}()
+			c, err := Dial("tcp", ln.Addr().String())
+			if err != nil {
+				t.Logf("Dial failed: %v", err)
+				return
+			}
+			defer c.Close()
+			var buf [msgLen]byte
+			for m := 0; m < msgs; m++ {
+				if !sendMsg(c, buf[:]) || !recvMsg(c, buf[:]) {
+					break
+				}
+			}
+		}()
+	}
+	for i := 0; i < conns; i++ {
+		<-done
+	}
+}

src/pkg/runtime/proc.c

--- a/src/pkg/runtime/proc.c
+++ b/src/pkg/runtime/proc.c
@@ -30,7 +30,7 @@ struct Sched {
 
 	M*	midle;	 // idle m's waiting for work
 	int32	nmidle;	 // number of idle m's waiting for work
-	int32	mlocked; // number of locked m's waiting for work
+	int32	nmidlelocked; // number of locked m's waiting for work
 	int32	mcount;	 // number of m's that have been created
 
 	P*	pidle;  // idle P's
@@ -95,7 +95,7 @@ static void stoplockedm(void);
 static void startlockedm(G*);
 static void sysmon(void);
 static uint32 retake(int64);
-static void inclocked(int32);
+static void incidlelocked(int32);
 static void checkdead(void);
 static void exitsyscall0(G*);
 static void park0(G*);
@@ -1019,7 +1019,7 @@ stoplockedm(void)\n 		p = releasep();\n 		handoffp(p);\n 	}\n-	inclocked(1);\n+	incidlelocked(1);\n 	// Wait until another thread schedules lockedg again.\n 	runtime·notesleep(&m->park);\n 	runtime·noteclear(&m->park);\
@@ -1042,7 +1042,7 @@ startlockedm(G *gp)\n 	if(mp->nextp)\n 		runtime·throw("startlockedm: m has p");\n 	// directly handoff current P to the locked m\n-	inclocked(-1);\n+	incidlelocked(-1);\n 	p = releasep();\n 	mp->nextp = p;\n 	runtime·notewakeup(&mp->park);\
@@ -1485,7 +1485,7 @@ void\n 	p = releasep();\n 	handoffp(p);\n 	if(g->isbackground)  // do not consider blocked scavenger for deadlock detection\n-		inclocked(1);\n+		incidlelocked(1);\
 \n 	// Resave for traceback during blocked call.\n 	save(runtime·getcallerpc(&dummy), runtime·getcallersp(&dummy));\
@@ -1505,7 +1505,7 @@ runtime·exitsyscall(void)\n 	m->locks++;  // see comment in entersyscall\n \n 	if(g->isbackground)  // do not consider blocked scavenger for deadlock detection\n-		inclocked(-1);\n+		incidlelocked(-1);\
 \n 	if(exitsyscallfast()) {\n 		// There's a cpu for us, so we can run.\
@@ -2159,10 +2159,10 @@ releasep(void)\n }\n \n static void\n-inclocked(int32 v)\n+incidlelocked(int32 v)\n {\n 	runtime·lock(&runtime·sched);\n-	runtime·sched.mlocked += v;\
+	runtime·sched.nmidlelocked += v;\
 	if(v > 0)\n 		checkdead();\n 	runtime·unlock(&runtime·sched);\
@@ -2177,12 +2177,12 @@ checkdead(void)\n 	int32 run, grunning, s;\n \n 	// -1 for sysmon\n-	run = runtime·sched.mcount - runtime·sched.nmidle - runtime·sched.mlocked - 1;\
+	run = runtime·sched.mcount - runtime·sched.nmidle - runtime·sched.nmidlelocked - 1;\
 	if(run > 0)\n 		return;\n 	if(run < 0) {\n-		runtime·printf("checkdead: nmidle=%d mlocked=%d mcount=%d\\n",\n-			runtime·sched.nmidle, runtime·sched.mlocked, runtime·sched.mcount);\
+		runtime·printf("checkdead: nmidle=%d nmidlelocked=%d mcount=%d\\n",\n+			runtime·sched.nmidle, runtime·sched.nmidlelocked, runtime·sched.mcount);\
 		runtime·throw("checkdead: inconsistent counts");\
 	}\n 	grunning = 0;\
@@ -2238,7 +2238,18 @@ sysmon(void)\n 		if(lastpoll != 0 && lastpoll + 10*1000*1000 > now) {\n 			runtime·cas64(&runtime·sched.lastpoll, lastpoll, now);\n 			gp = runtime·netpoll(false);  // non-blocking\n-			injectglist(gp);\
+			if(gp) {\n+				// Need to decrement number of idle locked M's\n+				// (pretending that one more is running) before injectglist.\n+				// Otherwise it can lead to the following situation:\n+				// injectglist grabs all P's but before it starts M's to run the P's,\n+				// another M returns from syscall, finishes running its G,\n+				// observes that there is no work to do and no other running M's\n+				// and reports deadlock.\n+				incidlelocked(-1);\n+				injectglist(gp);\n+				incidlelocked(1);\
+			}\n 		}\n 		// retake P's blocked in syscalls\n 		// and preempt long running G's\
@@ -2284,15 +2295,16 @@ retake(int64 now)\n 			if(p->runqhead == p->runqtail &&\n 				runtime·atomicload(&runtime·sched.nmspinning) + runtime·atomicload(&runtime·sched.npidle) > 0)\n 				continue;\n-			// Need to increment number of locked M's before the CAS.\n+			// Need to decrement number of idle locked M's\n+			// (pretending that one more is running) before the CAS.\n 			// Otherwise the M from which we retake can exit the syscall,\n 			// increment nmidle and report deadlock.\n-			inclocked(-1);\n+			incidlelocked(-1);\n 			if(runtime·cas(&p->status, s, Pidle)) {\n 				n++;\n 				handoffp(p);\n 			}\n-			inclocked(1);\n+			incidlelocked(1);\
 		} else if(s == Prunning) {\n 			// Preempt G if it's running for more than 10ms.\n 			if(pd->when + 10*1000*1000 > now)\

コアとなるコードの解説

src/pkg/net/tcp_test.go の変更

このファイルには、TestTCPStress という新しいテスト関数が追加されています。これは、Goランタイムのデッドロック検出ロジックの堅牢性を検証するためのストレス テストです。

  • 目的: 複数のTCP接続を同時に確立し、大量のメッセージを送受信することで、ランタイムスケジューラとネットワークI/O処理に高い負荷をかけます。これにより、以前のデッドロック誤検知が発生しやすい競合状態を再現し、修正が正しく機能していることを確認します。
  • テスト内容:
    • conns (接続数) と msgs (メッセージ数)、msgLen (メッセージ長) を定数として定義しています。
    • sendMsgrecvMsg というヘルパー関数が定義されており、それぞれTCP接続でのメッセージ送信と受信を処理します。
    • TCPリスナー (ln) を起動し、Acceptorゴルーチンを起動します。このAcceptorは、新しい接続を受け入れるたびに、その接続を処理する新しいサーバーゴルーチンを起動します。サーバーゴルーチンは、クライアントからメッセージを受信し、同じメッセージを返送するエコーサーバーとして機能します。
    • 複数のクライアントゴルーチンを起動します。各クライアントゴルーチンは、サーバーに接続し、定義された回数 (msgs) だけメッセージを送信し、その応答を受信します。
    • done チャネルを使用して、すべてのクライアントゴルーチンが完了するのを待ちます。
  • 重要性: このテストは、ランタイムの修正が実世界のネットワークI/Oパターンにおいて、デッドロックの誤検知を引き起こさないことを保証するために不可欠です。特に、多数のゴルーチンが同時にネットワークI/Oを待機し、完了するような状況は、スケジューラの負荷が高まり、デッドロック検出ロジックが試される典型的なシナリオです。

src/pkg/runtime/proc.c の変更

このファイルはGoランタイムのスケジューラの中核部分であり、M、P、Gの管理、システムコール処理、デッドロック検出などが行われます。

  1. Sched 構造体の変更:

    • struct Sched 内の mlocked フィールドが nmidlelocked に変更されました。
      • int32 mlocked; // number of locked m's waiting for work
      • int32 nmidlelocked; // number of locked m's waiting for work
    • この変更は、カウンタのセマンティクスをより正確に反映するためのものです。これは単に「ロックされたMの数」ではなく、「アイドル状態のロックされたMの数」を意味します。
  2. 関数名の変更:

    • static void inclocked(int32);static void incidlelocked(int32); に変更されました。
    • これに伴い、inclocked を呼び出すすべての箇所が incidlelocked に変更されています。
  3. stoplockedm および startlockedm の変更:

    • これらの関数は、ロックされたMがアイドル状態になったり、アイドル状態から復帰したりする際に incidlelocked を呼び出すように変更されました。これは、Mの状態変化と nmidlelocked カウンタの同期を正確に行うためです。
  4. runtime·exitsyscall の変更:

    • システムコールから戻る際に、ゴルーチンがバックグラウンド(例: スカベンジャー)である場合、incidlelocked(-1) が呼び出されます。これは、システムコールから戻ったMが、デッドロック検出の対象から一時的に除外されるべきであることを示します。
  5. inclocked / incidlelocked 関数の実装変更:

    • 関数名が inclocked から incidlelocked に変更され、内部で runtime·sched.mlocked ではなく runtime·sched.nmidlelocked を更新するように修正されました。
    • checkdead() の呼び出しは、nmidlelocked が増加した場合(つまり、アイドル状態のロックされたMが増えた場合)にのみ行われます。これは、デッドロックの可能性が高まる状況でのみチェックを行うことで、不要なチェックを減らすためです。
  6. checkdead 関数の変更:

    • デッドロック検出の主要なロジックが含まれる checkdead 関数内で、実行中のMの数を計算する際に runtime·sched.mlocked の代わりに runtime·sched.nmidlelocked が使用されるようになりました。
      • run = runtime·sched.mcount - runtime·sched.nmidle - runtime·sched.mlocked - 1;
      • run = runtime·sched.mcount - runtime·sched.nmidle - runtime·sched.nmidlelocked - 1;
    • デバッグ出力のメッセージも mlocked から nmidlelocked に変更されています。
  7. sysmon 関数の変更 (最も重要):

    • sysmon は、Goランタイムのバックグラウンドで動作し、スケジューラの健全性を監視し、必要に応じてPの再取得やゴルーチンのプリエンプションなどを行うシステムモニタゴルーチンです。
    • netpoll が非ブロッキングモードで呼び出され、実行可能なゴルーチン (gp) を返した場合の処理が変更されました。
      • 以前は injectglist(gp); が直接呼び出されていました。
      • 変更後: if(gp) ブロックが追加され、その中で以下の処理が行われます。
        • incidlelocked(-1);: injectglist が呼び出される前に、一時的にアイドル状態のロックされたMの数を減らします。これは、injectglist がPを確保し、Mがゴルーチンを実行するまでの短い期間に、別のMがシステムコールから戻ってデッドロックを誤検知するシナリオを防ぐためです。
        • injectglist(gp);: 実行可能なゴルーチンをスケジューラに注入します。
        • incidlelocked(1);: injectglist の後で、カウンタを元に戻します。
    • この変更は、netpoll がゴルーチンを注入する際に発生する可能性のある競合状態を正確に処理し、デッドロックの誤検知を防ぐための核心的な修正です。
  8. retake 関数の変更:

    • システムコールでブロックされているPを再取得する (retake) 際にも、同様の incidlelocked の調整が行われました。
    • Pを再取得する直前に incidlelocked(-1) を呼び出し、Pのステータスが Pidle に変更され、handoffp(p) が呼び出された後に incidlelocked(1) を呼び出します。
    • この調整は、Pを再取得するMが、システムコールから戻り、nmidle をインクリメントしてデッドロックを報告する可能性を考慮したものです。

これらの変更は、Goランタイムのスケジューラが、Mの状態(特にシステムコールでブロックされているM)をより正確に追跡し、デッドロック検出ロジックが一時的なアイドル状態を誤ってデッドロックと判断しないようにするためのものです。これにより、Goプログラムの安定性と信頼性が向上します。

関連リンク

参考にした情報源リンク