yohhoyの日記

技術的メモをしていきたい日記

本当は怖いfuture+promiseの話

C++11標準ライブラリのstd::futurestd::promiseクラス利用に関して、不適切な使用パターンに起因するやっかいな不具合についてメモ。

この不具合はマルチスレッドプログラムの未定義動作(undefined behavior)により引き起こされる。下記例のように一見関係ないコード箇所のバグとして検出される場合もあるが、試験等でバグ検出されずに潜在化することも十分ありえる(実運用でデータ破損や突発クラッシュなどを引き起こす)。

まとめ

  • (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メンバ関数の後半処理が再開されるが、既に同オブジェクトは破棄済みのため未定義動作を引き起こす。処理実行を時系列に並べると次のようになる。

  1. B: 新しいスレッド(A)を生成
  2. A: prm.set_valueによりshare stateをready状態に設定(処理(1)前半)
  3. B: ftr.waitによりshared stateの状態を確認(処理(2))
  4. B: 変数prmのスコープを抜けてオブへジェクト破棄(処理(3))
  5. 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 value r 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イベント でも同等機能を実現している。本記事での例と比較した場合、スレッド間の通知方向が逆向きになっている。