スレッド — 安く分ける代わりに守るべきものが生まれる
一言でいうと
スレッドは、コード、データ、ヒープ、開いているファイルを共有し、スタックとレジスターだけを別に持つ実行の流れです。その共有が、性能の理由であり、バグの理由でもあります。
なぜ必要なのか
プロセスは隔離を与えてくれますが、その代償として通信が高価になります。アドレス空間が別々なので、データをやり取りするにはパイプや共有メモリのような別の仕組みが必要です。Webサーバーのように、同じデータを複数のリクエストが一緒に見る必要があるプログラムでは、このコストが負担になります。
スレッドは隔離を手放す代わりに、このコストをなくします。同じアドレス空間を使うので、ポインターを1つ渡すだけでデータが共有されます。
どう動くのか
1つのプロセスの中のスレッドが何を分け合うのかを整理すると、次のとおりです。
| 共有するもの | 別々に持つもの |
|---|---|
| コード領域、グローバルデータ、ヒープ | スタック |
| 開いているファイルディスクリプター | レジスター、プログラムカウンター |
| シグナルハンドラー、カレントディレクトリ | スレッドローカルストレージ |
スレッドのコンテキストスイッチがプロセスより安い理由は、ここにあります。アドレス空間が同じなので、ページテーブルを差し替える必要がなく、TLBをまるごと空にしなくてもよいからです。
実装モデルも3つに分かれました。ユーザーレベルだけで管理する多対1モデルは、スイッチが極端に安い一方で、1つのスレッドがブロッキングシステムコールを行うとプロセス全体が止まります。LinuxとWindowsが採用した1対1モデルは、ユーザースレッドごとにカーネルスレッドが対応し、本当の並列実行と独立したブロッキングが可能ですが、スレッドの生成コストが相対的に大きくなります。多対多モデルは両者の折衷ですが、実装が複雑で主流にはなれませんでした。最近のゴルーチンやコルーチンは、この論争を言語ランタイムの層でもう一度解こうとする試みと見ることができます。
現場での姿
スレッドを使った瞬間に生まれる新しい責任があります。共有データに対する同期です。2つのスレッドが同じ変数を増やすコードは、機械語で見ると読み出し、加算、書き込みの3段階であり、その間にスイッチが挟まると更新が1つ消えます。この問題はテストではなかなか見つからず、負荷が上がったときにだけ現れるという特徴があります。
もう1つあります。「スレッドを増やせば速くなる」という直感は、CPUのコア数を超えると崩れます。コアより多い実行可能なスレッドは互いを押しのけ合い、コンテキストスイッチとキャッシュ汚染を増やすだけです。CPUバウンドな作業のスレッド数はコア数付近が出発点で、I/Oバウンドなら待ち時間を考慮してさらに増やせます。この区別なしに「スレッドプールのサイズ200」のような設定をコピーしてくることが、よくある事故の原因です。
続くクイズで確認すること
スレッドが何を共有し、何を別に持つのか、そしてその選択がどんなバグを呼ぶのかを区別できるか確認します。