C++11標準ライブラリのstd::future
+std::promise
クラス利用に関して、不適切な使用パターンに起因するやっかいな不具合についてメモ。
この不具合はマルチスレッドプログラムの未定義動作(undefined behavior)により引き起こされる。下記例のように一見関係ないコード箇所のバグとして検出される場合もあるが、試験等でバグ検出されずに潜在化することも十分ありえる(実運用でデータ破損や突発クラッシュなどを引き起こす)。
- c++ - race-condition in pthread_once()? - Stack Overflow
- c++ - Non-obvious lifetime issue with std::promise and std::future - Stack Overflow
まとめ:
- (future/promiseに限らず)オブジェクトのライフサイクル管理は 所有スレッド=利用スレッド を基本設計とする。
- スレッドをまたぐ場合はムーブによる “値渡し” を行い、ポインタ型経由や参照型による “参照渡し” は避けること。
- おまけ:オブジェクトを複数スレッド間で共有する場合は、各スレッドから
shared_ptr
経由で該当オブジェクトを保持すればよい。*1
不具合のあるコード
下記は「処理スレッドを新たに作成して初期化→本体処理を実行、呼出側スレッドでは初期化完了までは待機」を実装したコード例。*2
#include <thread> #include <future> // 処理スレッド[A] void process(std::promise<void> & prm) { // 初期化... prm.set_value(); // (1) 初期化完了を通知 // 本体処理... } // 呼出元スレッド[B] std::thread start_async_task() { std::promise<void> prm; std::future<void> ftr = prm.get_future(); std::thread th(process, std::ref(prm)); ftr.wait(); // (2) 初期化完了を待機 return th; // (3) promiseオブジェクト破棄 }
誤った解釈:スレッドAでの通知処理(1) “完了後” に、スレッドBの待機処理(2)ブロックが解除される。2スレッド間で(1)→(2)→(3)が保証されるため問題なし。
ソースコード上では特に問題無いように見えるが、このコードは競合状態(race condition)に起因する未定義動作を引き起こす。一方で偶然期待通りの実行結果となる可能性もあり、不具合の発見・解析・改修は難しいと予想される。
不具合の原因
不具合の直接原因は “promise
オブジェクトprm
のデストラクタ処理” と “同オブジェクトのset_value
メンバ関数呼び出し” による競合状態である。
まずpromise
オブジェクトprm
の生存期間は、スレッドBがstart_async_task
関数を抜けるまでとなる。並行実行されるスレッドA上でset_value
メンバ関数の呼出中に、スレッドBによって同オブジェクトが破棄されると、未定義動作により不具合が表面化する。これはスレッドA:処理(1)の処理途中であっても、スレッドB:処理(2)→(3)まで実行される可能性があるため。
C++11標準ライブラリの仕様上、処理(1)のset_value
メンバ関数が行う2つの処理「shared stateをready状態に設定」と「futureで待機中スレッドのブロック解除」は不可分に実行されるという保証がされない(30.6.4/p6)。このため次の状況が発生する;スレッドAにて処理(1)の前半「shared stateをready状態に設定」が行われた後に、スレッドBにて処理(2)→(3)と実行されてpromiseオブジェクトが破棄される。続いてスレッドAでは処理(1)set_value
メンバ関数の後半処理が再開されるが、既に同オブジェクトは破棄済みのため未定義動作を引き起こす。処理実行を時系列に並べると次のようになる。
- B: 新しいスレッド(A)を生成
- A:
prm.set_value
によりshare stateをready状態に設定(処理(1)前半) - B:
ftr.wait
によりshared stateの状態を確認(処理(2)) - B: 変数
prm
のスコープを抜けてオブへジェクト破棄(処理(3)) - A:
prm.set_value
において当該オブジェクトは破棄済み!(処理(1)後半)
N3337 30.6.4/p6, 30.6.5/p15, 30.6.6/p20より引用。
6 When an asynchronous provider is said to make its shared state ready, it means:
- first, the provider marks its shared state as ready; and
- second, the provider unblocks any execution agents waiting for its shared state to become ready.
void promise
::set_value();
15 Effects: atomically stores the valuer
in the shared state and makes that state ready (30.6.4).
void wait() const;
20 Effects: blocks until the shared state is ready.
修正版コード
このオブジェクト生存期間に起因する不具合は、promise
オブジェクトの所有権を新スレッドへ移動(ムーブ)してしまうことで修正できる。修正箇所を★で示す。
void process(std::promise<void> prm) // ★値渡し { // 初期化処理... prm.set_value(); // 本体処理... } std::thread start_async_task() { std::promise<void> prm; std::future<void> ftr = prm.get_future(); std::thread th(process, std::move(prm)); // ★ムーブ ftr.wait(); return th; }
関連URL
*1:スレッド毎に異なる shared_ptr<T> 型オブジェクトを保持し、それらが同じT型オブジェクトを指す構造。
*2:future+promiseでone-shotイベント でも同等機能を実現している。本記事での例と比較した場合、スレッド間の通知方向が逆向きになっている。