Chapter 02無料公開

スレッド(Thread)

このチャプターでは、新しいスレッド(Thread)の制御やカレントスレッドの操作機能を紹介します。

スレッド(Thread)制御

threadはプログラム実行時に新しいスレッドを作成し、またそのスレッドの完了を待機するために用いるクラスです。threadクラスはプログラム中で実行されるスレッドそのものではなく、threadに紐づけられたスレッドを他スレッドから制御するための ハンドル型です。

  • コンストラクタ:別スレッドにて並行実行する処理(関数やラムダ式)を指定して新しいスレッドを作成・開始します。
  • joinメンバ関数:別スレッドにて並行実行される処理(関数やラムダ式)の完了を待機します。
#include <iostream>
#include <thread>
using namespace std;

void task()
{
  // 新しいスレッドで実行される処理
  cout << "This is worker-thread" << endl;
}

int main()
{
  // C++プログラム開始直後はメインスレッド1つだけ存在する。

  // 新規スレッドを作成し、そのスレッド上でtask関数を実行開始する。
  // 呼出側では新規スレッド作成完了を待機せずに実行を継続する。
  thread thd{ task };
  // 事後条件:変数thdは新規作成スレッドと紐づけられた状態

  // メインスレッドで実行される処理
  cout << "This is main-thread" << endl;

  // 変数thdに紐づく別スレッド上の処理(task関数)が完了するまで、
  // 呼出側であるメインスレッド実行を休止して待機する。
  // 別スレッド上の処理が完了済みの場合は待機せずに実行継続する。
  thd.join();
  // 事後条件:変数thdはどのスレッドも管理しない
}

threadデストラクタ

前掲サンプルコードでメインスレッドのthd.join()呼び出しを忘れると、threadデストラクタがterminate関数を呼び出してプログラムは異常終了します。threadオブジェクトが破棄されるより前に、join(またはdetach)メンバ関数が確実に呼び出されることをプログラマが保証しなければなりません。

C++例外送出による実行パスはソースコードから直接読み取ることが難しく、threadクラスを直接利用する場合は細心の注意が必要となります。例えば前掲コードの "メインスレッドで実行される処理" から例外送出されると、thd.join()呼び出しがスキップされてthdのデストラクタが呼び出されます。

きめ細かいスレッド制御が必要なければ手軽なfuture<R>を返すasync関数や、高機能な自動Joinスレッドjthreadクラス[C++20]も検討候補としてください。

スレッド・グループ

複数のスレッドをまとめて制御するケースでは、threadを配列やコンテナに格納して一括管理します。このとき、スレッド作成ループのイテレーション中に値が変わる変数idは、ラムダ式へコピーキャプチャする必要があります。もし変数idを参照キャプチャとするとデータ競合による未定義動作を引き起こす、つまり壊れたマルチスレッドプログラムとなってしまいます。

#include <iostream>
#include <thread>
#include <vector>
using namespace std;

int main()
{
  // threadの可変長配列(vector)
  vector<thread> thds;

  // id=1..5のスレッドを作成
  for (int id = 1; id <= 5; id++) {
    // ループ変数idは必ずコピーキャプチャする
    thds.emplace_back([&,id]{
      // 新しいスレッドで実行される処理
      cout << "Worker#" << id << endl;
    });
  }

  // 全てのスレッド処理完了を待機
  for (auto& t : thds) {
    t.join();
  }
}

前掲マルチスレッド処理の実行結果は、"Worker#N"がスレッド作成順で一行づつ出力されるとは限りません。プログラムを実行するたびに各スレッドからの出力は順不同となり、時には"Worker#Worker#12"のように出力が混在することもあります。マルチスレッドプログラミングにおいては、あなたが書いた順序通りに都合よくスレッド実行されるといった幻想は捨ててください。異なるスレッド間の実行順が重要であれば、その順序性を保証するのはプログラマであるあなたの仕事です。

新規スレッド上でのメンバ関数呼び出し

threadコンストラクタにはクラスの非staticメンバ関数を直接指定できません。クラスCのオブジェクトobjの非staticメンバ関数mfを呼び出すには、メンバ関数ポインタ&C::mfと対象オブジェクトへのポインタ&objの2つを指定する必要があります。特定のオブジェクトに紐づかないstaticメンバ関数であれば、通常関数と同じようにthreadコンストラクタへ指定できます。

新規スレッド上でメンバ関数を呼び出す場合は、ラムダ式を用いるとコード意図がわかりやすくなります。

#include <iostream>
#include <thread>
using namespace std;

// 2つの値の加算結果を求めるクラス
class Adder {
  int a_, b_;  // 入力パラメータ
  int r_ = 0;  // 計算結果
public:
  Adder(int a, int b) : a_(a), b_(b) {}
  int result() const { return r_; }

  void process()
  {
    // 新しいスレッドで実行される処理
    r_ = a_ + b_;
  }
};

int main()
{
  Adder adder{1, 2};

  // 新規スレッド上でadder.process()を呼び出す。
#if 0
  // メンバ関数へのポインタとオブジェクトへのポインタを渡す
  thread thd{ &Adder::process, &adder };
#else
  // ラムダ式を利用したほうが意図が明確になる
  thread thd{ [&]{ adder.process(); } };
#endif
  // C++言語仕様では下記のような記述はできない
  //   thread thd{ adder.process };  // NG
  // 下記はメインスレッドがprocessメンバ関数を呼び出す
  //   thread thd{ adder.process() };  // NG

  // メインスレッドで実行される処理
  cout << "calculating..." << endl;

  // 別スレッド処理完了を待機してから結果取得
  thd.join();
  cout << adder.result() << endl;  // 3
}

クラスCの非staticメンバ関数R C::mf(Args...)は架空の関数R vmf(C*, Args...)のようにみなせます(実際には異なる型です)。つまりthreadコンストラクタには、この架空の関数へのポインタとthis相当のオブジェクトポインタを指定しています。詳細仕様はinvoke関数の動作仕様を参照ください。

別スレッドへの引数値渡し

threadコンストラクタの第2引数以降に引数を与えると、別スレッドで実行される関数やラムダ式への引数リストとして伝搬されます。一般的な関数呼び出しにおける引数指定とは異なり、threadコンストラクタに与える引数は常に値渡し(pass-by-value)になります。左辺値(変数var)を指定するとコピーされた値が、右辺値(式move(var)や一時オブジェクト)を指定するとムーブされた値が新規スレッド側へと渡されます。

#include <iostream>
#include <memory>
#include <thread>
#include <utility>
using namespace std;

void task(shared_ptr<int> sp, unique_ptr<int> up)
{
  // spはコピーされるため参照カウント(use_count)=2
  // upはムーブのみ/コピー不可のunique_ptrポインタ
  cout
    << *sp << " " << sp.use_count() << endl  // 100 2
    << *up << endl;                          // 200
}

int main()
{
  shared_ptr<int> sp = make_shared<int>(100);
  unique_ptr<int> up = make_unique<int>(200);  // [C++14]

  // 新規スレッドを作成し、そのスレッド上でtask関数を実行開始する。
  // task関数の引数としてspはコピー、upはムーブした値を渡す。
  thread thd{ task, sp, move(up) };

  //...
  thd.join();
}

make_unique<T>関数はC++14標準ライブラリで追加されました。C++11環境ではunique_ptr<int>{new int{200}}と記述する必要があります。もしくは次の簡易実装スニペットを利用ください(C++14機能と完全互換ではありません)。

namespace cxx14 {
  template<class T, class... Us>
  inline std::unique_ptr<T> make_unique(Us&&... args)
  { return std::unique_ptr<T>(new T(std::forward<Us>(args)...)); }
}

別スレッドへの引数参照渡し

新規スレッド側で引数を参照型として受け取るには、threadコンストラクタ呼び出し時にref関数cref関数を利用する必要があります。

別スレッドへ参照型を渡す方法としては、参照キャプチャを行うラムダ式([&]{...})を指定する実装もあります。当然ですがラムダ式の中では通常の関数呼び出し規則が適用されます。どちらのスタイルを利用するかは、ユーザの好みやプロジェクト規約にお任せします。

#include <functional>
#include <iostream>
#include <string>
#include <thread>
using namespace std;

struct Item {
  string name;
  int price = 0;
};

void bake(Item& item)
{
  item.name = "baked-" + item.name;
  item.price *= 2;
}

int main()
{
  Item item{ "apple", 150 };

  // 新規スレッド上でbake関数を実行する。
  // bake関数の引数にitemへの参照を渡す。
#if 1
  thread thd{ bake, ref(item) };
  // 下記呼び出しはコンパイルエラーを引き起こす
  //   thread thd{ bake, item };  // NG
#else
  // ラムダ式内では普通の関数呼び出しでよい
  thread thd{ [&]{ bake(item); } };
#endif

  cout << "baking..." << endl;
  // 別スレッド処理完了を待機してからitemを読み取る
  thd.join();
  cout << item.name << "@" << item.price << endl;  // baked-apple@300
}

新規スレッドで実行される関数の引数がconst左辺値参照型(const T&)で受ける場合、1)cref関数を通すとthreadコンストラクタで指定した "左辺値オブジェクトへのconst参照" を、2)直接指定すると "コピーされた一時オブジェクトへの参照" が関数に渡されます。

void process(const T& t);

T obj;
// 1) オブジェクトobjへのconst参照が渡される
thread thd1{ process, cref(obj) };
// 2) objからコピーされた一時オブジェクトへの参照が渡される
thread thd2{ process, obj };

発見困難なバグにつながる可能性もあるため、スレッド境界をまたぐ参照型の受け渡しは極力避けた方がよいでしょう。

スレッド間での戻り値返却

threadクラスだけの利用では、別スレッドで実行される関数の戻り値は破棄されます。ナイーブな解決策としては、戻り値を受け取る変数を参照キャプチャしたラムダ式を利用する実装が考えられます。

スレッド境界をまたぐデータ受け渡しの汎用的な仕組みとして、future<R>packaged_taskクラスの利用も検討ください。

#include <iostream>
#include <string>
#include <thread>
using namespace std;

string greeting()
{
  return {"Hello, world!"};
}

int main()
{
  string result;

  // 新規スレッド上でgreeting関数を実行する。
  // greeting関数の戻り値はresultに格納する。
  thread thd{ [&]{ result = greeting(); } };
  // 下記ではgreeting関数戻り値は無視される
  //   thread thd{ greeting };

  // 別スレッド処理完了を待機してからresultを読み取る
  thd.join();
  cout << result << endl;  // "Hello, world!"
}

異なるスレッド間での例外送出

C++の例外(Exception)は、スレッド単位に閉じたエラーハンドリング機構です。threadコンストラクタに指定した関数やラムダ式が例外送出すると、terminate関数が呼び出されてプログラム異常終了してしまいます。例外をexception_ptrクラスで持ち運べば、通常の戻り値のように取り扱うことはできます。

スレッド境界をまたぐデータ受け渡しの汎用的な仕組みとして、future<R>packaged_taskクラスの利用も検討ください。

#include <exception>
#include <thread>
using namespace std;

// int値を返すか例外送出する関数
int do_something();

int main()
{
  int result;
  exception_ptr ep;

  // 新規スレッド上でdo_something関数を実行する。
  // 正常終了の戻り値はresultに、例外送出時はepに格納する。
  thread thd{[&]{
    try {
      result = do_something();
    } catch (...) {
      ep = current_exception();
    }
  }};
  // 下記では例外送出時にプログラムが異常終了する
  //   thread thd{ do_something };  // NG

  // 別スレッド処理完了を待機してからresult/epを読み取る
  thd.join();
  if (ep) {
    // 例外処理を行う
  } else {
    // 戻り値resultを利用する
  }
}

スレッドID

threadクラスを連想コンテナ(mapunorderd_mapなど)で扱うために、整数値のように大小比較したりハッシュ値を計算したりできるスレッドIDが提供されます。

スレッドIDはOSやC++ランタイムによって自動採番されるため、人間が取り扱いやすい値域とは限りません(例えば140319001130816のような値)。またスレッドIDからthreadオブジェクトへと戻すことはできません。

#include <functional>
#include <map>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
using namespace std;

// スレッドIDをキーにしたスレッド名管理テーブル
map<thread::id, string> thdname_tbl;
mutex thdname_guard;

thread fork_task(function<void()> task, string name)
{
  thread thd{ move(task) };
  { // スレッド名管理テーブルに追加
    lock_guard<mutex> lk{thdname_guard};
    thdname_tbl[thd.get_id()] = move(name);
  }
  return thd;
}

int main()
{
  // 初期化としてメインスレッドIDを登録
  thdname_tbl[this_thread::get_id()] = "(main)";

  //...
}

スレッド休眠(Sleep)

this_thread名前空間では、呼び出したスレッド(カレントスレッド)を指定時間もしくは指定時刻まで休眠(Sleep)させる関数が提供されます。

スレッド休眠処理の時間管理に用いられるタイマーは、指定した時間長/時刻きっかりに厳密制御されるわけではありません。一般的なOSでは10ミリ秒程度のタイマー精度で制御されると期待できます(例:タイマー精度10ミリ秒の環境で500ミリ秒間の休眠指示を行うと、実際には500~510ミリ秒間のスレッド休眠が行われます)。

#include <chrono>
#include <iostream>
#include <thread>
using namespace std;
using namespace std::chrono_literals;  // [C++14]

int main()
{
  // メインスレッドを100msec間休眠×10回(計1秒)する
  for (int i = 0; i < 10; i++) {
    this_thread::sleep_for(100ms);
    cout << "." << flush;
  }
  cout << " done!" << endl;
}

この実装ではsleep_for関数呼び出しごとに休眠時間長の上振れ誤差が生じます。より厳密な時間制御を必要とするなら、処理開始直前にchrono::steady_clock::now関数で基点時刻(base)を取得し、絶対時刻base + 100ms * iまでsleep_until関数で休眠させます。

Fire-and-Forget実行

joinメンバ関数の代わりにdetachメンバ関数を用いると、threadオブジェクトとスレッドの紐づけを切り離すことができます。切り離されたスレッドの処理はそのまま継続しますが、threadクラス経由での制御は行えなくなります。

#include <thread>
#include <iostream>
using namespace std;
using namespace std::chrono_literals;  // [C++14]

void fire_and_forget(string msg)
{
  thread thd{[msg]{
    // 新しいスレッドで実行される処理
    this_thread::sleep_for(1s);
    cout << msg << endl;
  }};

  // thdオブジェクトとスレッドを切り離す
  thd.detach();
}

int main()
{
  fire_and_forget("detached thread");

  // メインスレッドで実行される処理
  this_thread::sleep_for(2s);
  cout << "done!" << endl;
}

main関数の終了はexit関数呼び出しに相当し、Detachされたスレッドの処理継続中の場合は未定義動作を引き起こすリスクがあります。プログラム終了にquick_exit関数を用いると問題が緩和される可能性もありますが、スレッドのdetach操作は本当に必要とされるケースでのみ利用を検討ください。condition_variableクラスpromise<R>クラスなどは、スレッドローカルストレージを利用するDetach済みスレッドで生じる順序問題を回避するための特殊機能を提供します。

その他の提供機能

ここまでで紹介しなかったスレッド関連機能として、下記の関数群があります。

  • joinableメンバ関数threadオブジェクトがスレッドと紐づくか否かを返します。スレッドと紐づいている場合は、オブジェクト破棄より前にjoinまたはdetachメンバ関数呼び出しが必要です。
  • thread::hardware_concurrency関数:プログラムで利用可能なプロセッサコア数を取得します。ヒント情報となっており、一部環境では値0を返す可能性があります。
  • this_thread::yield関数:カレントスレッドの再スケジュールを指示します。ビジーループなどの特殊な状況下でのみ利用されます。

C++標準ライブラリで出来ないこと

C++11以降は動作環境によらないマルチスレッド処理を記述できるようになりました。一方で各種環境が提供する共通機能しか標準化されておらず、C++標準ライブラリのみでは細かい動作調整を行えないケースもあります。

  • スレッドの実行優先度(Priority)指定をサポートしません。
  • スレッドのCPUアフィニティ(Affinity)指定をサポートしません。
  • スレッドのスタックサイズ指定をサポートしません。
  • スレッドへの名前設定をサポートしません(特にデバッグ時に有用です)。

もし動作環境OSがこれらの機能をネイティブAPI提供するなら、threadオブジェクトのnative_handleメンバ関数が返すネイティブハンドルを利用できます。

一部のスレッド制御と代替実装

OSが提供するいくつかのスレッド制御機能は、C++標準ライブラリのthreadクラスでは直接サポートしません。C++標準ライブラリ提供クラスと組み合わせることで相当動作を実現できる機能もあります。

  • 中断状態スレッド作成をサポートしませんが、future<R>クラスlatchクラス[C++20]にて相当機能を実現できます。
  • タイムアウト付きJoin操作は提供されませんが、future<R>クラスのタイムアウトサポートにて相当機能を実現できます。

非協調的な他スレッド制御

C++標準ライブラリでは、他スレッドを外部から中断や休眠させたり強制停止させるといった操作を提供しません。これらの非協調的な操作は本質的に安全でないため、将来的にサポートされる可能性もないでしょう。

  • 他スレッドに対する中断(Suspend)/再開(Resume)や休眠(Sleep)機能は提供されません。
  • 他スレッドを強制停止(Kill)させる機能は提供されません。

上記のような機能が必要になったと感じるのであれば、マルチスレッドプログラムとしては設計不良の兆候を示しています。