KODAMA LAB
Home
Lecture notes

Concurrent Programming in Python

From the MRO to the GIL, threading, multiprocessing and asyncio.

16 sections 6 live demos 5 exercises

→ Java edition → JavaScript edition

01オブジェクト指向とは

Java 版で述べたとおり、オブジェクト指向は Simula67 に始まる考え方で、世界をもの(object) の 集まりとして捉え、もの同士のメッセージの受渡しで現象が進むと考える。オブジェクトは 状態メソッド計算を実行する主体の 3 つから成る、という見方も同じである。

Python はこの考え方を徹底していて、整数も関数もクラスも、すべてがオブジェクトである。 一方で「計算を実行する主体」については独特の事情があり、それがこの資料の後半の主題になる。

この資料は Java 版JavaScript 版 と 3 つで対になっています。 同じ題材を 3 つの言語でたどると、「同時に動く」ということの意味が言語ごとに違うことがはっきりします。

02Python のオブジェクト

Python でオブジェクトの状態は、既定では辞書(__dict__) に入っている。 外から自由に足せてしまうのは、Java とも JavaScript の #private とも違う点である。

class Tree:
    def __init__(self):
        self.status = '立っている'

    def fall(self):
        self.status = '倒れた'

t = Tree()
t.fall()
print(t.status)      # 倒れた
print(t.__dict__)    # {'status': '倒れた'}

t.color = '緑'       # 定義していない属性も後から生やせる

「外から触ってほしくない」ことを示すには、名前の先頭にアンダースコアを付ける習慣がある。 アンダースコア 2 つにすると名前マングリングが働き、クラスの外からは _クラス名__名前 でしか触れなくなる。ただしこれも「隠す」のではなく 「うっかり衝突させない」ための仕組みで、Java の private のような強制力はない。

class Tree:
    def __init__(self):
        self._status = '立っている'    # 「触らないでね」という合図(強制力なし)
        self.__secret = 42             # _Tree__secret に名前が変わる

t = Tree()
t._status            # 触れてしまう
t.__secret           # AttributeError
t._Tree__secret      # 42  ← 実は触れる

03class 構文

Python にはメソッドの多重定義(オーバーロード)が無い。同じ名前で 2 回定義すると 後の定義が前を上書きする。これは JavaScript と同じ事情である。

class Calc:
    def add(self, a, b):    ...
    def add(self, a, b, c): ...    # ← 前の add を消してしまう

引数の数で使い分けたいときは、既定値・可変長引数で書く。型で分けたいときは functools.singledispatch がある。

from functools import singledispatch

@singledispatch
def draw(x):            return f'なにか: {x}'
@draw.register
def _(x: int):          return f'整数: {x}'
@draw.register
def _(x: str):          return f'文字列: {x}'

draw(3)      # 整数: 3
draw('あ')   # 文字列: あ
JavaJavaScriptPython
私有private(強制)#x(強制)_x は合図のみ
多重定義ありなしなし(singledispatch で代用)
多重継承なし(インタフェースのみ)なし(mixin で代用)あり
インタフェースありなしProtocol/ダックタイピング

継承

継承は括弧に基底クラスを並べて書く。super() は「自分の次に呼ぶべきもの」を返す。 Java の super親クラスそのものを指すのに対し、Python の super()次に述べる MRO の次の要素を指す。多重継承があるためで、ここが決定的に違う。

class Shape:
    def __init__(self, name):
        self.name = name
    def draw(self):
        return f'{self.name} を描く'

class Square(Shape):
    def __init__(self, side):
        super().__init__('正方形')
        self.side = side
    def draw(self):
        return super().draw() + f'(一辺 {self.side})'

print(Square(3).draw())    # 正方形 を描く(一辺 3)

多重継承と MRO

Python は多重継承を許す。すると「同じメソッドを複数の親が持っていたらどれが呼ばれるか」 という問題が起きる。Java がインタフェースしか許さなかったのは、まさにこれを避けるためであった (Java 版「継承の問題点」参照)。

Python の答えが MRO(Method Resolution Order、メソッド解決順序) である。 C3 線形化という算法でクラスを一列に並べ、その順に探す。

class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass        # ダイヤモンド継承

D.__mro__
# (D, B, C, A, object)

規則は 3 つである。①自分が先②基底クラスを書いた順を守る③子より先に親が来てはいけない。この 3 つを同時に満たす並びが C3 線形化で、 満たす並びが存在しないクラス定義は定義した時点でエラーになる

class X: pass
class Y: pass
class P(X, Y): pass
class Q(Y, X): pass
class R(P, Q): pass
# TypeError: Cannot create a consistent method resolution order (MRO) for bases P, Q

super() はこの並びを 1 つずつ進む。だから多重継承では、 super() が親とは限らない。次のデモで実際に並びを作って確かめられる。

Try it: computing the MRO

Swap the base order or toggle which classes define draw().

D's base order
D ← 実行
B(A) ← 実行
C(A) ← 実行
A ← 実行
object (終点) ← 実行
D.__mro__

オーバーライドと動的束縛

メソッドを探す道すじは MRO そのものである。実体のクラスから MRO を順にたどり、 最初に見つかった定義が呼ばれる。Java の継承の鎖が「一本道」だったのに対し、 Python では並び替えられた一列を進む、と考えればよい。

class Base:
    def draw(self):
        return 'Base.draw'

class Mixin:
    def draw(self):
        return 'Mixin.draw → ' + super().draw()   # 次の MRO 要素へ渡す

class Widget(Mixin, Base):
    pass

Widget().draw()      # Mixin.draw → Base.draw
Widget.__mro__       # (Widget, Mixin, Base, object)

MixinBase を継承していないのに super().draw()Base.draw に届く。誰が次に来るかは、そのクラス単体ではなく、 最終的にどう組み合わされたかで決まる。これが Python の多重継承の強みであり、 同時に読みにくさの原因でもある。

04Python の並行性

ここからが本題である。Python には本物のスレッド(OS のスレッド)がある。 Java と同じように threading.Thread で作れるし、いつ割り込まれるか分からない プリエンプティブな切り替えも起きる。

ところが GIL(Global Interpreter Lock、グローバルインタプリタロック) があるため、 同時に Python のバイトコードを実行できるスレッドは常に 1 つだけである。 スレッドは存在するのに、計算は並列にならない。

JavaJavaScriptPython
スレッドあるない(Worker は別)あるが GIL で1つずつ
切り替わり方プリエンプティブ協調的(awaitプリエンプティブ
切り替わる場所どこでもawait だけバイトコードの切れ目
競合状態起きるawait をまたぐと起きる起きる
CPU を使う処理が速くなるかなるWorker ならなるならないmultiprocessing が要る)

つまり Python のスレッドは「速くするため」ではなく「待ち time を重ねるため」にある。 通信やファイル入出力の待ちの間は GIL が解放されるので、そこは重ねられる。

GIL とは

GIL は Python インタプリタ全体に 1 つだけある鍵である。バイトコードを実行するには この鍵を持っていなければならず、持てるのは 1 スレッドだけである。 鍵は次のときに手放される。

  1. 一定時間が経ったとき — 既定は 5 ミリ秒(sys.getswitchinterval()
  2. 入出力で待つとき — ファイル・通信・time.sleep など
  3. C 拡張が明示的に手放したとき — NumPy の重い計算などはここで手放す
import sys
sys.getswitchinterval()      # 0.005
sys.setswitchinterval(0.001) # 短くすると切り替わりが増える(速くはならない)

だから CPU をひたすら使う処理をスレッドで 4 本に分けても、合計時間はほぼ変わらない。 むしろ切り替えのぶん少し遅くなる。一方 待ちが中心の処理なら、待っている間に 他のスレッドが動けるので、本数に応じて短くなる。次のデモで両方を試せる。

Python 3.13 から、GIL を無くしたフリースレッド版(PEP 703)が実験的に選べるようになりました。 将来はこの前提が変わる可能性がありますが、当面は標準の版に GIL がある前提で考えます。

Try it: what the GIL looks like

Four threads; dark bars are real execution.

経過 0 ms

05threading

スレッドの作り方は Java とよく似ている。

import threading, time

def worker(name):
    for i in range(3):
        print(name, i)
        time.sleep(0.1)      # ここで GIL を手放す

ts = [threading.Thread(target=worker, args=(f'T{i}',)) for i in range(3)]
for t in ts: t.start()
for t in ts: t.join()        # 終わるまで待つ

Java 版の WireFrame の例のように、各オブジェクトに自分のスレッドを持たせることもできる。

class Rotator(threading.Thread):
    def __init__(self, shape):
        super().__init__(daemon=True)
        self.shape = shape

    def run(self):               # start() から呼ばれる
        while True:
            self.shape.rotate()
            time.sleep(1 / 30)

競合状態

Python でも count += 1不可分ではない。バイトコードに分解すると 複数の命令になり、その切れ目で他のスレッドに切り替わりうる。

import dis

def inc():
    global count
    count += 1

dis.dis(inc)
#   LOAD_GLOBAL   count      ← 読む
#   LOAD_CONST    1
#   BINARY_OP     +=         ← 足す
#   STORE_GLOBAL  count      ← 書く

「読む」と「書く」の間で切り替わると、古い値をもとに書き戻してしまい、更新が消える。 Java 版とまったく同じ現象である。

count = 0

def add_many():
    global count
    for _ in range(100000):
        count += 1

ts = [threading.Thread(target=add_many) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()

print(count)      # 200000 にならないことがある

直し方も同じで、読んでから書くまでを他に渡さないようにする。Python では threading.Lock を使い、with 文で囲むのが定石である。

lock = threading.Lock()

def add_many():
    global count
    for _ in range(100000):
        with lock:        # Java の synchronized に相当
            count += 1

Try it: switching between bytecodes

Two threads run count += 1 a hundred times each.

0 / 200
T1 · 0 T2 · 0 lost · 0
dis.dis(inc) — T1

  

06同期をとる

Python の threading には、Java 版で扱った道具がひととおり揃っている。

道具役割Java でいうと
Lock1 つだけ通すsynchronized
RLock同じスレッドなら入れ子で取れる再入可能ロック
Semaphore(n)n 個まで通すセマフォ
Event合図を待つwait/notify
Condition条件が整うまで待つwait/notifyAll
Barrier全員そろうまで待つCyclicBarrier

セマフォの PV は、Python では acquire()release() である。

sem = threading.Semaphore(2)     # 資源は 2 つ

with sem:            # acquire()(=P)、抜けるとき release()(=V)
    ...              # ここを通れるのは同時に 2 スレッドまで

有限バッファ問題

Java 版と同じ生産者・消費者を書く。Python には queue.Queue という スレッド安全な有限バッファが最初から用意されていて、 emptyfullmutex の 3 つのセマフォを自分で書く必要がない。

import queue, threading, time

buf = queue.Queue(maxsize=6)      # ← 有限バッファ

def producer():
    for i in range(1, 100):
        buf.put(i)                # 満杯なら空くまで待つ
        print('置いた', i)
        time.sleep(0.2)

def consumer():
    while True:
        x = buf.get()             # 空なら来るまで待つ
        print('取った', x)
        buf.task_done()
        time.sleep(0.3)

threading.Thread(target=producer, daemon=True).start()
threading.Thread(target=consumer, daemon=True).start()

中身は Java 版で書いたのと同じで、Condition を使って 「空きが出るまで待つ」「品物が来るまで待つ」を実現している。自分で書くならこうなる。

class BoundedBuffer:
    def __init__(self, n):
        self.buf, self.n = [], n
        self.cv = threading.Condition()

    def put(self, x):
        with self.cv:
            while len(self.buf) == self.n:   # while であって if ではない
                self.cv.wait()
            self.buf.append(x)
            self.cv.notify_all()

    def get(self):
        with self.cv:
            while not self.buf:
                self.cv.wait()
            x = self.buf.pop(0)
            self.cv.notify_all()
            return x

待つ条件を if ではなく while で書くのが要点である。 起こされた時点で条件がまだ成り立っているとは限らないためで、これは Java でも同じである。

Try it: queue.Queue(maxsize=6)

The code above, running.

qsize() = 0

07multiprocessing

CPU を使う処理を本当に速くしたいなら、プロセスを分ける。 プロセスごとに Python インタプリタが 1 つずつ動くので、GIL も 1 つずつになり、 本当に並列に実行される。

from concurrent.futures import ProcessPoolExecutor

def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

with ProcessPoolExecutor() as ex:
    print(list(ex.map(fib, [32, 32, 32, 32])))   # 4 コアならほぼ 1 個分の時間

代わりにメモリを共有しない。やりとりする値は pickle で直列化されて プロセス間を渡るので、渡せない値(ラムダ式、開いたファイルなど)は使えず、 大きなデータのやりとりには時間がかかる。これは Web Worker の postMessage とまったく同じ事情である。

# 共有したいときは専用の入れ物を使う
from multiprocessing import Value, Lock

count = Value('i', 0)          # 共有メモリ上の整数
lock  = Lock()

with lock:
    count.value += 1           # ここでもロックは要る

08asyncio

もう一つの道具が asyncio である。こちらはスレッドを使わず、 1 本の流れの中で待ち合わせ点を作って譲り合う。JavaScript のイベントループと 同じ考え方で、実際 asyncawait という書き方まで同じである。

import asyncio

async def worker(name):
    print(name, '開始')
    await asyncio.sleep(1)      # ここで他に譲る(1秒間ずっと譲っている)
    print(name, '終了')

async def main():
    await asyncio.gather(worker('A'), worker('B'), worker('C'))

asyncio.run(main())
# 3 つ同時に開始し、約 1 秒後に 3 つ同時に終わる

ここでも競合は起きる。await をまたいで「読む→書く」をすると、 JavaScript 版で見たのと同じように更新が消える。

count = 0

async def inc():
    global count
    v = count               # 読む
    await asyncio.sleep(0)  # ← ここで譲る
    count = v + 1           # 古い v をもとに書く

asyncio.run(asyncio.gather(inc(), inc()))
# count は 2 ではなく 1

直すには asyncio.Lock を使う。threading.Lock とは別物なので 取り違えないこと(threading.Lock をイベントループの中で使うと、 待っている間ループ全体が止まってしまう)。

lock = asyncio.Lock()

async def inc():
    global count
    async with lock:
        v = count
        await asyncio.sleep(0)
        count = v + 1

Try it: the asyncio event loop

Three coroutines under gather.

t = 0.0 s
実行中
実行可能キュー
待ち(sleep)

09どれを使うか

3 つの道具の使い分けは、処理が何を待っているかで決まる。

処理の性質使うもの理由
入出力待ちが中心Web API を何百回も叩く、ファイル読み書きasyncio または threading待っている間 GIL が空くので重ねられる
CPU を使い切る画像処理、数値計算、探索multiprocessingGIL があるためスレッドでは速くならない
CPU だが C 拡張の中NumPy、Pillow の重い処理threading でも可C 側が GIL を手放すため
待ちが多く、本数も多い同時接続を何千も抱えるasyncioスレッドより1本あたりが軽い

asynciothreading はどちらも入出力待ちに使えるが、性格が違う。 asyncio譲る場所が await に限られるので、どこで割り込まれるかが コードから読める。threadingどこでも割り込まれる代わりに、 既存の同期的なコードをそのまま動かせる。次のデモで、条件を変えて所要時間を比べられる。

Try it: which one is faster

Pick the kind of work and the number of tasks.

10課題

課題 1 次のクラス定義について、D.__mro__ を手で求めよ。 C3 線形化の 3 つの規則のどれがどこで効いているかを説明すること。求まったら MRO のデモで確かめよ。

class A: pass
class B(A): pass
class C(A): pass
class D(C, B): pass      # ← B, C ではなく C, B

課題 2 count += 1 を 10 万回ずつ 2 スレッドで実行したとき、 なぜ 20 万にならないことがあるのか。dis の出力のどの命令とどの命令の間で 切り替わると更新が消えるのかを示して説明せよ。

課題 3 fib(32) を 4 個計算する処理を、①素直に順番に ②ThreadPoolExecutorProcessPoolExecutor の 3 通りで書き、所要時間を測って比べよ。 ②が速くならない理由を GIL の言葉で説明すること。

課題 4 上の BoundedBufferwhileif に書き換えると、 どんなときに壊れるか。生産者 2 人・消費者 1 人の場合で、誰がいつ起こされるかを追って示せ。

課題 5 asyncio で、同時に 3 本までしか走らせない async def limit(n, coros)asyncio.Semaphore を使って書け。 JavaScript 版の課題 3 と同じ問題を、Python で解くとどうなるか比べよ。

The same material in the other two languages: Java editionJavaScript edition