sync.Cond/コンディション変数についての解説

Daisuke Maki
10 min readJul 1, 2020

--

sync.Cond(コンディション変数)について、Goをよく使ってる人たちですら「うまく説明できん」という話がmercarigoで出てたので、あとで誰かの役に立てばよいな、という気持ちで新たな解説記事を書いてみたいと思います。

自分はPerl5をバリバリ書いていた時代ではAE::Cond、Goを使うようになってからはsync.Condと、とにかく非同期処理でピタゴラ装置を作らないといけない時はこの仕組みがあったことでロジックを無駄に小難しくしないで実装することができたので、この仕組みは是非広く知られて欲しいのです。

というわけでまたあらたにGoのsync.Condを解説してみます。

なお、Web+DB Press vol 113の私の連載、「Goにいりては…」でもこの話を書いていますが、あちらの記事では Broadcast を使った方法ではなく、 Signal を使った方法について書いてありますので、もしよければこちらもどうぞ

sync.Condを使ったコードの記述とその意味

まずコンディション変数には基本的にひとつしか記述方法がありません(例外は常にあります。またここではBroadcastを使う場合の想定のみです)。それは以下の通り。このコードは何かのお知らせを cond.Wait() でお知らせが来るまで待ち、そのたびに条件を確認しにいくコードです。

cond.L.Lock()
for !条件 {
cond.Wait()
}
cond.L.Unlock()

このコードのポイントは 条件 部分はロックによって守られたクリティカルセクションでないと確認できない、ということです(例:複数のスレッドが同じ変数を参照している)。例えば、この条件を制御する変数に書き込むスレッドと、読み込むスレッドがある場合、ロックによって書き込みと読み込み処理を排他制御する必要がありますね。

このコードを見せるとだいたい「???」という反応をもらいますが、これは forの中の Wait() がわかりにくくしています。例えば以下のようなコードだったらわかりやすいでしょう。

lock.Lock()
if 条件 { // !条件ではなくなってるのに注意
lock.Unlock()
return
}
lock.Unlock()

これは1回しか条件を確認できないですし、条件が負であってもそのままコードが進行してしまいます。条件の確認をし続ける形に戻してみましょう(※1)

lock.Lock() // クリティカルセクションをロックで守る
for !条件 { // 条件が真になるまで確認し続ける
lock.Unlock() // 自分は条件を確認したので、他のスレッドが条件を確認できるように
// ロックは解放する
// なんらかの方法でここで次の確認のタイミングを待つ lock.Lock() // また次の確認のタイミングが来たのでロックを取得する
}
lock.Unlock() // 確認が終わったのでロックを解放する

元のコードより少し行数は増えましたが、あと足りないのは次の確認のタイミングを待つ、という処理だけですね。

定期的にある変数の値が真だったら… というようなコードもかけますが、これは追加でロックが必要だったり、いわゆるビジーループが必要になってしまうのでちょっと無駄なやり方になってしまいます。

lock.Lock()
for !条件 {
lock.Unlock()
for nextCheckLoop := true; nextCheckLoop; {
nextCheckLock.Lock()
if nextCheck {
nextCheckLoop = false
}
nextCheckLock.Unlock()
time.Sleep(50*time.Milliseconds)
}

lock.Lock()
}
lock.Unlock()

余計なロックが必要なのもそうですが、特にビジーループはとにかく避けたい構造です。

ラッキーなことにこの「お知らせを待つ」という機能はGoならばチャンネルで簡単に実装可能ですので、今度はそれを入れてみましょう。

lock.Lock()
for !条件 {
lock.Unlock()
<-ch
lock.Lock()
}
lock.Unlock()

ずっとシンプルになりました。これで任意のタイミングを待つ、まってから条件を確認しにいく、それを何回もやり続ける、ということができますね。

ところがここでチャンネルの特性上ひとつ問題がでてきます。チャンネルを通して一度に通信できるのはひとつのメッセージだけです。同じチャンネルを複数のスレッドから同時に読み込む事はできますが、そのうち「お知らせ」をもらえるのはひとつだけです。

ですが、もし上記のコードを複数goroutineに同時に伝えて、全員がよーいどん、で何か処理をしてほしい時はどうなるでしょう。

あ、今読者のあなたのどや顔が見えました。

「close()だな」「知ってるぞ」とどや顔
「五等分の花嫁」より。残念ながら読んだことはありません…

たしかに書き込みスレッドから上記の ch に対して close() を呼べば <-ch で待機している全てのスレッドが確認を始めます。

…が、ここでやりたい処理は条件がそろうまで何回も繰り返されなければいけないのです。一回 close() してしまったチャンネルは再利用できません。2回目以降のループでは期待した動作にはならないのです。

ということで、実はこの機能を実装するのには単純にチャンネルを使うわけにはいきません。

さて、ここから別の方法でもできるー、できない、という議論を続けることはできますが、ここで主題に戻ります。

sync.Cond とその Broadcast() メソッドはまさにこのために作られているのです。

ここで一番最初の例を思い出してください。

cond.L.Lock()
for !条件 {
cond.Wait()
}
cond.L.Unlock()

実は最初にわかりにくいと思ったこの cond.Wait() は内部的には以下のコードと同等です

cond.L.Unlock()
// お知らせを待つ
cond.L.Lock()

これを元の例にあてはめて、※1のコードと比較すると、全く同じ事をしているのがわかるはずです。

さらに、このお知らせを待つ、という部分はGoランタイムが実装してくれている部分で、何回でも再利用できますし、我々は具体的にどのような実装になっているのかを知る必要はありません。

というわけで結局この仕組みは sync.Cond を使った方式が一番エレガントに書けるというところに戻ります。

cond.L.Lock()
for !条件 {
cond.Wait()
}
cond.L.Unlock()

このように 内部構造がわかるとなぜ sync.Condの どの例も ロックをとって、ループに入って、ループを抜けた後にロックを解放して… という処理をしているのかその理由が理解できるかと思います。

sync.CondとBroadcastの組み合わせの使いどころ

sync.Cond 自体はただの部品ですので、使いどころ、と言われてもなかなか難しいのですが、以下のような条件を満たすコードが必要な場合には自然と選択されるツールになると思います

  1. 大前提: 非同期・並行処理が発生する。
  2. ある変数の状態の遷移(「イベント」)が起こった事実を繰り返し確認する必要がある。
  3. その状態変化が起こるまでは確認スレッドは待機している必要がある。
  4. ただし、確認するタイミングは確認スレッドから見て外部要因によって決まる(例:カウントがある一定の数値を超えた、バッファが埋まった、等)。
  5. 上記の処理を繰り返し行う必要がある

また、Goの場合、以下のような条件も加味するとチャンネルを使うべきかsync.Condを使うべきか判断する条件になると思います

  1. 「お知らせイベント」を発火する時にブロックしたくない(例:イベント自体は一気に100回くらい起こる可能性もあるが、そのイベントから待機していたスレッドは1回だけ動けばよい。チャンネルで実装するとイベント書き込み側が詰まってしまう)
  2. チャンネルで実現できない、「繰り返し同じイベントを待つ」ような条件を満たす必要がある

もっと具体的には例えば、byteをため込んでおく []byteバッファがあって、そこにユーザーが任意のタイミングでデータを追記していきます。

そのバッファの内容をどこかのタイミングでメモリ上からログや別のサーバーに移動させたいが、厳密にユーザーが追記したタイミングでなくてもよい。なんなら、任意の閾値を超えた場合のみログに書き込みたい。

このような場合、考えられる実装は以下のようなイメージになります:

  1. ユーザーからの書き込みがあったタイミングでイベント発生( Broadcast )が起こり、データを書き込むスレッドを起こす
  2. データを書き込むスレッドは、バッファのサイズが閾値を超えていた時だけ書き込みを行う
  3. データ書き込みスレッドは確認と(必要であれば)書き込みが終わったあとは待機状態に戻る。

ちなみにこれは拙作のgithub.com/lestrrat-go/fluent-clientの実装そのままです。

その… つまり… どういうことよ?

ここまで読んでもよくわからない。それは普通の感覚だと思います。なぜなら、私の理解ではこのツールは「必要になるまでどう扱えばいいのか全くわからない」類いのツールだと思っているからです。

ここまで読んでピンと来なかった方は(私の説明能力の欠落もあるかもしれませんが)そもそもこのような並行処理の同期を取る必要性がこれまで発生する状況になってこなかったから、だと私は想像しています。

いいんです、こんな小難しいこと、やらないで済むならそれに超したことはありません。並行処理そのものもそうですが、複雑な事はやらないで済むならそれに超したことはないのです。

ツールベルトに持ってて欲しい

ただ、この手の「必要になるまでいらない」系のツールは、実際必要になった時には本当に強力なアイテムとなります

そしていざ必要になった時にその存在を全く知らなかった、ではそもそも皆さんの選択肢にいれられません。

ですので、私はこのような解説記事を書いて、皆さんの頭の片隅にこのツールの存在を知って欲しいと思っています。「そのとき」が来たらさっと調べられるようにあなたのツールベルトにとりあえずしまっておいてもらえるように。

実は自分がコンディション変数と出会ったのは大学3年の時、OSの実装に関する授業を受けていた時にセマフォア、ミューテックス、クリティカルセクション等の理論について習ってた時でした。そしてそのときは正直なぜこれが必要なのか一切わかりませんでした。ロックをこんな小難しい使い方してたらバグの温床になるんじゃないの?くらいに思っていました。

でもやはり頭のいい人が考え抜いた形は意味があるのです… まさか大人になって非同期サーバーやらなにやらを書き始めた時に「ハッ… これって… あのコンディション変数ってのを使える?」と気づいた瞬間に電撃が走りました。本当にこんな便利なものを標準的な実装として提供しようと思ってくれた人々には感謝感謝しかありません。

そういう感じで sync.Condの解説でした。あんまりピンとこなくても、なんとなくそういうものがあって、本当に便利な時がある、ということだけ覚えておいてもらって、皆さんの頭の片隅にとどめておいてくれればうれしいです。

--

--

Daisuke Maki
Daisuke Maki

Written by Daisuke Maki

Go/perl hacker; author of peco; works @ Mercari; ex-mastermind of builderscon; Proud father of three boys;

No responses yet