👋

スレッドとasyncの違いについて

に公開

スレッドとasyncの違いについて

気になる技術を深堀りするシリーズ第2弾です。
何も分からんフラットな状態から、なるべく追い込みつつ最大限の理解を図ります。
もうスレッドとプロセス周りはコスられすぎて技術記事が世の中にたくさんあるので、メモとしての役割を期待します。

スレッドとは?

プロセス内で動く、「実行の流れ(execution flow)」の最小単位を指す。
1つのプロセスに対して複数のスレッド立つことを、「マルチスレッド」と呼ぶ。

1つのスレッドは1つの命令列(処理)を「逐次的に実行」する。

プロセスとは?

OSに寄って管理される、「実行中プログラムのインスタンス」を指す。
1アプリ1プロセスのイメージ。

独立性

  • 各プロセスは独立したアドレス空間(メモリ空間)を持つ。
  • 1つのプロセスがクラッシュしても、他のプロセスには影響しない。
  • プロセス間での直接的なメモリ共有はできない。

リソース管理

  • プロセスごとに(スタック・ヒープ)、ファイルディスクリプタ、環境変数、カレントディレクトリなどのリソースを管理する。
    • リソースのアサインは、オペレーティングシステム(OS)が行う。

OSによる制御

  • pidで識別される。よくみるkill -9 <pid>とかのpid
  • プロセスの生成・終了・スケジューリングはOSによって制御される。

プロセスのライフサイクル

プロセスの内部構造

主要構成要素

  • コード領域(Text Segment): プログラムの実行コード。
  • データ領域(Data Segment): 静的変数やグローバル変数。
  • ヒープ領域(Heap): 動的に割り当てられるメモリ領域。
  • スタック領域(Stack): 関数呼び出しやローカル変数の管理。

プロセス制御ブロック

OSがプロセスごとに管理するメタデータの塊。

  • プロセスID (PID)
  • プロセス状態 (実行中、待機中など)
  • プログラムカウンタ (PC)
  • 所有リソース
  • 優先度
  • スケジューラ用データ
  • メモリ管理情報

プロセス間通信 IPC (Inter-Process Communication)

プロセス同士は基本的に直接メモリを共有しないが、連携は必要。

主なIPCメカニズム

  1. パイプ: 一方向の通信チャネル。
  2. ソケット: ネットワーク越しの通信。Webアプリケーションなどでよく使われる。
  3. メッセージキュー: メッセージをキューに入れて、他のプロセスがそれを取り出す。OS管理のメッセージ置き場。
  4. 共有メモリ: 複数のプロセスが同じメモリ領域を共有する。高速だが、同期が必要。
  5. シグナル: プロセス間での通知メカニズム。特定のイベントを通知するために使用。

この辺掘り下げ始めたらまじで沼を感じるので、一旦本題のスレッドとasyncに戻る。

プロセスのつらみ

  • プロセス間通信はオーバーヘッドが大きい。
  • プロセスの生成・終了はコストが高い。
  • 連携が難しい。

スレッドとプロセスの関係

プロセスの独立した仮想メモリ空間の中で、スレッドが実行される。

んじゃま、同じプロセス内で何を共有しているのか?

共通項目 説明
コード領域(Text Segment) プログラムのバイナリ本体
グローバル変数・静的変数(Data Segment) 全スレッドが同じ値を参照できる
ヒープ領域(Heap) malloc/newで確保した動的なメモリ領域
オープン中のファイルディスクリプタ 同じプロセス内で開いたファイルやソケットは全スレッドからアクセス可能
カレントディレクトリ 変更は全スレッドに影響する
シグナル処理ハンドラ 同じプロセス内でのシグナル処理は全スレッドに影響する
プロセスID (PID) 全スレッドが同じプロセスIDを持つ

逆に共有しないものは?

独立項目 説明
スタック領域(Stack) 各スレッドは独自のスタックを持つ。関数呼び出しやローカル変数はスレッドごとに管理される。
スレッドIDを 各スレッドは独自のIDを持つ。OSがスケジューリングや管理に使用する。
レジスタ状態 各スレッドは独自のCPUレジスタ状態を持つ。
スレッド固有のローカル変数 各スレッドは独自のローカル変数を持つ。スレッドローカルストレージ(TLS)を使用することもある。
実行状態 各スレッドは独自の実行状態を持つ。実行中、待機中、終了など。

asyncとは?

非同期処理を指す。
内部原理として大きく2つある。

  1. イベントループ型

JavaScript、Python、Node.js、Go Goroutineなど。

イベントループ:

  • 1つのスレッドで「タスクキュー」を順番に回す。
  • IOやネットワーク待ちの場合は、OSに制御を渡して、待機状態に入る。
  • 終わったらコールバックやPromiseで結果を受け取る。
  • IO待ちの間でもCPUは別の処理を進めることができる
I/O待ちとは?

I/O待ちとは、データの読み書きなど、外部リソースとのやり取りを待つ状態を指す。

外部からの応答を待ちつつ、CPU自体は何もやることがない状態。

with open("large_file.txt") as f:
    data = f.read()
# ↑ ここでファイルシステムからデータが届くまでI/O待ち
fetch("https://api.example.com/user")
  .then(res => res.json())
  .then(data => { /* ... */ });
// ↑ サーバーの応答が返ってくるまで、I/O待ち
  1. マルチスレッド/プロセス非同期型

Java、C#、Rustなど。

  • 複数のスレッドやプロセスを使って非同期処理を実現する。
  • 各スレッド/プロセスが独立して実行され、I/O待ちの間も他のスレッド/プロセスが動く。

スレッドとasyncの違い

  • asyncの特徴

    • 原則1スレ度でI/O待ちを効率化。
    • スレッド、プロセスは物理的な並列性
    • asyncは論理的な並列性を提供。
      • したがってCPUバウンドな処理には向かない。 → 重い計算など。
  • スレッド・プロセスの特徴

    • 並列にCPUを使うことができる。
    • メモリ空間の分離や排他制御のコストがある。
特徴 スレッド async
実行単位 プロセス内の実行の流れ イベントループやコールバック
メモリ共有 同じプロセス内で共有 独立したメモリ空間を持つ
スケジューリング OSによるスケジューリング イベントループによるスケジューリング
I/O待ち スレッドはブロックされる イベントループは非同期で待機
リソース管理 OSが管理 言語ランタイムが管理
スレッド数 OSの制限に依存 イベントループは1つで済む
デバッグ スレッド間の競合が発生しやすい イベントループは単一スレッドでデバッグしやすい

実装例

  • Node.jsの非同期処理
    • すべてイベントループ。1スレッドで大量のアクセスを捌く。
  • Pythonのasyncio
    • イベントループ型の非同期処理を提供。
  • JavaのCompletableFuture
    • マルチスレッド/プロセス非同期型の非同期処理を提供。
  • C#のasync/await
    • async/awaitスレッドプールの組み合わせ。
  • Rustのasync/await
    • async/awaitで非同期処理を提供。スレッドプールを使うこともできる。
イベントループわからん

イベントループとは、「タスクキュー」を1スレッドで順番に高速で回す仕組み。
非同期I/Oや、コールバック処理など、「完了したイベント」を拾って処理する。

ながれ

  1. タスクやI/Oリクエストが発生
    → タスクキューやI/Oキューに追加される。
  2. イベントループがキューをチェックし続ける
    → I/Oが終わった、タイマーが発火、ユーザー操作など
  3. 完了したイベントが発生したら「タスク」を実行
    → コールバックやPromiseがループの中で順番に実行される。
  4. イベントループは次のタスクを待つ

さらに

I/O多重化
selectpollepollなどの仕組みを使って、複数のI/O操作を同時に監視する。
→ OSにI/O待ちを任せて、CPUは他の処理を進める。

リアクティブプログラミングとの違い

リアクティブプログラミングは、データやイベントの流れをリアルタイムに反応でつなぐ。

観点 async/await リアクティブプログラミング
コアとなる発想 非同期タスクを効率的よく順番に実行 データやイベントの流れをリアルタイムに反応
処理の単位 タスク データストリーム(Observable)
プログラミングモデル 命令形(imperative) 宣言形(declarative)
実装イメージ async/await, Callback RxJS, Akka Streams, Reactor
用途 I/O待ちの効率化、高スループット UI更新、連続イベント処理
イベント管理 明示的(タスクの完了を待つ) 自動的(データの変化に反応)

リアクティブプログラミングはワケワカメなので、別途やる。

async/awaitとスレッドのバックプレッシャーの制御について

バックプレッシャーとは、システムが処理できる以上のデータが流れ込んだときに、どのように制御するかを指す。

スレッドにおけるバックプレッシャーの対処

よくあるのは「スレッドプール」、「プロデューサー・コンシューマー」パターン。

制御方法

  1. キューサイズ制限
    • バウンデッドキュー(有限長)を使用する
    • 満杯なら、プロデューサー側で待機やエラー処理を行う
  2. スレッドプールのサイズ制限
    • ワーカー数を制限して、それ以上は処理できない状態を明示する
  3. タスクドロップ、レートリミット
    • 古いタスクをドロップする、または新しいタスクの受け入れを制限する

async/awaitにおけるバックプレッシャーの対処

イベントループ+タスク/コルーチン+I/O待ちの組み合わせ。
→ 非同期処理に対応のI/Oリクエストが発生しても、消費側が遅いとバッファオーバーフローする。

制御方法

  1. 非同期キューのサイズ制限
    • 溢れたらプロデューサー側でasyncを控える
  2. ストリーム/Observableのバックプレッシャー
    • プル型にして、消費側がデータを要求するまで待機
  3. awaitによる流量制限
    • awaitを使って、消費側が処理を完了するまで次のタスクを待機
  4. Windowing,Batching
    • データを一定サイズごとにまとめて処理する
    • 例えば、1秒間に100件までのデータをバッチ処理する

async/awaitとスレッドのサイキッットブレーカー

サイキッットブレーカーは、システムの過負荷や障害を防ぐためのパターン。
外部サービスや重い処理へのアクセスで連続失敗や遅延が起きたときに、自動で遮断して消費者側や全体の崩壊を防ぐ。

スレッドにおけるサイキッットブレーカー

各スレッドやワーカープール単位で、外部リソースへのアクセス回数などを監視する。

  • 状態管理: オープン、クローズ、ハーフオープンの3つの状態を持つ。
    • オープン: サービスへのアクセスを遮断中。
    • クローズ: 通常通りアクセス可能。
    • ハーフオープン: 一部のアクセスを試みて、サービスが復旧しているか確認中。

async/awaitにおけるサイキッットブレーカー

非同期タスク、Promiseなどの論理的な単位で状態を管理する。
→ イベントループ内でリクエストごとの失敗回数や遅延などを監視する。

async/awaitとスレッドのメモリ使用量の違い

結論、メモリ消費という観点だけで言えばスレッドのほうが直線的に増える。

スレッドのメモリ使用量

構造

  • 各スレッドごとにスタック領域を確保(1MBとか)
  • スレッドごとに独立したメモリ空間を持つ。

特徴

  • スレッド数が増えると、メモリ消費も直線的に増加する。
  • スレッドのメモリ消費はOSによって管理される。
  • ネイティブスレッドだと、OSのスレッド管理に依存するため、メモリ消費が大きくなることがある。

async/awaitのメモリ使用量

構造

  • 論理的なタスク谷でメモリ上に保持(数KB程度)
  • スタックは共有される

特徴

  • 論理的な同時実行数を増やしても、メモリは激増しない。

スレッドとasyncの例外実装の違い

こちらも原則として、asyncのほうが例外の伝播が簡単。

スレッドの例外処理

  • スレッド内部で例外
    → 他のスレッドは生存している。

  • メインスレッドで例外
    → プロセス全体が終了することがおい

  • スレッドプールで例外
    → タスク単位での例外はあるが、プール全体には波及しない

async/awaitの例外処理

  • async関数内で例外
    awaitで待機しているタスクに伝播する。

  • イベントループで例外
    → イベントループ全体が停止することはないが、未処理の例外はログに出力される。

  • Promiseチェーンで例外
    → チェーンの最後まで伝播する。catchでキャッチ可能。


実際実務レベルだとどうなの?

asyncが重宝されるケース

I/Oバウンドな処理(外部待ち多いとき)

  • WebAPI、DB、ファイルの大量同時アクセス
  • Webサーバの高付加ハンドリング
  • サーバー側のプッシュ通知送信処理

スレッドが重宝されるケース

CPUバウンドな処理(画像処理、計算)

  • 画像変換
  • バッチ集計
  • 並列計算

まとめ

スレッドとasyncは、非同期処理を実現するための異なるアプローチ。

スレッドはプロセス内での並列実行を提供し、CPUバウンドな処理に適し、
一方、asyncはイベントループを使用してI/Oバウンドな処理を効率的に扱うのです。

どちらのアプローチも、特定のユースケースに応じて使い分けることが重要なんだな。

もう忘れつつある鳥頭でした🐦

関連ある他の深堀りトピック
  • リアクティブプログラミング
  • バックプレッシャーの制御方法
  • サイキットブレーカー
  • メモリ使用量の違い
  • スケーラビリティの限界
  • リソースプール: スレッドプール、コネクションプール
  • グリーンスレッド
  • Work Stealing
  • async/awaitの内部実装
  • 例外伝播の違い
  • プロファイリング
  • デバッグの難易度
参考文献

Discussion