C++標準ライブラリはスレッド操作を包括的にサポートしており、特にstd::threadやstd::asyncが頻繁に利用されます。『Effective Modern C++』では、スレッドのネイティブなローカル処理が必要でない限り、タスクベースのプログラミングを推奨しており、std::asyncの使用が望ましいとされています。本記事では、std::asyncを中心にその仕組みと使用法を解説します。
std::asyncはタスクベースの抽象化により、std::threadよりも柔軟性を持ち、非同期結果の取得が可能です。クロージャをstd::asyncに渡すと、std::futureオブジェクトが返され、wait()メソッドでスレッド終了を待機し、get()で結果を取得できます。
std::futureは以下の3つの状態を持ちます:
- std::future_status::ready:タスク完了
- std::future_status::timeout:タイムアウト
- std::future_status::deferred:実行未開始
状態確認にはwait_for()メソッドを使用します。
auto result = std::async(performTask);
... // 他の処理
result.wait(); // バリア設定
auto value = result.get(); // 結果取得
システムがサポートするスレッド数には限界があり、それを超えるとstd::system_error例外が発生します。また、ハードウェアスレッド数を超えるソフトウェアスレッドの生成(オーバーアロケーション)も問題となることがあります。
std::asyncには2つの起動戦略があります:
- std::launch::async:別スレッドでの実行
- std::launch::deferred:get()またはwait()呼び出し時に実行
デフォルトでは両方の戦略をORで許容しており、これはスレッドの作成・破棄をライブラリに任せることでリソース管理を簡略化します。この柔軟性がstd::asyncの利点です。
注意点として、デフォルト戦略ではスレッドの実行状態(同期/非同期)を把握できないため、予測困難な挙動が生じる可能性があります。
std::futureの破棄時、std::threadと異なり例外を発生しません。これは、futureが通信チャネルの一端であり、計算結果を送信する側(被呼ぶ側)がstd::promise経由で共有状態(shared state)に結果を書き込み、呼び出し側がfutureで読み取る仕組みだからです。
共有状態は通常ヒープ上に配置されますが、標準では具体的な実装は規定されていません。以下は関係者のイメージ図(破線=情報流れ):
std::futureが以下の条件を満たす場合、デストラクタで暗黙のjoinが実行されます:
- 共有状態がstd::async呼び出しで生成された場合
- 起動戦略がstd::launch::asyncの場合
- その共有状態を参照する最後のfutureである場合
std::atomicは原子的な操作を可能にし、読み書きやインクリメント/デクリメントが1つの不可分な操作として行われます。これによりデータ競合による未定義動作を防げます。
std::atomicは移動構築や移動代入をサポートせず、load()・store()メソッドで明示的に操作します。一方、volatile修飾変数は原子性を保証しません。
以下はメモリオーダーの例:
int importantValue = calculateImportantValue(); // 値計算
readyFlag = true; // 別スレッドに通知
このコードでは、コンパイラが命令順序を変更する可能性があります。これを防ぐにはstd::atomicを使用します:
std::atomic<bool> readyFlag(false);
int importantValue = calculateImportantValue(); // 値計算
readyFlag = true; // 別スレッドに通知
volatileはメモリ読み込みの最適化を無効化するだけで、メモリオーダーの制御はできません。
要約すると:
- std::atomic:ロックを使わずにスレッド安全な変数操作を実現
- volatile:特別なメモリ領域の最適化を防ぐためのキーワード
本記事は『Effective Modern C++』を参考にしつつ、筆者の考察を加えたものです。