スレッド
Thread ・ すれっど
プロセスの中を流れる実行の筋。メモリを共有したまま、複数の処理を並行に進める単位。
✦ 30秒で試す — 航海Goは同時に10万の処理を走らせても、平気な顔をしている2本とも終わったとき、count はいくつ?予想してみる概要
スレッドは、1つのプロセスの中で複数の処理を並行に進めるための実行の単位です。プロセスが「資源の入れ物」だとすれば、スレッドは「その中を流れる実行の筋」です。どんなプロセスも最低1本のスレッド(メインスレッド)を持ち、必要に応じてスレッドを増やせます。CPUが実際に命令を実行し、スケジューラが切り替えて回す相手は、プロセスではなくこのスレッドです。
プロセスとの決定的な違いはメモリの扱いです。同じプロセスの中のスレッドは、メモリ空間を共有します。だからスレッド間のデータの受け渡しは同じ変数を読むだけで済み、生成や切り替えも軽くなります。その代わり、共有しているがゆえの危険 — 複数のスレッドが同じデータを同時に触って壊す — が常につきまといます。「マルチスレッド」「スレッドセーフ」といった言葉は、この共有と並行のトレードオフを扱う場面で登場します。
なぜ生まれたか
並行処理の単位としてプロセスしかなかった時代、「1つのアプリケーションの中で複数の仕事を同時に進めたい」という需要に対して、プロセスは重すぎました。たとえば GUI のアプリで「画面の描画を続けながら裏でファイルを読む」ためにプロセスを分けると、生成のコストが高いうえ、メモリが隔離されているので読み込んだデータを渡すにもプロセス間通信が要ります。同じアプリの中の協調作業なのに、他人行儀な仕組みしかなかったのです。
そこで「保護の単位(プロセス)」と「実行の単位」を切り離し、1つのプロセスの中に複数の実行の筋を持てるようにしたのがスレッドです。1980年代から90年代にかけて Mach や Solaris などの OS がスレッドを取り入れ、1995年には POSIX スレッド(pthreads)として UNIX 系の標準のインタフェースが定まりました。同じ年に登場した Java は、スレッドを言語の標準機能として最初から備えていました。
2000年代半ばからCPUのマルチコア化が進むと、スレッドの意味はさらに増しました。1本のスレッドは一度に1つのコアでしか動けないので、複数のコアを実際に同時に使う(並列に動かす)には、仕事を複数のスレッドに分ける必要があるからです。
詳細
スレッドが共有するもの・しないもの
同じプロセスの中のスレッドは、コード、ヒープ上のデータとグローバル変数、開いているファイルやソケットを共有します。一方、各スレッドは自分専用のスタック(関数呼び出しの履歴とローカル変数)と、CPUのレジスタの状態(どこまで実行したかを示すプログラムカウンタを含む)を持ちます。
つまり「ローカル変数は自分だけのもの、ヒープ上のオブジェクトやグローバル変数はみんなのもの」という構図です。この線引きはスタックとヒープの区別そのもので、マルチスレッドのプログラムを読むときの出発点になります。あるデータが複数のスレッドから同時に触られうるかは、「どちらの領域にあり、誰から参照されているか」で判断します。
共有の代償 — 競合状態
共有メモリの怖さは「競合状態(race condition)」に集約されます。2つのスレッドが同じカウンタを同時に1増やすと、「読む → 足す → 書く」の3段階が交錯して、片方の加算が消えることがあります。これを防ぐ基本の道具が、共有データを触る区間を一度に1つのスレッドしか通さない排他制御(ミューテックスなどのロック)です。更新が消える様子とロックによる直列化は、排他制御の記事の部品で自分の手で確かめられます。
ただしロックには副作用があります。取りすぎれば並行性が失われ、2つのスレッドが互いの持つロックを待ち合うデッドロックで全体が止まることもあります。しかも競合状態のバグはタイミングに依存して再現しにくく、テストをすり抜けて本番の高負荷のときだけ現れます。マルチスレッドが「難しい」と言われる理由はここにあります。「スレッドセーフ」とは、複数のスレッドから同時に使っても壊れないように作られていることを指す言葉です。
スレッドの一生と切り替え
1本のスレッドがOSのスケジューラのもとでどんな状態を行き来するかを押さえておきましょう。
同じプロセスの中のスレッドへの切り替えは、アドレス空間(ページテーブル)を替えなくてよいぶん、プロセス間の切り替えより軽く済みます。それでも、レジスタを退避して戻す手間や、CPUキャッシュの中身が入れ替わる損失があり、1回あたり数マイクロ秒かかります。スレッドを数千本に増やすと、この切り替えと、スレッドごとに確保されるスタック(1本あたり数百KB〜数MBの仮想メモリ)が効いてきます。
スレッドを直接作らない — プールと軽量スレッド
実務では、処理のたびにスレッドを作って捨てることはあまりしません。あらかじめ決まった本数のスレッドを作っておき、仕事をキューに積んで空いたスレッドに順に回すスレッドプールを使うのが定石です。生成のコストを払わずに済み、同時に動く本数に上限をかけられるので、負荷が急に増えてもメモリを食いつぶしません。Java の ExecutorService や .NET のスレッドプールがその代表です。
もう一歩進んだのが、OS のスレッドより軽い実行単位を言語の処理系が自前で管理する方式です。Go の goroutine は、数KBの小さなスタックから始まり、何十万個でも作れます。Go のランタイムは多数の goroutine を少数の OS スレッドの上に割り振って動かします(M 個の軽量スレッドを N 本の OS スレッドに載せるので M:N スケジューリングと呼ばれます)。goroutine が I/O で待つと、ランタイムはその OS スレッドで別の goroutine を動かします。Java 21 の仮想スレッドも同じ考え方です。プログラマは「1リクエスト1スレッド」の書きやすさのまま、スレッドの重さを気にせずに済むようになりました。
プロセス・スレッド・イベントループの使い分け
並行処理のモデルは、言語や実行環境によって流儀が分かれます。Java・C++・Rust はマルチスレッドを正面から使う文化です。JavaScript(Node.js やブラウザ)は逆に、アプリケーションのコードを1本のスレッドで動かし、I/O の待ちをイベントループと非同期処理でさばくことで、ロックの難しさそのものを避けました(Node.js も、ファイル操作などの裏方には libuv のスレッドプールを使っています)。
Python(標準の CPython)と Ruby には、インタプリタ全体を守る1本のロック — Python では GIL(グローバルインタプリタロック)、Ruby では GVL — があり、スレッドを増やしても Python や Ruby のコードは同時に1本しか進みません。I/O を待つ間はロックが手放されるので、I/O 中心の処理ならスレッドでも速くなりますが、CPU を使い切りたい計算ではマルチプロセスを選ぶのが定石でした。この状況は変わりつつあり、CPython 3.13 では GIL を外したビルド(free-threaded ビルド)が実験的に提供され、3.14 で正式にサポートされる扱いになりました。ただし既定のビルドには今も GIL があり、拡張ライブラリの対応も途上です。Ruby は Ractor という、メモリを共有しない並列の仕組みを別に用意しています。
サーバの設計にもこの選択が現れます。リクエストごとにスレッドを割り当てるモデル(従来の Java のアプリケーションサーバなど)は書きやすい一方、大量の同時接続ではスレッドのメモリと切り替えのコストが効いてきます。nginx は、CPU のコア数ほどのワーカープロセスを立て、それぞれがイベントループで多数の接続をさばく構成で、いわゆる C10K 問題(1万の同時接続)への答えとして広まりました。Node.js は1本のスレッドのイベントループで同じことをします。どれが優れているかではなく、処理が CPU 中心か I/O 中心か、同時接続数はどの程度か、1つが落ちたときに巻き添えを許せるか、で選ぶトレードオフです。
自分でスレッドを直接作る機会は減りましたが、その下で何本の実行の筋が、どのメモリを共有して動いているのかというスレッドの視点は、性能の問題や「たまにしか起きない不可解なバグ」を追うときの最後の拠り所になります。
試問
1つのプロセスの中で、3本のスレッドが並行に動いています。スレッドどうしで共有されるもの(どのスレッドからも同じものが見える)と、スレッドごとに1組ずつ持つものがあります。下の7つが、どちらに当たるかを考えます。
プロセスの中の7つのものを、全スレッドで共有するものと、スレッドごとに持つものに仕分けよ
項目を選んでから、入れる箱を選びます
