Rustの std::sync::RwLock はLinuxでwriter starvation問題を起こす (macOSなら平気)
まとめ:
std::sync::RwLock::{write(), try_read()}
を併用した場合には「書き込みロックを最優先」という挙動は必ずしも期待できない (LinuxではNG)- Pthread の規約が挙動に自由度をもたせており、Linuxにおけるデフォルト実装では writer starvation が発生する
- Rustにおいて writer starvation を回避しつつ readers-writer lock を使うには
parking_lot::RwLock
を使うと良い
目次
- 背景: Readers-writer lock とは?
- 背景: Rustにおける readers-writer lock
- 背景: RwLock::write() と RwLock::try_read()
- 再現コード
- 原因分析
- 修正:
parking_lot::RwLock
を使う - おわりに
背景: Readers-writer lock とは?
あるリソースがあり、並列に動作する複数のスレッドからそのカウンタ変数を読み書きしたいとします。
カウンタ変数 c = 1
をリソースの例とします。
スレッド1がカウンタ変数をインクリメントして c = 2
にし、スレッド2がもう一度インクリメントして c = 3
になることが期待結果だとします。
しかしスレッド1とスレッド2が同時にインクリメントを走らせ、どちらも c = 1
の時点でカウンタ変数を読んでしまった場合、結果は c = 2
になってしまいます。
リソースを複数スレッドで更新する場合、よく使われるのは排他ロック (mutex) ですね。
上記の例でも、各スレッドが c
を読む前に排他ロックを獲得し、更新が完了したら排他ロックを解放すれば、必ず c = 3
の結果が得られます。
しかし、多くのスレッドがリソースに対して読み取りアクセスのみをし、少ないスレッドが書き込みアクセスをするようなケースでは、排他ロックよりも効率の良いロックがあります。それが readers-writer lock です。
ロックの獲得を待っている間は、プログラムで本当に行いたい処理ができないので、できる限りロック待ちの時間は短くしたいです。ただ待ってるだけなら自分のプログラムにしか迷惑を書けませんが、spin waitでロックが空くのを待ってしまうと、OS上の他のプログラムに割当てられるはずだったCPU時間まで奪ってしまいます。
Readers-writer lock では、読み取りロック (reader lock) を取るスレッドしかいない場合にはロック待ちが発生しません。書き込みロック (writer lock) を獲得しているスレッドが1つでも存在した場合、その間は他のスレッドは読み取りロックも書き込みロックも獲得できません。
この性質から、読み取りロックは shared lock, 書き込みロックは exclusive lock とも呼ばれます。
背景: Rustにおける readers-writer lock
std::sync::RwLock が通常使われます。
上記ページ Examples からの引用ですが、こんなセマンティクスで読み取りロックと書き込みロックを取得します。
1 | use std::sync::RwLock; |
背景: RwLock::write() と RwLock::try_read()
「リソースを更新する頻度は少ないが、更新したいときは(読み取りを止めて)最優先で更新したい」というケースはよくあるものです。
その場合は、リソースを更新する側のロックには std::sync::RwLock::write() を、読み取る側のロックには std::sync::RwLock::try_read() を使うと良いと考えられます (筆者は考えました)。RwLock::write()
はブロッキングコールであり、書き込みロックが獲得できるまでロック待ちをします。 RwLock::try_read()
はノンブロッキングコールです。書き込みロックが獲得されていなければロック取得できるのはもちろん、書き込みロックが取得されている場合は、ロック待ちなしでエラー (Err
) が返却されます。
しかし std::sync::RwLock::write()
と std::sync::RwLock::try_lock()
の併用では、プラットフォームによっては 「更新が最優先にならない」 という事象を発見しました。
再現コード
短いのでまず再現コードを貼ります。下記のコードは、macOSだと期待通りに終了し、Linuxだと終了せずに走り続けます。
1 | use std::{process::exit, sync::Arc, thread}; |
まず30個 (CPUコア数より多ければいくつでも良い) の読み取りスレッドを立ち上げます。読み取りスレッドは無限ループの中で try_read()
を発行し続けます。
次に1個の書き込みスレッドを立ち上げます。書き込みスレッドは、 write()
で書き込みロックの獲得に成功したらその直後にプロセスを exit
します。
「更新が最優先」の挙動になるならば、 exit
が呼ばれてプロセスが終了します。これが期待挙動です。
しかしLinuxでは write()
がいつまで経っても成功せず、プロセスは終了しません。
原因分析
std::sync::RwLock::write()
の実装を追うと、libcの pthread_rwlock_wrlock()
を呼び出している箇所 にたどり着きます。pthread_rwlock_wrlock()
のマニュアルを読むと、
Implementations may favor writers over readers to avoid writer starvation.
とあります (強調は筆者による)。
つまり、「書き込み側を読み取り側よりも優先するかどうかは実装次第」ということです。
Writer starvationというのは、「書き込み側が、読み取り側に邪魔されて、いつまで立ってもロック獲得の機会を与えられない」状況のことです。
macOSの実装では writer starvation が発生しない、つまり書き込み側が読み取りに優先するのですが、Linuxはそうはなっていないというのが原因でした。
上記再現コードでは、CPUコアよりも多くのスレッドが無限ループで (CPU時間を明け渡すことなく) 読み取りロックを獲得しています。Linuxでは writer starvation が起こるケースです。
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP
属性 をセットすれば writer starvation を避けられそうですが、Rustで素直に解決するための方法を以下に記載します。
修正: parking_lot::RwLock
を使う
parking_lot::RwLock を見ると、
This lock uses a task-fair locking policy which avoids both reader and writer starvation.
とあります (強調は筆者による)。
Writer starvation を避けられるように作られており、実際 parking_lot::RwLock
を使用するように再現コードを書き換えれば、期待通りLinuxでもプロセスが終了するようになります。
おわりに
業務で作っている IoTや車載機のためのストリーム処理系SpringQL のデバッグ中にこの問題を発見しました。
自分の開発PC (macOS) では快調に動くのに、CIの ubuntu-latest では毎回テストが刺さっていて、(動くと思ってるのは自分だけで本当は世界の誰も動かせないのでは…?)と疑心暗鬼になりながらのデバッグでした。
Linuxだけ書き込みロック獲得がどうも遅い (ちょうど4秒くらい待たされる) と気づいてからは実装依存の箇所を探そうと思い至り、そこからは楽しく修正できました。
再現コード・修正コードはこちらのリポジトリに置いています。お手元の環境でもお試しください。