Posted at

A Tour of Go の sync.Mutex サンプルは絶対に壊れない?

みんなだいすきTour of Go。Concurrencyの章に、よくある並行カウントアップのサンプルもあります。

https://tour.golang.org/concurrency/9

どの言語でもよくある Lock/Unlock の話。「ロックしないとカウンタが壊れて変な値になっちゃうね!だからロックするよ!ほら期待通りの値になっただろう?」ってやつ。

このページの説明、最初から正しく Lock / Unlock されたバージョンが提示されていて、実行すれば当然成功します。そうなるとつい壊して失敗したバージョンを見てみたくなるもの。ですが、Lock / Unlock を全部削除してみても成功します。何度実行してもきっちり1000とprintされます。どういうことだよ…。goroutineの中身が一瞬で終わってしまうのが原因か?と中で10000回ループしてみても同じ。

どうやら、2019年4月現在ではtour.golang.orgの実行環境ではプロセッサ数が1のため、goroutineをいくら生成しようが同時には実行されないようです。fmt.Println(runtime.NumCPU()) を入れてみたら 1 と出ました。

手元のマルチプロセッサ環境で go get golang.org/x/tour を用いてローカル実行した場合、Lock / Unlock を外したら期待通り失敗してくれました。 ただ fatal error: concurrent map writes で終わってしまって、結局壊れたカウンタを見ることはできません。こういうのは、学習過程では壊れたものを見せてくれたほうがわかりやすいです。普通に int のカウンタにしなかった理由はなんだろう。


シングルプロセッサでも壊れるはずなのでは?

OSのスケジューラがプロセスに対して行うのと同様に、goroutineでも無慈悲なコンテキストスイッチが起きうるのであれば、カウンタの読み→書きの隙間にスイッチされて死ぬパターンがあるはずです。ですが、ユーザー空間で自前スイッチングを行う場合は中身がわかるので、無駄の少ないタイミングを狙ってスイッチングをしてくれるようです。

https://qiita.com/niconegoto/items/3952d3c53d00fccc363b より基本的な切り替えタイミングは:


アンバッファなチャネルへの読み書きが行われる

システムコールが呼ばれる

メモリの割り当てが行われる

time.Sleep()が呼ばれる

runtime.Gosched()が呼ばれる


なので、ループでインクリメントするだけのgoroutineでは…

func (c *SafeCounter) Inc(key string) {

for i := 0; i < 10000; i++ {
c.v[key]++
}
fmt.Printf("%d\n", c.v[key])
}
// 以下呼び出し側
for i := 0; i < 100; i++ {
go c.Inc("somekey")
}

などとすると…

10000

20000
30000
40000
50000
(以下略)

生成した順に、直列に走り切っています。

まとめると、「Web環境ではプロセッサ数が1で並列実行されない」かつ「このgoroutineでは内部で再スケジューリングも起きない」という2点により、tour.golang.org環境でカウンタが壊れることはないようです。