はじめに
アプリ開発を行っていると、「スレッド」という単語が頻繁に登場します。
でも、「スレッドって何?」と聞かれると、意外とうまく説明できなかったりします。
この記事では、スレッドという仕組みを基礎から順番に説明していきます。
ハードウェアの知識がなくても大丈夫なように書いていくので、安心してください(?)。
🍴 身近な例から考えてみる
スレッドを理解するために、まずは料理をする場面を想像してみましょう。
あなたがキッチンで一匹で料理をしているとき、できることは一つずつです。
野菜を切りながら同時に鍋をかき混ぜることはできません。
これがシングルスレッドの状態です。
でも、もう一匹手伝ってくれる猫がいたらどうでしょう?
あなたが野菜を切っている間に、その猫が鍋をかき混ぜられます。
二つの作業が同時進行できるわけです。これがマルチスレッドのイメージです。
ここで大事なのは、「同時に作業できる数は、猫の数で決まる」ということです。
これがスレッドを理解する第一歩です😺
Gemini:「このイラストかわいいやろ」
スレッドには3種類ある
実は、「スレッド」という言葉は文脈によって3つの異なる意味で使われます。これが混乱の元なので、最初に整理しておきましょう。
1. ハードウェアスレッド
CPUが物理的に持っている、同時実行できる数のこと。
「4コア8スレッドCPU」と言ったときの「8スレッド」がこれです。
2. OSスレッド(カーネルスレッド)
オペレーティングシステムが管理する実行単位。
私たちがプログラムで「スレッドを作る」と言ったときは、通常これを指します。
3. ユーザースレッド(グリーンスレッド)
プログラムのランタイムやライブラリが管理する実行単位。
OSからは見えません。
この記事では主にOSスレッドについて説明していきますが、まずはハードウェアの仕組みから理解していきましょう。
CPUの物理的な構造を理解する
コンピュータのハードウェアについて少しだけ説明します。
CPUコアって何?
CPUコアは、実際に計算処理を行うハードウェアの部品です。先ほどの料理の例で言うと、「実際に手を動かせる人」がコアです。
例えば「4コアCPU」と言われたら、「同時に4つの作業ができる人が4人いる」と考えてください。
ハードウェアスレッドって何?
一部のCPUにはハードウェアスレッドという機能があります(Intelの「Hyper-Threading」が有名です)。
これは、1つのコアが2つの作業を素早く切り替えながらこなす技術です。完全な同時実行ではありませんが、効率よく処理できます。
「4コア8スレッドCPU」なら、「4人の作業員がいて、それぞれが2つの作業を並行してこなせる」というイメージです。理論上、最大8つの処理が同時に進められます。
ここで覚えておいてほしいのは、「物理的に同時実行できる数には限界がある」ということです。
OSスレッドという仕組み
さて、ここからが本題です!
私たちがプログラムで「スレッドを作る」と言うとき、それはOSスレッドのことを指します。
これは、CPUのハードウェアスレッドとは別物なんです。
OSスレッドって何をしているの?
OSスレッドは、オペレーティングシステムが管理する実行の単位です。
もう少し正確に言うと、OSスレッドは「CPUに実行してもらう命令の流れ」を表す単位です。プログラムのコードは、必ずどこかのOSスレッド上で実行されています。
各OSスレッドは、以下のような情報を持っています。
- レジスタの状態 - 現在どんな計算をしているかの情報
- スタック領域 - そのスレッド専用の作業メモリ(数百KB〜数MB)
- スレッドID - OSスレッドを識別するための番号
- 実行状態 - 今実行中なのか、待機中なのか、など
- プログラムカウンタ - 次にどの命令を実行するかを示す情報
プログラムのコードは複数のOSスレッドで共有されますが、それぞれのOSスレッドは独自の「作業スペース」を持っているわけです。
重要なポイントは以下の3つです。
- OSスレッドは、コードを順番に実行していく「実行の流れ」を表す
- 一つのアプリ(プロセス)の中に、複数のOSスレッドを作ることができる
- 最終的にコードを実行するのは、常にCPUのコア
つまり、あなたが書いたコードは、必ずどこかのOSスレッドによって実行されているということです。
OSスレッドはいくつでも作れる?
ここが重要なポイントです!
OSスレッドは、ハードウェアスレッドの数を超えて作成できます。例えば、8ハードウェアスレッドのCPUでも、100個のOSスレッドを作ることは可能です。
「あれ?同時に実行できるのは8つまでじゃないの?」と思いますよね。その通りです。
ここで登場するのがスケジューラという仕組みです。
スケジューラの役割
スケジューラは、OSの中にいる「作業の割り振り係」のようなものです。
たくさんのOSスレッドがある場合、スケジューラは次のように動きます。
- 実行したいOSスレッドがたくさんある
- でも、同時に実行できるのは限られた数(ハードウェアスレッド数)だけ
- スケジューラが「次はこのOSスレッドを実行しよう」と決める
- 一定時間実行したら、別のOSスレッドに切り替える
- これを繰り返す
つまり、多数のOSスレッドを高速に切り替えながら、あたかも同時に動いているかのように見せているわけです。
この切り替え作業をコンテキストスイッチと呼びます。
コンテキストスイッチのコスト
コンテキストスイッチは便利な仕組みですが、タダではありません。
OSスレッドを切り替えるとき、OSは以下のような作業をする必要があります。
- 現在のOSスレッドの状態を保存 - レジスタの内容をメモリに書き出す
- 次のOSスレッドの状態を読み込む - メモリから別のOSスレッドの情報を読み出す
- CPUのキャッシュがリセットされる - 高速メモリの中身が無効になる
- 実行を再開 - プログラムカウンタを復元して処理を続ける
この一連の処理には時間がかかります。特に、CPUキャッシュがリセットされるのが痛いです
キャッシュとは、CPUがよく使うデータを一時的に保存しておく高速メモリのこと。これが無効になると、メモリから再度データを読み込む必要があり、処理が遅くなります。
なぜOSスレッドを増やしすぎると遅くなるのか
ここまで読めば、「OSスレッドを増やしすぎると遅くなる」という話の理由がわかるはずです。
例えば、8ハードウェアスレッドのCPUで1000個のOSスレッドを動かそうとすると、どうなるでしょう?
- ほとんどのOSスレッドは待機状態になる
- OSは頻繁にコンテキストスイッチを行う必要がある
- 切り替えのコストばかりかかって、実際の処理が進まない
つまり、作業員が多すぎて引き継ぎばかりしている状態になるわけです。これでは効率が悪いですよね。
OSスレッドのコストまとめ
OSスレッドは「軽量」と言われることもありますが、実際には以下のようなコストがあります。
- 生成コスト - OSがOSスレッドを作る処理には時間がかかる
- メモリコスト - 各OSスレッドがスタック領域(数百KB〜数MB)を消費する
- 切り替えコスト - コンテキストスイッチには確実に時間がかかる
- キャッシュミス - 切り替えのたびにCPUキャッシュが無効になる
- スケジューリングコスト - OSがOSスレッドを管理する処理自体にも負荷がある
だから、無制限にOSスレッドを作ることはできないし、作るべきでもないのです。
スレッドプールという解決策💡
OSスレッドの生成コストを避けるために、多くのシステムはスレッドプールという仕組みを使っています。
スレッドプールの仕組み
スレッドプールは、こんな風に動きます。
- 事前準備 - アプリ起動時に、あらかじめ一定数のOSスレッドを作っておく
- タスクの割り当て - 実行したい処理が来たら、空いているOSスレッドに渡す
- 再利用 - 処理が終わってもOSスレッドは破棄せず、次の処理を待つ
- 動的調整 - 必要に応じてOSスレッド数を増やしたり減らしたりする
これによって、以下のメリットが得られます
- OSスレッドを毎回作る手間が省ける
- 同時実行するOSスレッド数をコントロールできる
- CPU使用率を適切に保てる
- システム全体の効率が上がる
🍽️ レストランのホールスタッフを想像してみましょう!
お客さんが来るたびに新しいスタッフを雇って、帰ったら解雇する、なんてことはしませんよね。あらかじめ必要な人数を確保しておいて、お客さんに対応してもらう。それと同じ考え方です。
ユーザースレッドという仕組み
OSスレッドとは別に、ユーザースレッド(グリーンスレッドとも呼ばれます)という仕組みもあります。これは少し応用的な話ですが、知っておくと理解が深まります。
ユーザースレッドとは
ユーザースレッドは、プログラムのランタイム(実行環境)やライブラリが管理する実行単位です。
ユーザースレッドの特徴は以下の通りです。
- ライブラリやランタイムが管理する
- OSから見えるOSスレッド数は増えない
- 切り替えをライブラリが制御できる
- コンテキストスイッチのコストが低い
例えば、100個のユーザースレッドがあっても、実際のOSスレッドは8個だけ、ということができます。
OSスレッドとユーザースレッドの違い
整理すると、こんな感じです
OSスレッド(カーネルスレッド)
- OSが直接管理する
- コンテキストスイッチもOSが行う
- Swiftの
pthreadがこのタイプ - 生成・切り替えのコストが高い
ユーザースレッド
- ライブラリやランタイムが管理する
- OSから見えない
- 切り替えコストが低い
Swift Concurrencyの仕組みも、このユーザースレッド的な動作を取り入れています。でも、この記事の主題は「スレッドそのもの」なので、詳細は省略します。
Swiftでスレッドはどう見えるか
Swiftは、OSスレッドを直接意識しなくても済むように設計されています。でも、実際にはあなたのコードは必ずどこかのOSスレッド上で動いています。
SwiftでOSスレッドが「見える」ポイントは、こんなところです
-
Thread.currentを使うと、現在どのOSスレッドで実行されているか確認できる -
MainActorのコードは、必ずメインスレッド(特定のOSスレッド)上で実行される - バックグラウンド処理は、OSのスレッドプールで実行される
- 非同期処理(
async/await)では、再開時に別のOSスレッドになることがある
つまり、Swiftの高レベルな仕組み(async/awaitなど)は、このOSスレッドという土台の上に構築されているわけです
まとめ
長くなりましたが、スレッドについて整理してみましょう。
スレッドの整理
3種類のスレッドを理解する
- ハードウェアスレッド - CPUが物理的に持つ同時実行数
- OSスレッド - OSが管理する実行単位(私たちが普段扱うスレッド)
- ユーザースレッド - ライブラリが管理する軽量な実行単位
ハードウェアとソフトウェアの関係
- CPUコアは物理的な実行ユニット
- ハードウェアスレッドは1コアで複数処理を扱う技術
- OSスレッドはハードウェアスレッド数を超えて作成できる
- スケジューラがOSスレッドを切り替えながら実行する
コストと効率
- コンテキストスイッチには確実にコストがかかる
- OSスレッドを増やしすぎると逆に遅くなる
- スレッドプールで効率化できる
- 適切なOSスレッド数を保つことが重要
OSスレッドの仕組みを理解すると、「なぜメインスレッドをブロックしてはいけないのか」「なぜasync/awaitが便利なのか」といったことが、より深く理解できるようになります。
アプリのパフォーマンスや設計を考える上で、この知識はきっと役に立つはずです。
ここまで読んでいただき、ありがとうございました😺
