R

ReflexKR

Reflex公式ドキュメント

深夜厨房の並行処理

·

講座の概要

1

初日の行列:並行と並列

2

料理人を増やす:threadingの基本

3

黒板の数が合わない:競合状態とLock

4

コンロは3つだけ:同期の道具

5

包丁とまな板:デッドロック

6

伝票差し:queue

7

出張料理人:concurrent.futures

8

生地はなぜ速くならない:GILと並列

9

一人で回すホール係:asyncioの第一歩

10

団体予約のキャンセル:構造化された並行処理

11

止まったホール:ブロッキングと組み合わせ

12

評論家の夜:総まとめ

深夜厨房の並行処理
8限目

時間 5:50–6:40

生地はなぜ速くならない:GILと並列

今夜の注文書

#08 · 八日目の夜

生地はなぜ速くならない

ピンチ

明日使う麺の生地が4玉。料理人を4人つけたのに、一人でこねたときと時間がまったく同じ。

目標

生地4玉を、一人でやるときの2倍以上の速さで仕上げる。

深夜食堂 ドンシ

営業が終わった深夜1時。明日使う麺の生地が4玉、作業台に置かれています。ミンホ、スア、タクさん、そしてあなた。4人で1玉ずつ受け持ったのだから、4倍は速くなるはずです。

ところがシェフのタイマーは、一人でこねたときとほぼ同じ数字で止まります。ミンホが首をかしげます。「みんな手は動いてたんですけど?」

ユン・ソヨンシェフが作業台の上の包丁差しを指さします。包丁がたった1本だけ差してあります。「この厨房には古いルールがひとつある。切ったりこねたりする人は一度に一人だけ。今日はそのルールの話をしよう」

8-1. 料理人4人、時間はそのまま (5分)

1限目の注文は、スープが煮えるのを待つ仕事でした。今日の生地は違います。数を200万回足すループ、つまり手が休まない仕事(CPU処理)です。

import threading
import time

def knead(n=2_000_000):
    s = 0
    for i in range(n):   # 休まず計算する:生地をこねる
        s += i
    return s

start = time.perf_counter()
for _ in range(4):
    knead()
print(f"sequential {time.perf_counter() - start:.2f}s")

start = time.perf_counter()
cooks = [threading.Thread(target=knead) for _ in range(4)]
for th in cooks:
    th.start()
for th in cooks:
    th.join()
print(f"4 threads  {time.perf_counter() - start:.2f}s")
# sequential 0.24s
# 4 threads  0.23s   (12コアPC、Python 3.14での実測)
  • スレッド4本がちゃんと動いたのに、かかった時間は順番にやったときとほとんど同じです。
  • コアが12個あるPCです。コアが足りないせいではありません。
  • 数字はPCごと、実行ごとに少しずつ変わります。大事なのは、2つの数字がほぼ同じだという点です。

8-2. GIL:包丁は1本だけ (8分)

CPython(私たちが普段使っているPython)にはGIL(Global Interpreter Lock、グローバルインタープリターロック)があります。ルールはひとつです。

ひとつのプロセスの中で、Pythonのバイトコードを実行するスレッドは一度にひとつだけ。

厨房でいえば「包丁は1本だけ」ルールです。料理人が4人いても、包丁を握った人しか切れず、残りは包丁が戻ってくるのを待ちます。包丁はおよそ0.005秒ごとに次の人へ渡ります(sys.getswitchinterval()、3限目)。

時刻ミンホスアタクさんあなた
0〜5ms包丁を握ってこねる待ち待ち待ち
5〜10ms待ち包丁を握ってこねる待ち待ち
10〜15ms待ち待ち包丁を握ってこねる待ち
15〜20ms待ち待ち待ち包丁を握ってこねる

4人がかわるがわるこねるので、生地4玉はどれも少しずつ進みます。並行はあっても並列はありません(1限目)。全体の作業量は変わらないので、時間も変わりません。

なぜこんなルールがあるのでしょう? Pythonはすべてのオブジェクトについて「いくつの場所から自分が使われているか」(参照カウント)を数えています。複数のスレッドがこの数を同時に書き換えると、3限目の黒板のように狂ってしまいます。昔のCPythonは、ひとつのロックでインタープリター全体を守るという簡単な道を選びました。それがGILです。

待つときは包丁を置く

GILには大事な例外があります。スレッドは待っているあいだ(time.sleep、ファイルの読み込み、ネットワークの応答)はGILを手放します。スープが煮えるのを待つ人に包丁はいりませんから。

def simmer():
    time.sleep(0.3)   # スープが煮えるのを待つ:このあいだGILを手放す

start = time.perf_counter()
cooks = [threading.Thread(target=simmer) for _ in range(4)]
for th in cooks:
    th.start()
for th in cooks:
    th.join()
print(f"4 threads {time.perf_counter() - start:.2f}s")
# 4 threads 0.30s   (順番にやれば1.2秒)

Cで書かれた関数も、重い計算をしているあいだはGILを手放せます。hashlib が大きなデータをハッシュするときがそうです。人の手ではなくミキサー(Cのコード)が回す仕事なので、包丁ルールの外にいるわけです。

import hashlib

dough = b"x" * 50_000_000   # 50MB

def press():
    hashlib.sha256(dough).hexdigest()   # Cのコードが計算しているあいだGILを手放す

# 上と同じやり方で測ると(このPCでだいたい)
# sequential 0.11s
# 4 threads  0.04s
仕事の種類GILのあるスレッドだと理由
待つ仕事(sleep、ファイル、ネットワーク)速くなる待っているあいだGILを手放す
C拡張の重い計算(hashlib、zlibなど)たいてい速くなるCのコードがGILを手放して計算する
純粋なPythonの計算(ループ、文字列の組み立て)速くならないバイトコードは一度にひとつのスレッド

そしてGILはあなたのコードを守ってはくれません。GILが守るのはインタープリターの内部だけで、count += 1 のような複数段階の仕事は途中で切られます。3限目の黒板が、GILがあるのに狂った理由です。

下の実習台は、デプロイ先のサーバーで本物のスレッドが time.sleep する厨房です。サーバーはGILのあるPythonですが、待つ仕事なのでバーが重なります。

厨房タイムライン

注文

6

料理人

3

調理

待ち

キャンセル

失敗

待つ仕事4つ、それぞれ0.3秒。GILのあるサーバーでも、料理人が何人もいればバーが重なって約0.3秒で終わります。「一人で順番に」と比べてみてください。

シェフのひとこと

スレッドを増やしたのに速くならないなら、まずその仕事がスープなのか生地なのかを考えて。スープならスレッドで十分。生地なら、人を増やすんじゃなくて厨房を増やさなきゃ。

8-3. 支店の厨房:multiprocessing (7分)

包丁が1本しかないなら、包丁が別にある厨房をもうひとつ構えればいいのです。それがプロセスです。プロセスごとにPythonのインタープリターが別々に動き、GILも別々にあります。

import multiprocessing as mp
import os

def branch(name):
    print(name, "pid", os.getpid(), "parent", os.getppid())

if __name__ == "__main__":
    print("main pid", os.getpid())
    p = mp.Process(target=branch, args=("branch-1",))
    p.start()
    p.join()
    print("exitcode", p.exitcode)
# 例:
# main pid 39516
# branch-1 pid 40268 parent 39516
# exitcode 0

Process の使い方は threading.Thread と同じです(target、args、start、join)。違うのは os.getpid()、つまりプロセス番号です。支店は本店とは別の番号を持つ、独立したプログラムです。

冷蔵庫まで別々

支店はメモリを共有しません。支店でリストに何を入れても、本店のリストはそのままです。

orders = []

def add(n):
    orders.append(n)
    print("in branch:", orders)

if __name__ == "__main__":
    p = mp.Process(target=add, args=("#1",))
    p.start()
    p.join()
    print("in main:", orders)
# in branch: ['#1']
# in main: []

3限目の黒板の問題がそもそも起きない代わりに、結果を受け取るには別途返してもらう必要があります。複数の作業を手分けして任せるなら Pool が便利です。

def knead(n):
    s = 0
    for i in range(n):
        s += i
    return s

if __name__ == "__main__":
    with mp.Pool(processes=4) as pool:
        print(pool.map(knead, [10, 100, 1000, 10_000]))
# [45, 4950, 499500, 49995000]

if __name__ == "__main__": がどうしても必要な理由

WindowsとmacOSは、支店をspawn方式で開きます。新しいPythonを立ち上げ、そのPythonがあなたのファイルを最初からimportし直します。関数の定義を読み直さないと、knead が何なのかわからないからです。

このチェックがないと、支店がファイルを読んでいる途中で mp.Process(...).start() の行にまた出会い、支店がさらに支店を開こうとします。Pythonはこれを止めて、エラーを出します。

# チェックなしで、いちばん外側でいきなり始めると(Windows)
p = mp.Process(target=knead, args=(1,))
p.start()
# 支店側で:
# RuntimeError:
#         An attempt has been made to start a new process before the
#         current process has finished its bootstrapping phase.
#         ...

そのため、multiprocessingの例はファイルに保存して python ファイル名.py で実行します。対話シェルやノートブックでは、支店が関数を見つけられずに失敗しがちです。

8-4. 出張料理人を支店へ:ProcessPoolExecutor (6分)

7限目の ThreadPoolExecutor を覚えていますか? ProcessPoolExecutor は同じ使い方で、作業をプロセスに任せます。submit、Future、result()、map、as_completed がすべてそのままです。

import os
from concurrent.futures import ProcessPoolExecutor

def knead(n):
    s = 0
    for i in range(n):
        s += i
    return os.getpid(), s

if __name__ == "__main__":
    print("main", os.getpid())
    with ProcessPoolExecutor(max_workers=2) as ex:
        future = ex.submit(knead, 1000)
        print(future.result())
        for pid, s in ex.map(knead, [10, 100, 1000]):
            print(pid, s)
# 例:
# main 43452
# (42784, 499500)
# 42784 45
# 42784 4950
# 42784 499500

プロセス番号が main とは違います。どの支店がどの生地を受け持つかは、実行ごとに変わります。

やりとりする値はpickleできなければならない

支店と本店はメモリを共有しないので、関数と引数と結果をバイト列に梱包して(pickle)送り合います。梱包できないものは送れません。代表的な例がラムダです。

def double(x):
    return x * 2

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=2) as ex:
        print(ex.submit(double, 21).result())
        try:
            ex.submit(lambda x: x * 2, 21).result()
        except Exception as e:
            print(type(e).__name__, e)
# 42
# PicklingError Can't pickle <function <lambda> at 0x...>: it's not found as __main__.<lambda>
  • 関数は名前で梱包されます。支店は「ファイルの中の double」を探して使います。名前のないラムダは、支店が見つけられません。
  • だから支店に任せる関数は、ファイルのいちばん外側に def で定義します。関数の中の関数やラムダはだめです。
  • 開いたファイル、ロック、ソケットなども引数として送れません。数値、文字列、リスト、dictのような単純な値をやりとりしてください。

8-5. 支店を開くコスト (4分)

支店はタダではありません。Pythonを新しく立ち上げ、ファイルをimportし直し、値を梱包して行き来させなければなりません。生地が小さいと、この準備時間のほうが仕事より大きくなります。

# 8-1の小さな生地(200万回)4つをプロセス4つで
with ProcessPoolExecutor(max_workers=4) as ex:
    results = list(ex.map(knead, [2_000_000] * 4))
# 4 processes 0.31s   (12コアPCでの実測、順番にやると0.24秒)
方法(小さな生地4つ)時間(12コアPCでの実測)
順番に0.24秒
スレッド4本0.23秒
プロセス4つ0.31秒

かえって遅くなりました。生地を10倍(2000万回)にすると話が変わります。このPCでは、だいたい順番に2.4〜2.7秒、プロセス4つで0.9〜1.1秒でした。準備のコストはほとんど変わらないのに、仕事が大きくなったからです。

  • プロセスは1玉が十分に大きい計算に使います。小さな仕事を何千回も送るなら、いくつかまとめて一度に送りましょう(map の chunksize=)。
  • プールは一度作ったら何度も使いましょう。作業のたびに with ProcessPoolExecutor() を新しく開くのは、毎回支店を一から構えるようなものです。

8-6. 支店を開く3つの方法:spawn、fork、forkserver (3分)

支店をどうやって構えるかを開始方式(start method)といいます。

方式すること厨房でいえば
spawn新しいPythonを立ち上げ、ファイルをimportし直す空きビルに厨房を一から構える
fork今のプロセスをまるごと複製本店をそのままコピーする(作業中の料理人のロックまで)
forkserverきれいなサーバープロセスをひとつ置き、そこから複製きれいな見本の厨房を置いて、それをコピーする
  • 3.14からLinuxのデフォルトは forkserver です。WindowsとmacOSは spawn です。以前のLinuxのデフォルトだった fork は、スレッドが動いているプログラムを複製するとロックがこじれることがあるため変更されました。
  • Windowsには spawn しかありません。
import multiprocessing as mp

if __name__ == "__main__":
    print(mp.get_start_method())        # spawn   (Windows)
    print(mp.get_all_start_methods())   # ['spawn']

    ctx = mp.get_context("spawn")       # このプールだけspawnで
    with ctx.Pool(processes=2) as pool:
        print(pool.map(knead, [10, 100]))   # [45, 4950]
  • mp.get_context("spawn") は、そのコンテキストで作った Pool、Process にだけ適用されます。ライブラリの中ではこちらが安全です。
  • プログラム全体を変えたいなら、if __name__ == "__main__": の先頭で mp.set_start_method("spawn") を一度だけ呼びます。二度呼ぶと RuntimeError: context has already been set になります。
  • OSによってデフォルトが違うので、どこでも同じように動かしたいなら spawn を明示してテストしてください。

8-7. リフォーム:GILのないPython 3.14t (5分)

3.14からフリースレッド(free-threaded)版が公式にサポートされました。GILを外して作ったPythonで、実行ファイルの名前は python3.14t です(末尾の t)。厨房をリフォームして、包丁が料理人の数だけそろったようなものです。

  • python.orgのインストーラーでfree-threadedバイナリも一緒にインストールするか、uvなら uv run --python 3.14t ファイル名.py で実行します。
  • いまGILが有効かどうかは sys._is_gil_enabled() で確かめます。
# knead_threads.py:8-1のコードのいちばん上に2行足すだけ
import sys
print(sys._is_gil_enabled())
...

# python knead_threads.py
# True
# sequential 0.24s
# 4 threads  0.23s

# python3.14t knead_threads.py
# False
# ...
# 4 threads  0.09s   (12コアPCでの実測、約2.7倍)

コードは1文字も変えていません。同じスレッドのコードが、インタープリターを替えただけで約2.7倍速くなりました。4倍にならないのは、スレッドを立ち上げて管理するコストと、GILなしでも安全に動くための内部の仕組みのコストのせいです。その代わり、スレッドをひとつしか使わないコードは、普通の版より少し遅くなることがあります。

知っておきたい点が2つあります。

  • C拡張がGILを再び有効にすることがあります。フリースレッドに対応していると表明していない拡張モジュールをimportすると、Pythonが警告を出してGILを再び有効にします。環境変数 PYTHON_GIL=0(または -X gil=0)で強制的に無効にでき、PYTHON_GIL=1 なら3.14tでもGILが有効になります。
  • 競合状態はもっと起きやすくなります。3限目の黒板の実験は、3.14tで4000のうち2410(実測)でした。本当に同時に動くので、ロックがいっそう大事になります。

下の実習台の上側にあるバー4本は、教材を準備しながら12コアのPCで測った値です。その下の「サーバーで直接こねる」は、このサイトのデプロイ先サーバーで生地(60万回 × 4)を順番に、そしてスレッド4本で実際にこねます。デプロイ先のサーバーはGILのある版で、コア数も前もってわからないので、数字は実測のバーと違うことがあります。2本のバーの長さが同じくらいかどうかを見てください。

生地くらべ

ローカル実測(12コアPC、Python 3.14)

順番に

0.24s

スレッド4本

0.23s

プロセス4つ

0.31s

スレッド4本(3.14t、GILなし)

0.09s

CPU処理4つを、順番に、スレッドで、プロセスで、GILのない3.14tのスレッドで回した実測のバーです。サーバーで直接回して、サーバーのGILの状態とコア数も確かめてみてください。

シェフのひとこと

GILがなくなったら、消えたのは包丁ルールであって、黒板ルールじゃないからね。同じ黒板に何人も書いたら、今度は本当に同時に書くことになる。3.14tに移す前に、まずロックを確かめ直して。

8-8. 仕切りのある厨房:サブインタープリター (2分)

支店は高くつくし、リフォームにはまだすべてのライブラリが追いついていません。そのあいだにもうひとつ選択肢があります。ひとつの建物の中に仕切りを立てた厨房、つまりサブインタープリターです。ひとつのプロセスの中にインタープリターをいくつも置き、仕切りごとにGIL(包丁)が別々にあります。3.14で concurrent.interpreters モジュールとして標準ライブラリに入りました。

from concurrent import interpreters

booth = interpreters.create()
booth.exec("menu = ['#1', '#2']; print('booth menu:', menu)")
booth.exec("menu.append('#3'); print('booth menu:', menu)")
print("main has menu?", "menu" in globals())

def knead(n):
    s = 0
    for i in range(n):
        s += i
    return s

print(booth.call(knead, 1000))
booth.close()
# booth menu: ['#1', '#2']
# booth menu: ['#1', '#2', '#3']
# main has menu? False
# 499500
  • create() で仕切りを作り、exec(コード文字列) でその仕切りの中でコードを動かし、call(関数, 引数) で関数を呼んで結果を受け取ります。
  • 仕切りの中の変数(menu)は仕切りの中にしかありません。支店と同じく、メモリを共有しません。

実務では、7限目と同じ形の InterpreterPoolExecutor(3.14)が便利です。

from concurrent.futures import InterpreterPoolExecutor

if __name__ == "__main__":
    with InterpreterPoolExecutor(max_workers=4) as ex:
        results = list(ex.map(knead, [2_000_000] * 4))
# このPCでだいたい0.18秒(GILのある3.14、順番にやると0.24秒)

仕切りごとに包丁が別にあるので、普通のPythonでも生地が並んで進みます。まだ多くのC拡張がサブインタープリターに対応していない点は覚えておいてください。

閉店後 (10分)

今夜の目標は、生地4玉を一人でやるときの2倍以上の速さで仕上げることでした。純粋なPythonの計算なのでスレッドではだめで、1玉が大きいので支店を開くコストを払う価値があります。

import time
from concurrent.futures import ProcessPoolExecutor

BIG = 20_000_000   # 大きな生地1玉

def knead(n):
    s = 0
    for i in range(n):
        s += i
    return s

if __name__ == "__main__":
    start = time.perf_counter()
    alone = [knead(BIG) for _ in range(4)]
    print(f"sequential {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as ex:
        branches = list(ex.map(knead, [BIG] * 4))
    print(f"4 processes {time.perf_counter() - start:.2f}s", branches == alone)
# 例(このPCで):
# sequential 2.43s
# 4 processes 0.93s True

だいたい2.5倍。結果も一人でやったものとまったく同じです。3.14tがあれば、knead をスレッド4本で回しても同じような効果が得られます。

道具包丁(GIL)メモリ立ち上げのコスト向いている仕事
スレッド(3.14)プロセスにひとつ共有する小さい待つ仕事
スレッド(3.14t)ない共有する小さい待つ仕事 + 計算
プロセス支店ごとにひとつ別々(pickle)大きい大きな計算
サブインタープリター仕切りごとにひとつ別々中くらい計算、純粋なPythonのコード

シェフがエプロンをほどきながら言います。「生地は厨房を増やして解決した。じゃあホールは? テーブル10卓に係を10人置く? それとも一人で10卓を回してもらう?」

営業日誌 — 八日目の夜。生地はスレッドでは速くならなかった。包丁が1本しかないから。4つの支店に分けて任せ、2.5倍速く仕上げた。明日はホールを改装する。

練習問題

  1. 8-1の knead を time.sleep(0.06) に置き換え、順番にやる場合とスレッド4本の場合をもう一度測ってみましょう。今度はなぜスレッドで速くなるのか、GILを使って説明してみてください。
  2. 8-5の生地の大きさを20万、200万、2000万回と変えながら、順番にやる場合と ProcessPoolExecutor の時間を表にまとめてみましょう。あなたのPCでプロセスが勝ち始めるのはどの大きさからでしょう?
  3. ProcessPoolExecutor に、関数の中で定義した関数(入れ子の関数)を submit してみて、どんなエラーが出るか確かめてから直してみましょう。

シェフのレシピカード

  • GIL:Pythonのバイトコードは一度にひとつのスレッドだけ。待つとき(I/O)と一部のCのコードはGILを手放す。

  • 計算(CPU)処理はスレッドでは速くならない → プロセス、3.14t、サブインタープリター。

  • ProcessPoolExecutor は ThreadPoolExecutor と同じ使い方。if __name__ == "__main__": は必須。

  • 支店とやりとりする値はpickleされる:ラムダや入れ子の関数はだめ。いちばん外側の def で。

  • プロセスは立ち上げのコストが大きい:大きな計算だけに。開始方式はWindows・macOSがspawn、3.14のLinuxはforkserver。

  • sys._is_gil_enabled():3.14t(python3.14t)では False。同じスレッドのコードがそのまま並列に。

  • concurrent.interpreters:create() → exec() / call()、InterpreterPoolExecutor(3.14)。

← 前へ

7限目 · 出張料理人:concurrent.futures

次へ →

9限目 · 一人で回すホール係:asyncioの第一歩

新しい授業・講座のお知らせを受け取る

この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。

収集項目:メールアドレス・目的:この講座の新しい授業・公開のお知らせ・保有期間:解除のご依頼まで。他の目的には使わず、第三者にも提供しません。お知らせメールに返信いただければすぐに削除します。

質問と回答

この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。

リンクや書式なしで、そのままの文字で表示されます。

R

ReflexKR

触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。

ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する

アフィリエイトと支援について
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex