awaitを1つ忘れただけで、なぜ本番だけ壊れるのか:並行と並列の設計【第8回・完結】
テストでは1回も失敗しなかったコードが、本番でだけ月に数回壊れる。原因は1行の
awaitの抜けでした。この記事を読み終えると、レース条件がなぜ起きるのかを仕組みから説明でき、非同期と並列の違いを区別し、それでも同時に動かすための設計を自分で組めるようになります。ジュニアエンジニア向けコンピュータサイエンス入門シリーズ(全8回)の最終回です。
🎯 テーマの主役:「並行」と「並列」——似て非なる2つ
今回の主役は並行(concurrency)と並列(parallelism)です。この2つは日本語でも英語でも混同されがちですが、指しているものが違います。一言で言えば、並行は「複数の仕事を同時に扱うこと」、並列は「複数の仕事を同時に実行すること」です。
日常の例えで言うなら、料理人1人と料理人3人の違いです。並行は、料理人1人が複数の鍋を同時に担当することです。スープを煮ている間に野菜を切り、野菜を炒めている間に味見をする。同時に進んでいるように見えますが、手は1つです。並列は、料理人が3人いて、それぞれが別の鍋を担当することです。本当に同時に手が動いています。
この違いが実務で効くのは、「待ち時間」を扱う場面です。並行が効くのは、待ち時間があるときです。煮込みの20分間、料理人は別の仕事ができます。ところが並列が効くのは、計算が詰まっているときです。3人いれば、3倍の量を切れます。この2つは目的が違うので、打ち手も違います。待ち時間に悩んでいるのに人を増やしても効果がありませんし、計算に悩んでいるのに手順を工夫しても限界があります。
第7回で、データベースが「100人が同時に書き換えても壊れない」仕組みを見ました。あの仕組みを提供してくれたのはデータベースです。最終回の今回は、その守りを自分で実装する番です。第4回で「プロセスは独立した記憶空間を持つから互いを壊せない」と学びました。ところが1つのプロセスの中で複数の処理を動かすと、同じメモリを共有します。守ってくれる壁が、そこにはないのです。
この仕組みを理解すると、次の4つができるようになります。第一に、レース条件がなぜ起きるのかを、実行順序の可能性として説明できること。第二に、非同期と並列を区別し、どちらが必要かを判断できること。第三に、共有状態を減らすという設計原則の理由を理解すること。第四に、テストで再現しない不具合に遭遇したとき、確率の問題として対処できることです。
2つの違いを、目的・手段・限界で整理します。「速くしたい」と思ったときに、どちらの問題かを切り分けるのが第一歩です。
| 観点 | 並行(concurrency) | 並列(parallelism) |
|---|---|---|
| 目的 | 待ち時間のあいだに別の仕事を進める | 計算そのものを同時に進めて時間を短縮する |
| 必要な資源 | 1つのCPUコアで足りる | 複数のCPUコアが要る |
| 効く場面 | 外部I/O待ち・ネットワーク待ち・利用者の操作待ち(全体の大半が待ち時間) | 計算が詰まっている処理(待ち時間がほとんどない) |
| 限界 | 待ちがなければ効果が出ない | コア数とメモリ帯域、そしてアムダールの法則 |
| 難しさの源 | 実行順序が入れ替わること | 共有資源の取り合いと、結果の合成 |
| 典型的な道具 | 非同期処理・イベントループ | スレッド・プロセス・GPU |
😓 動機:再現しない不具合が、いちばん厄介
並行処理のトラブルは、再現しないという点で他のすべてのトラブルと違います。第2回のデータ、第3回の性能、第4回の資源枯渇、第6回のネットワーク。どれも「条件を揃えれば再現する」ものでした。並行処理の不具合は、条件を揃えても再現しないことがあります。
よくある場面を4つ挙げます。ひとつ目は、テストでは100回成功したのに、本番で月に2回失敗する。ふたつ目は、本番でだけ数字が合わない。第7回のデータベースの問題と区別がつかない。みっつ目は、負荷をかけると壊れる。開発環境では軽いので気づかない。よっつ目は、await を付け忘れた1行が、たまたま動いているように見える。
これらに共通するのは、「実行の順序」が実行のたびに変わることです。そして厄介なのは、順序が「都合のよい方」になる確率が、テスト環境では高いことです。開発環境は速く、負荷が低く、処理が詰まらない。順序が入れ替わる隙が生まれにくい。本番は遅く、負荷が高く、順序が入れ替わる隙が無数にある。だから「テストで通った」は「正しい」の証明になりません。
そして、この知識の重要性はAI時代に上がっています。AIが生成するコードは、非同期処理を自然に混ぜ込んできます。async と await が散らばったコードは読みにくく、1箇所の抜けを見落としやすい。そして抜けても動いてしまいます。動いてしまうから、レビューで止めるしかないのです。
🧪 仮説:並行処理の不具合は「共有状態」と「順序の仮定」から生まれる
仮説を立てます。並行処理のトラブルは、ほぼ2つの原因に分類できる。第一に「複数の処理が同じ状態を共有していること」、第二に「実行順序について根拠のない仮定を置いていること」である。
この仮説を支持する観察が3つあります。第一に、検証①で扱う「更新が消える」問題は、共有状態が原因です。第二に、await の付け忘れは「この処理はここで終わっているはず」という順序の仮定が原因です。第三に、この2つは対策が同じ方向を向きます。共有状態を減らし、順序の仮定を明示する。この2つで、大半のトラブルは防げます。
🔬 検証①:消える更新——第7回の小さな再現
まず、最も基本的なトラブルから見ます。第7回ではデータベースの世界で「更新が消える」問題を扱いました。同じことが、1つのプロセスの中でも起きます。しかもデータベースが守ってくれません。
題材はカウンタです。2つの処理が同時に、同じ変数を1ずつ増やします。
この現象をレース条件(race condition)と呼びます。2つの処理が競争(race)して、勝った方だけが結果を残すという意味です。
そして重要なのは、第7回の「読む→確認→書く」とまったく同じ構造だということです。データベースでは、トランザクションとロックが守ってくれました。1つのプロセスの中では、誰も守ってくれません。自分で守る必要があります。
| 対策 | やり方 | 得るもの | 代金 |
|---|---|---|---|
| 原子的操作を使う | 「読む→計算→書く」を1つの命令として実行する | 待ちが発生しない | 使える場面が限られる(単純な演算のみ) |
| 鍵(ロック)で囲む | 触る前に鍵をかけ、終わったら外す | 複雑な処理でも守れる | 待ち時間が増える。かけ忘れ・順番で問題が出る |
| 共有しない | 各処理が自分のデータだけを持つ | レース条件が原理的に起きない | 設計の変更が要る。結果の合成方法を考える必要 |
3つ目が本記事の結論の予告です。最も強い対策は「そもそも共有しない」。第7回でも同じ結論でした(条件付き更新は、待ちを発生させずに正しさを守る方法でした)。共有状態を減らすほど、正しさを保つための仕掛けが要らなくなるのです。
🔬 検証②:awaitの付け忘れ——順序についての根拠のない仮定
次は、非同期処理の話です。ここが最終回の中心です。
非同期処理の考え方は単純です。「待たされる処理を始めておいて、終わるのを待たずに次の処理に進む」。そして、結果が必要になったら待つ。料理の例でいえば、煮込みを火にかけて、番をせずに野菜を切り始めることです。
問題は、「待つ」を忘れたときに起きることです。結果がまだ出ていないのに、結果を使う処理に進んでしまう。
await の付け忘れが厄介なのは、動いてしまうことです。そして開発環境では間に合ってしまう。だからレビューで見つけるしかありません。第1回で「抽象は漏れる」と書きましたが、非同期処理は最も漏れやすい抽象の1つです。「待つ」という1語が、見えない依存を作っているからです。
ここで押さえておきたい設計の指針が3つあります。
第一に、「待つべき場所」を明示する。非同期の処理を呼んだら、結果を使う直前に必ず待つ。これを機械的に徹底するだけでも、多くのトラブルが防げます。
第二に、待つ場所を減らす。第6回で見たとおり、往復を減らすのが最も効きます。100件を1件ずつ非同期で呼んで1件ずつ待つより、まとめて1回で呼ぶほうが速く、正しく、読みやすくなります。第7回のN+1問題と同じ解決策です。
第三に、「並行にできるもの」と「順番が必要なもの」を分ける。独立した3つの取得は並行にできますが、「取得してから保存する」は順番が必要です。依存関係を意識して、依存のないものだけを並行にする。
🔬 検証③:デッドロックと飢餓——待たせ合うと止まる
次に、ロックにまつわる2つのトラブルを見ます。第7回でデータベースのデッドロックを扱いましたが、1つのプロセスの中でも起きます。
デッドロックは、第7回とまったく同じ構造です。2つの処理が、互いが持っているロックを待ち合う。
| 時刻 | 処理A | 処理B | 状態 |
|---|---|---|---|
| 1 | ロック1を取得 | — | 正常 |
| 2 | — | ロック2を取得 | 正常 |
| 3 | ロック2を待つ | — | Aが停止 |
| 4 | — | ロック1を待つ | 止まる(どちらも永遠に進まない) |
対処法も第7回と同じです。ロックを取得する順番を統一する。これが最も確実です。そして、ロックを保持する時間を短くする。ロックの中で外部I/Oを待つのは最悪の組み合わせです(第6回・第7回の原則がそのまま効きます)。
もう1つ、飢餓(starvation)というトラブルがあります。特定の処理がいつまでも順番をもらえない状態です。行列に並んでも、割り込みを許す方式だと後から来た人が先に進み続け、最初の人が永遠に待つことがあります。対処は公平な順番付け(先着順で処理する方式)です。
そして、ここで注意すべき設計ミスを挙げておきます。ロックを持ったまま別のロックを取ろうとすると、デッドロックの確率が跳ね上がります。「鍵を2つ以上同時に持たない」という制約を守るだけで、多くの事故が防げます。
🔬 検証④:並列の限界——アムダールの法則
ここからは並列の話です。「コアを増やせば速くなる」はどこまで正しいのでしょうか。
1967年、ジーン・アムダールが示した原則があります。「並列化できない部分が少しでもあると、全体の高速化には上限がある」というものです。これをアムダールの法則と呼びます。
この法則が実務で効くのは、「並列化したのに速くならない」場面です。コアを倍にしたのに1.2倍しか速くならない。原因は、並列化できない部分が残っていることです。典型的なのは次のような箇所です。
- 結果を1つのデータにまとめる処理(合計を足し合わせる部分)
- 共有のロックやカウンタ(全員が待つ部分)
- 外部への問い合わせ(第6回で見た往復。並列にしても帯域やレイテンシの限界がある)
- 消費する側が1つの資源(ディスクへの書き込み、1つのデータベース)
そして、並列化で最も多い落とし穴が共有資源の取り合いです。コアを増やすと、全員が同じメモリ帯域・同じロック・同じディスクを取り合う。第3回で見たとおり、メモリ帯域は有限です。だからコアを増やすほど効率が落ちることがあります。
| 状況 | 並列化したときの期待 | 実際に起きること | 打ち手 |
|---|---|---|---|
| 計算が大半で、共有が少ない | コア数に近い倍率 | ほぼ期待どおり | 有効。コアを増やす |
| 1割の直列部分がある | コア数に比例 | 10倍で頭打ち(アムダールの法則) | 直列部分を減らす。それが難しければ並列化を諦める |
| 全員が同じロックを取る | コア数に比例 | 待ち時間が支配的になり、逆に遅くなる | ロックの範囲を細かく分ける。共有を減らす |
| 全員が同じメモリ帯域を使う | コア数に比例 | 帯域が上限になり頭打ち | データを局所化する。合計せずに分けたまま扱う |
| 外部I/Oが詰まっている | 待ちが隠れる効果 | 相手が捌けず、待ちが増える | 並列度に上限を設ける(第6回のリトライの話と同じ) |
5行目は、第6回と第7回で見た「リトライ」の話とつながります。並列度を上げれば上げるほど、相手への負荷が増えます。上限を設けずに無限に並列化すると、相手を潰して自分も遅くなる。だから実務では同時実行数の上限を設けます。
🔬 検証⑤:非同期の正体——待っているのは誰か
ここで、非同期処理の仕組みを一段深く見ます。「待っているあいだ、何が起きているのか」を理解すると、判断が変わります。
第4回を思い出してください。 プロセスは状態を持ち、待機中(I/O待ち)になれるのでした。OSが「このプロセスは待っている」と判断すれば、CPUを他のプロセスに回します。これが非同期処理の土台です。
| 方式 | 待っているあいだ何が起きるか | 何が増えるか | 向いているもの |
|---|---|---|---|
| 同期(待つ) | そのスレッドは止まる。他のスレッドが動けるが、この処理は何も進まない | — | 順番が本質的な処理 |
| 非同期(待たない) | その処理は一旦返り、他の処理が同じスレッドで進む | 状態の管理(どこまで進んだか) | I/O待ちが多い処理 |
| 並列(スレッド・プロセスを増やす) | 別のコアで本当に同時に進む | メモリと切り替えのコスト | 計算が詰まった処理 |
ここで重要な性質があります。 非同期処理は、少ないスレッドで多くの待ちを扱えます。1000件のネットワーク要求を1つのスレッドで扱うことも可能です。待っているあいだ、そのスレッドは他の要求を進められるからです。
一方で、計算が詰まった処理を非同期にしても速くなりません。待ち時間がないからです。むしろ遅くなります。1つのスレッドを長時間占有するので、他の処理が進めなくなる。だから重い計算は別のスレッドやプロセスに逃がす必要があります。
そして、ここが第7回との決定的な違いです。データベースは自動で調停してくれました。非同期処理では、調停は行われません。1つのスレッドが長い計算で塞がれば、他のすべての処理が待たされます。「1つの重い処理が全体を止める」という構造は、第5回の「1件ずつ外部に問い合わせる」アンチパターンと同じ形です。
だから非同期の設計原則は「短く区切る」になります。待つ場所で必ず他の処理に道を譲る。重い計算は譲る場所を作って分割するか、別のスレッドに逃がす。
📊 結果:症状から原因を引く
ここまでの内容を、切り分けの形にまとめます。「共有の問題か、順序の問題か、粒度の問題か」を先に決めるのがポイントです。
| 症状 | 疑う原因 | 確認するもの | 打ち手 |
|---|---|---|---|
| たまに数値がずれる・更新が消える | 共有状態のレース条件 | 複数の処理が同じ変数を触っていないか | 原子的操作・ロック・共有をやめる |
| 本番でだけ「存在しない値」に触る | 待ち忘れ | 非同期の結果を使う前に待っているか | 結果を使う直前に必ず待つ |
| 月に数回、理由もなく失敗する | 順序の仮定 | 「この順番で起きるはず」と思っている場所 | 順序を明示する。依存関係を書き出す |
| アプリ全体が止まる | デッドロック、または1つの重い処理が占有 | ロックを2つ以上同時に持っていないか、長い計算が入っていないか | ロックの順番を統一する。計算を分割・退避する |
| コアを増やしても速くならない | アムダールの法則、または共有資源の競合 | 直列部分の割合、ロックやメモリ帯域の取り合い | 直列部分を減らす。共有を局所化する |
| 負荷を上げると急に遅くなる | 並列度の上げすぎ | 同時実行数と、相手側の応答時間 | 同時実行数の上限を設ける |
| テストでは絶対に再現しない | 確率的な順序の入れ替わり | 同時実行の数を増やして再現を試みる | 負荷試験で再現させる。設計で順序を消す |
特に7行目が、この記事の核心です。再現しない不具合は、確率の問題です。だから同時実行数を増やして確率を上げる、あるいは設計で順序の入れ替わりを消す。この2つのアプローチしかありません。
💭 考察:並行処理は「時間」を設計に持ち込む最後の階である
ここまでの話を一段深く掘ります。第1回から7回まで、各階にはそれぞれ固有の難しさがありました。今回の階の難しさは「時間」そのものです。
振り返ってみます。第3回のメモリは、場所の問題でした(どこに置くか)。第5回のアルゴリズムは、回数の問題でした(何回まわるか)。第6回のネットワークは、距離の問題でした(どれだけ遠いか)。そして第8回は、順序の問題です。同じコードが、実行のたびに違う順序で動く。
この性質が、他の階と決定的に違います。 メモリもネットワークも、条件が同じなら結果は同じです。並行処理は条件が同じでも結果が変わります。確率的にしか扱えない。だからこそ、テストで「100回成功した」が保証になりません。
ここから3つの深い見方が出ます。第一に、正しさの基準が「どの順序でも正しいか」に変わるということです。第7回で「同時に実行しても順番に実行したのと同じ結果か」という基準を扱いました。まったく同じ基準です。データベースはそれを保証してくれましたが、自分で書くコードでは自分で保証する必要があります。
第二に、並行処理の設計は「共有を減らす方向」に収束するということです。検証①の結論、検証③の結論、検証④の結論は、すべて同じ方向を向いていました。共有状態を減らし、共有資源を局所化し、共有のロックを持たない。第7回で「追記のみの設計は衝突しにくい」を扱いましたが、これも共有の形を変えることで衝突を減らす話でした。最も強い並行処理の対策は、並行処理を減らすことなのです。
第三に、「非同期」は魔法ではなく、OSの仕組みの上に載っているということです。第4回で見た「プロセスの状態」と「I/O待ちのあいだCPUを譲る仕組み」が土台にあります。非同期処理の難しさは、OSが守ってくれない部分を自分で守ることに由来します。第1回で「抽象は漏れる」と書きました。非同期処理は、OSのスケジューラという抽象の上に、さらに薄い抽象を重ねている状態です。だから漏れやすい。
そして、全8回を貫く原則がここで完成します。第2回「型を選ぶとは将来の壊れ方を選ぶこと」、第3回「データの置き場所を選ぶこと」、第5回「データ構造を選ぶとは手間の増え方を選ぶこと」、第6回「往復の回数を選ぶこと」、第7回「何を許すかを選ぶこと」。今回も同じです。並行性の設計とは、何を共有し、どの順序を前提にするかを選ぶこと。そして最も安全な選択は、共有しないこと。
📌 注目ポイント
この記事の核心を4点に絞ります。
第一に、並行と並列は別物です。 並行は「1人で段取り」、並列は「人数を増やす」。待ち時間で悩むなら並行、計算量で悩むなら並列。ここを間違えると、見当違いの対策を打つことになります。
第二に、レース条件は「読む→計算→書く」の隙間で起きます。 第7回のデータベースとまったく同じ構造です。ただし1つのプロセスの中では、誰も守ってくれません。原子的操作・ロック・そして最も強いのは「共有しない」。
第三に、await の付け忘れは「動いてしまう」ので厄介です。 開発環境では速いので間に合ってしまう。負荷が上がると間に合わなくなる。結果を使う直前に必ず待つを機械的に徹底してください。
第四に、並列化には上限があります。 並列化できない部分が1割でもあれば、理論上の上限は10倍です(アムダールの法則)。そして共有資源の取り合いで、コアを増やすと逆に遅くなることもあります。
💡 活用事例:非同期が広がった本当の理由
ここまでの話が現実の技術選択でどう現れているかを見ます。なぜ近年、非同期処理がこれほど重視されるのかです。
理由は「待ち時間が支配的になったから」です。これを数字で見てみます。1つのリクエストを処理するのに、CPUで計算する時間が1ミリ秒、データベースの応答を待つのが20ミリ秒だったとします。同じスレッドで処理するなら、1リクエストあたり21ミリ秒。ところが待っている20ミリ秒のあいだ、そのスレッドは何もできません。1秒あたりに処理できるのは約48リクエストです。
非同期で待たないようにすると、20ミリ秒のあいだに他のリクエストを処理できます。CPUを使うのは1ミリ秒だけなので、1つのスレッドで毎秒1,000リクエスト近くを扱えます。同じハードウェアで20倍。これが、非同期が選ばれる理由です。
| 構成 | 1リクエストの所要時間 | 必要なスレッド数(同時100件) | 1秒あたりの処理数(目安) |
|---|---|---|---|
| 同期・1スレッド | 21ms | 1 | 約48 |
| 同期・スレッドを増やす | 21ms | 100 | 約4,700 |
| 非同期・少数スレッド | 21ms(待ちは他の処理に使う) | 数スレッド〜十数 | 約1,000(CPU律速) |
| 非同期+並列(複数コア) | 同上 | コア数に応じて | コア数に応じて増加(上限まで) |
この表の読み方が重要です。 2行目(スレッドを増やす)と3行目(非同期)は、どちらも速くなりますが、代金が違います。スレッドを増やす方式は、スレッド1つあたりにメモリが必要です(第4回で見たスタックの容量)。1000スレッドを作れば、それだけで大量のメモリを消費し、切り替えのコストも増えます。非同期方式は、少ないスレッドで同じ仕事をさばけます。
これが、近年のサーバー設計が非同期に寄った理由です。第3回で見たとおり、メモリは有限で、キャッシュを汚すと遅くなります。スレッドを大量に作るより、少ないスレッドで多数の待ちを扱うほうが効率的なのです。
ただし、この表には落とし穴があります。 3行目と4行目を見比べてください。非同期にしても、CPUを使う部分は速くなりません。1リクエストあたり1ミリ秒の計算は、どこかで誰かがやる必要があります。だから「重い計算」を非同期の世界に持ち込むと、1つのスレッドを塞いで全体を止めます。検証⑤で見たとおりです。非同期は「待ち」の対策であって、「計算」の対策ではありません。
✅ 要点まとめ
読み終えたあなたが持ち帰るべきエッセンスを、6つに圧縮します。
- 並行は「1人で段取り」、並列は「人数を増やす」。目的が違う。待ちなら並行、計算なら並列
- レース条件は「読む→計算→書く」の隙間で起きる。第7回のデータベースとまったく同じ構造。ただしここでは誰も守ってくれない
- 対策の強さは「原子的操作 < ロック < 共有しない」。最も強いのは共有状態を減らすこと
awaitの付け忘れは動いてしまうので厄介。開発では間に合い、本番で間に合わなくなる。結果を使う直前に必ず待つ- 並列化には上限がある(アムダールの法則)。1割の直列で10倍止まり。共有資源の取り合いで逆に遅くなることもある
- 非同期は「待ち」の対策であって「計算」の対策ではない。重い計算は別スレッドに逃がし、同時実行数には上限を設ける
🚀 取り込み方
「明日から使うには何をすればいいか」を、期間ごとに分けて示します。
今日(5分でできること)
自分のコードから共有されている状態を探してください。見つけるだけで、この記事の話が自分の環境の話になります。
- グローバル変数・クラスのフィールド・モジュールレベルの辞書で、複数の処理から触られうるものをリストアップする
- 非同期処理の呼び出し箇所で、結果を使う前に待っているかを確認する
- 同時実行数の設定があるか確認する(HTTPクライアント・DB接続プール・タスクの同時実行数)
「思ったより共有が多い」と感じたら、それが潜在的なリスクです。
今週(小さく試す)
担当コードから、次の4つを探してください。(1) 共有のカウンタやフラグを複数箇所から更新している、(2) 非同期の呼び出しで結果を使う前に待っていない、(3) ロックを2つ以上同時に取っている、(4) 無限に並列実行する箇所(同時実行数の上限がない)。見つけたら、すぐ直さずに「同時に実行されたらどうなるか」を1行メモしてください。
再現しない不具合に備えるには、同時実行数を増やして確率を上げるのが有効です。次のコードは、レース条件を意図的に再現させるものです。
import threading
counter = 0
ITER = 100_000
def increment():
global counter
for _ in range(ITER):
counter += 1 # 読む→足す→書く が分かれている
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"期待値: {ITER * 4}")
print(f"実際: {counter}")
手元の環境では、実際の値が期待値より小さくなるはずです。差の大きさは環境によって変わりますが、「読む→足す→書く」が分かれていると値が失われることを自分の目で確認できます。同じコードを、ロックで囲んだ版と比べてみてください。
補足:Pythonの実装には、同時に1つのスレッドだけが実行されるように制限する仕組み(GIL)があります。そのため、上のコードで値が失われるのは「同時に同じ変数を触る」からではなく、「読み取りと書き込みの間に実行が切り替わる」からです(この切り替えはバイトコード単位で起きます)。上の counter += 1 は複数の命令に分かれるため、その途中で切り替わると同じ問題が起きます。「同じ変数を複数から触るな」という原則は、GILの有無に関係なく変わりません。
今月(業務に組み込む)
チームに次の3点を提案できないか検討してください。第一に、共有状態を減らす設計を優先する(レビューで「これは共有が必要か」を問う)。第二に、非同期の呼び出しには必ず待つ場所を明示する。第三に、外部への並列実行には同時実行数の上限を設ける。これらはすべて「共有と順序を意識する」という1つの姿勢にまとまります。
🔥 ハマりポイント
つまずきやすい5つの落とし穴を、「〜と思いがちだが、実は〜」の形で整理します。
その1:テストが通ったから大丈夫と思いがちだが、実は順序は毎回違う
症状は、テスト100回成功のあとに本番で失敗すること。原因は、順序が入れ替わる確率が環境によって違うこと。対処法は、同時実行数を増やして再現を試みること、そして設計で順序の入れ替わりを消すことです。「テストで通った」は、その順序では正しかったという意味しか持ちません。
その2:変数を1つ増やすだけなら安全と思いがちだが、実は3つの操作に分かれている
症状は、カウンタの値が合わないこと。原因は、counter += 1 が「読む→足す→書く」に分かれていること。対処法は、原子的操作を使うか、ロックで囲むことです。「1行だから安全」は成り立ちません。行数と操作数は違います。
その3:ロックを取れば安全と思いがちだが、実は待ちとデッドロックを生む
症状は、全体が遅くなる、あるいはアプリが停止すること。原因は、ロックの範囲が広すぎる、またはロックを2つ以上同時に持っていること。対処法は、ロックの範囲を最小限にし、取得する順番を統一することです。そしてロックの中で外部I/Oを待たない。
その4:コアを増やせば速くなると思いがちだが、実は上限と逆効果がある
症状は、コアを倍にしたのに1.2倍しか速くならないこと。原因は、並列化できない部分の存在(アムダールの法則)と共有資源の取り合い。対処法は、まず直列部分を特定して計測すること(第3回の「まず計測する」と同じです)。
その5:非同期にすれば速くなると思いがちだが、実は待ちにしか効かない
症状は、非同期に書き換えたのに遅くなったこと。原因は、計算が詰まった処理を1つのスレッドで占有し、他の処理が進めなくなったこと。対処法は、重い計算を別のスレッドやプロセスに逃がすことです。非同期は「待ち時間を隠す」技術であり、「計算時間を短くする」技術ではありません。
🔄 比較:4つの同時実行の方法と、それぞれの代金
最後に、同時に動かす方法を整理します。どれを選ぶかは、待ちと計算のどちらが支配的か、そして共有がどれだけあるかで決まります。
| 方法 | 向いているもの | 強み | 代金・難しさ |
|---|---|---|---|
| 同期(何もしない) | 順番が本質的な処理 | 最も単純で、間違えにくい | 待ち時間がそのまま加算される |
| 非同期(待たない) | I/O待ちが支配的な処理 | 少ないスレッドで多くの待ちを扱える | 順序の管理が必要。重い計算を混ぜると全体を止める |
| スレッド(1プロセス内で並列) | 計算が重く、共有も必要な処理 | メモリを共有できる。プロセスより軽い | 共有状態のレース条件とロックの管理が必須 |
| プロセス(別の実行単位) | 強い分離が必要な処理 | メモリを共有しないので、レース条件が起きない | 起動と通信のコスト。データの受け渡しが必要 |
| 上限つきの並列実行 | 外部への多数の呼び出し | 待ちを隠しつつ、相手を潰さない | 上限値の見極めが必要(第6回の話) |
この表から持ち帰ってほしいのは、4行目「プロセス」の位置づけです。メモリを共有しないという代金の払い方で、レース条件という問題そのものを消しています。第4回で「プロセスは仮想メモリで互いに見えない」と学びました。あの分離が、並行処理の安全性として効いてくるのです。分離は、調停より強い。これがこのシリーズを貫く結論の1つです。
📅 今後の展望
並行処理は、これからどうなるのでしょうか。方向性は3つ考えられます。
第一に、並列の単位が大きくなる方向です。コア単体の性能向上が鈍り、1つのチップに多数のコアを載せる方向が進んでいます。そして並列化できないコードは、その恩恵を受けられません。第1回で「下の階ほど長寿」と書きましたが、この変化はソフトウェアの書き方そのものを変えます。アムダールの法則は、これからさらに重みを増します。
第二に、言語とツールが安全性を担保する方向です。レース条件は、コンパイル時に検出できる場合があります。所有権の仕組みを持つ言語や、型で共有を制限する言語が、「間違ったコードが書けない」方向を進んでいます。人の注意力に頼らない設計です。
第三に、分散が並行の延長になる方向です。1つのマシンの中の並行処理が、複数のマシンにまたがる形に広がっています。第6回で「ネットワークは失敗が前提」、第7回で「データベースは調停してくれる」を学びました。分散システムでは、両方の問題が同時に起きます。部分的な失敗があり、調停も完全ではない。ここが、現代のソフトウェアで最も難しい領域です。
なお、並行処理の理論的な基礎は1960年代から1970年代に作られました。1965年にエドガー・ダイクストラがセマフォ(複数の処理の待ち合わせの仕組み)を提案し、1972年にはアントニー・ホーアがモニタ(1つのデータへの同時アクセスを直列化する仕組み)を提案しています。50年以上たっても基本の道具は変わっていません。それだけ、この問題の本質が変わっていないということです。
🗺️ シリーズの総括:8回で手に入れたもの
全8回を振り返ります。あなたが手に入れたのは、5階建てのビルの全体像です。
振り返ると、各回が独立した知識ではなく、1つの原則の現れだったことが分かります。
| 回 | 扱った階 | 選んでいたもの | 最も強い対策 |
|---|---|---|---|
| 第1回 | 全体 | どの階を見るか | 階を分けて切り分ける |
| 第2回 | 1F(表現) | 型と符号化 | 意味を機械が検証できる形にする |
| 第3回 | 1F(装置) | データの置き場所 | 近くにまとめる |
| 第4回 | 2F(OS) | 資源の配分と保護 | プロセスとして分ける |
| 第5回 | 4F(手順) | データ構造と手間の増え方 | 増え方のクラスを変える |
| 第6回 | ネットワーク | 往復の回数 | まとめて1回にする |
| 第7回 | データ層 | 独立性の段階と索引 | 待たせずに正しくする |
| 第8回 | 実行 | 共有と順序 | 共有しない |
そして、いちばん最後に伝えたいことがあります。 このシリーズで扱った知識は、どれも「暗記」では役に立ちません。「この不具合はどの階の話か」「この処理は何回まわるか」「この値は誰と共有されているか」。問いを立てる力として使うものです。第1回で「地図があれば、分からないことは空白になる」と書きました。8回を読んだあなたは、自分の知らないことの場所が分かるようになっています。それが、学び続けられる人の状態です。
まとめ
この記事を読んだあなたは、await を見たときに「待つべき場所かどうか」を意識するようになります。そして、再現しない不具合に遭遇したとき、「確率の問題だ」と考え、共有状態と順序の仮定を探すようになります。
そして、並行処理の最も強い対策が「共有しないこと」だと知っています。第7回のデータベースが守ってくれたものを、自分で実装するときの基準ができました。ロックを増やすのではなく、共有を減らす。順序を保証するのではなく、順序に依存しない設計にする。
全8回を通じて、あなたはコンピュータを「動く箱」から「構造を持つ系」として見る目を手に入れました。速い記憶は小さく、遠い記憶は遅く、同時に触れば壊れ、抽象はいつか漏れる。そのどれもが、設計の判断材料になります。エラーが出たときにどの階の話かを考え、性能が問題になったときに回数を数え、正しさが問題になったときに共有を疑う。この3つの習慣が、これからのあなたの仕事の土台です。
参考文献
- Edgar W. Dijkstra, “Cooperating Sequential Processes”, 1965(セマフォの提案。並行処理の基礎) — https://www.cs.utexas.edu/users/EWD/transcriptions/EWD01xx/EWD123.html
- C. A. R. Hoare, “Monitors: An Operating System Structuring Concept”, Communications of the ACM, 1974(モニタの提案) — https://dl.acm.org/doi/10.1145/355620.361161
- Leslie Lamport, “Time, Clocks, and the Ordering of Events in a Distributed System”, Communications of the ACM, 1978(順序と時刻の扱い) — https://lamport.azurewebsites.net/pubs/time-clocks.pdf
- Gene M. Amdahl, “Validity of the Single Processor Approach to Achieving Large-Scale Computing Capabilities”, AFIPS, 1967(アムダールの法則の原典) — https://dl.acm.org/doi/10.1145/1465482.1465560
- John L. Gustafson, “Reevaluating Amdahl’s Law”, Communications of the ACM, 1988(規模を変えた場合の見方) — https://dl.acm.org/doi/10.1145/42411.42415
- Maurice Herlihy, Nir Shavit, “The Art of Multiprocessor Programming”, Morgan Kaufmann(並行データ構造と同期の教科書) — https://www.sciencedirect.com/book/9780123705914/the-art-of-multiprocessor-programming
- Brian Goetz et al., “Java Concurrency in Practice”, Addison-Wesley(実務的な並行処理の定番書) — https://jcip.net/
- Remzi H. Arpaci-Dusseau, Andrea C. Arpaci-Dusseau, “Operating Systems: Three Easy Pieces”(第4部 並行性) — https://pages.cs.wisc.edu/~remzi/OSTEP/
- Python Software Foundation, “asyncio — Asynchronous I/O”(公式ドキュメント) — https://docs.python.org/3/library/asyncio.html
- Python Software Foundation, “threading — Thread-based parallelism” — https://docs.python.org/3/library/threading.html
- MDN Web Docs, “Asynchronous JavaScript” — https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Async_JS
- Martin Kleppmann, “Designing Data-Intensive Applications”, O’Reilly Media, 2017(分散システムにおける順序と一貫性) — https://dataintensive.net/
- Herb Sutter, “The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software”, Dr. Dobb’s Journal, 2005(並列化が必須になった転換点) — http://www.gotw.ca/publications/concurrency-ddj.htm
- Rust Project, “Fearless Concurrency”(The Rust Programming Language, 第16章。所有権によるデータ競合の防止) — https://doc.rust-lang.org/book/ch16-00-concurrency.html
- 独立行政法人情報処理推進機構(IPA), 「基本情報技術者試験 シラバス」(並行処理・排他制御・デッドロック) — https://www.ipa.go.jp/
- ACM/IEEE-CS Joint Task Force, “Computer Science Curricula 2023 (CS2023)”(Parallel and Distributed Computing 領域) — https://csed.acm.org/
Rui Software