なにこれ
Go言語による並行処理を1/4くらい読んで、排他処理はチャネルで置き換えたほうがスマートでパフォーマンスが出るケースがあることを学んだ。複数のgoroutineからのアクセスがあるケースでの排他処理について試してメモする。
多分今の時点では思想的に間違っているところがあると思うので、残り3/4を読んで、その2につなげる予定。(とりあえずコードを書きたくなったので書いたところ)
想定
goroutineによってmapへの書き込みが並列に行われるケース
mapは
var mymap = map[string]int{}
こんな感じ
ロックする場合
何も考えずに書き込み時にMutexでロックする
mu.Lock()
mymap[v.key] = v.value
mu.Unlock()
チャネル経由にすることで排他
CRUD操作それぞれのチャネルを作成して、チャネルをselectする
mymapを操作するのはsync.Onceで生成された処理用のルーチン
func generateCRUDRoutine() *MapCRUD {
crudwg = &sync.WaitGroup{}
crudFunc := func() {
mapCRUD = new(MapCRUD)
mapCRUD.create = make(chan myItem)
mapCRUD.update = make(chan updateItem)
mapCRUD.read = make(chan readItem)
mapCRUD.delete = make(chan myItem)
mapCRUD.end = make(chan interface{})
crudwg.Add(1)
go func() {
defer crudwg.Done()
for {
select {
case v := <-mapCRUD.create:
mymap[v.key] = v.value
case v := <-mapCRUD.delete:
delete(mymap, v.key)
case v := <-mapCRUD.read:
value, ok := mymap[v.key]
if ok {
} else {
mymap[v.key] = 0
}
v.rc <- value
case v := <-mapCRUD.update:
if _, ok := mymap[v.olditem.key]; !ok {
mymap[v.olditem.key] = v.newvalue
v.uc <- true
break
} else if v.olditem.value != mymap[v.olditem.key] {
v.uc <- false
break
} else {
mymap[v.olditem.key] = v.newvalue
v.uc <- true
}
}
}
}()
}
once.Do(crudFunc)
return mapCRUD
}
チャネルの生成と、CRUD処理用のgoroutineを一度だけ生成する。生成したチャネルの掃除はしていない。本当はこのルーチンを終了させるためのチャネルを用意して、それをきっかけとしてチャネルの掃除も行う。
この処理は、CRUDを利用するAPIが呼ぶことで、チャネルを取得できる。
Read処理の値返却は、read用のチャネルの中に返却用のチャネルを入れてもらって返却する。存在しないキーを読んだ場合、初期値(0)が初めから入っていたかのように振る舞う。
Updateは更新するつもりの値がすでに書き換えられている場合に失敗する。キーが存在しない場合は作成する。成否はupdate用のチャネルの中に成否を返すためのチャネルを入れてもらって返却する。
ベンチマーク用の呼び出し処理は以下の通り
func crudMapWithChannel(n int) {
numoffail := 0
resetMap()
wg := &sync.WaitGroup{}
chs := generateCRUDRoutine()
for i := 0; i < n; i++ {
wg.Add(1)
proc := func(i int) {
key := fmt.Sprintf("key:%d", rand.Int()%20)
c := make(chan int)
defer close(c)
LOOP:
for {
chs.read <- readItem{key: key, rc: c}
select {
case oldvalue := <-c:
uc := make(chan bool)
defer close(uc)
chs.update <- updateItem{olditem: myItem{key: key, value: oldvalue}, newvalue: oldvalue + 1, uc: uc}
if r := <-uc; r {
break LOOP
} else {
//fmt.Println("Failed to update ", oldvalue, " to ", oldvalue+1)
numoffail++
break LOOP
}
}
}
defer wg.Done()
}
go proc(i)
}
wg.Wait()
fmt.Println("number of fail: ", numoffail)
dumpMap()
}
更新するレコードを20個に制限させることで、updateが頻繁に失敗するのを想定した処理。
ベンチマークを取ってみた結果、今のところ、単純にMutexでロックをかける処理と比べてこの処理は2倍程度遅く、updateの失敗率が9割を超える。
Mutexのほうは、失敗率は0%
$ go test -bench BenchmarkCrudChannel -test.benchtime 2s
1000000 3995 ns/op
$ go test -bench BenchmarkCrudLock -test.benchtime 2s
2000000 1255 ns/op
update失敗時のリトライはしていないので、readとupdateで2回チャネル待ちが入り、このタイミングで恐らく確実にコンテキストスイッチして、他のgoroutineによって対象のレコードが書き換えられるのだろう。
read, update戻り値用のチャネルをバッファ付きmake(chan int,2)
にすると、失敗率が6割に下がった。バッファ付きチャネルはコンテキストスイッチ条件ではない。ということなので割り込まれる割合が減ったのかもしれない。ただ、今回の処理だとチャネルによるリクエストの応答待ちなので、結局コンテキストスイッチが入ると思うのだけれど、理由はまだわからない。そして、CRUDリクエスト側のチャネルをバッファ付きにしたところ(戻り値用のチャネルはバッファ付きのままで)失敗率95%に戻った。
Lockのほうはreadとupdateの間にコンテキストスイッチが入らないのでずるい。ということで、LockとChannelの両方の処理にreadとupdateの間に1nsのスリープを入れてみた。
結果、失敗率が両方とも99%超え。もはや何の実験をしているかわからなくなってきたので、まとめへ。
まとめと思いつき
チャネルによる同期は、Mutexと比べてコストが高い。今回のアプリケーションレベルではおよそ2倍遅くなる。
悲観的な状況で楽観ロックをかけている状況でチャネルを使うとコンテキストスイッチが入ってしまうので、失敗率が高い。そもそもこれは自業自得。
チャネルを使って悲観ロックをかける方法は面倒そう。
チャネルを使って、Mutexを使わずに処理を書くと、排他処理書いてる気がしなくて楽しい(個人の感想です)
次回は、どんな状況でチャネルによる同期処理がイケてる状態になるかを書く予定