Day 013 — sync.Mutex / RWMutex

2026-08-10 🔵 䞭玚者 / Phase 2 実装 sync.Mutex / RWMutex

📚 背景知識読んでから問題ぞ

前回のsync.WaitGroupは「耇数のgoroutineの完了を埅぀」ための道具でした。今回は芖点が倉わりたす。runWorkersでは各goroutineが結果スラむスの異なるindexにしか曞き蟌たなかったため排他制埡は䞍芁でしたが、実務では耇数のgoroutineが同じ倉数やmapを同時に読み曞きする堎面が頻繁に発生したすアクセスカりンタ、むンメモリキャッシュ、蚭定倀のホットリロヌドなど。これを保護せずに曞くずdata race未定矩動䜜になりたす。そこで䜿うのがsync.Mutexです。

var mu sync.Mutex
var count int

mu.Lock()
count++       // 䞀床に1぀のgoroutineしか入れない「クリティカルセクション」
mu.Unlock()

sync.Mutex盞互排他ロックは「今この区間を実行しおいるのは自分だけだ」ずいう保蚌を䜜る道具です。ルヌルは単玔です。

  • Lock()でロックを獲埗する。他のgoroutineが既にロック䞭なら、解攟されるたでブロックされる
  • Unlock()でロックを解攟する。Lock()の盎埌にdefer mu.Unlock()ず曞くのが定石途䞭でpanicしおも確実に解攟される
  • ロックしおいる区間クリティカルセクションはできるだけ短くするのがGoの流儀。ロック䞭にI/Oや重い凊理を入れるず、他のgoroutineを長時間埅たせおしたう

sync.WaitGroupず同様、sync.Mutexもコピヌしおはいけたせんgo vetのcopylocksで怜出される。倀枡しするず別々のロックになり、排他制埡が意味を倱いたす。

もう1぀、読み取りが曞き蟌みより圧倒的に倚いケヌス䟋: 蚭定倀をmainで1回曞き蟌み、倚数のgoroutineが頻繁に読み取るキャッシュ向けにsync.RWMutexがありたす。

var rw sync.RWMutex

rw.RLock()        // 読み取りロック他の読み取りずは同時実行できる
_ = data["key"]
rw.RUnlock()

rw.Lock()          // 曞き蟌みロック他の読み取り・曞き蟌みを党おブロック
data["key"] = "v"
rw.Unlock()

RLock/RUnlockは「読み取り専甚ロック」で、耇数のgoroutineが同時にRLockを保持できたす読み取り同士は競合しないため。䞀方Lock/Unlockは「曞き蟌みロック」で、これを保持しおいる間は他の読み取り・曞き蟌みも䞀切入れたせん。読み取りが倚いワヌクロヌドでは、単玔なMutexより䞊行性が䞊がりたす。

📝 問題

以䞋の芁件を満たす、1぀の実行可胜なGoプログラムを実装しおください。

  1. SafeCounter型を実装しおください。mu sync.Mutexずcounts map[string]intをフィヌルドに持ち、以䞋のメ゜ッドを実装したす。
    • Inc(key string): 指定したキヌのカりントを1増やすLock/Unlockで保護するこず
    • Value(key string) int: 指定したキヌの珟圚のカりントを返す読み取りもLock/Unlockで保護するこず。理由は「よくある誀解・ミス」で扱いたす
  2. Cache型を実装しおください。mu sync.RWMutexずdata map[string]stringをフィヌルドに持ち、以䞋のメ゜ッドを実装したす。
    • Set(key, value string): Lock/Unlockで保護しお曞き蟌む
    • Get(key string) (string, bool): RLock/RUnlockで保護しお読み取るmapのカンマOKむディオムで存圚確認も返す
  3. mainでは、SafeCounterに察しお100個のgoroutineを起動し、それぞれが同じキヌ"requests"を1000回むンクリメントしおください。党goroutineの完了をsync.WaitGroup前回孊んだもので埅っおから、最終的なカりントが正確に100000になっおいるこずを出力で確認しおくださいロックがなければ倀が倱われおこの数にならないこずがこの問題の芁点です
  4. mainでは、Cacheに察しお1぀の曞き蟌みgoroutineで10個のキヌを曞き蟌んだ埌、5個の読み取りgoroutineが䞊行しお党キヌを耇数回読み取るデモを行い、go run -raceでdata raceが出ないこずを確認できる状態にしおください

go run main.goおよびgo run -race main.goでそのたた実行できる、完党な1぀のプログラムずしお提出しおください。

🔍 ヒント段階的開瀺

ヒント1 — 方向性

sync.Mutexは「今クリティカルセクションに入っおいるのは自分だけ」ずいう排他性を保蚌するだけの道具です。Lockした瞬間からUnlockするたでの間に曞かれたコヌドだけが保護察象になりたす。今回のポむントは、曞き蟌みだけでなく読み取りValueも保護が必芁だずいう点です。Goのmapはスレッドセヌフではないため、1぀のgoroutineが曞き蟌み䞭に別のgoroutineがロックなしで読み取るず、たずえ論理的には安党に芋えおもGoランタむムがfatal error: concurrent map read and writeを怜出しおプログラムを異垞終了させるこずがありたす。

ヒント2 — アプロヌチ

RWMutexは「読み取りは䜕人でも同時に入れるが、曞き蟌みは1人だけ、か぀曞き蟌み䞭は誰も入れない」ずいう制玄をAPIずしお衚珟したものです。Getの䞭ではRLock()/RUnlock()、Setの䞭ではLock()/Unlock()を䜿い分けたす。前回同様、ロック獲埗の盎埌にdeferでアンロックを曞くず安党です。曞き蟌みgoroutineを先にsync.WaitGroupで完了させおから読み取りgoroutine矀を起動するず、デモの出力が安定しお読みやすくなりたす芁件を満たす䞊で必須ではありたせんが掚奚したす。

ヒント3 — コヌド骚栌
package main

import (
	"fmt"
	"sync"
)

type SafeCounter struct {
	mu     sync.Mutex
	counts map[string]int
}

func NewSafeCounter() *SafeCounter {
	return &SafeCounter{counts: make(map[string]int)}
}

func (c *SafeCounter) Inc(key string) {
	// TODO: Lock/Unlockで保護しおcounts[key]++
}

func (c *SafeCounter) Value(key string) int {
	// TODO: Lock/Unlockで保護しおcounts[key]を返す
	return 0
}

type Cache struct {
	mu   sync.RWMutex
	data map[string]string
}

func NewCache() *Cache {
	return &Cache{data: make(map[string]string)}
}

func (c *Cache) Set(key, value string) {
	// TODO: Lock/Unlockで保護しおdata[key] = value
}

func (c *Cache) Get(key string) (string, bool) {
	// TODO: RLock/RUnlockで保護しおdata[key]をカンマOKむディオムで返す
	return "", false
}

func main() {
	// ここから、SafeCounterずCacheの動䜜確認を曞いおいく
	fmt.Println("skeleton")
}

✅ 暡範解答

package main

import (
	"fmt"
	"sync"
)

// SafeCounter は耇数goroutineから安党にむンクリメント・参照できるカりンタ。
type SafeCounter struct {
	mu     sync.Mutex
	counts map[string]int
}

func NewSafeCounter() *SafeCounter {
	return &SafeCounter{counts: make(map[string]int)}
}

func (c *SafeCounter) Inc(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.counts[key]++
}

func (c *SafeCounter) Value(key string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.counts[key]
}

// Cache は読み取りが倚いワヌクロヌド向けに RWMutex で保護されたキャッシュ。
type Cache struct {
	mu   sync.RWMutex
	data map[string]string
}

func NewCache() *Cache {
	return &Cache{data: make(map[string]string)}
}

func (c *Cache) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

func (c *Cache) Get(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[key]
	return v, ok
}

func main() {
	fmt.Println("=== SafeCounter: 100 goroutine x 1000回むンクリメント ===")
	counter := NewSafeCounter()
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				counter.Inc("requests")
			}
		}()
	}
	wg.Wait()
	fmt.Println("  requests count:", counter.Value("requests"), "(期埅倀: 100000)")

	fmt.Println("\n=== Cache: 1 writer -> 5 readers ===")
	cache := NewCache()

	var writerWG sync.WaitGroup
	writerWG.Add(1)
	go func() {
		defer writerWG.Done()
		for i := 0; i < 10; i++ {
			cache.Set(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i))
		}
	}()
	writerWG.Wait()
	fmt.Println("  曞き蟌み完了: 10 keys")

	var readerWG sync.WaitGroup
	for r := 0; r < 5; r++ {
		readerWG.Add(1)
		go func(readerID int) {
			defer readerWG.Done()
			hits := 0
			for round := 0; round < 20; round++ {
				for k := 0; k < 10; k++ {
					if _, ok := cache.Get(fmt.Sprintf("key-%d", k)); ok {
						hits++
					}
				}
			}
			fmt.Printf("  reader-%d: %d回ヒット\n", readerID, hits)
		}(r)
	}
	readerWG.Wait()

	v, _ := cache.Get("key-0")
	fmt.Println("  最終確認 key-0 =", v)
}
▶ 実行結果を芋るgo run main.go / go run -race main.go / go vet main.go すべお怜蚌枈み
=== SafeCounter: 100 goroutine x 1000回むンクリメント ===
  requests count: 100000 (期埅倀: 100000)

=== Cache: 1 writer -> 5 readers ===
  曞き蟌み完了: 10 keys
  reader-3: 200回ヒット
  reader-4: 200回ヒット
  reader-1: 200回ヒット
  reader-2: 200回ヒット
  reader-0: 200回ヒット
  最終確認 key-0 = value-0

※ reader-Nの出力順序はgoroutineのスケゞュヌリングに䟝存するため実行のたびに倉わりたす。requests countず各readerの200回ヒットずいう倀自䜓はRWMutexが正しく機胜しおいれば垞に䞀臎したす。go run -race main.goでもdata race怜出なしを確認枈みです。

🪜 Step-by-Step 解説

1
Incの Lock/Unlockが守っおいるもの
c.counts[key]++は䞀芋1行の単玔な操䜜に芋えたすが、内郚的には「読み取り→加算→曞き蟌み」の3ステップです。100個のgoroutineがロックなしでこれを同時に実行するず、耇数のgoroutineが同じ叀い倀を読み取っおから加算するため、いく぀かの加算が倱われたすread-modify-writeのrace。mu.Lock()からmu.Unlock()たでを1぀のgoroutineだけが独占するこずで、この3ステップが䞍可分atomicに振る舞うこずを保蚌しおいたす。だからこそ100 x 1000 = 100000ずいう倀が正確に埗られたす。
2
読み取りメ゜ッドValueも保護が必芁な理由
Valueは倀を倉曎しないので「保護䞍芁」に芋えるかもしれたせんが、実際には他のgoroutineが曞き蟌み䞭かもしれないmapに察しおロックなしでアクセスするず、Goランタむムはfatal error: concurrent map read and writeで即座にプロセスを萜ずしたす。これは「間違った倀が読めおしたう」ずいう緩やかな䞍具合ではなく、怜出されたら実行が止たる臎呜的゚ラヌです。読み取りであっおも、曞き蟌みず同じmapにロックなしでアクセスしおはいけたせん。
3
RWMutexで読み取りを䞊行化する
func (c *Cache) Get(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[key]
	return v, ok
}
SafeCounterず同じ発想でMutexを䜿うこずもできたすが、Cacheは「曞き蟌みは最初の1回だけ、その埌は読み取りが倧量に発生する」ずいう非察称なワヌクロヌドです。sync.Mutexだず読み取り同士も盎列化されおしたいたすが、sync.RWMutexのRLockは耇数goroutineが同時に保持できるため、5぀のreaderが互いにブロックし合わずに䞊行しお読み取れたす。Set偎だけLock曞き蟌みロックを䜿うこずで、曞き蟌み䞭は誰も読めない・読み取り䞭は誰も曞けない、ずいう敎合性を保っおいたす。

💡 蚭蚈思想・なぜこう曞くのか

📌
排他制埡の粒床をワヌクロヌドに合わせお遞べるようにする: GoがMutexずRWMutexを分けお甚意しおいるのは、「排他制埡の粒床をワヌクロヌドの性質に合わせお遞べるようにする」ずいう思想の衚れです。読み曞きの比率が均等、あるいはクリティカルセクションが極めお短い堎合はsync.Mutexの方がシンプルでオヌバヌヘッドも小さく、RWMutexを䜿う利点はほずんどありたせん。䞀方、今回のCacheのように「読み取りが曞き蟌みより桁違いに倚い」堎合にのみ、RWMutexのRLock䞊行性が効いおきたす。「たずMutexで曞き、蚈枬しおから必芁ならRWMutexに切り替える」ずいう順番がGoらしいアプロヌチです。最初から最適化を先取りしおRWMutexを遞ぶのではなく、シンプルさを優先し、必芁になっおから道具を持ち替えるずいう考え方は、Goの暙準ラむブラリ党䜓に䞀貫しおいたす。
📌
defer Unlock()はdefer Done()ず同じ理由: Lock()の盎埌にdefer Unlock()を曞く慣習は、sync.WaitGroupのdefer wg.Done()ず同じ理由に基づきたす。クリティカルセクションの途䞭でpanicが起きおも、deferがあればロックは確実に解攟されたす。これを怠るず、ロックを持ったたたpanicしたgoroutineが終了し、他の党goroutineが氞遠にLock()でブロックし続けるデッドロックになりたす。

🌐 他蚀語ずの比范

芳点GoJavaPythonJavaScript/Node.js
基本的な排他ロックsync.MutexLock/Unlocksynchronizedブロック たたは ReentrantLockthreading.Lockacquire/release、with lock:掚奚シングルスレッドのむベントルヌプのため通垞䞍芁Worker Threads間はSharedArrayBuffer+Atomicsが必芁な特殊ケヌスのみ
読み取り優遇ロックsync.RWMutexRLock/RUnlock, Lock/UnlockReentrantReadWriteLockthreading.LockにRWMutex盞圓の暙準型はなく、サヌドパヌティreaderwriterlock等が必芁該圓なし
再入可胜性䞍可同じgoroutineが2回Lockするずデッドロックsynchronized/ReentrantLockは再入可胜同䞀スレッドなら倚重ロック可Lockは非再入・RLockは再入可胜な別クラスずしお提䟛該圓なし
ロック解攟忘れ察策defer mu.Unlock()をLock盎埌に曞く芏玄蚀語機胜ではなく慣習try { synchronized } finally { }、たたはtry-with-resources的パタヌンwith lock:コンテキストマネヌゞャで自動解攟該圓なし

Goのsync.Mutexが非再入可胜non-reentrantである点はJavaのsynchronizedず倧きく異なりたす。Javaでは同じスレッドが再垰的に同じロックを取埗しおも問題ありたせんが、Goで同じgoroutineが保持䞭のロックをもう䞀床Lock()しようずするず、自分自身の解攟を氞遠に埅぀デッドロックになりたす。これはGoが「ロックの状態を暗黙的に远跡する仕組みどのスレッドが持っおいるか等」をあえお持たず、シンプルなカりンタ的実装に留めおいるためです。再入が必芁になる蚭蚈自䜓を避け、ロックを取る責務を1぀の関数に閉じ蟌める、ずいうのがGoらしい回避策です。

🏆 実務での䜿いどころ

  • むンメモリキャッシュ・蚭定のホットリロヌド: Cacheのように、アプリ起動時や定期的に曞き蟌み、リク゚スト凊理䞭は頻繁に読み取るずいう非察称ワヌクロヌドにRWMutexは最適
  • メトリクス・カりンタ集蚈: リク゚スト数・゚ラヌ数などをハンドラから䞊行にむンクリメントするSafeCounterパタヌンは、簡易的な自前メトリクス実装でよく䜿われる本番ではPrometheusクラむアントラむブラリの内郚実装も同様の考え方
  • コネクションプヌル・レゞストリの状態管理: 珟圚䜿甚䞭のリ゜ヌス䞀芧などをmapで管理し、耇数goroutineから登録・解陀・参照が発生する堎面
  • グレヌスフルシャットダりン䞭のフラグ管理: 「シャットダりン䞭かどうか」のようなフラグを耇数goroutineから読み曞きする堎合ただし単玔なbool 1぀なら次々回孊ぶsync/atomicの方が適しおいるこずも倚い

⚠ よくある誀解・ミス

誀解・ミスなぜ起こるか正しい理解
読み取りメ゜ッドはロック䞍芁だず思い蟌む「倀を倉曎しないなら安党」ずいう盎感で曞いおしたう他のgoroutineが曞き蟌み䞭の可胜性がある以䞊、読み取りも必ず保護する。mapぞの無保護な䞊行アクセスはfatal error: concurrent map read and writeでプロセスが萜ちる
RWMutexを垞にMutexより速いず思い蟌む「読み取り専甚ロックがあるから垞に有利」ずいう名前からの誀解曞き蟌みが倚い、たたはクリティカルセクションが極めお短いワヌクロヌドではRWMutexの管理オヌバヌヘッドがMutexより遅くなるこずがある。蚈枬せずに遞ばない
同じgoroutine内でLock()を2回呌んでしたう再垰的なメ゜ッド呌び出しなどJavaのsynchronizedの再入可胜な感芚を持ち蟌んでしたうGoのsync.Mutexは非再入可胜。2回目のLock()は自分自身のUnlock()を埅ち続け、デッドロックになる
RLock䞭にLockを呌んでしたう読み取りロック保持䞭に同じgoroutineで曞き蟌みロックを取ろうずする「読んでからその堎で曎新したい」ずいう自然な発想RLockを保持したたたLockは取埗できずデッドロックになる。読み取り→刀断→曞き蟌みが必芁な堎合は、いったんRUnlockしおから改めおLockを取り盎す蚭蚈にする
sync.Mutexやsync.RWMutexを倀ずしお構造䜓ごずコピヌしおしたう構造䜓を関数の匕数や戻り倀ずしお倀枡しした結果、意図せずロックもコピヌされるWaitGroupず同様コピヌ犁止。構造䜓はポむンタ*SafeCounterで受け枡す。go vetのcopylocksが怜出しおくれる

🚀 次のステップ

  • 発展: SafeCounter.Incを、ロックを䜿わずにsync/atomicパッケヌゞのatomic.Int64で曞き換えおみたしょう。単玔な数倀のむンクリメントだけならMutexより軜量に実装できるこずを䜓感しおくださいCacheのような耇合的な状態にはatomicは䜿えず、Mutex/RWMutexが必芁であるこずも合わせお確認したしょう
  • 次回予告: Day 014 — sync.Once, sync.Pool実装問題。初期化を䞀床だけ実行する仕組みず、オブゞェクトの再利甚によるGC負荷軜枛を孊びたす

🎯 自己評䟡

自分の回答

気づき・メモ