🎈

【Go】排他制御に sync.RWMutex を使う

2021/12/13に公開

まえがき

いきなり超個人的な内容ですが、今年〜今年まで結婚やら子どもが産まれたりやら引っ越しやらなどで個人的に大忙しだったので、技術記事のアウトプット等がおざなりになってしまってました🚙

アドベントカレンダーにも参加予定ですのでリハビリを兼ねて記事を書きます。
お仕事では主にAWSとかGoとかPythonとか社内のルールを決めたりとかエンジニアチームのマネジメントとかしたりされたり色々してます。Yagiです。
どうぞよろしくおねがいします😌

概要

goroutine から共通のデータに対して排他制御をかけるときには sync.Mutex でやれば良いのですが、現在開発中の自社サービスで以下のような要件があったので共有と記録に残しておきます。

今回の前提条件

  • 読み込みが主な処理なのでなるべく並行性を保たせながら実行させたい。
  • 読み込み対象のデータは更新されることが稀によくある
  • 更新が開始された場合は更新完了まで読み込みは待たせたい。
  • 更新開始時に読み込みが実行されている場合は、更新前の値を読み出すことを許容する。

今回の実装方針

色々やり方はあると思いますが sync.RWMutex を使うのが楽だろうということになりました。
書き込み時はクリティカルセクションを守りながら、読み込みルーチンはできるだけ早くさっさと捌いていきたいということです。

その点では sync.RWMutex を使えば楽に実装できるのが嬉しい🎉
といった内容です。(そもそも並行処理自体がややこしいのであんまり使いたくなかったのですが、なるべく速度も犠牲にしたくなかった)

排他制御についてとかはすでに素晴らしい記事があるので解説しません。
sync.WaitGroup とかも使用していますが、今回の記事では取り上げません。

サンプル実装

今回のサンプルではこんな感じのデータがあるとします。datavalue の値が読んだり更新したりするものと思って下さい。

共有のデータ

type Data struct {
	mu    sync.RWMutex
	data  string
	value int
}

読み込み処理

d.mu.RLock() が読み取り用のロックです。
RLock() 同士はブロックせず Rlock() でロックを行なった goroutine 同士であれば並行して処理を進めることができます。
後述の排他ロック( Lock() )を取得しようとする goroutine が来た場合も待ちが発生するので、その点は注意が必要でしょう。

func (d *Data) Read() {
	d.mu.RLock()
	defer d.mu.RUnlock()

	// 読み込みに2秒ほどのオーバーヘッドがあることを想定
	time.Sleep(2 * time.Second)
	println("complete read :", d.data, ",", d.value)
}

書き込み処理

書き込み時は更新のデータ整合性を守るため排他ロック( Lock() )を取ります。
書き込み中(このサンプルでは2秒の間)は他の goroutine からの読み込みも書き込みも出来なくなります。

func (d *Data) Write(v string) {
	d.mu.Lock()
	defer d.mu.Unlock()

	// 書き込みに2秒ほどのオーバーヘッドがあることを想定
	time.Sleep(2 * time.Second)
	d.data = v
	d.value++

	println("complete write :", v, ",", d.value)
}

サンプルコード

サンプルコードは以下です。ループで10回まわすうちに2回だけ書き込みが行われる想定のサンプルです。

package main

import (
	"sync"
	"time"
)

type Data struct {
	mu    sync.RWMutex
	data  string
	value int
}

func (d *Data) Read() {
	println("start reading...")

	d.mu.RLock()
	defer d.mu.RUnlock()

	time.Sleep(2 * time.Second)
	println("complete read :", d.data, ",", d.value)
}

func (d *Data) Write(v string) {
	println("start writing...")
	d.mu.Lock()
	defer d.mu.Unlock()

	time.Sleep(2 * time.Second)
	d.data = v
	d.value++

	println("complete write :", v, ",", d.value)
}

func main() {
	data := Data{
		mu:    sync.RWMutex{},
		data:  "this is initial data",
		value: 1,
	}
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		if i%5 == 0 {
			go func() {
				defer wg.Done()
				data.Write("data has updated")
			}()
		} else {
			go func() {
				defer wg.Done()
				data.Read()
			}()
		}
	}
	wg.Wait()
	println("done")
}

実行結果

実行結果は以下です。

~/src/sample main*
>> go run main.go
start reading...
start reading...
start writing...
start reading...
start reading...
start reading...
start reading...
start reading...
start writing...
start reading...
complete read : this is initial data , 1
complete read : this is initial data , 1
complete write : data has updated , 2
complete read : data has updated , 2
complete read : data has updated , 2
complete read : data has updated , 2
complete read : data has updated , 2
complete read : data has updated , 2
complete read : data has updated , 2
complete write : data has updated , 3
done

~/src/sample main* 9s

読み込みルーチンはできるだけ早く並行してさっさと捌けていることがわかるかと思います。

まとめ

今回のような緩い縛りでの排他制御ではとても楽に実装できましたね。
最近よく思うことですがより簡単に目的を実現させるための仕様や要件の調整もエンジニアの大切なお仕事だなぁと思います🙋🏻‍♂️
言うまでも無いですがこれを sync.MutexLock() だけでやってしまうと読み込みも待たされることになるので goroutine の意味がなくなります。

補足・参考

サンプルソースコード

https://go.dev/play/p/31E9wZUtRAs

参考・補足記事

排他制御について

排他制御について根本から説明されていてとても良い記事で勉強になりました。
https://zenn.dev/satoru_takeuchi/articles/e0636407a0040c

Discussion