Go言語クイズ:deferとgoroutine
これは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")
でコンパイルエラーです。簡単すぎますね。
GoのSpecのDefer Statementsを見ると、次のように定義されています。
DeferStmt = “defer” Expression .
defer
に続けられるのはExpression
のみです。
ところが、go print("C")
はStatement
(GoStmt
)であって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")
}
- FEABCD まで表示されてDeadlock
- FEBADC まで表示されてDeadlock
- FEBACD まで表示されてDeadlock
- FEBADCG と表示されて終了
解答と解説
解答
正解: 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されたのと逆順で実行されるとあります。 なので、今回のコードでは次のように実行されます。
func() { go print("D") }()
func() { go print("C") }()
print("B")
print("A")
goroutineのスケジューリング
最後にgoroutineがど順序で呼ばれるかが問題となります。 ただしこれはSpecに定義されているわけではなく、処理系依存です(ごめんなさい)。
現在のGoのオフィシャルバイナリのgoroutineのスケジューラについては、次の記事が大変わかりやすいです。
問題のコードでは、わざとらしくruntime.GOMAXPROCS(1)
を呼んでいます。
これによりP(Processor)は1つだけになり、goroutineのキューも1つになります。
goステートメントが実行された時、そのgoroutineはすぐには実行されずにキューに積まれます。
そして現在のgoroutineが完了した時、キューに最後に積まれたgoroutineを取り出して処理を始めます。
(キューと呼ばれていますが、動作はFILOのスタックです)
(01/06追記)
キュー (p.runq
)はスタックではなく正しくキュー(FIFO)でした。
goステートメントはコンパイラによりruntime.newproc
に置き換えられます。
この関数内でrunqput
を呼ぶことで新しいgoroutineをキューへの追加をするのですが、第三引数next
がtrue
になっています。
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 でした。