共有データを守る
sync.Mutex は、Lock と Unlock の間のコードに一度に1つのゴルーチンだけを入れます。ミューテックスは、守るデータのすぐ隣、たいてい同じ構造体に置きます。
これは常に hits: 10000 と表示します。ロックがないと、100個のゴルーチンが同じマップに書き込み、たいていは fatal error: concurrent map writes でプログラムがクラッシュします。このエラーはランタイムのベストエフォートのチェックから来るもので、回復できず、クラッシュしないことはコードが正しいことの証明になりません。
重要な点は次のとおりです。
sync.Mutexのゼロ値はロックされておらず、すぐに使えます。コンストラクタはありません。- メソッドはポインタレシーバー(
*Counter)を使います。値レシーバーだとミューテックスのコピーをロックすることになり、何も守れません。 Lockの直後のdefer c.mu.Unlock()によって、どのreturnの経路でもpanicでもロックが解放されます。- 読み取りも含め、すべてのアクセスがロックを通ります。別のゴルーチンが書き込んでいる間のロックなしの読み取りも、データ競合です。
クリティカルセクションを小さく保つ
defer は関数の終わりにアンロックします。上のような短いメソッドにはそれが正しい選択です。長い関数では、共有データに触れなくなったらすぐにアンロックして、他のゴルーチンがロックの要らない処理を待たなくて済むようにします。
func (s *Store) Save(key string) error {
s.mu.Lock()
data := s.items[key] // copy what you need
s.mu.Unlock()
return writeToDisk(key, data) // slow I/O, outside the lock
}
ネットワークの呼び出し、ディスクI/O、チャネルの送信をまたいでロックを保持するのは、遅い並行プログラムの最もよくある原因で、ロック中のチャネルの送信はデッドロックのよくある原因でもあります。
読み取りが多いデータのためのRWMutex
sync.RWMutex には2つのモードがあります。RLock/RUnlock は、多くのゴルーチンが同時に保持できる共有の読み取りロックを取ります。Lock/Unlock は排他的な書き込みロックを取り、すべての読み手が去るまで待ちます。
RWMutex が効果を発揮するのは、読み取りが大半を占め、各読み取りがロックの下で本当の処理をするときです。マップの1回の参照のような小さなクリティカルセクションでは、読み取りロック自体に管理のコストがあるので、普通の Mutex でも同じくらい速いことがよくあります。選ぶ前にベンチマークしましょう。
読み取りロックを書き込みロックに格上げすることはできません。同じゴルーチンで RLock を保持したまま Lock を呼ぶとデッドロックします。先に読み取りロックを解放し、それから書き込みロックを取って、条件をもう一度チェックします。その間に別の書き手がデータを変えているかもしれないからです。
単一の値にはsync/atomic
1つのカウンタやフラグなら、sync/atomic のほうがミューテックスより単純で安価です。使うべきなのは型付きのラッパー(Go 1.19)です。
アトミックが一度に守れるのは1つの値だけです。2つの値を一緒に変える必要が出てきたら(残高と取引数、マップとそのサイズ)、ミューテックスを使います。2つの別々のアトミック操作の間には、他のゴルーチンが割り込めます。
sync.Once
sync.Once は、いくつのゴルーチンが同時に呼んでも関数をちょうど一度だけ実行します。Do を呼んだ全員が、最初の呼び出しが終わるまで待ちます。何かを遅延初期化する標準的な方法です。
「runs once」の行はそれぞれちょうど1回だけ現れます。Do に渡した関数がpanicしても、Once はそれを完了とみなし、再試行しません。sync.OnceValues は、2つの値(典型的には値とエラー)を返す関数に対して同じことをします。
ミューテックス、チャネル、sync.Mapの使い分け
| 状況 | 使うもの |
|---|---|
| 複数のゴルーチンがその場で更新する構造体やマップ | 構造体の中の sync.Mutex |
| ほとんどが読み取りで書き込みはたまに、読み取りが本当の処理をする | sync.RWMutex |
| 1つのカウンタやフラグ | sync/atomic |
| 一度きりの初期化 | sync.Once、sync.OnceValue |
| あるゴルーチンから別のゴルーチンへデータを渡す | チャネル |
| キーが一度書かれて何度も読まれるキャッシュ、またはゴルーチンが互いに重ならないキーを扱う | sync.Map |
sync.Map はロック付きのマップの汎用的な代わりではありません。型パラメータがないので値は any として返り、速いのは表の2つのケースだけです。まずはミューテックスと普通のマップから始めましょう。
よくある間違い
- ミューテックスをコピーする。
sync.Mutexを含む構造体を値で渡したり、値レシーバーを使ったりすると、ロックがコピーされます。go vetはpasses lock by valueやcopies lock valueと報告します。 - 1つのゴルーチンで2回ロックする。 Goのミューテックスは再入可能ではありません。
IncがGetを呼び、両方がロックを取ると、Incは永遠にブロックします。公開メソッドがロックし、非公開のヘルパーはロックが保持されていることを前提にします。 - 早期リターンでアンロックを忘れる。 理由がない限り
deferを使います。 - 異なる順序でロックする。 あるゴルーチンがA、Bの順にロックを取り、別のゴルーチンがB、Aの順に取ると、互いを永遠に待つことがあります。複数のロックは常に同じ順序で取得します。
- 守っているデータを外に出す。 メソッドから内部のマップを返すと、呼び出し側がロックなしで読み書きできてしまいます。コピー(
maps.Clone、Go 1.21)か単一の値を返します。 - 書き込みだけを守る。 ロックした書き込みと並行するロックなしの読み取りも競合です。テストは
go test -raceで実行しましょう。
よくある質問
Goのミューテックスとは何ですか?
sync.Mutex は、mu.Lock() と mu.Unlock() の間のコードを一度に1つのゴルーチンだけが実行できるようにするロックです。マップや構造体のように、複数のゴルーチンが読み書きするデータを守るのに使います。ゼロ値はロックされていないミューテックスで、すぐに使えます。
MutexではなくRWMutexを使うべきなのはいつですか?
読み取りが書き込みよりはるかに多く、各読み取りがそれなりの時間ロックを保持するときです。RLock は何個の読み手でも同時に入れ、Lock は排他的なアクセスを待ちます。短いクリティカルセクションなら普通の Mutex のほうが同じくらいか速いことも多いので、切り替える前に計測しましょう。
Goのマップは並行処理で安全に使えますか?
使えません。並行した読み取りは問題ありませんが、他の読み取りや書き込みと並行した書き込みはデータ競合で、ランタイムがたいてい検出して fatal error: concurrent map writes(または concurrent map read and map write)でクラッシュさせます。sync.Mutex か sync.RWMutex でマップを守るか、sync.Map をその特定の用途で使います。
Goのsync.Mutexは再入可能ですか?
再入可能ではありません。ロックを保持しているゴルーチンが再び Lock を呼ぶと、自分自身を待って永遠にブロックします。公開メソッドがロックを取り、ロックがすでに保持されていることを前提とする非公開のヘルパーを呼ぶように、コードを構成します。