並行と並列
Concurrency and Parallelism ・ へいこうとへいれつ
複数の処理を扱う2つの考え方。切り替えて進める並行と、同時に実行する並列の区別。
✦ 30秒で試す — 航海CPUが1つしかなくても、4つの仕事を同時に進められる同じ仕事をコア4つで動かすと、完了はどうなる?予想してみる概要
並行(concurrency)と並列(parallelism)は、どちらも「複数の処理を扱う」話でありながら、指しているものが違う2つの概念です。核心の区別はこうです — 並行は構造の話、並列は実行の話。並行とは「複数の処理を独立に進められるようにプログラムを組み立てること」であり、並列とは「複数の処理を物理的に同時に実行すること」です。Go 言語の設計者 Rob Pike の言葉を借りれば、並行は「多くのことを一度に扱う(dealing with)」こと、並列は「多くのことを一度に行う(doing)」ことです。
この区別が大切なのは、両者が独立に成り立つからです。CPU が1コアしかなくても、処理を細かく切り替えれば「同時に進んでいるように扱う」並行は実現できます。逆に、並行に組み立てられたプログラムは、コアが複数あれば自然に並列実行の恩恵を受けられます。Web サーバが数千の接続を同時にさばく、UI が通信中も固まらない、動画のエンコードが8コアを使い切る — どれも並行・並列の語彙で語られる場面ですが、前の2つは主に並行の、最後の1つは並列の話です。
なぜ生まれたか
初期のコンピュータはプログラムを1本ずつ順番に実行していましたが、これは資源の大きな無駄でした。プログラムはディスクやネットワークの応答を待つ間、CPU を遊ばせているからです。そこで OS は、待ちが発生したら別のプロセスに CPU を切り替える「マルチタスク」を生み出しました。1つの CPU を細かい時間に区切って使い回す時分割(どの処理にいつ CPU を渡すかはスケジューラが決めます)で、複数の処理が「同時に進んでいるように見せる」— 並行という考え方はここから始まっています。
一方の並列が切実になったのは2000年代半ばです。それまでソフトウェアは「待てば CPU が勝手に速くなる」恩恵を受けていましたが、クロック周波数の向上が発熱と消費電力の壁に突き当たり、CPU メーカーは「1コアを速くする」代わりに「コアを増やす」方向へ舵を切りました。Herb Sutter が2005年の記事「The Free Lunch Is Over(フリーランチは終わった)」で指摘したのがこの転換です。逐次的に書かれたプログラムは、コアが8個あっても1個しか使えません。ハードウェアの進化の恩恵を受け続けるには、ソフトウェア側が処理を分割できる構造 — つまり並行な構造 — を持たなければならなくなったのです。
詳細
タイムラインで見る並行と並列
区別を一枚で押さえましょう。1コアの並行では、複数のタスクを細かい時間片(タイムスライス)で切り替えながら進めます。どの瞬間を切り取っても実行中のタスクは1つだけですが、巨視的にはすべてのタスクが進んでいます。マルチコアの並列では、同じ瞬間に複数のタスクが物理的に同時に実行されています。
図が示す通り、並行は「見かけ上の同時」でも成立し、並列は「物理的な同時」を必要とします。そして並行に組み立てられたプログラム(図の A・B・C のように独立して進められるタスクたち)は、コアが増えればそのまま並列に実行できます。並行という構造が先にあり、並列はその構造を活かす実行の形、という関係です。
I/OバウンドとCPUバウンドで処方箋が違う
実務でこの区別が効くのは、「遅い処理を速くしたい」ときに打つ手が変わるからです。処理の遅さには2種類あります。I/Oバウンド — ネットワークやディスクの応答待ちが支配的な処理(外部の Web API 呼び出し、DB へのクエリなど)と、CPUバウンド — 計算そのものが支配的な処理(画像処理、暗号化の計算など)です。
I/Oバウンドな処理に必要なのは並行です。待っている間 CPU は暇なのだから、その間に別のタスクを進めればよく、コアが1つでも劇的に速くなります。逆に CPUバウンドな処理は、いくら切り替えても計算の総量は減らないため、並行だけでは速くなりません(切り替えの手間の分、むしろ少し遅くなります)。必要なのは並列 — 複数のコアに計算を分けて同時に実行することです。「遅いからスレッドを増やそう」と考える前に、まずどちらのバウンドかを見極めるのが定石です。
下の部品では、4つのタスクの中身(計算か I/O 待ちか)を自分で書き換えられます。マスをクリックして I/O 待ちを差し込み、コア数を変えながら、どこで時間が縮みどこで縮まないかを確かめてください。
スケジューラがタイムラインを組んでいます。1マスずつ、空いているコアにタスクを割り当てます。
並列化の天井 — アムダールの法則
並列にすれば何倍でも速くなるわけではありません。どんなプログラムにも、分割できない逐次的な部分(入力の読み込み、結果の集約、共有データの更新など)が残ります。全体の1割が逐次部分なら、コアを無限に増やしても速さは10倍で頭打ちになります。これを式にしたのが 1967年に Gene Amdahl が示したアムダールの法則で、「並列化の効果は逐次部分の割合で上限が決まる」ことを教えています。
さらに現実には、仕事を分けて配り、結果を集める手間(スレッドの生成、同期、データの受け渡し)がかかります。小さすぎる仕事を細かく分けると、この手間が計算そのものより大きくなって、並列版のほうが遅いことさえあります。並列化は「分ける価値があるほど大きく、互いに独立した仕事」に対して効く道具です。
並行の代償 — 競合状態・ロック・デッドロック
複数の処理が同じデータを共有すると、逐次実行では起きなかった問題が生まれます。代表が競合状態(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つの世界からデータセンター規模まで一貫して通用する見取り図です。
試問
画面の上は4つのタスクの中身(1マスが1目盛りぶんの仕事で、「計算」はコアを使い、「I/O」は外部の応答を待つだけでコアを使わない)、下はコアごとの実行のタイムラインです。仕事を「動画の変換(計算だけ)」と「API の呼び出し(待ちが多い)」から選び、コア数を 1・2・4 から選ぶと、その場で実行されます。コアを借りる費用を無駄にしないよう、コアが働いている時間の割合(使用率)も気にしてください。
スケジューラがタイムラインを組んでいます。1マスずつ、空いているコアにタスクを割り当てます。
