cover_effective_python

【 Effective Python, 2nd Edition 】Python のスレッド ( thread ) はブロッキング I/O ( blocking I/O ) 対策で存在しています。決して並行処理 ( parallelism ) を実現するためではありません! 投稿一覧へ戻る

Tags: Effective , GIL , python , blocking I/O , thread

Published 2020年8月15日22:18 by T.Tsuyoshi

Python の標準実装は CPython と呼ばれ、Python プログラムを 2 ステップで実行します。


まず最初に、ソースコードを解析し、バイトコード ( bytecode ) へとコンパイルします。


続いて、そのバイトコードをスタックベースのインタプリタ ( stack-based interpreter ) で実行します。


そのインタプリタは、GIL ( Global Interpreter Lock ) と呼ばれるメカニズムを通して、Python プログラムの実行中一貫して保持されるべき「状態」を所有し続けています。


GIL の実体は相互排他ロック ( mutual-exclusion lock; mutex ) で、1 つのスレッドが他のスレッドに割り込むことでプログラムの実行権を取得するプリエンプティブ・マルチスレッド ( preemptive multithreading ) の影響が CPython に及ばないようにするためのものです。


予測不能な割り込みが発生することで、ガーベッジコレクション ( garbage collection ) が参照しているリファレンスカウント等のインタプリタ状態の辻褄が合わなくなってしまうことのないようにしているんですね。


そしてこういった割り込みを防止するだけではなく、全てのバイトコード命令が、CPython と C 拡張モジュール ( C-extension modules ) 下で正しく機能するように保証してくれるのも GIL の役割なんです。


しかし、GIL が Python プログラムに大きな副作用をもたらしているのも事実です。


C++ や Java といった言語で記述されたプログラムでは、「マルチスレッドで実行する」ということは、イコール、「複数の CPU コアを同時に利用する」ということにほかなりません。


しかし Python でいう「マルチスレッド実行」とは、CPU が複数コアを持っていたとしても、「ある瞬間瞬間で動作しているコアはただ 1 つだけであるように GIL によって制御されている実行形態」、ということです。


すなわち、並行実行するように複数のスレッドを利用したプログラムを記述して実行速度の向上を期待したとしても、ガッカリするしかない、ということです。


例えば、CPU 負荷が高い何らかの計算をしたいとしましょう。


そうですねぇー、非常に簡単な「因数発見アルゴリズム ( number factorization algorithm ) を実装したとします。


def factorize(number):
for i in range(1, number + 1):
if number % i == 0:
yield i



こういった計算を複数の数値に対して 1 つずつ連続して行えばかなりの時間が必要となります。


import time


numbers = [1234567, 8901234, 5678901, 2345678]

start = time.perf_counter()


for number in numbers:
list(factorize(number))


end = time.perf_counter()
delta = end - start


print(f"処理にかかった時間: {delta:.3f} 秒")

# 処理にかかった時間: 1.404 秒



こういう計算をするときこそ、マルチスレッドの出番です、マシンの全ての CPU コアを活用できますから、他の言語では。

では、Python で実装するどうでしょう? やってみましょう。


from threading import Thread


class FactorizeThread(Thread):
def __init__(self, number):
super().__init__()
self.number = number

def run(self):
self.factors = list(factorize(number))



複数スレッドに処理を分散させる準備が出来ました。

では、因数発見プログラムを全ての数値に対してそれぞれ別のスレッドで同時に実行してみましょう。


start = time.perf_counter()

threads = []

for number in numbers:
thread = FactorizeThread(number)
thread.start()
threads.append(thread)


for thread in threads:
thread.join()


end = time.perf_counter()
delta = end - start


print(f"処理にかかった時間: {delta:.3f} 秒")

# 処理にかかった時間: 1.465 秒



ビックリな結果ではありませんか?


順番 ( シリアル; serial ) に処理するのと同等か、下手をするとより時間がかかってしまっています。


たとえスレッドを作成し管理するためのオーバーヘッドが生じるにしても、4 コア以上ある CPU を利用していれば処理速度は 4 倍程度に、たとえ 2 コアであっても 2 倍近くにはなるだろう、という淡い期待は無残にも打ち砕かれちゃったわけです。


しかしこれが標準の CPython インタプリタにおけるマルチスレッドプログラム実行時の現実です。そしてこの結果をもたらしている大元は GIL なのです。


もちろん Python には CPython インタプリタを複数のコアを利用して動作させるようにする機能が用意されています。


しかし残念ながら、その機能は標準の Thread クラスで実現できるものではなく、より複雑な実装を伴うものです。


こうなると、「Python がスレッドをサポートしているのは何故なんだ?」という疑問が出てくるのは当然です。だって結局同時に動かせるのは 1 つのコアだけなんですから...


でも提供されているのにはやっぱりワケがあるんです。


1 つ目の理由は、「模擬的同時実行処理」機能の提供 です。


もし複数のタスクを同時進行でこなす必要がある場合、そのマネジメントを自分自身で実装するのはかなり大変です。


その点、Thread を使って後のことは CPython に任せてしまえば、たとえ GIL の影響で瞬間々々に進行しているタスクは 1 つだけだとしても、それぞれのスレッドが切り替えられながらほぼ均等にしかも安全に実行されることが保証されるんです。


Python がスレッドをサポートしている 2 つ目の理由は、ある種のシステムコール ( system calls ) を実行した際に生じるブロッキング I/O ( blocking I/O ) に対処するため です。


ブロッキング I/O に属する処理としては、ファイルの読み書き、ネットワークを通じたやり取り、周辺機器との通信、等があげられます。


そして、スレッドを利用することで、システムが I/O 処理をするために要する時間からプログラム本体を解放することが出来るようになるんです。


例えば、シリアルポートを通してドローンに指令を送信するプログラムを考えましょう。


ここでは実際に指令を送る代わりに、select() を利用して I/O システムコールを模倣することにします ( しかも処理時間が 0.1 秒もかかるという恐ろしさです )。


import select
import socket


def slow_systemcall():
select.select([socket.socket()], [], [], 0.1)


start = time.perf_counter()


for _ in range(5):
slow_systemcall()


end = time.perf_counter()
delta = end - start


print(f"処理にかかった時間: {delta:.3f} 秒")

# 処理にかかった時間: 0.503 秒



ここでの問題は、slow_systemcall() が実行されている間、プログラム本体では何も処理することが出来ないただ待っているだけの状態になってしまうことです。


つまり、select システムコールによって、プログラムのメインスレッドの動作がブロックされてしまっているんです。


考えてみてください、これは大変なことです!!


ドローンをコントロールしているわけです。指令を送って次の指令を送るまでに、どのように操作すべきなのかを計算しておかなければいけません。


でも、信号を送信するシステムコールのおかげでメインスレッドは動けないんです。もう、ダメです。ドローンはお山のテッペンに激突です。


こういう状況こそがマルチスレッドの出番なんです。


さあ、slow_systemcall() を別々のスレッドで複数実行してみましょう。


そうすることで、同時に複数のシリアルポートとコミュニケーションできるばかりか、メインスレッドを解放できるので、必要な計算のために CPU を利用することが可能になります。


start = time.perf_counter()

threads = []

for _ in range(5):
thread = Thread(target=slow_systemcall)
thread.start()
threads.append(thread)



別スレッドで動作しているシステムコールが終了するまでに、ドローンの挙動を計算し次の指示を決定しておきましょう。


def compute_drone_location():
pass


for i in range(5):
compute_drone_location()


for thread in threads:
thread.join()


end = time.perf_counter()
delta = end - start


print(f"処理にかかった時間: {delta:.3f} 秒")

# 処理にかかった時間: 0.110 秒



処理速度はシリアル実行のほぼ 5 倍です。


GIL という「制約」があるにもかかわらず、システムコールに関しては Python におけるマルチスレッド実行が機能しているんです。


これは、GIL が Python コードの並行処理を許可していない一方で、システムコールに関しては影響を及ぼしていない、ということですね。


実は、Python のスレッドは、システムコールをする際に GIL を一旦解放し、システムコールの終了後直ちに GIL を再度要求、取得している んです。


もちろん、スレッド以外にも asyncio 組み込みモジュール等、ブロッキング I/O 対策として利用できるものは沢山存在しますが、それらのほとんどは、実装するために一苦労二苦労が必要です。


その点スレッドを利用したブロッキング I/O 対策は非常にシンプルなため、既存のコードを大幅にリファクタリング ( refactoring ) することなく実装可能なんです。


まとめ:

1: Python におけるマルチスレッドは、GIL ( Global Interpreter Lock ) の制限のため、複数の CPU コアを利用した並行処理には対応していません。

2: スレッドを利用することで「模擬的同時実行処理」機能を簡単に実装可能です。

3: Python のマルチスレッド実行は複数のシステムコールを並行処理することが可能です。これによりメインスレッドが解放されるため、ブロッキング I/O ( blocking I/O ) 対策と平行してプログラム本体の処理を実行可能です。

この投稿をメールでシェアする

0 comments

コメントはまだありません。

コメントを追加する(不適切と思われるコメントは削除する場合があります)