並行と並列
へいこうとへいれつ
複数の処理を扱う2つの考え方。切り替えて進める並行と、同時に実行する並列の区別。
概要
並行(concurrency)と並列(parallelism)は、どちらも「複数の処理を扱う」話でありながら、指しているものが違う2つの概念です。核心の区別はこうです — 並行は構造の話、並列は実行の話。並行とは「複数の処理を独立に進められるようにプログラムを構成すること」であり、並列とは「複数の処理を物理的に同時に実行すること」です。Go 言語の設計者 Rob Pike の言葉を借りれば、並行は「多くのことを一度に扱う(dealing with)」こと、並列は「多くのことを一度に行う(doing)」ことです。
この区別が重要なのは、両者が独立に成立するからです。CPU が1コアしかなくても、処理を細かく切り替えれば「同時に進んでいるように扱う」並行は実現できます。逆に、並行に構成されたプログラムは、コアが複数あれば自然に並列実行の恩恵を受けられます。Web サーバが数千の接続を同時にさばく、UI が通信中も固まらない、動画のエンコードが8コアをフル活用する — これらはすべて並行・並列の語彙で語られる場面です。
なぜ生まれたか
初期のコンピュータはプログラムを1本ずつ順番に実行していましたが、これは資源の大きな無駄でした。プログラムはディスクやネットワークの応答を待つ間、CPU を遊ばせているからです。そこで OS は、待ちが発生したら別のプロセスに CPU を切り替える「マルチタスク」を発明しました。1つの CPU を時分割で使い回し、複数の処理が「同時に進んでいるように見せる」— 並行という考え方はここから始まっています。
一方の並列が切実になったのは2000年代半ばです。それまでソフトウェアは「待てば CPU が速くなる」フリーランチを享受していましたが、クロック周波数の向上が発熱の壁に突き当たり、CPU メーカーは「1コアを速くする」代わりに「コアを増やす」方向へ舵を切りました。マルチコア時代の到来です。ここで問題が顕在化します。逐次的に書かれたプログラムはコアが8個あっても1個しか使えません。ハードウェアの進化の恩恵を受け続けるには、ソフトウェア側が処理を分割できる構造 — つまり並行な構造 — を持たなければならなくなったのです。
詳細
タイムラインで見る並行と並列
区別を一枚で押さえましょう。1コアの並行では、複数のタスクを細かい時間片(タイムスライス)で切り替えながら進めます。どの瞬間を切り取っても実行中のタスクは1つだけですが、巨視的にはすべてのタスクが進行しています。マルチコアの並列では、同じ瞬間に複数のタスクが物理的に同時に実行されています。
図が示す通り、並行は「見かけ上の同時」でも成立し、並列は「物理的な同時」を要求します。そして並行に構成されたプログラム(図の A・B・C のように独立して進められるタスクたち)は、コアが増えればそのまま並列に実行できます。並行という構造が先にあり、並列はその構造を活かす実行形態、という関係です。
I/OバウンドとCPUバウンドで処方箋が違う
実務でこの区別が効くのは、「遅い処理を速くしたい」ときに打つ手が変わるからです。処理の遅さには2種類あります。I/Oバウンド — ネットワークやディスクの応答待ちが支配的な処理(Web API 呼び出し、DB クエリなど)と、CPUバウンド — 計算そのものが支配的な処理(画像処理、暗号計算など)です。
I/Oバウンドな処理に必要なのは並行です。待っている間 CPU は暇なのだから、その間に別のタスクを進めればよく、コアは1つでも劇的に改善します。逆に CPUバウンドな処理は、いくら切り替えても計算総量は減らないため、並行だけでは速くなりません。必要なのは並列 — 複数のコアに計算を分割して同時に実行することです。「遅いからスレッドを増やそう」と考える前に、まずどちらのバウンドかを見極めるのが定石です。
コア数とタスクの性質を切り替えて実行し、この「処方箋の違い」を自分の目で確かめてみてください。
「実行」を押すと、ここにコアごとのタイムラインが描かれます。
コア数とタスク種別を選んで「実行」。構成を変えて実行するたびに結果が下の表に並び、比較できます。
並行の代償 — 競合状態・ロック・デッドロック
複数の処理が同じデータを共有すると、逐次実行では起きなかった問題が生まれます。代表が競合状態(race condition)です。2つのスレッドが同じカウンタに「読んで、+1して、書き戻す」を同時に行うと、互いの更新を上書きして片方の加算が消える — 実行タイミング次第で結果が変わる、再現困難なバグです。
対策の基本はロック(排他制御)で、「この区間は一度に1スレッドしか入れない」と保護します。しかしロックは新たな問題を連れてきます。スレッド1がロックAを持ってBを待ち、スレッド2がBを持ってAを待つと、双方が永遠に待ち続けるデッドロックに陥ります。ロックの粒度が粗ければ並列性が死に、細かければデッドロックや取り忘れのリスクが増える。この難しさから、「共有メモリ+ロック」を避けて、メッセージの受け渡しで通信する設計(Go のチャネル、アクターモデル)や、そもそも共有データを不変にする関数型のアプローチが発達してきました。なお、この「共有状態の同時更新」という問題は、データベースのトランザクションの分離レベルや分散システムの合意問題として、スケールを変えて何度も現れます。
イベントループ — シングルスレッドの並行
並行はマルチスレッドの専売特許ではありません。JavaScript はシングルスレッドですが、イベントループという仕組みで高い並行性を実現しています。時間のかかる I/O は「終わったら呼んでね」とコールバック(async/await の裏側も同じです)を登録して即座に手放し、1本のスレッドはキューに届いたイベントを順に処理し続けます。スレッドが1本しかないので競合状態やロックの悩みが激減する一方、CPUバウンドな重い計算を書くとループ全体が止まり、UI もリクエスト処理もすべて固まります。Node.js のサーバが「I/Oは得意、重い計算は苦手」と言われる理由がここにあります。
超並列の世界 — GPUと分散システム
並列の極端な形が GPU です。数千の演算コアが「同じ計算を大量のデータに一斉に適用する」ことに特化しており、行列演算が支配的な機械学習やグラフィックスで圧倒的な性能を発揮します。さらにスケールを上げると、1台のマシンに収まらない処理を多数のマシンに分割する分散システムに行き着きます。ここでも「タスクをどう独立させるか」という並行の設計が土台にあり、その上で「どれだけ同時に走らせるか」という並列の実行が載る — 本記事の区別は、コア1つの世界からデータセンター規模まで一貫して通用する見取り図です。