ジュニアエンジニア向け コンピュータサイエンス入門シリーズ(全8回)
第8回:並行と並列:awaitを1つ忘れただけで、なぜ本番だけ壊れるのか
← 前の回(第7回) | 次の回はありません → | 記事一覧

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つができるようになります。第一に、レース条件がなぜ起きるのかを、実行順序の可能性として説明できること。第二に、非同期と並列を区別し、どちらが必要かを判断できること。第三に、共有状態を減らすという設計原則の理由を理解すること。第四に、テストで再現しない不具合に遭遇したとき、確率の問題として対処できることです。

並行と並列を料理人1人と3人で対比した概念イラスト 左は料理人1人が複数の鍋を掛け持ちして待ち時間に別の作業をする並行、右は料理人3人が本当に同時に作業する並列を示す図。 並行は「1人で段取り」、並列は「人数を増やす」。目的が違う 待ち時間で悩むなら段取り(並行)、計算量で悩むなら人数(並列) 並行 ── 料理人1人の段取り 手は2本 本当は同時に 動けない スープを煮込む 煮込み中に野菜を切る 炒めながら味見する 待ち時間に別の仕事を詰める 向いている場面 外部への問い合わせ待ち ネットワークの応答待ち 利用者の操作待ち 待ちが多い処理 = 1つのCPUで足りる = コアを増やしても効かない 並列 ── 料理人3人の分担 向いている場面:計算が詰まっていて、待ちが少ない処理 大量の集計・画像や動画の変換・機械学習の計算 コア数を増やすと、その分だけ速くなる(上限まで) ただし人数分の場所と食器が要る =メモリと管理のコストが増える 混同すると、見当違いの対策を打つことになる 「速くしたい」の一言では、どちらの話か分からない

2つの違いを、目的・手段・限界で整理します。「速くしたい」と思ったときに、どちらの問題かを切り分けるのが第一歩です。

観点並行(concurrency)並列(parallelism)
目的待ち時間のあいだに別の仕事を進める計算そのものを同時に進めて時間を短縮する
必要な資源1つのCPUコアで足りる複数のCPUコアが要る
効く場面外部I/O待ち・ネットワーク待ち・利用者の操作待ち(全体の大半が待ち時間)計算が詰まっている処理(待ち時間がほとんどない)
限界待ちがなければ効果が出ないコア数とメモリ帯域、そしてアムダールの法則
難しさの源実行順序が入れ替わること共有資源の取り合いと、結果の合成
典型的な道具非同期処理・イベントループスレッド・プロセス・GPU

😓 動機:再現しない不具合が、いちばん厄介

並行処理のトラブルは、再現しないという点で他のすべてのトラブルと違います。第2回のデータ、第3回の性能、第4回の資源枯渇、第6回のネットワーク。どれも「条件を揃えれば再現する」ものでした。並行処理の不具合は、条件を揃えても再現しないことがあります

よくある場面を4つ挙げます。ひとつ目は、テストでは100回成功したのに、本番で月に2回失敗する。ふたつ目は、本番でだけ数字が合わない。第7回のデータベースの問題と区別がつかない。みっつ目は、負荷をかけると壊れる。開発環境では軽いので気づかない。よっつ目は、await を付け忘れた1行が、たまたま動いているように見える。

これらに共通するのは、「実行の順序」が実行のたびに変わることです。そして厄介なのは、順序が「都合のよい方」になる確率が、テスト環境では高いことです。開発環境は速く、負荷が低く、処理が詰まらない。順序が入れ替わる隙が生まれにくい。本番は遅く、負荷が高く、順序が入れ替わる隙が無数にある。だから「テストで通った」は「正しい」の証明になりません。

そして、この知識の重要性はAI時代に上がっています。AIが生成するコードは、非同期処理を自然に混ぜ込んできますasyncawait が散らばったコードは読みにくく、1箇所の抜けを見落としやすい。そして抜けても動いてしまいます動いてしまうから、レビューで止めるしかないのです。

🧪 仮説:並行処理の不具合は「共有状態」と「順序の仮定」から生まれる

仮説を立てます。並行処理のトラブルは、ほぼ2つの原因に分類できる。第一に「複数の処理が同じ状態を共有していること」、第二に「実行順序について根拠のない仮定を置いていること」である。

この仮説を支持する観察が3つあります。第一に、検証①で扱う「更新が消える」問題は、共有状態が原因です。第二に、await の付け忘れは「この処理はここで終わっているはず」という順序の仮定が原因です。第三に、この2つは対策が同じ方向を向きます共有状態を減らし、順序の仮定を明示する。この2つで、大半のトラブルは防げます。

🔬 検証①:消える更新——第7回の小さな再現

まず、最も基本的なトラブルから見ます。第7回ではデータベースの世界で「更新が消える」問題を扱いました。同じことが、1つのプロセスの中でも起きます。しかもデータベースが守ってくれません

題材はカウンタです。2つの処理が同時に、同じ変数を1ずつ増やします。

2つの処理が同時にカウンタを増やすと増分が失われる仕組み カウンタを読んで1を足して書き戻す処理を2つ同時に実行すると、読み取りと書き戻しの間に相手の更新が入り、増分が1つ失われることを示す図。 2回足したのに、1しか増えないことがある カウンタの値は0。「読んで、1足して、書き戻す」を2つの処理が同時に行う 処理A 処理B ① カウンタを読む → 0 を手元に持つ ① カウンタを読む → 0 を手元に持つ ② 1を足す 手元の値が 0 → 1 になる ② 1を足す こちらも 0 → 1 になる ③ 書き戻す カウンタに 1 を書く ③ 書き戻す カウンタに 1 を書く 2回とも「1」を書いた 2つの処理が、どちらも 「0から1へ」を計算した 結果は 1(本来は 2) 読む→計算→書くの間に、相手が割り込める 1回分の増加が消えた 対策は「読む→計算→書く」を分けさせないこと。第7回とまったく同じ形 ① 専用の仕組みで1つにまとめる(原子的操作・不可分操作) ② 鍵(ロック)で囲む。ただし囲んでいる間は他の処理が待つ ③ そもそも共有しない(第7回の正解と同じ:最も強い対策)

この現象をレース条件(race condition)と呼びます。2つの処理が競争(race)して、勝った方だけが結果を残すという意味です。

そして重要なのは、第7回の「読む→確認→書く」とまったく同じ構造だということです。データベースでは、トランザクションとロックが守ってくれました。1つのプロセスの中では、誰も守ってくれません。自分で守る必要があります。

対策やり方得るもの代金
原子的操作を使う「読む→計算→書く」を1つの命令として実行する待ちが発生しない使える場面が限られる(単純な演算のみ)
鍵(ロック)で囲む触る前に鍵をかけ、終わったら外す複雑な処理でも守れる待ち時間が増える。かけ忘れ・順番で問題が出る
共有しない各処理が自分のデータだけを持つレース条件が原理的に起きない設計の変更が要る。結果の合成方法を考える必要

3つ目が本記事の結論の予告です。最も強い対策は「そもそも共有しない」。第7回でも同じ結論でした(条件付き更新は、待ちを発生させずに正しさを守る方法でした)。共有状態を減らすほど、正しさを保つための仕掛けが要らなくなるのです。

🔬 検証②:awaitの付け忘れ——順序についての根拠のない仮定

次は、非同期処理の話です。ここが最終回の中心です。

非同期処理の考え方は単純です。「待たされる処理を始めておいて、終わるのを待たずに次の処理に進む」。そして、結果が必要になったら待つ。料理の例でいえば、煮込みを火にかけて、番をせずに野菜を切り始めることです。

問題は、「待つ」を忘れたときに起きることです。結果がまだ出ていないのに、結果を使う処理に進んでしまう

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年、ジーン・アムダールが示した原則があります。「並列化できない部分が少しでもあると、全体の高速化には上限がある」というものです。これをアムダールの法則と呼びます。

並列化できる割合ごとに、コアを増やしたときの高速化の上限を示した図 並列化できる部分が95%、90%、75%、50%の場合に、コア数を増やしても高速化が上限に張り付く様子を示す図。 並列化できない1割が、10倍を上限にしてしまう 縦=コアを増やしたときの高速化の倍率。横=コア数。並列化できる割合ごとに曲線が変わる CPUコア数 → 高速化の倍率 95%並列 → 上限20倍 90%並列 → 上限10倍 75%並列 → 上限4倍 50%並列 → 上限2倍 1倍=並列化なし 1割が直列だと、コアを何個足しても10倍で頭打ちになる

この法則が実務で効くのは、「並列化したのに速くならない」場面です。コアを倍にしたのに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スレッド21ms1約48
同期・スレッドを増やす21ms100約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階建てのビルの全体像です。

全8回で身につけた知識の全体像 第1回の地図から第8回の並行処理まで、各回がどの階のどの問題を扱い、どの知識がどの回につながっているかを示す図。 8回で、コンピュータの下の階から上の階までを通した 各回は独立しつつ、後半になるほど前半の知識を必要とする 第1回 地図 5階建てとエラーの階切り分け 第2回 データの正体 符号化・型・誤差 第3回 CPUとメモリ 速度の階段・スタックとヒープ 第4回 OSの仕事 プロセス・仮想メモリ・ファイル・権限 下の階を1つずつ降りていく。ここまでが「土台」の3回+地図 第5回 アルゴリズム 計算量とデータ構造 第6回 ネットワーク 配送・往復回数・TLS 第7回 データベース 同時実行・索引・N+1 第8回 並行と並列 レース条件・非同期 後半は「性能と正しさ」を扱う4回。前半の土台の上に成り立っている 8回を貫いていた原則は1つ:「何を犠牲にするかを選ぶ」 型・置き場所・データ構造・往復回数・独立性の段階・並行性。どれも「速さと正しさ」の配分を決める行為だった そして最も強い対策は、いつも「分ける」ことだった(階を分ける・データを分ける・共有しない)

振り返ると、各回が独立した知識ではなく、1つの原則の現れだったことが分かります。

扱った階選んでいたもの最も強い対策
第1回全体どの階を見るか階を分けて切り分ける
第2回1F(表現)型と符号化意味を機械が検証できる形にする
第3回1F(装置)データの置き場所近くにまとめる
第4回2F(OS)資源の配分と保護プロセスとして分ける
第5回4F(手順)データ構造と手間の増え方増え方のクラスを変える
第6回ネットワーク往復の回数まとめて1回にする
第7回データ層独立性の段階と索引待たせずに正しくする
第8回実行共有と順序共有しない

そして、いちばん最後に伝えたいことがあります。 このシリーズで扱った知識は、どれも「暗記」では役に立ちません「この不具合はどの階の話か」「この処理は何回まわるか」「この値は誰と共有されているか」問いを立てる力として使うものです。第1回で「地図があれば、分からないことは空白になる」と書きました。8回を読んだあなたは、自分の知らないことの場所が分かるようになっています。それが、学び続けられる人の状態です。

まとめ

この記事を読んだあなたは、await を見たときに「待つべき場所かどうか」を意識するようになります。そして、再現しない不具合に遭遇したとき、「確率の問題だ」と考え、共有状態と順序の仮定を探すようになります。

そして、並行処理の最も強い対策が「共有しないこと」だと知っています。第7回のデータベースが守ってくれたものを、自分で実装するときの基準ができました。ロックを増やすのではなく、共有を減らす。順序を保証するのではなく、順序に依存しない設計にする。

全8回を通じて、あなたはコンピュータを「動く箱」から「構造を持つ系」として見る目を手に入れました。速い記憶は小さく、遠い記憶は遅く、同時に触れば壊れ、抽象はいつか漏れる。そのどれもが、設計の判断材料になります。エラーが出たときにどの階の話かを考え、性能が問題になったときに回数を数え、正しさが問題になったときに共有を疑うこの3つの習慣が、これからのあなたの仕事の土台です。

参考文献

  1. Edgar W. Dijkstra, “Cooperating Sequential Processes”, 1965(セマフォの提案。並行処理の基礎) — https://www.cs.utexas.edu/users/EWD/transcriptions/EWD01xx/EWD123.html
  2. C. A. R. Hoare, “Monitors: An Operating System Structuring Concept”, Communications of the ACM, 1974(モニタの提案) — https://dl.acm.org/doi/10.1145/355620.361161
  3. 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
  4. 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
  5. John L. Gustafson, “Reevaluating Amdahl’s Law”, Communications of the ACM, 1988(規模を変えた場合の見方) — https://dl.acm.org/doi/10.1145/42411.42415
  6. Maurice Herlihy, Nir Shavit, “The Art of Multiprocessor Programming”, Morgan Kaufmann(並行データ構造と同期の教科書) — https://www.sciencedirect.com/book/9780123705914/the-art-of-multiprocessor-programming
  7. Brian Goetz et al., “Java Concurrency in Practice”, Addison-Wesley(実務的な並行処理の定番書) — https://jcip.net/
  8. Remzi H. Arpaci-Dusseau, Andrea C. Arpaci-Dusseau, “Operating Systems: Three Easy Pieces”(第4部 並行性) — https://pages.cs.wisc.edu/~remzi/OSTEP/
  9. Python Software Foundation, “asyncio — Asynchronous I/O”(公式ドキュメント) — https://docs.python.org/3/library/asyncio.html
  10. Python Software Foundation, “threading — Thread-based parallelism” — https://docs.python.org/3/library/threading.html
  11. MDN Web Docs, “Asynchronous JavaScript” — https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Async_JS
  12. Martin Kleppmann, “Designing Data-Intensive Applications”, O’Reilly Media, 2017(分散システムにおける順序と一貫性) — https://dataintensive.net/
  13. 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
  14. Rust Project, “Fearless Concurrency”(The Rust Programming Language, 第16章。所有権によるデータ競合の防止) — https://doc.rust-lang.org/book/ch16-00-concurrency.html
  15. 独立行政法人情報処理推進機構(IPA), 「基本情報技術者試験 シラバス」(並行処理・排他制御・デッドロック) — https://www.ipa.go.jp/
  16. ACM/IEEE-CS Joint Task Force, “Computer Science Curricula 2023 (CS2023)”(Parallel and Distributed Computing 領域) — https://csed.acm.org/
ジュニアエンジニア向け コンピュータサイエンス入門 ── 全8回の一覧
いま読んでいるのは 第8回 です。読みたい回から始めても構いません。
  1. 第1回:全体地図:その1行は「5階建てのビル」で動いている
  2. 第2回:データの正体:0.1+0.2はなぜ0.3にならないのか
  3. 第3回:CPUとメモリ:1回のメモリアクセスは100回の計算より高くつく
  4. 第4回:OSの仕事:「ファイルを開く」は何をしているのか
  5. 第5回:アルゴリズムとデータ構造:100万件から1人を探すのに20回で足りる
  6. 第6回:ネットワーク:クリックした荷物はどうやって海を渡るのか
  7. 第7回:データベース:100人が同時に書き換えても壊れないのはなぜか
  8. ▶ 第8回:並行と並列:awaitを1つ忘れただけで、なぜ本番だけ壊れるのか(この記事)

© Copyright 2005-2026| Rui Software | All Rights Reserved