Go の RWMutex で読み取りと書き込みを分けてロックする

sync.Mutex は排他制御に使いますが、読み取りが多く書き込みが少ない場面では非効率です。sync.RWMutex は読み取りロックと書き込みロックを分けることで、複数の goroutine が同時に読み取れるようにします。

Mutex の問題点

通常の Mutex では、読み取りだけの処理でも他のすべての処理をブロックしてしまいます。

var mu sync.Mutex
var data int

func read() int {
    mu.Lock()
    defer mu.Unlock()
    return data
}

func write(v int) {
    mu.Lock()
    defer mu.Unlock()
    data = v
}

読み取り同士は競合しないのに、Mutex ではすべてが直列化されてしまいます。読み取りが多いシステムではこれがボトルネックになります。

RWMutex の基本

RWMutex は RLock() / RUnlock() で読み取りロック、Lock() / Unlock() で書き込みロックを行います。

package main

import (
    "fmt"
    "sync"
    "time"
)

var rwmu sync.RWMutex
var counter int

func read(id int) {
    rwmu.RLock()
    defer rwmu.RUnlock()
    fmt.Printf("Reader %d: %d\n", id, counter)
    time.Sleep(100 * time.Millisecond)
}

func write(id int, v int) {
    rwmu.Lock()
    defer rwmu.Unlock()
    counter = v
    fmt.Printf("Writer %d: wrote %d\n", id, v)
    time.Sleep(100 * time.Millisecond)
}

読み取りロック中は他の読み取りも許可されますが、書き込みはブロックされます。書き込みロック中はすべての読み取り・書き込みがブロックされます。

動作の違いを確認

複数の読み取りが同時に実行できることを確認しましょう。

func main() {
    var wg sync.WaitGroup

    // 5 つの読み取りを同時に開始
    for i := 1; i <= 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            read(id)
        }(i)
    }

    wg.Wait()
}

RWMutex を使うと、5 つの読み取りがほぼ同時に実行されます。通常の Mutex では 1 つずつ順番に処理されるため、約 5 倍の時間がかかります。

読み取りと書き込みの混在

実際のシステムでは読み取りと書き込みが混在します。

func main() {
    var wg sync.WaitGroup

    // 複数の読み取り
    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for j := 0; j < 3; j++ {
                read(id)
            }
        }(i)
    }

    // 書き込み
    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := 1; i <= 3; i++ {
            write(0, i*10)
        }
    }()

    wg.Wait()
}

書き込みが発生すると、その間は読み取りもブロックされます。書き込みが完了すると、待機していた読み取りが一斉に再開します。

キャッシュの実装例

RWMutex はキャッシュの実装によく使われます。

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

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

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

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

キャッシュは読み取りが圧倒的に多いため、RWMutex が効果的です。複数の goroutine が同時にキャッシュを参照でき、スループットが向上します。

注意点

RWMutex を使う際にはいくつかの注意点があります。

書き込み優先ではない

Go の RWMutex は読み取り優先です。読み取りが連続すると書き込みが長時間待たされる可能性があります。

ロックのアップグレード不可

RLock を保持したまま Lock に切り替えることはできません。一度 RUnlock してから Lock する必要があります。

書き込みが頻繁に発生する場合や、読み取りと書き込みの比率が近い場合は、通常の Mutex のほうがシンプルで適切なこともあります。使い分けを意識しましょう。