yohhoyの日記

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

threadの利用と例外安全(その1)

C++11標準ライブラリとBoost.Threadライブラリ(Boost 1.48.0)に含まれる、threadオブジェクトのデストラクタの振る舞いと例外安全に関するメモ。

2020-12-02追記:C++2a(C++20)標準ライブラリでは、デストラクタで自動的にjoinを呼び出すstd::jthreadが追加される。std::thread動作はC++11時点と同一。

2013-02-05追記:Boost.Thread 1.50.0〜1.56.0では記事内容に関する破壊的変更が行われる。id:yohhoy:20120206 も参照のこと。

std::threadboost::threadのデストラクタは、それぞれ下記の動作を行う。C++0xドラフト段階ではstd::threadboost::threadと同じ動作仕様だったが、N2802の指摘をうけてC++11標準ライブラリの仕様に変更された経緯がある。

std::thread
threadオブジェクトに対してjoindetachのいずれも行われていなければ、デストラクタはstd::terminateを呼び出してプログラムを終了する。(明示的にjoin/detach済みオブジェクトの場合は、デストラクタは何もしない。)
boost::thread
デストラクタは常にdetachを呼び出す。このときスレッド処理実行中であれば、threadオブジェクト生存期間とは無関係にスレッド処理は継続していく。(join済みオブジェクトの場合は、既にスレッド処理は終了している。)

一見するとstd::threadの動作仕様は不便に思えるが、boost::threadの動作仕様の元では例外安全という観点から注意深くコーディングしないと、非常に深刻な不具合を引き起こしうる。N2802では潜在的セキュリティーホールにもなると警告している*1

「新しいスレッドを作成して2関数を並行処理し、並行処理完了を待って結果を集計する」という単純なシナリオを用いて説明する。
注意:決して “boost::threadは危険だから利用すべきでない” という意味ではなく、“ある利用シナリオでは非常にやっかいなバグの温床となる” ことの説明。

問題のあるコード(std::thread版)

std::threadを用いたコードでは、funcB関数が例外を送出するとプログラムが終了してしまう。これはプログラマが意図した動作ではないが、コードに問題があることにすぐ気付くことができる。

#include <thread>

void funcA(int& a) { /* 処理A; 結果をaに代入 */ }
void funcB(int& b) { /* 処理B; 結果をbに代入 */ }

int call()
{
  int a, b;
  std::thread th1(funcA, std::ref(a));
  funcB(b);  // (1) funcBが例外送出すると...
  th1.join();
  return (a + b);
  // (2) join前のth1デストラクタが呼ばれ、
  //     std::terminateによりプログラムが終了する。
}

潜在的な問題のある危険なコード(boost::thread版)

boost::threadを用いたコードでは、funcB関数が例外を送出してもプログラム処理は継続する。一方、変数th1が指していたスレッドはdetachされてfuncA関数の処理を継続し、変数aへの代入により意図しないメモリ領域(=かつてcall関数のローカルな変数aが存在していたメモリ位置)が上書きされてしまう。

#include <boost/thread/thread.hpp>

void funcA(int& a) { /* 処理A; 結果をaに代入 */ }
void funcB(int& b) { /* 処理B; 結果をbに代入 */ }

int call()
{
  int a, b;
  boost::thread th1(funcA, boost::ref(a));
  funcB(b);  // (1) funcBが例外送出すると...
  th1.join();
  return (a + b);
  // (2) th1デストラクタによりth1.detach()が呼ばれ、スレッド実行は継続する。
  // (3) 関数callを抜けるため変数a, bの生存期間が終了する。
  // (4) 別スレッド上の処理Aは元aのメモリ領域を上書きしてしまう!
}

多くの処理系では自動変数をスタック上に配置し、関数が呼び出されたときに必要なスタック領域を確保し(wind)、関数を抜けるときはスタックを巻き戻す(unwind)ため、自動変数が位置していたメモリ領域は高確率で再利用される。さらに、関数呼び出しからの戻り先アドレス(return address)も同じスタック領域に格納している処理系も多く、この戻り値アドレス格納領域も自動変数と並んで配置される。

このような処理系においては、funcA関数実行中スレッドが変数aに代入するタイミングでメインスレッドは別のfuncC関数を実行していた場合、funcC関数の自動変数もしくは戻り先アドレスが意図せず書き換えられてしまう。しかもこの意図しないメモリ破壊は非決定的に行われるため、「プログラム実行のたびに正常終了することもあれば、予期できない出力結果となったり、また突然クラッシュするこもとある」という非常に不安定な動作として表面化する。

//(続き)
void funcC() { int x, y, z; /* 処理C */ }

int main() {
  try { call(); } catch (...) {}
  funcC();
  return 0;
}

thread::join()メンバ関数の罠

boost::threadオブジェクトのjoinメンバ関数例外を送出する可能性がある。このため、下記のような問題が無さそうなコードでも、前述のような意図しないメモリ破壊が引き起こされる懸念がある。(std::threadjoinメンバ関数でも例外std::system_errorを送出する可能性があるためBoost.Threadに限った話ではないが、こちらは意図しないプログラム終了となる。)

#include <boost/thread/thread.hpp>

void funcA_nothrow(int& a) { /* 例外を投げない処理A; 結果をaに代入 */ }
void funcB_nothrow(int& b) { /* 例外を投げない処理B; 結果をbに代入 */ }

int call()
{
  int a, b;
  boost::thread th1(funcA_nothrow, boost::ref(a));
  boost::thread th2(funcB_nothrow, boost::ref(b));
  // 例外を投げない処理
  th1.join();  // ★例外boost::thread_interruptedを送出する可能性がある
  th2.join();
  return (a + b);
}

その2 に続く。

関連URL

*1:コールスタック上の戻り先アドレスを書き換えた場合、原理的には任意のコード位置にプログラム制御を移すことができる。