並行処理と並列処理 入門 - プロセス・スレッド・非同期の違い

並行処理と並列処理 入門 - プロセス・スレッド・非同期の違い

作成日:
読了:14
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

「非同期にすれば速くなる」「スレッドを増やせば並列で動く」——こうした言葉は、並行並列、そしてプロセススレッド非同期の関係を混同したまま使われがちです。ここを整理できると、「この処理はスレッドで速くなるのか、それともプロセスを分けるべきか」を自分で判断できるようになります。この記事では入門者向けに、用語の定義から具体例までを、各言語の公式ドキュメントや著名な解説を一次ソースにコード例つきで整理します。

並行(concurrency)と並列(parallelism)の違い

まず混同しやすい2語をはっきり分けます。Goの設計者 Rob Pike は、講演「Concurrency is not Parallelism」で次のように定義しています。

  • 並行(concurrency): 独立して実行される複数の処理を組み立てる(composition)こと。プログラムの構造の話
  • 並列(parallelism): 複数の計算を同時に実行する(execution)こと。実際の実行の話

Pike はこれを平易に「並行はたくさんのことを扱う(dealing with)こと、並列はたくさんのことを同時にやる(doing)こと」とも言い換えています。

ポイントは、並行は単一コアでも成立するという点です。1人の料理人がパスタを茹でながら、待ち時間にサラダを刻む——これは同時に手を2つ動かしていなくても「並行」です。一方、並列は物理的に複数の実行主体(CPUコアなど)が必要です。料理人が2人いて、それぞれ別の鍋を同時に扱うのが「並列」です。

観点並行(concurrency)並列(parallelism)
本質処理の構造・組み立て方処理の同時実行
必要なコア数1コアでも成立物理的に複数コアが必要
たとえ1人が仕事を切り替えてこなす複数人が同時にこなす
目的良い設計・待ち時間の有効活用実行時間の短縮(スループット向上)

うまく並行に設計されたプログラムは、コアが増えればそのまま並列実行できることが多く、Pike は「並行設計は並列化を可能にする」と述べています。並行は目的ではなく、良い構造のための手段だと捉えると腑に落ちます。

プロセスとスレッドの違い

並行・並列を実現する土台がOSのプロセススレッドです。

  • プロセス: 実行中のプログラムの単位。独立したメモリ空間を持ち、他のプロセスから直接メモリを触られません
  • スレッド: プロセス内の実行の流れ。同じプロセス内のスレッドはメモリ空間を共有します

この「メモリを分けるか共有するか」の違いが、そのまま性質の違いに直結します。

観点プロセススレッド
メモリ空間分離(独立)共有(同一プロセス内)
生成コスト重い軽い
通信手段IPC(パイプ・ソケット等)が必要変数を直接共有できる
隔離性・安全性高い(1つ落ちても他へ波及しにくい)低い(1つのクラッシュが全体に波及しうる)
データ競合のリスク低い(メモリを共有しない)高い(共有メモリを取り合う)

スレッドは生成が軽く、変数を直接共有できるので手軽です。しかしその共有こそが後述のレースコンディションの温床になります。プロセスはメモリが独立している分安全ですが、生成が重く、やり取りにはIPC(プロセス間通信)という明示的な仕組みが必要です。

コンテキストスイッチ

1つのCPUコアが複数のスレッドやプロセスを「並行」に動かすとき、OSは短時間ごとに実行主体を切り替えています。これをコンテキストスイッチと呼びます。切り替えの際は、レジスタやプログラムカウンタといった実行状態を保存・復元する必要があり、この切り替え自体にコストがかかります

一般に、メモリ空間ごと切り替わるプロセスの切り替えはスレッドの切り替えより重い傾向があります。スレッドを無闇に増やすと、実際の仕事よりも切り替えのオーバーヘッドが目立つようになるのはこのためです。

非同期・ノンブロッキングI/Oとの関係

ここで3つ目の主役、非同期(async)が登場します。非同期を理解する鍵は、処理を2種類に分けることです。

  • I/Oバウンド: ネットワーク・ディスク・DBなど外部の応答待ちが支配的な処理。CPUはほとんど遊んでいる
  • CPUバウンド: 数値計算・画像処理・暗号化などCPUの計算が支配的な処理

同期的(ブロッキング)なI/Oは、応答が返るまでスレッドがその場で待ち続けます。待っている間、CPUは何もしません。ノンブロッキングI/O非同期は、この待ち時間に別の仕事を進める仕組みです。1本のスレッドが「Aの応答を待つ間にBを開始し、Bを待つ間にAの完了を受け取る」といった具合に、待ち時間を埋めていきます。

ここが重要な分かれ道です。

  • I/Oバウンドなら、待ち時間を埋めるだけで速くなるので非同期やスレッドが有効。1コアでも効果が出ます(これは並行)
  • CPUバウンドなら、CPUが常に働いているので待ち時間がありません。速くするには複数コアで同時計算する並列化=プロセスや複数スレッドが必要です

NOTE

非同期はあくまで「待ち時間の有効活用」であり、計算そのものを速くはしません。CPUバウンドな重い計算を非同期にしても、1コアで動く限り合計時間は縮まりません。ここを取り違えると「asyncにしたのに速くならない」という悩みに陥ります。

選び方の目安

状況向いている手段
ネットワーク・DBアクセスが大量(I/Oバウンド)非同期(async)またはスレッド
CPUを使う重い計算(CPUバウンド)マルチプロセス(+複数コア)
大量の同時接続を1台でさばく非同期(イベントループ型)
隔離性・堅牢性を重視プロセス分離

具体例で見る各言語のモデル

言語ごとに「どう並行・並列を実現するか」の設計思想が違います。代表的な3つを、公式ドキュメントで確認した範囲でまとめます。

Python: GILとマルチプロセス

CPythonにはGIL(グローバルインタプリタロック)という仕組みがあり、これは一度に1つのスレッドしかPythonのバイトコードを実行できないようにするロックです。つまり標準のCPythonでは、スレッドを何本立ててもPythonコードの実行は同時に1つで、CPUバウンドな計算はマルチスレッドにしても並列にはなりません。

一方、I/O待ちの間はGILが解放されるため、I/Oバウンドならスレッドや asyncio で十分効果があります。CPUバウンドを本当に並列化したいなら、各プロセスが独立したインタプリタとGILを持つマルチプロセス(multiprocessingを使います。

CPUバウンドはプロセス、I/Oバウンドはスレッド/async
# CPUバウンド: プロセスを分けて複数コアで並列計算
from multiprocessing import Pool
 
def heavy(n):
    return sum(i * i for i in range(n))
 
if __name__ == "__main__":
    with Pool(4) as p:
        print(p.map(heavy, [10_000_000] * 4))  # 4コアで同時に計算

NOTE

Python 3.14 では、GILを無効化したfree-threadedビルドが「公式サポート(ただしオプション扱い)」の段階に入りました(PEP 703 / PEP 779)。ただし標準の配布ビルドは引き続きGILありです。本記事はGILありの標準CPythonを前提に説明しています。

Pythonの実行環境の管理についてはpyenvからuvへ移行するも参考になります。

JavaScript: シングルスレッド+イベントループ

JavaScriptは基本的にシングルスレッドで動き、非同期I/Oをイベントループでさばきます。1本のスレッドが、タスクとマイクロタスクを順に処理しながら、I/Oの完了通知を受け取って続きを実行します。これは典型的な「1コアで待ち時間を埋める並行」の設計です。

1スレッドでも待ち時間中に別の仕事が進む
console.log("start");
setTimeout(() => console.log("timer done"), 1000); // 待つ間ブロックしない
fetch("/api/data").then(() => console.log("fetch done"));
console.log("end"); // これが先に出る

イベントループとマイクロタスクの詳しい順序はJavaScriptの非同期とイベントループで解説しています。CPUバウンドな重い処理を並列化したい場合は、別スレッドを使う Web Workers などが選択肢になります。

Go: goroutine

Goはgoroutineという軽量な実行単位を持ちます。goroutineはOSスレッドそのものではなく、Goランタイムが多数のgoroutineを少数のOSスレッド上で動かすM:Nスケジューリングで管理します。生成が非常に軽く、数千・数万のgoroutineを立てても実用的に動きます。

並列度は環境変数・関数の GOMAXPROCS(同時にGoコードを実行できるOSスレッド数、既定はCPUコア数)で決まります。goroutineを立てること自体は並行で、複数コア上で実際に同時実行されて初めて並列になる、という関係です。

goキーワードでgoroutineを起動
go doWork()  // 別のgoroutineとして並行に走らせる
// チャネルで安全に値を受け渡す(共有メモリの取り合いを避ける設計)

Goは「メモリを共有して通信するのではなく、通信によってメモリを共有せよ」という思想で、チャネルを使った受け渡しを推奨します。

共有状態の危険 - レースコンディションとデッドロック

スレッドのようにメモリを共有すると、複数の実行主体が同じデータを同時に読み書きして問題が起きます。

レースコンディション(競合状態)は、結果が実行タイミングに依存してしまう状態です。典型が「読み込んで、1足して、書き戻す」という一見単純な処理です。

ロックなしのカウンタは値がずれる
# 2スレッドが同時に count を読み、同じ値に+1して書き戻すと
# 本来2増えるはずが1しか増えないことがある(更新の消失)
count += 1  # 実は「読み込み → 加算 → 書き込み」の3手順で、途中で割り込まれる

これを防ぐのがロック(mutex)で、共有データを触る区間(クリティカルセクション)を一度に1つの実行主体だけが通れるようにします。ただしロックには別の落とし穴があります。

デッドロックは、複数のロックを取り合って互いに相手の解放を待ち続け、全員が永久に止まる状態です。スレッドAがロック1を持ってロック2を待ち、スレッドBがロック2を持ってロック1を待つと、どちらも進めません。ロックの取得順を全体で統一する、といった対策が基本になります。

Loading diagram...

WARNING

共有状態はバグの温床です。可能なら「共有しない設計」(プロセス分離、イミュータブルなデータ、Goのチャネルのようなメッセージ受け渡し)を優先し、どうしても共有するならロックの範囲を最小に、取得順を統一するのが鉄則です。

なお、複数の操作をまとめて「起きたか・起きなかったか」を保証したい場面では、DBのトランザクションが近い考え方を提供します。分離レベルとの関係はデータベーストランザクションとACID・分離レベルを参照してください。

まとめ

  • 並行は処理の構造(1コアでも成立)、並列同時実行(複数コアが必要)。両者は別物
  • プロセスはメモリ独立で安全だが重い、スレッドはメモリ共有で軽いが競合のリスクがある。切り替えにはコンテキストスイッチのコストがかかる
  • 非同期は待ち時間の有効活用。I/Oバウンドは非同期やスレッドCPUバウンドはマルチプロセスが基本
  • 言語ごとに設計が違う。PythonはGILゆえCPUバウンドはマルチプロセス、JavaScriptはシングルスレッド+イベントループ、GoはgoroutineのM:Nスケジューリング
  • 共有状態はレースコンディションやデッドロックを招く。共有しない設計を優先し、必要ならロックを最小限・順序統一で

「並行は構造、並列は実行」「I/O待ちは非同期、計算はプロセス」——この2軸を押さえておけば、手段選びで迷うことは大きく減ります。

参考リンク