基礎●●○○○

スレッド

プロセス内部の実行の流れ。メモリを共有しながら並行に動作する。

概要

スレッドは、1つのプロセスの内部で複数の処理を並行して進めるための実行単位です。プロセスが「資源の入れ物」だとすれば、スレッドは「その中を流れる実行の筋」です。どんなプロセスも最低1本のスレッド(メインスレッド)を持ち、必要に応じて追加のスレッドを生成できます。

プロセスとの決定的な違いはメモリの扱いです。同じプロセス内のスレッドはメモリ空間を共有します。だからスレッド間のデータ受け渡しは変数を読むだけで済み、生成や切り替えも軽量です。その代わり、共有しているがゆえの危険——複数のスレッドが同じデータを同時に触って壊す——が常につきまといます。「マルチスレッド」「スレッドセーフ」といった言葉は、この共有と並行性のトレードオフを扱う文脈で登場します。

なぜ生まれたか

並行処理の単位としてプロセスしかなかった時代、「1つのアプリケーションの中で複数の仕事を同時に進めたい」という需要に対してプロセスは重すぎました。たとえばGUIアプリで「画面の描画を続けながら裏でファイルを読む」ためにプロセスを分けると、生成コストが高いうえ、メモリが隔離されているので読み込んだデータを渡すにもプロセス間通信が必要になります。同じアプリ内の協調作業なのに、他人行儀な仕組みしかなかったのです。

そこで「保護の単位(プロセス)」と「実行の単位」を分離し、1つのプロセスの中に複数の実行の流れを持てるようにしたのがスレッドです。メモリを共有する仲間同士なら、隔離のコストを払わずに軽量に並行処理ができます。CPUがマルチコア化した現代では、スレッドは「複数のコアを実際に同時に使う」ための基本手段にもなっています。

詳細

スレッドが共有するもの・しないもの

同じプロセス内のスレッドは、コード・ヒープ上のデータ・開いているファイルやソケットを共有します。一方で、各スレッドは自分専用のスタック(関数呼び出しの履歴とローカル変数)とCPUレジスタの状態を持ちます。つまり「ローカル変数は自分だけのもの、グローバル変数やヒープ上のオブジェクトはみんなのもの」という構図です。この線引きを理解していることが、マルチスレッドプログラミングの出発点になります。

競合状態とロック

共有メモリの怖さは「競合状態(race condition)」に集約されます。たとえば2つのスレッドが同じカウンタを同時にインクリメントすると、「読む→足す→書く」の3ステップが交錯して片方の加算が消えることがあります。2本の実行の流れが交錯する様子を追ってみましょう。

スレッドB共有カウンタスレッドAスレッドB共有カウンタスレッドA2回インクリメントしたはずが結果は 11。Aの加算が消えた値を読む10値を読む1010 に 1 を足した 11 を書き込む10 に 1 を足した 11 を書き込む

これを防ぐ基本道具が「ロック(ミューテックス)」です。共有データを触る区間を1つのスレッドしか入れないように囲みます。ただしロックには副作用があり、取りすぎれば並行性が失われ、2つのスレッドが互いのロックを待ち合う「デッドロック」でシステム全体が固まることもあります。しかも競合状態のバグはタイミング依存で再現しづらく、テストをすり抜けて本番でだけ発火する——マルチスレッドが「難しい」と言われる理由はここにあります。「スレッドセーフ」とは、複数スレッドから同時に使っても壊れないように設計されていることを指す言葉です。

プロセス・スレッド・イベントループの使い分け

並行処理のモデルは言語や環境によって流儀が分かれます。JavaやC++、Rustはマルチスレッドを正面から使う文化です。JavaScript(Node.jsやブラウザ)は逆に「シングルスレッド+イベントループ」を採用し、I/O待ちを非同期処理でさばくことで、ロックの難しさ自体を回避しました。PythonやRubyにはGIL(グローバルインタプリタロック)があり、スレッドを作ってもCPU処理は同時に1つしか進まないため、CPUを使い切りたい場合はマルチプロセスを選ぶのが定石です。

サーバ設計でもこの選択は現れます。リクエストごとにスレッドを割り当てるモデル(従来のJavaアプリケーションサーバなど)は書きやすい一方、大量の同時接続ではスレッドのメモリコストが効いてきます。nginxやNode.jsは少数のスレッドでイベント駆動に多数の接続をさばくモデルで、いわゆるC10K問題(1万同時接続)への回答として広まりました。どちらが優れているかではなく、処理がCPU中心かI/O中心か、同時接続数はどの程度か、で選ぶトレードオフです。

実務では「自分でスレッドを直接作る」機会は減り、スレッドプール、async/await、goroutineのような高水準の仕組みを使うことがほとんどです。それでも、その下で何本の実行の流れがどのメモリを共有しているのかというスレッドの視点は、パフォーマンス問題や「たまにしか起きない不可解なバグ」を追うときの最後の拠り所になります。