Goで共有データを複数のゴルーチンから触るとき、sync.Mutex と sync.RWMutex のどちらを選ぶかは「読み取りが多いならRWMutex」という一文で片付けられがちです。しかし手元のgo1.26.5で測ると、読み取り100%でも並列度2まではMutexのほうが速く、書き込みが1%混ざっただけで並列度8以上ではRWMutexがMutexに負けました。この記事では両者の違いを構造から押さえたうえで、実測値と公式ドキュメントの記述をもとに、どちらを選ぶかを条件で判断できる形にまとめます。
まとめ:MutexとRWMutexの選び方
結論を先に置きます。判断の軸は「読み取りが多いかどうか」ではなく、並列度と書き込み比率の組み合わせです。
- 迷ったら
sync.Mutex。8バイト(RWMutexは24バイト)で、メソッドがLock/Unlockの2つだけなので取り違えようがない。今回の測定では書き込み比率0〜10%で性能も横ばい sync.RWMutexが読み取り専用ベンチで優位だったのはGOMAXPROCSが4以上のときで、2まではMutexのほうが速い(後述の表B)- 1,024キーのマップに書き込みを1%混ぜると、
GOMAXPROCS=8以上でRWMutexがMutexに負ける。ライター優先設計のため、待機中のライターが後続のリーダーを止めるから(表C) - 数値はgo1.26.5・Intel Core i9-9880Hでの実測。ロック内の処理量や実行環境が変われば逆転点も動く
- キーごとに独立した読み書きをするマップなら、ロックを比べる前に
sync.Mapとシャーディングを検討する RWMutexには再帰的なRLockがデッドロックするという固有の罠がある
sync.MutexとRWMutexの違い:メソッドと同時実行の可否
両者はどちらもsyncパッケージの排他ロックですが、公開しているメソッドと、同時に何本のゴルーチンが入れるかが違います。
| 項目 | sync.Mutex | sync.RWMutex |
|---|---|---|
| 取得メソッド | Lock / TryLock | Lock / TryLock / RLock / TryRLock |
| 解放メソッド | Unlock | Unlock / RUnlock |
| 同時に入れる数 | 常に1本 | リーダーは複数、ライターは1本 |
| 構造体サイズ | 8バイト | 24バイト |
| 内部の状態管理定数 | mutexLocked / mutexWoken / mutexStarving | rwmutexMaxReaders = 1<<30 |
| ゼロ値 | そのまま使える | そのまま使える |
| 再帰取得 | 不可(自己デッドロック) | RLockも不可 |
サイズはgo1.26.5のdarwin/amd64でunsafe.Sizeofを取った実測値です。TryLock・TryRLockはGo 1.18で追加されたもので、それ以前のコードには存在しません。
ミューテックスの意味とGoにおける排他制御
ミューテックスはmutual exclusion(相互排他)の略で、Goの標準ライブラリでもsync.Mutexのドキュメント冒頭は「A Mutex is a mutual exclusion lock.」と定義しています。守るのはコードではなくデータです。同じ変数を触る箇所が3か所あるなら、その3か所すべてが同じMutexを取ってからアクセスしていなければ意味がありません。
OSやC++の文脈で言うミューテックスはスレッドを止めますが、Goのsync.Mutexが止めるのはゴルーチンです。待たされたゴルーチンはランタイムのセマフォで停止(park)し、OSスレッドは別のゴルーチンを走らせに行きます。この切り替えを担うのがGMPスケジューラで、仕組みはGoroutineのM:Nスケジューリングの解説にまとめています。
なおsyncパッケージが公開している型はCond・Locker・Map・Mutex・Once・Pool・RWMutex・WaitGroupの8つで、セマフォ型はありません。同時実行数をNに制限したい場合は、バッファ付きチャネルを使うか、golang.org/x/sync/semaphore(2026年9月23日時点でv0.23.0、公開は2026年8月31日)のWeightedを使います。
RWMutexの内部構造:Mutexとリーダーカウンタの組み合わせ
go1.26.5のsrc/sync/rwmutex.goを開くと、RWMutexは次の5フィールドで構成されています。
type RWMutex struct {
w Mutex // ライター同士の競合を解決する内部Mutex
writerSem uint32 // ライターがリーダーの完走を待つセマフォ
readerSem uint32 // リーダーがライターの完了を待つセマフォ
readerCount atomic.Int32 // RLock中のリーダー数(ライター待機中は負の値になる)
readerWait atomic.Int32 // 退出待ちのリーダー数
}
const rwmutexMaxReaders = 1 << 30
先頭にMutexが埋まっている点に注目してください。RWMutex.Lockはまず内部のw.Lock()でライター同士の順番を決め、そのうえでreaderCountからrwmutexMaxReadersを引いて値を負に振り、「ライターが待っている」ことをリーダー側へ知らせます。RWMutexはMutexの上位互換ではなく、Mutexに読み取り用の会計処理を足したものだと考えると挙動が読めます。
リーダー側の高速パスはreaderCount.Add(1)の1回だけです。裏を返すと、複数のコアが同時にRLockを呼ぶと全員が同じ1本のカウンタに書き込むことになり、そのキャッシュラインをコア間で奪い合います。この性質が実測値にどう出るかは次章の表で確認します。
sync.RWMutexの公式リファレンスが明記している制約も押さえておきます。「If any goroutine calls RWMutex.Lock while the lock is already held by one or more readers, concurrent calls to RWMutex.RLock will block until the writer has acquired (and released) the lock」。つまりライターが1本待ち始めた時点で、後続のリーダーは全員止まります。後続のリーダーを止めることで、待機中のライターに取得の順番を回す設計です。ライターの餓死は防げますが、性能面ではこれが効いてきます。
MutexとRWMutexの実測比較:並列度と書き込み比率
ここからはgo1.26.5・Intel Core i9-9880H(8物理コア/16論理CPU、darwin/amd64)で実際に測った値を出します。「RWMutexのほうが軽い」と結論づけるベンチマークをよく見かけますが、単一ゴルーチンで測れるのはロック1回あたりのコストだけで、RWMutexの狙いである並行読み取りの効果はそこに出ません。並列度と書き込み比率を振ってはじめて、どちらを選ぶべきかが決まります。
以下の4表はいずれもgo test -run XXX -bench ... -benchtime 1sで測り、-countで繰り返した各回の値から中央値(偶数回のときは中央2値の平均)を採っています。クリティカルセクションの中身は、表Aがロックの取得と解放のみ、表Bが固定キー1件のマップ参照、表Cが1,024キーのマップに対する1件の参照または代入です。
非競合時のMutex・RWMutexの取得と解放のコスト
まず競合が一切ない単一ゴルーチンで、ロックの取得と解放だけを繰り返した結果です。-benchtime=1s -cpu=1 -count=5の5回計測で、最小値と最大値、中央値を並べます。
| 操作 | 最小 | 中央値 | 最大 |
|---|---|---|---|
| Mutex Lock+Unlock | 35.82 ns | 36.62 ns | 37.20 ns |
| RWMutex RLock+RUnlock | 31.28 ns | 31.55 ns | 32.47 ns |
| RWMutex Lock+Unlock | 60.66 ns | 62.83 ns | 64.87 ns |
RLockそのものは重くありません。5回ともMutex.Lockを下回り、中央値で約1.16倍速い。高速パスがreaderCount.Add(1)の1回で済むぶん、ロックの獲得・解放を伴うMutexより短いからです。重いのはRWMutexの書き込みロックのほうで、Mutexの約1.7倍かかります。内部Mutexの取得にリーダーカウンタの操作が乗るためです。つまりRWMutexを選ぶコストは書き込み側に集中して現れます。
読み取り専用ベンチにおける並列度別の測定結果
次にb.RunParallelで並列度を変えながら、読み取りだけを回した結果です。表の単位はns/opで、ベンチ全体の経過時間を総操作数で割った値であり、個々の呼び出しの待ち時間ではありません。-count=4の中央値を載せます。
| GOMAXPROCS | Mutex | RWMutex | 勝者 |
|---|---|---|---|
| 1 | 55.7 ns | 56.9 ns | ほぼ同着 |
| 2 | 68.3 ns | 77.0 ns | Mutex(1.13倍) |
| 4 | 94.8 ns | 75.5 ns | RWMutex(1.26倍) |
| 8 | 160.5 ns | 73.7 ns | RWMutex(2.18倍) |
| 16 | 177.4 ns | 70.4 ns | RWMutex(2.52倍) |
逆転は並列度2と4のあいだで起きています。並列度2ではMutexのほうが速く、4を超えると差が開いて16ではRWMutexが2.5倍速い。注目したいのは右の列の形です。Mutexのns/opは今回の測定では並列度とともに増える(55.7→177.4 ns)のに対し、RWMutexは並列度2以降ほぼ横ばい(70〜77 ns)で、コア数に比例して速くなるわけでもありません。リーダー全員がreaderCountという同じ1本のカウンタを更新するため、コア間でそのキャッシュラインを奪い合うからです。RWMutexの利点は「複数の読み取り処理を同時に実行できる」ことだと捉えると、次の表の結果も読めます。
GOMAXPROCS=16での書き込み比率別の測定結果
実際のコードで書き込みが完全にゼロということはまずありません。1,024キーのマップに対して読み書きを混ぜ、並列度16で測りました。クリティカルセクションはマップ1回の参照または代入で、-count=4の中央値です。
| 書き込み比率 | Mutex | RWMutex | sync.Map |
|---|---|---|---|
| 0% | 198.5 ns | 71.9 ns | 7.95 ns |
| 1% | 204.1 ns | 220.2 ns | 10.38 ns |
| 10% | 214.1 ns | 317.2 ns | 21.17 ns |
0%ではRWMutexが2.8倍速いのに、書き込みが1%入った時点で逆転され、10%では1.5倍遅くなります。Mutexのほうは書き込み比率が0%から10%まで変わっても198.5→214.1 nsとほぼ横ばいです。実装を読むと、前節で引用したライター優先の仕様で説明が付きます。書き込みロックが待機を始めた後に到着したリーダーはブロックされ、ライターが抜けるたびにreaderSem経由で起こし直されるからです。
ただしこの逆転は並列度にも依存します。書き込み比率を1%に固定したままGOMAXPROCSを振り直すと、次のようになりました(-count=3の中央値)。
| GOMAXPROCS(書き込み1%) | Mutex | RWMutex | 勝者 |
|---|---|---|---|
| 2 | 90.09 ns | 91.98 ns | ほぼ同着 |
| 4 | 126.8 ns | 123.7 ns | ほぼ同着 |
| 8 | 184.3 ns | 200.3 ns | Mutex |
| 16 | 203.1 ns | 247.4 ns | Mutex |
並列度4まではほぼ互角で、差が開くのは8以上です。読み取り100%の表Bでは並列度4でRWMutexが勝っていたことと合わせると、この処理量でのRWMutexの優位は「並列度が高い」と「書き込みがほぼゼロ」が揃ったときに出ており、どちらかが崩れるとMutexと並ぶか負けました。ロック内の処理が長ければ並行読み取りの取り分は増えるので、判断は自分のワークロードで測り直してください。
書き込みが混ざる前提なら、まずMutexで書き、プロファイルでロック競合が実際にボトルネックだと分かってからRWMutexを検討する順序が妥当です。なお、安全に公開したあと一切書き換えない設定テーブルなら、そもそもロック自体が要りません。公式FAQは「Map access is unsafe only when updates are occurring.」「it is safe for them to access the map concurrently without synchronization」と明記しています。RWMutexを入れる前に、その共有データが本当に更新されるのかを確かめてください。
同じ表のsync.Map列が桁違いに速いのは、このベンチが「1,024個のばらばらなキーを読み書きする」というsync.Mapの得意な条件そのものだからです。Go 1.24で実装がHashTrieMapに置き換わり、リリースノートは「The implementation of sync.Map has been changed, improving performance, particularly for map modifications.」「For instance, modifications of disjoint sets of keys are much less likely to contend on larger maps」と説明しています。ただし、このベンチでは各ゴルーチンが同じキー列を走査しており、ゴルーチンごとに互いに素なキー集合を割り当てた条件ではありません。少数のキーに集中する用途は今回測っていないので、この倍率をそのまま当てにはできません。sync.Map自身のドキュメントが「Most code should use a plain Go map instead」と書いている点も含めて、万能の置き換え先ではありません。
共有mapを守るRWMutexの実装例
MutexもRWMutexもゼロ値でそのまま使えます。newや初期化関数は不要で、守りたいデータと同じ構造体に値で埋め込むのが定石です。
type Store struct {
mu sync.RWMutex
m map[string]int
}
func (s *Store) Get(k string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}
func (s *Store) Set(k string, v int) {
s.mu.Lock()
defer s.mu.Unlock()
if s.m == nil {
s.m = make(map[string]int)
}
s.m[k] = v
}
deferで解放するのは、途中のreturnやpanicでもロックを取りこぼさないためです。ただしdeferはクリティカルセクションを関数の末尾まで引き延ばすので、ロックの外でやれる重い処理(I/O、ログ出力、シリアライズ)は関数を分けてロックの外へ出します。
Setの中でs.mを確認しているのは、var s Storeのゼロ値から使えるようにするためです。これを省くとpanic: assignment to entry in nil mapで落ちます。nilのmapは参照だけなら安全で(上のGetはその性質に乗っています)、panicになるのは代入と削除です。Mutexのゼロ値はそのまま使えるのにmapのゼロ値は書き込めない、という非対称がここに出ます。上のコードはgo1.26.5でgo run -raceまで通しています。
待ちたくない場合はTryLock系を使います。go1.26.5で実測した戻り値は次のとおりです。
| ロックの状態 | TryRLock | TryLock |
|---|---|---|
| ライターが保持中 | false | false |
| リーダーが保持中・ライター待機なし | true | false |
| リーダーが保持中・ライター待機あり | false | false |
3行目が見落としやすいところです。リーダーが入れているかどうかは、ライターが待ち行列にいるかで変わります。後述のデッドロック例でfor rw.TryRLock()をライター待機の検出に使えるのは、この挙動があるからです。ただし標準ライブラリ自身が「use of TryLock is often a sign of a deeper problem」と注意しているとおり、常用するものではありません。
MutexとRWMutexの誤用で起きる停止とデータ競合
再帰的なRLockとライター待機によるデッドロック
RWMutex固有の罠がこれです。RLockを取ったまま、同じロックのRLockをもう一度取る関数を呼ぶと、そのあいだにライターが割り込んだ瞬間にデッドロックします。ドキュメントも「Note that this prohibits recursive read-locking.」と明記しています。
go1.26.5で実際に止めたコードが次です。outerがRLockを保持したままinnerを呼び、innerが同じロックを再びRLockします。ライターは最初のRLockを取った後に起動し、TryRLockがfalseを返す(=ライターが待ち始めた合図)まで待ってから再取得しているので、実行順によらず毎回止まります。
var rw sync.RWMutex
func outer() {
rw.RLock()
defer rw.RUnlock()
go func() { rw.Lock(); rw.Unlock() }() // RLock保持後にライターを起動する
for rw.TryRLock() { // ライターが待ち行列に入るまで回す
rw.RUnlock()
}
inner()
}
func inner() {
rw.RLock() // outerのRLockを持ったまま再取得=ここで自分が止まる
defer rw.RUnlock()
}
func main() {
outer()
}
実行するとfatal error: all goroutines are asleep - deadlock! で落ち、スタックトレースにはgoroutine 1 [sync.RWMutex.RLock]とsync.runtime_SemacquireRWMutexRが並びます。outerのdefer rw.RUnlock()はinnerが返らない限り走らないので、自分の解放を自分で待つ形になって永久に抜けられません。ライターが待っていなければ2回目のRLockは通ってしまうため、テストでは再現せず本番の負荷でだけ止まる、という出方をします。ロックを取った状態で呼ぶ関数が内部で同じロックを触っていないか、リファクタリングのたびに確認が要ります。
同じ理由でRLockをLockへ昇格させることもできません。ドキュメントの「A RWMutex.RLock cannot be upgraded into a RWMutex.Lock, nor can a RWMutex.Lock be downgraded into a RWMutex.RLock.」がそれです。
Mutex.Lockの待機とcontextキャンセルの非連動
ロック待ちにタイムアウトを設けたい場面でcontext.WithTimeoutを持ち出す解説を見かけますが、sync.Mutex.Lockはcontextを引数に取らず、参照もしません。実際に、200ミリ秒でタイムアウトするcontextを用意したうえで保持中のMutexにLockを掛けると、contextが期限切れになってもゴルーチンは待ち続け、500ミリ秒後に保持側がUnlockした時点で初めて取得しました。contextのキャンセルはロック待ちには一切効きません。
本当に待ちを打ち切りたいなら、TryLockとリトライを組むか、ロックではなくバッファ1のチャネルを排他に使ってselectでctx.Done()と競わせます。contextそのものの挙動やキャンセル理由の切り分けはGoのcontext canceledとdeadline exceededの切り分けで扱っています。
未取得ロックのUnlockによるfatal終了とrecoverの限界
ロックしていないMutexをUnlockすると、fatal error: sync: unlock of unlocked mutex でプロセスが落ちます。これはpanicではなくfatalなので、deferとrecoverを仕掛けていても拾えません。実際にrecover付きのテストで試すと、recoverの行には到達せずテストバイナリごと終了します。
RWMutexにも同じ扱いのfatal("sync: RUnlock of unlocked RWMutex")とfatal("sync: Unlock of unlocked RWMutex")があります。HTTPハンドラの外側でpanicを拾っているから大丈夫、という前提は通用しません。RLockとUnlock、LockとRUnlockを取り違えるとサーバーごと停止します。
使用開始後のロックの値コピーと排他制御の破綻
MutexもRWMutexも、一度使ったあとにコピーしてはいけません。ドキュメントの「A Mutex must not be copied after first use.」がそれです。ロックを含む構造体を値レシーバのメソッドに渡したり、スライスにappendしたりすると、コピー先とコピー元で別々のロックになり、排他が静かに効かなくなります。
go vetのcopylocks解析がこれをassignment copies lock valueとして報告します。検出の仕組みと、go testの既定設定では素通りしてしまう範囲はGoのDoNotCopy・DoNotCompare・DoNotImplementの解説にまとめています。
単一ロックへの競合を減らす設計の選択肢
MutexとRWMutexのどちらを選ぶかは、共有データを1本のロックで守ると決めた後の話です。その前に、競合そのものを減らす設計が3つあります。
1つ目はチャネルです。チャネル自身も内部にロックを持つので同期が消えるわけではありませんが、共有データを守るロックをアプリケーション側から無くせます。syncパッケージのドキュメント自身が「Higher-level synchronization is better done via channels and communication.」と述べているとおり、データを共有して守るのではなく、所有権を1本のゴルーチンに集めて受け渡す設計にすればロックは要りません。使い方はGo言語のChannelとはで整理しています。
2つ目はsync.Mapです。ロックが消えるわけではなく(内部のHashTrieMapもMutexを持ちます)、競合の粒度がマップ全体からノード単位へ細かくなります。表Cではその差が10倍以上として出ました。ただし型安全性が失われ、anyとの変換コストがかかるうえ、公式ドキュメントが通常のマップを推奨している点は変わりません。Go 1.24ではGOEXPERIMENT=nosynchashtriemapで旧実装へ戻せましたが、本記事のgo1.26.5ではこの設定は削除されています。
3つ目はシャーディングです。キーのハッシュで16本や64本のロックに分散させれば、1本のロックに集中していた競合がそのまま分散します。自前で書く前に、キャッシュ用途ならBigCacheの設計コンセプトのような既存実装が同じ考え方を採っているので参考になります。同じキーに対する重複した重い処理をまとめたいだけなら、singleflightによる重複呼び出し抑制のほうが適します。
データ競合とデッドロックの検出
ロックの掛け忘れはgo test -raceとgo build -raceのrace detectorが検出します。ただし検出できるのは実際に競合が起きた実行パスだけなので、テストがその並行パスを踏んでいなければ通り抜けます。常時有効にできないのはコストが理由で、公式のData Race Detectorドキュメントは「memory usage may increase by 5-10x and execution time by 2-20x」と書いています。CIの並行テストだけ-raceを付け、本番ビルドでは外すのが現実的な落としどころです。
永久にブロックされたゴルーチンについては、Go 1.26で実験機能として入ったgoroutineleakプロファイルが、Go 1.27(2026年8月)で正式版になりました。Go 1.27のリリースノートは「previously available as an experiment in Go 1.26, is now generally available.」と書いており、1.26で必要だったGOEXPERIMENT=goroutineleakprofileの指定は不要になっています。あわせて「A leaked goroutine is a goroutine blocked on some concurrency primitive (channels, sync.Mutex, sync.Cond, etc) that cannot possibly become unblocked.」と定義し、runtime/pprofから取得できるほかnet/http/pprofの/debug/pprof/goroutineleakエンドポイントでも公開されると説明しています。検出はGCの到達可能性解析を使い、ブロック先の同期プリミティブがどの実行可能なゴルーチンからも到達できなければリークと判定する仕組みです。
この方式には明示された弱点があります。リリースノートは「the runtime may fail to identify leaks caused by blocking on concurrency primitives reachable through global variables or the local variables of runnable goroutines.」と書いています。パッケージレベルのvar mu sync.Mutexはグローバル変数から常に到達可能なので、まさにこの検出漏れのパターンに当たります。構造体のフィールドにしても、その構造体がグローバル変数や実行可能なゴルーチンから到達可能なら、同じ検出漏れが起こり得ます。
なおデータベースのデッドロックは、ここで扱ったアプリ内のロックとは検出も解消も別物です。トランザクション同士が待ち合う場合の原因と対処はデータベースで起きるデッドロックの原因と解消を参照してください。
よくある質問
mutexは何の略ですか?
mutual exclusion(相互排他)の略です。Goの標準ライブラリでもsync.Mutexのドキュメントは「A Mutex is a mutual exclusion lock.」と定義しています。読み方はミューテックスです。
セマフォとミューテックスの違いは何ですか?
ミューテックスは同時に入れるのが常に1本であるのに対し、セマフォは同時実行数をNに制限する仕組みです。Goのsyncパッケージが公開している型はCond・Locker・Map・Mutex・Once・Pool・RWMutex・WaitGroupの8つで、セマフォ型は含まれません。同時実行数を絞りたい場合は容量Nのバッファ付きチャネルを使うか、golang.org/x/sync/semaphoreのWeightedを使います。
MutexとRWMutexはどちらが速いですか?
条件によって逆転します。go1.26.5・8物理コア/16論理CPUの実測では、読み取り100%なら並列度4でRWMutexが逆転し、並列度16では2.5倍速くなります。一方、並列度2まではMutexのほうが速く、書き込みが1%混ざると並列度16でもRWMutexが負けます(220.2 ns対204.1 ns)。非競合ではRLockとRUnlockの1往復が約31.6 ns、MutexのLockとUnlockが約36.6 nsで、RWMutexのLockとUnlockだけが約62.8 nsと突出します。
Lockしたゴルーチンと別のゴルーチンでUnlockしてよいですか?
できます。ドキュメントは「A locked Mutex is not associated with a particular goroutine. It is allowed for one goroutine to lock a Mutex and then arrange for another goroutine to unlock it.」と明記しています。RWMutexにも同じ記述があります。ただし解放し忘れると、そのロックを待つ処理が進まなくなるため、原則は同じ関数内でdeferを使って閉じる書き方を勧めます。
sync.Mutexは初期化が必要ですか?ポインタで持つべきですか?
初期化は不要です。「The zero value for a Mutex is an unlocked mutex.」のとおり、ゼロ値がそのまま未ロック状態です。持ち方は、守りたいデータと同じ構造体に値で埋め込み、その構造体自体をポインタで受け渡すのが定石です。構造体を値でコピーするとロックが分裂して排他が効かなくなります。