Dev Study
← どの言語にもある仕組み — 値・型・数・文字・エラー・メモリ・並行
レッスン 12 / 12·約 9 分·第25期 どの言語にもある仕組み

並行処理は、切り替わりうる場所で壊れる

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

コースの最終回です。同じ「1を足す」でも、途中で割り込まれうるかどうかは言語の並行モデルが約束した分しか決まっていないことを、3つの言語で並べて確かめます。

まず結論

「読んで・足して・書く」が壊れるのは、読みと書きの間で別の処理に切り替わりうるときです。切り替わりうる場所は言語の並行モデルが決めていて、JavaScript の async と Python の asyncio では await の地点だけ、Go の goroutine ではどこでも、しかも複数のコアで同時にです。

実測では、1000 回の加算が await を1つ挟むだけで 1 になり、Go の 8,000,000 回は9回の測定では約157万〜475万に減りました。1コアに絞ると毎回合いましたが、それを約束する文書はありません。

? 読む前に予測

JavaScript で、共有の変数を読み、`await null` を1つ挟んでから「読んだ値 + 1」を書く async 関数を 1000 個同時に走らせ、全部の完了を待ちます。最初は 0 です。最後の値はいくつになるでしょうか(Node v24)。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

壊れるのは「読んだ後・書く前」に割り込まれたとき

第10期で見たとおり、count += 1 は読んで・変えて・書くの3段階に分かれます。3段階に分かれていても、読みと書きの間に別の処理が入り込めなければ値は消えません。入り込めるなら、古い値を元にした結果で上書きされ、他方の加算が消えます。

では、どこで入り込めるのか。これは CPU ではなく、その言語の並行モデルが約束しています。

Python の asyncio の文書はこう書きます。

Event loops use cooperative scheduling: an event loop runs one Task at a time. While a Task awaits for the completion of a Future, the event loop runs other Tasks, callbacks, or performs IO operations.

一度に走るタスクは1つ。別のタスクに移るのは、今のタスクが await で待つときです。切り替わりうる地点はコードに書かれた await の位置で、読めば分かります。JavaScript の async 関数も、await の地点で呼び出し元に制御を返す手順を仕様(ECMA-262 の Await)に持っています。

Go は逆です。Go 1.14 のリリースノートにこうあります。

Goroutines are now asynchronously preemptible. As a result, loops without function calls no longer potentially deadlock the scheduler or significantly delay garbage collection.

goroutine は自分から手放さなくても途中で切り替えられます(一部のプラットフォームを除く)。しかも runtime の文書によれば GOMAXPROCS は Go のコードを同時に実行できる OS スレッドの数の上限で、2 以上なら複数の goroutine が同時に走りえます。切り替わりうる地点は、ソースを読んでも1か所に絞れません。

同じ「読む→足す→書く」と、割り込みうる場所
読むv = n
足すv + 1
書くn = v + 1
JS の async / Python の asyncio
await を置いた位置だけ置かなければ割り込めない
Go の goroutine
どこでも非同期プリエンプション+複数コアで同時
  • ・壊れるかどうかは「読む」と「書く」の間に割り込める地点があるかで決まる
  • ・その地点を決めているのは CPU ではなく、言語とランタイムの並行モデル
具体実物

await を1つ挟むと、1000 が 1 になる

Node v24.21.0 と Python 3.14.7(標準ビルド)で、同じことを並べました。環境は Docker の linux/arm64(aarch64)です。JavaScript 版の全文です。

a.mjs(Python 版も同じ構成で、await asyncio.sleep(0) を挟む)設定ファイル
let a = 0, b = 0;
async function incNoAwait() { const v = a; a = v + 1; }
async function incAwait() { const v = b; await null; b = v + 1; }
await Promise.all(Array.from({ length: 1000 }, incNoAwait));
await Promise.all(Array.from({ length: 1000 }, incAwait));
console.log("no await between:", a, " await between:", b);

違いは incAwait の読みと書きの間にある await null だけです。

実行結果(生ログ 1回目。3回とも同じ値)コマンド出力
### run 1 node:24.21.0-alpine
no await between: 1000 await between: 1
exit=0
### run 1 python:3.14-slim
no await between: 1000 await between: 1
exit=0

await が無ければ 1000、1つ挟むと 1 です。結果が 1 なのは、最後に書いた処理も 0 を読んでいたからです。1000 回の加算のうち 999 回が上書きで消えました。エラーも警告も出ていません。

「JS はシングルスレッドだから競合しない」とよく言われます。JS のコードを実行するスレッドが1本であることと、読みと書きの間に別の処理が入り込めないことは、別の話です(Node の実行の仕組みは第7期で扱います)。Python の asyncio も同じで、asyncio.sleep(0) について文書は always suspends the current task, allowing other tasks to run と書きます。

✓ ここまでの確認

同じ Node と Python で、await を挟まない版は 1000 回の加算が 1000 のまま残りました。なぜ消えなかったのですか。

抽象原理

どこでも切り替わるなら、約束は「同期したときだけ」

切り替わりうる地点が読めない Go では、何が約束されているのでしょうか。Go のメモリモデルはまずデータ競合を定義します。

A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package.

そのうえで、筋の通った実行順が約束されるのは競合が無いときだけです(In the absence of data races, Go programs behave as if all the goroutines were multiplexed onto a single processor.)。定義にコア数は出てきません。1コアで動かしても、同期していない n++ はデータ競合のままです。

同じ文書の助言は短いものです。

If you must read the rest of this document to understand the behavior of your program, you are being too clever. Don't be clever.

チャネルか sync・sync/atomic で直列化せよ、ということです。Effective Go の share memory by communicating も、直後の段落で This approach can be taken too far. Reference counts may be best done by putting a mutex around an integer variable と続きます。「常にチャネル」ではありません。

Python のスレッドはどうか。用語集は GIL を The mechanism used by the CPython interpreter to assure that only one thread executes Python bytecode at a time. と定義し、続けて As of Python 3.13, the GIL can be disabled using the --disable-gil build configuration. と書きます。3.14 では GIL を外したビルドが公式サポートになりました(任意)。「Python なら同時には走らない」は、ビルドによって変わる約束です。

具体実物

Go では 8,000,000 が実行ごとに違う値になる

Go 1.27.1(linux/arm64、既定の GOMAXPROCS は 10)で、8 個の goroutine がそれぞれ n++ を 100 万回行います。正しければ 8 × 1,000,000 = 8,000,000 です。

main.go(1回の実行で3回数える)設定ファイル
package main
import ("fmt"; "sync"; "runtime")
func main() {
fmt.Println("GOMAXPROCS", runtime.GOMAXPROCS(0))
for run := 0; run < 3; run++ {
n := 0; var wg sync.WaitGroup
for i := 0; i < 8; i++ { wg.Add(1); go func() { defer wg.Done(); for j := 0; j < 1000000; j++ { n++ } }() }
wg.Wait(); fmt.Println("n =", n)
}
}
生ログ run 1 からプログラムの出力だけを抜き出して連結(上が既定、下が GOMAXPROCS=1)コマンド出力
GOMAXPROCS 10
n = 2280697
n = 1910674
n = 1566926
GOMAXPROCS 1
n = 8000000
n = 8000000
n = 8000000

既定の設定では、9回の値が 1,566,926〜4,747,378 に散りました。失われた加算は 325万〜643万で、量は毎回違います。

GOMAXPROCS=1 では9回とも 8,000,000 でした。ここで「1コアなら安全」と結論したくなりますが、先ほどの定義どおり、これはデータ競合のあるプログラムです。9回合ったのは、この版・この環境での観測です。

-race を付けると報告が出ます(alpine に gcc と musl-dev を入れ、CGO_ENABLED=1)。

-race 付きの実行(抜粋。... の位置で goroutine の生成位置・2件目の報告・n の行を省略)コマンド出力
==================
WARNING: DATA RACE
Read at 0x00c0000140f8 by goroutine 11:
main.main.func1()
/w/main.go:7 +0x88
 
Previous write at 0x00c0000140f8 by goroutine 15:
main.main.func1()
/w/main.go:7 +0x98
...
Found 2 data race(s)
exit status 66

7行目の n++ で、読みと書き、書きと書きの2件です。exit status 66 は文書が既定とする値です。ただし検出器は値を直しません。-race 下の n もこの測定(7回)では約673万〜762万で、8,000,000 にはなりませんでした。n を atomic.AddInt64 か sync.Mutex で守った版は、3回×2 すべて 8,000,000 でした。

✓ ここまでの確認

Go で `GOMAXPROCS=1` にすると、9回とも 8,000,000 になりました。この結果の扱いとして適切なのはどれですか。

⇄ 2つを見比べる
具体例 A

JS の async(Node v24.21.0)

読みと書きの間に await null を1つ置いただけで、1000 回の加算が 1 になりました。3回とも同じ値です。割り込める地点はコードに書いた await の位置なので、置く場所を変えれば避けられます。

await 1つの有無コマンド出力
no await between: 1000 await between: 1
具体例 B

Go の goroutine(Go 1.27.1、GOMAXPROCS 10)

コードには何も挟んでいないのに、8,000,000 回の加算が9回の測定では約157万〜475万に減りました。割り込める地点を書き手は選べないので、atomic か mutex で読みと書きを1つにします。

1回目の3回分コマンド出力
n = 2280697
n = 1910674
n = 1566926

片方は毎回同じ値、片方は毎回違う値で壊れました。2つに共通する壊れ方と、違いを生んでいるものは何でしょうか。

「読む」と「書く」の間に、別の処理が入り込める地点がいくつあるかを数えてください。

この章のまとめ

  • n = n + 1 が壊れるのは、読みと書きの間に割り込める地点があるとき。
  • JS の async と Python の asyncio では、その地点は await の位置だけ。await を1つ挟むと 1000 → 1。「スレッドが1本だから競合しない」は誤り。
  • asyncio は runs one Task at a time。async にしても並列にはならない。
  • Go は 1.14 から非同期にプリエンプトされ、複数コアで同時に走る。8,000,000 が9回の測定では約157万〜475万で、値は実行ごとに違う。
  • GOMAXPROCS=1 で9回とも合っても、同期なしの n++ はデータ競合のまま。-race は実行した経路の競合しか見つけず、値も直さない。
  • Python の GIL は標準ビルドでは、の限定つき。

このコースは、代入・const・型・「無い」の表し方・整数・浮動小数点・文字列・等価・エラー・寿命・後始末、そして並行と、同じ問いを12回繰り返しました。同じ書き方でも、何が起きるかは、その言語が約束した分しか決まっていない。 約束の外で観測した値は、GOMAXPROCS=1 の 8,000,000 のように、合っていても次の版や環境で合う保証がありません。コードを読むとき、生成されたコードを受け取るとき、問うのは「動いたか」ではなく「その言語は何を約束したか」です。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

「I/O 待ちの無い計算を asyncio の async 関数に書き換えれば、複数コアで並列に走って速くなる」という主張の扱いとして適切なのはどれですか。

用語集(4語)
協調的スケジューリング
実行中の処理が自分から制御を手放した地点でだけ、別の処理に切り替わる方式。asyncio の文書はイベントループをこれとして説明し、手放す地点は await。
非同期プリエンプション(Go)
goroutine が自分から手放さなくても、ランタイムが途中で切り替えられること。Go 1.14 から。関数呼び出しの無いループも対象になった。
データ競合(Go の定義)
同じメモリ位置への書き込みが、別の読み書きと並行して起きること。すべて sync/atomic によるアクセスなら除く。Go のメモリモデルの定義。
GIL
CPython が同時に Python バイトコードを実行するスレッドを1本にする仕組み。3.13 から GIL を外したビルドがあり、3.14 で公式サポート(任意)。