これはGoクイズ Advent Calendar 2020の12日目の穴埋め記事です。

次のコードを実行するとどうなるでしょう。

package main

import (
	"runtime"
	"sync"
)

func main() {
	runtime.GOMAXPROCS(1)

	wg := sync.WaitGroup{}
	wg.Add(1)
	go func(wg sync.WaitGroup){
		defer print("A")
		defer print("B")
		defer go print("C")
		defer func() { go print("D") }()

		print("E")
		wg.Done()
	}(wg)
	
	print("F")
	wg.Wait()
	print("G")
}

そうです、defer go print("C")でコンパイルエラーです。簡単すぎますね。

Playground

GoのSpecのDefer Statementsを見ると、次のように定義されています。

DeferStmt = “defer” Expression .

deferに続けられるのはExpressionのみです。

ところが、go print("C")StatementGoStmt)であってExpressionではありません。

問題

さて、ここからが問題です。

先程のコンパイルエラーを解消しました。 これを実行するとどうなるでしょう。

(処理系は Go 1.15.6 とします)

package main

import (
	"runtime"
	"sync"
)

func main() {
	runtime.GOMAXPROCS(1)

	wg := sync.WaitGroup{}
	wg.Add(1)
	go func(wg sync.WaitGroup){
		defer print("A")
		defer print("B")
		defer func() { go print("C") }()
		defer func() { go print("D") }()

		print("E")
		wg.Done()
	}(wg)

	print("F")
	wg.Wait()
	print("G")
}
  1. FEABCD まで表示されてDeadlock
  2. FEBADC まで表示されてDeadlock
  3. FEBACD まで表示されてDeadlock
  4. FEBADCG と表示されて終了

Playground

解答と解説

解答 正解: 3. FEBACD まで表示されてDeadlock

sync.WaitGroupの使い方

最初にDeadlockするかどうかを考えます。

これは標準ライブラリのsync.WaitGroupでごくまれにやってしまうミスの問題です。 ドキュメントには次のようにあり、Add(1)したあとコピーして使うことはできません。

A WaitGroup must not be copied after first use.

今回のコードでは、goroutineで呼び出している無名関数の引数として値渡ししているため、そこでコピーが発生しています。 幸いこのミスはgo vetで警告されるので、簡単に気づくことができます。 sync.Waitgroupを関数に渡すときはかならずポインタ渡ししましょう。

deferの処理順序

続いて、deferの処理順序を確認します。 Defer Statementsには次のようにあります。

Instead, deferred functions are invoked immediately before the surrounding function returns, in the reverse order they were deferred.

関数から戻る直前に、deferされたのと逆順で実行されるとあります。 なので、今回のコードでは次のように実行されます。

  1. func() { go print("D") }()
  2. func() { go print("C") }()
  3. print("B")
  4. print("A")

goroutineのスケジューリング

最後にgoroutineがど順序で呼ばれるかが問題となります。 ただしこれはSpecに定義されているわけではなく、処理系依存です(ごめんなさい)。

現在のGoのオフィシャルバイナリのgoroutineのスケジューラについては、次の記事が大変わかりやすいです。

Golangのスケジューラあたりの話

問題のコードでは、わざとらしくruntime.GOMAXPROCS(1)を呼んでいます。 これによりP(Processor)は1つだけになり、goroutineのキューも1つになります。

goステートメントが実行された時、そのgoroutineはすぐには実行されずにキューに積まれます。 そして現在のgoroutineが完了した時、キューに最後に積まれたgoroutineを取り出して処理を始めます。 (キューと呼ばれていますが、動作はFILOのスタックです)

(01/06追記)

キュー (p.runq)はスタックではなく正しくキュー(FIFO)でした。

goステートメントはコンパイラによりruntime.newprocに置き換えられます。 この関数内でrunqputを呼ぶことで新しいgoroutineをキューへの追加をするのですが、第三引数nexttrueになっています。

runtime.runqputでは、next==trueのときはp.runqにenqueueするのではなく、p.runnextに保存します。 この時すでにp.runnextにgoroutineがあるときは、すでにあったほうをp.runqにenqueueします。

そして、キューからgoroutineを取り出すruntime.runqgetでは、 まずp.runnextにgoroutineがあったらそれを、無かったらp.runqからdequeueするようになっています。

以下、結果は同じですが細部の表現を修正しました。

(追記ここまで。以下微修正)

ここで問題のコードを見ます。

まずgo func(wg sync.WaitGroup){...でgoroutineが作られキューに積まれますがまだ実行されません。 そのままprint("F")により画面にFが表示され、wg.Wait()にたどり着きます。

ここでmain関数のgoroutineは待機してしまうので、キューに積まれたgoroutineに処理が移ります。 4つのdeferが処理された後、print("E")によりEが表示されます。 ここでwg.Wait()を呼んでいますが、先に説明したように値渡しをしてしまっているため、main関数は待機状態のままです。

無名関数が終了したのでdeferを逆順に実行します。 最初のfunc() { go print("D") }()によってgoroutineが作られキューに積まれます runqputされ、p.runnextにセットされます。 次にfunc() { go print("C") }()でも同様にgoroutineがキューに積まれます runqputされ、先程のprint("D")キューに積み、新たなgoroutineをp.runnextにセットします。 続いてprint("B")print("A")が実行され、画面にBAが表示されます。

ここでgoroutineが終了したので、キューに最後に積まれたものに処理が移ります runqgetにより次に実行するgoroutineとしてp.runnextが取り出されます。 それはprint("C")でした。画面にCが表示されます。 次にまた実行可能なgoroutineをキューから取り出すと、それはprint("D")で、画面にDが表示されます。

ここで実行可能なgoroutineがなくなってしまったので、Deadlockとなり異常終了します。

ということで、正解は3. FEBACDと表示されてDeadlock でした。