C++のスレッド処理について

AI実装検定のご案内

C++のスレッド処理とは、1つのプログラムの中で複数の処理を並行して動かす仕組みです。

通常のプログラムは、上から順番に処理を実行します。

ある処理が終わってから次の処理に進むため、重い計算やファイル読み込み、ネットワーク通信などがあると、その間ほかの処理が待たされます。

一方、スレッドを使うと、複数の処理を同時進行できます。

たとえば、メインの処理を動かしながら、別のスレッドで重い計算を行ったり、データの読み込みを行ったりできます。

C++では、C++11以降、標準ライブラリだけでスレッド処理を扱えるようになりました。

代表的な機能には、std::threadstd::mutexstd::condition_variablestd::atomicstd::futurestd::async、C++20以降の std::jthread などがあります。

目次

スレッド処理が使われる場面

重い処理を並列化したい場合

画像処理、動画処理、数値計算、大量データの解析など、CPUを長時間使う処理では、複数のスレッドに作業を分けることで処理時間を短縮できる場合があります。

ただし、スレッド数を増やせば必ず速くなるわけではありません。

CPUコア数、メモリ帯域、ロックの競合、タスク分割の粒度などによって、効果は大きく変わります。

I/O待ちを分離したい場合

ファイル読み込み、ネットワーク通信、データベースアクセスなどでは、処理の多くが待ち時間になることがあります。

このような場合、待っている間に別の処理を進めるためにスレッドを使うことがあります。

たとえば、メイン処理とは別に通信処理を走らせることで、プログラム全体の応答性を保てます。

UIの応答性を維持したい場合

GUIアプリケーションでは、重い処理をメインスレッドで実行すると画面が固まってしまいます。

そのため、画面操作を受け付けるスレッドと、重い処理を実行するスレッドを分ける設計がよく使われます。

サーバーで複数の処理を同時に扱う場合

サーバーアプリケーションでは、複数のクライアントから同時にリクエストが来ることがあります。

そのような場合、リクエストごとに処理を分けたり、スレッドプールを使って複数のタスクを並行処理したりします。

C++における基本的なスレッド機能

std::thread

std::thread は、C++でスレッドを作成・管理するための基本的なクラスです。

関数やラムダ式などの処理を渡すと、その処理を別スレッドで実行できます。

ただし、std::thread を使う場合は、スレッドの終了管理が非常に重要です。

作成したスレッドは、基本的に join() または detach() のどちらかで管理状態を解消する必要があります。

join()

join() は、作成したスレッドが終了するまで待つ操作です。

メインスレッド側で join() を呼ぶと、対象のスレッドの処理が終わるまでそこで待機します。

スレッド処理では、最も基本的で安全な終了管理方法です。

detach()

detach() は、スレッドを std::thread オブジェクトの管理から切り離す操作です。

切り離されたスレッドは、バックグラウンドで独立して実行されます。

ただし、detach() は慎重に使う必要があります。

切り離した後は、そのスレッドの終了を待つことができません。

また、切り離されたスレッドがローカル変数や破棄済みオブジェクトにアクセスすると、未定義動作につながる可能性があります。

実務では、detach() を安易に使うよりも、join()std::jthread、スレッドプール、タスクキューなどを使って寿命を明確に管理する方が安全です。

std::threadで注意すべき重要な仕様

join()detach()をしないまま破棄すると異常終了する

std::thread で特に重要なのは、スレッドを所有している std::thread オブジェクトが、joinable() なまま破棄されると std::terminate() が呼ばれるという点です。

つまり、単に「メイン処理が先に終わるかもしれない」という話ではありません。

C++の仕様上、スレッドを適切に終了管理しないと、プログラムは異常終了します。

そのため、std::thread を使う場合は、必ず次のどちらかを行う必要があります。

join()で終了を待つ

通常はこちらを優先します。

スレッドが終わるまで待つため、スレッド内で使うデータの寿命を比較的管理しやすくなります。

detach()で管理から切り離す

これは必要な場合だけ使うべきです。

detach() を使うなら、スレッドが参照するデータの寿命、終了条件、例外処理、プログラム終了時の扱いを明確に設計しておく必要があります。

ラムダ式とスレッド処理

ラムダ式はスレッド処理でよく使われる

C++では、スレッドに処理を渡すときにラムダ式を使うことがよくあります。

ラムダ式を使うと、短い処理をその場で定義できるため、スレッド処理との相性がよいです。

値キャプチャと参照キャプチャの違い

ラムダ式では、外側の変数を値でコピーすることも、参照で扱うこともできます。

スレッド処理では、この違いが非常に重要です。

値キャプチャの場合、基本的には変数のコピーをスレッド側に渡します。

そのため、元の変数がスコープを抜けても、コピーされた値はスレッド側で使えます。

一方、参照キャプチャの場合、スレッドは元の変数そのものを参照します。

そのため、スレッドが動いている間に元の変数が破棄されると、破棄済みのメモリにアクセスする危険があります。

値キャプチャでも完全に安全とは限らない

値キャプチャは安全寄りですが、万能ではありません。

たとえば、ポインタを値キャプチャした場合、コピーされるのはポインタの値だけです。

ポインタが指しているオブジェクトの寿命までは保証されません。

そのため、スレッドに渡すデータについては、「何をコピーしているのか」「参照先の寿命は保証されているのか」を常に意識する必要があります。

データ競合とは

複数スレッドが同じデータを同時に触る問題

C++のスレッド処理で最も重要な問題の1つが、データ競合です。

データ競合とは、複数のスレッドが同じデータに同時にアクセスし、そのうち少なくとも1つが書き込みを行うにもかかわらず、適切な同期が行われていない状態を指します。

たとえば、複数のスレッドが同じカウンタを同時に増やすような処理は、見た目より危険です。

一見すると単純な加算でも、内部的には「読み込み」「加算」「書き戻し」のような複数の操作に分かれます。

複数スレッドが同時にこれを行うと、更新が失われる可能性があります。

C++ではデータ競合は未定義動作

重要なのは、C++ではデータ競合が単なる「結果のズレ」ではなく、未定義動作になるという点です。

未定義動作とは、プログラムの動作が仕様上保証されない状態です。

期待した値にならないだけでなく、プログラム全体が不安定になる可能性があります。

そのため、共有データに複数スレッドからアクセスする場合は、必ず適切な同期が必要です。

std::mutexによる排他制御

mutexは共有データを守るための仕組み

std::mutex は、複数スレッドから共有データに同時アクセスされないようにするための仕組みです。

あるスレッドが mutex をロックしている間、他のスレッドは同じ mutex をロックできません。

これにより、共有データに対する同時アクセスを防げます。

手動のlockとunlockは避けるべき

mutexは手動でロックし、手動でアンロックすることもできます。

しかし、実務ではこの書き方は避けるべきです。

ロックした後、アンロックする前に例外が発生すると、ロックが解除されないままになり、他のスレッドが永遠に待ち続ける可能性があります。

そのため、C++ではRAIIを使ったロック管理が基本です。

std::lock_guard

スコープを使って自動的にロック解除する

std::lock_guard は、スコープに入ったときに mutex をロックし、スコープを抜けると自動的にアンロックする仕組みです。

これにより、例外が発生した場合でも、通常のスタック巻き戻しが行われる限りロックが解除されます。

単純な排他制御では基本的にlock_guardを使う

共有データを短い範囲で保護するだけなら、std::lock_guard が最もシンプルで安全です。

特別な理由がない限り、手動で lock()unlock() を呼ぶよりも、std::lock_guard を使うべきです。

std::unique_lock

lock_guardより柔軟なロック管理

std::unique_lock は、std::lock_guard よりも柔軟なロック管理を行えるクラスです。

途中でロックを解除したり、後からロックしたり、所有権を移動したりできます。

condition_variableと組み合わせるときによく使う

std::condition_variable を使う場合は、通常 std::unique_lock を使います。

なぜなら、条件待ちの間に一時的に mutex を解放し、条件が満たされて起床したあとに再びロックを取得する必要があるからです。

このような柔軟な制御には、std::lock_guard ではなく std::unique_lock が適しています。

std::condition_variable

条件が満たされるまでスレッドを待たせる仕組み

std::condition_variable は、ある条件が満たされるまでスレッドを待機させるための仕組みです。

たとえば、キューにデータが入るまで consumer 側のスレッドを待たせ、producer 側がデータを追加したら通知する、といった使い方をします。

predicate付きwaitを使うのが基本

condition_variable を使うときは、条件式、つまり predicate 付きの wait() を使うのが基本です。

理由は主に2つあります。

1つ目は、spurious wakeup、つまり偽の起床があり得るためです。

通知されていないにもかかわらず、待機中のスレッドが起きることがあります。

2つ目は、通知の取り逃がしを防ぐためです。

通知が先に発生し、その後で待機に入るような設計だと、スレッドが永遠に待ち続ける可能性があります。

そのため、condition_variable では、「通知そのもの」を信頼するのではなく、「共有状態が条件を満たしているか」を毎回確認する設計にします。

条件はmutexで保護する

condition_variable で待つ条件は、通常、共有変数として持ちます。

たとえば、「キューが空ではない」「終了フラグが立っている」「準備完了フラグがtrueになっている」といった状態です。

この共有状態は、mutexで保護する必要があります。

条件の変更と確認が適切に同期されていないと、正しく待機・通知できません。

std::atomic

単純な値を安全に扱うための仕組み

std::atomic は、複数スレッドから安全に読み書きできる値を扱うための仕組みです。

単純なカウンタやフラグであれば、mutexを使わずに std::atomic で対応できる場合があります。

代表的な用途は、次のようなものです。

  • 処理件数のカウント
  • 停止フラグ
  • 状態フラグ
  • 単純な統計値

atomicは万能ではない

std::atomic は便利ですが、万能ではありません。

複数の値をまとめて一貫した状態として扱いたい場合、個々の値を atomic にするだけでは不十分です。

たとえば、残高とポイントを同時に更新するような処理では、片方だけ更新された中途半端な状態を他のスレッドが見る可能性があります。

このような場合は、mutexで構造全体を保護する方が自然です。

atomicが常に高速とは限らない

std::atomic は軽量に見えますが、常に mutex より速いとは限りません。

複数のCPUコアから同じ atomic 変数を高頻度で更新すると、キャッシュライン競合が起こり、性能が落ちることがあります。

大量のカウント処理では、スレッドごとにローカルカウンタを持ち、最後に集約する方が速い場合もあります。

std::asyncstd::future

結果をあとで受け取る非同期処理

std::async は、処理を非同期に実行し、その結果を std::future として受け取るための仕組みです。

「別スレッドで処理を行い、あとで結果を受け取りたい」という場合に便利です。

起動ポリシーを明示した方がよい

std::async を使うときは、非同期実行したいなら起動ポリシーを明示する方が安全です。

起動ポリシーを省略すると、実装によってはすぐに別スレッドで実行される場合もあれば、結果を取得するタイミングまで処理が遅延される場合もあります。

つまり、std::async を使ったからといって、必ず別スレッドで動くとは限りません。

並列実行を明確に意図するなら、非同期実行のポリシーを指定するべきです。

futureをすぐ破棄しない

std::async が返す std::future を受け取らずにすぐ破棄すると、期待したような非同期処理にならない場合があります。

特に、非同期実行された処理に対応する future が破棄されるとき、処理の完了を待つ挙動になることがあります。

そのため、std::async を使う場合は、返された future を保持し、適切なタイミングで結果を取得する設計にするのが基本です。

C++20のstd::jthread

std::threadより安全に扱いやすいスレッド

C++20以降では、std::jthread が使えます。

std::jthreadstd::thread と似ていますが、より安全に使いやすい特徴を持っています。

最大の違いは、破棄時に自動的に停止要求を出し、その後 join() する点です。

jthreadは停止要求を出してからjoinする

std::jthread は、スコープを抜けて破棄されるとき、join可能な状態であれば停止要求を出し、その後スレッドの終了を待ちます。

そのため、std::thread のように join() を書き忘れて std::terminate() が呼ばれる、という事故を避けやすくなります。

停止は強制終了ではない

ただし、std::jthread の停止要求は強制終了ではありません。

スレッド関数側が stop_token を受け取り、停止要求が来ているかを定期的に確認する必要があります。

つまり、std::jthread を使えば自動的に安全に止まるのではなく、スレッド側を協調的停止に対応させる必要があります。

jthreadのデストラクタは待機する

std::jthread は自動で join するため便利ですが、裏を返すと、破棄時にスレッド終了を待ちます。

スレッド側が停止要求を無視して無限ループしている場合、std::jthread のデストラクタで待ち続ける可能性があります。

そのため、std::jthread を使う場合でも、終了条件を明確に設計することが重要です。

メンバ関数をスレッドで実行する場合

オブジェクトの寿命に注意する

C++では、クラスのメンバ関数を別スレッドで実行することもできます。

この場合、スレッドは対象オブジェクトにアクセスするため、そのオブジェクトがスレッドの実行中に破棄されないように注意する必要があります。

特に、オブジェクトへのポインタや参照をスレッドに渡して detach() する設計は危険です。

joinするまでオブジェクトを生かす

メンバ関数をスレッドで実行する場合は、対象オブジェクトがスレッド終了まで確実に生きている必要があります。

基本的には、オブジェクトのスコープ内でスレッドを作成し、同じスコープ内で join() する設計が安全です。

複数スレッドを扱う場合

std::vectorで複数のthreadを管理できる

複数のスレッドをまとめて扱う場合、std::vector<std::thread> のようなコンテナに格納して管理することがよくあります。

作成したスレッドを最後に順番に join() することで、すべての処理完了を待てます。

例外安全に注意する

ただし、複数の std::thread を扱う場合は、例外安全に注意が必要です。

スレッドをいくつか作成した後で例外が発生すると、すでに作成済みのスレッドを join() できないままスコープを抜ける可能性があります。

その場合、std::thread のデストラクタで std::terminate() が呼ばれるおそれがあります。

C++20以降なら、std::jthread を使うことで、このような事故を減らせます。

スレッド数の決め方

CPUバウンドならコア数前後が目安

CPUを多く使う処理では、スレッド数はCPUコア数前後が1つの目安になります。

ただし、常にコア数と同じ数が最適とは限りません。

処理内容によっては、メモリ帯域やキャッシュ競合がボトルネックになり、スレッドを増やしても性能が上がらない場合があります。

I/Oバウンドならコア数より多くてもよい場合がある

ファイル読み込みやネットワーク通信など、待ち時間が多い処理では、CPUコア数より多いスレッドを使っても効果がある場合があります。

ただし、スレッド数を増やしすぎると、コンテキストスイッチやメモリ消費が増え、かえって遅くなることもあります。

hardware_concurrencyはあくまでヒント

C++には、実行環境でサポートされる並行スレッド数の目安を取得する機能があります。

ただし、この値はあくまでヒントです。

環境によっては正確な値ではなかったり、取得できずに0相当の値が返る場合もあります。

そのため、実務では固定値や設定値と組み合わせて調整することが多いです。

デッドロックとは

複数のmutexを取り合って停止する状態

デッドロックとは、複数のスレッドがお互いに相手の持っているロックを待ち続け、処理が進まなくなる状態です。

典型的には、スレッドAがmutex1を持ったままmutex2を待ち、スレッドBがmutex2を持ったままmutex1を待つような状況で発生します。

ロック順序を統一する

デッドロックを防ぐ基本は、複数のmutexを取る順序を常に統一することです。

すべてのスレッドで同じ順番でロックを取得するようにすれば、循環待ちが起きにくくなります。

scoped_lockを使う

C++17以降では、複数のmutexをまとめて安全にロックするために std::scoped_lock が使えます。

複数mutexを扱う場合は、手動で順番にロックするよりも、こうした標準機能を使う方が安全です。

ライフタイム問題

スレッドが参照するデータの寿命が重要

スレッド処理では、データの寿命管理が非常に重要です。

スレッドがある変数やオブジェクトを参照している間に、その参照先が破棄されると、未定義動作につながります。

これは、参照キャプチャ、ポインタ渡し、メンバ関数呼び出し、detach() を使った処理で特に起こりやすい問題です。

detachと参照の組み合わせは危険

切り離されたスレッドがローカル変数を参照する設計は、非常に危険です。

関数が終了するとローカル変数は破棄されます。

しかし、detach() されたスレッドはその後も動き続ける可能性があります。

その結果、破棄済みの変数にアクセスしてしまうことがあります。

値渡しや所有権管理を使う

安全性を高めるには、スレッドに必要なデータを値として渡す、または所有権を明確に管理する必要があります。

共有所有が必要な場合は、スマートポインタを使うこともあります。

ただし、スマートポインタを使えばすべて解決するわけではなく、共有データへの同時アクセスには別途同期が必要です。

スレッド処理と例外安全

例外でjoinが飛ばされる可能性がある

std::thread を使う場合、スレッド作成後に例外が発生すると、join() が呼ばれないままスコープを抜けてしまう可能性があります。

その場合、std::thread が joinable なまま破棄され、std::terminate() が呼ばれるおそれがあります。

RAIIでスレッド管理する

この問題を避けるには、スレッドの終了処理もRAIIで管理するのが有効です。

C++20以降であれば、std::jthread を使うことで、自動的に停止要求と join が行われます。

C++17以前では、スコープを抜けるときに自動で join するラッパークラスを用意することがあります。

スレッドプール

スレッドを使い回す仕組み

スレッドプールとは、あらかじめ複数のワーカースレッドを作成しておき、タスクをキューに積んで順番に処理する仕組みです。

毎回スレッドを作成するのではなく、既存のスレッドを使い回すため、大量の小さなタスクを処理する場合に有効です。

大量のthread作成は避けるべき

タスクごとに毎回 std::thread を作る設計は、コストが高くなりやすいです。

スレッド作成にはオーバーヘッドがあり、数千・数万のスレッドを作ると、メモリ消費やスケジューリングコストが大きくなります。

大量のタスクを処理する場合は、スレッドプールを使う設計が一般的です。

学習用と実務用は分けて考える

簡単なスレッドプールは、学習用としては有用です。

しかし、実務で使うには、戻り値、例外処理、停止制御、キューサイズ制限、キャンセル、優先度、タスク投入終了後の扱いなどを考える必要があります。

単純なサンプルをそのまま本番環境で使うのは避けるべきです。

Producer-Consumerパターン

データを作る側と処理する側を分ける設計

Producer-Consumerパターンは、スレッド処理でよく使われる設計です。

Producerはデータやタスクを作る側、Consumerはそれを取り出して処理する側です。

両者の間には、通常、スレッドセーフなキューを置きます。

共有データを直接触らせない

Producer-Consumerパターンの利点は、複数スレッドが同じデータを直接触る設計を避けやすいことです。

データの受け渡しをキューに集約すれば、同期の範囲を限定できます。

condition_variableとの相性がよい

Consumerは、キューが空の間は待機します。

Producerがデータを追加したら、Consumerに通知します。

このような設計には、std::condition_variable がよく使われます。

std::coutとマルチスレッド

出力が混ざる可能性がある

学習用のサンプルでは、複数スレッドから std::cout に出力することがよくあります。

ただし、複数スレッドから同時に出力すると、表示内容が混ざる可能性があります。

たとえば、あるスレッドの出力の途中に、別のスレッドの出力が割り込むことがあります。

ログ出力も同期対象になる

スレッド処理でログを出す場合は、出力処理も同期の対象として考える必要があります。

C++20以降では std::osyncstream を使う選択肢があります。

C++17以前では、出力用の mutex を用意することが一般的です。

実務で重要な設計方針

まず共有データを減らす

スレッド処理で最も安全なのは、そもそもデータを共有しない設計です。

各スレッドが独立したデータを処理し、最後に結果だけ集約する形が理想です。

共有データが少ないほど、mutexやatomicに頼る場面も減り、バグも減ります。

共有するなら所有者を明確にする

共有データを使う場合は、誰が読み、誰が書き、どのmutexで守るのかを明確にする必要があります。

「どのスレッドからでも自由に更新できるグローバル状態」は、非常にバグを生みやすい設計です。

mutexはデータとセットで管理する

mutexだけを単独で置くと、どのデータを守るためのmutexなのかが曖昧になりがちです。

実務では、共有データとそれを守るmutexを同じクラスや構造に閉じ込め、外部から直接触れないようにする設計が望ましいです。

ロック範囲を小さくする

mutexでロックしている間は、他のスレッドが同じ共有データにアクセスできません。

そのため、重い計算や外部I/Oをロック中に行うと、性能が大きく低下します。

ロックするのは、共有データを読み書きする最小限の範囲に限定するべきです。

ロック中に外部コードを呼ばない

ロックを保持したままコールバック関数や外部ライブラリを呼ぶのは危険です。

呼び出し先で別のmutexを取得したり、同じオブジェクトに再入したりすると、デッドロックや予期しない挙動につながる可能性があります。

C++スレッド処理でよくある誤解

スレッドを増やせば必ず速くなるわけではない

スレッドを増やしても、CPU、メモリ、I/O、ロック競合のどこがボトルネックかによって効果は変わります。

むしろ、スレッドを増やしすぎると、コンテキストスイッチや同期コストで遅くなることがあります。

atomicを使えば何でも安全になるわけではない

std::atomic は単一の値に対する安全な操作には向いています。

しかし、複数の値をまとめて一貫性のある状態として扱う場合は、atomicだけでは不十分です。

そのような場合は、mutexや設計そのものの見直しが必要です。

detachは便利なバックグラウンド実行ではない

detach() は、スレッドを管理から切り離す操作です。

終了を待てなくなるため、データの寿命や終了条件が不明確なまま使うと危険です。

「とりあえず裏で動かしたい」という理由で使うべきではありません。

asyncは必ず別スレッドで動くとは限らない

std::async は便利ですが、起動ポリシーを省略すると、実装によっては遅延実行になる可能性があります。

本当に並列実行したい場合は、起動ポリシーを明示する必要があります。

C++スレッド処理の学習順序

最初に学ぶべき内容

まずは、std::threadjoin()detach() の基本を理解するのがよいです。

ただし、この段階で必ず、std::threadjoin() または detach() しないまま破棄すると std::terminate() が呼ばれる、という仕様も理解しておくべきです。

次に学ぶべき内容

次に、データ競合、std::mutexstd::lock_guard を学ぶとよいです。

スレッドを作れるだけでは不十分で、共有データを安全に扱えなければ実用的なコードは書けません。

その次に学ぶべき内容

その後、std::unique_lockstd::condition_variablestd::atomic を学ぶと、より実用的な並行処理を書けるようになります。

特に condition_variable は、Producer-Consumerパターンやタスクキューの実装で重要です。

C++20以降ならjthreadも学ぶ

C++20が使える環境なら、std::jthread も早めに学ぶ価値があります。

std::thread より安全に扱いやすく、協調的停止の考え方も身につきます。

まとめ

C++のスレッド処理は、複数の処理を並行して実行するための強力な仕組みです。

基本になるのは std::thread ですが、スレッドを作るだけでは不十分です。

作成したスレッドは、必ず join() または detach() で管理する必要があります。

特に、std::thread が joinable なまま破棄されると std::terminate() が呼ばれる点は重要です。

共有データを扱う場合は、データ競合に注意する必要があります。

C++ではデータ競合は未定義動作になるため、mutex、lock_guard、atomic、condition_variableなどを適切に使って同期する必要があります。

単純な排他制御には std::mutexstd::lock_guard、条件待ちには std::condition_variable、単純なフラグやカウンタには std::atomic、結果をあとで受け取る非同期処理には std::futurestd::async が使えます。

C++20以降であれば、std::jthread も有力です。

std::jthread は破棄時に停止要求を出し、自動的に join してくれるため、std::thread より安全に扱いやすい場面があります。

ただし、停止は強制ではなく、スレッド側が協調的に停止要求を確認する必要があります。

実務で最も重要なのは、スレッドを増やすことではなく、共有状態を減らし、所有者を明確にし、ロック範囲を小さくし、終了条件を明確にすることです。

C++のスレッド処理は非常に強力ですが、そのぶんライフタイム問題、データ競合、デッドロック、例外安全などの落とし穴も多くあります。

まずは std::threadjoin()std::mutexstd::lock_guardstd::condition_variablestd::atomic を確実に理解し、その上で std::asyncstd::futurestd::jthread、スレッドプールへ進むのがよいです。

以上、C++のスレッド処理についてでした。

最後までお読みいただき、ありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次