Pythonの並行処理を学ぶ
概要
Pythonプログラムの性能を向上させるための手段として並行化があります。
並行化の手法は次の3つに大別されます。
- マルチスレッド
- マルチプロセス
- マルチインタープリター(with マルチスレッド)
これらをひとつずつ学び、実際に試すことで理解を深めようと思います。
並行化の手法
マルチスレッド
マルチスレッドとは単一プロセス内で複数のスレッドを立てて並行化することです。
私のプログラマーとしての経験はJavaがベースにあるため、マルチスレッドと聞くとCPUバウンドな処理の性能向上に役立つと考えてしまいますが、Pythonでは事情が異なります。
PythonはGIL(Global Interpreter Lock)という仕組みがあり、これは「一度にPythonのバイトコードを実行するスレッドはひとつだけであることを保証する」というものです。
このため、単純にマルチスレッド化してもCPUバウンドな処理の性能向上には繋がりません。
ではマルチスレッドは性能向上の文脈で役立たないのかというと、そうではありません。
あるスレッドがIOでブロックされているときは別のスレッドに切り替わって処理が進むため、IOバウンドな処理の性能向上に役立ちます。
ファイル操作の非同期IOサポートを提供しているaiofilesというOSSも、ファイル操作を別スレッドに委譲しており、それをasync/awaitで透過的に扱えるようにしてあります。
これはマルチスレッドでIOバウンドな処理の性能向上を図っている好例です。
マルチプロセス
マルチプロセスとは主となるプロセスから複数のサブプロセスを立てて並行化することです。
前述のGILによる制限が問題にならず、CPUバウンドな処理の性能向上に役立ちます。
マルチインタープリター(with マルチスレッド)
マルチインタープリターとは単一プロセス内で複数のインタープリターを立てることです。
Python 3.13までは複数のインタープリターを実行するにはC言語のAPIを使用する必要があったようですが、Python 3.14からはPythonのAPIでもそれが可能になったとのことです。
GILはインタープリターに対するロックなので、マルチスレッドを併用してスレッド毎に異なるインタープリターを割り当てればGILによる制限を問題とせず、CPUバウンドな処理の性能向上に役立ちます。
ここまでのまとめ
並行化の手法ごとにCPUバウンドな処理およびIOバウンドな処理の性能向上に役立つかどうかを表にしました。
| 並行化の手法 | CPUバウンド | IOバウンド |
|---|---|---|
| マルチスレッド | × | ○ |
| マルチプロセス | ○ | ○ |
| マルチインタープリター(with マルチスレッド) | ○ | ○ |
なお、マルチプロセスの項では特に言及していませんでしたが特定のプロセスがIOでブロックされているときでも別のプロセスは処理を進められるため、IOバウンドな処理の性能向上にも役立つと言えます。
実験
CPUバウンドな処理の性能向上
ある整数の範囲内にいくつ素数が含まれているか算出するプログラムを用意しました。
def is_prime(n: int) -> bool:
if n < 2:
return False
if n % 2 == 0:
return n == 2
limit = int(math.sqrt(n)) + 1
for i in range(3, limit, 2):
if n % i == 0:
return False
return True
def count_primes_range(args: tuple[int, int]) -> int:
start, end = args
count = 0
for n in range(start, end):
if is_prime(n):
count += 1
return count
このプログラムを各手法で並行に動かして実行時間を計測します。
条件は次の通りです。
- 整数の範囲: 0以上、10,000,000未満
- 並行度: 8
- 計測回数: 5回
並行度はマルチスレッドにおいてはスレッド数、マルチプロセスにおいてはプロセス数、マルチインタープリター(with マルチスレッド)においてはインタープリター数(各インタープリターに1スレッドを割り当て)を指します。
計測結果は次の通りです。
| 並行化の手法 | 1回目 | 2回目 | 3回目 | 4回目 | 5回目 |
|---|---|---|---|---|---|
| デフォルト(※) | 14.587秒 | 14.588秒 | 14.593秒 | 14.648秒 | 14.719秒 |
| マルチスレッド | 14.279秒 | 14.319秒 | 14.406秒 | 14.404秒 | 14.507秒 |
| マルチプロセス | 2.673秒 | 2.652秒 | 2.654秒 | 2.654秒 | 2.664秒 |
| マルチインタープリター (with マルチスレッド) |
2.637秒 | 2.657秒 | 2.645秒 | 2.648秒 | 2.694秒 |
※「デフォルト」は並行化していない単一プロセス単一スレッドを指します
前述のようにマルチスレッドだけはGILの制限で顕著な性能向上が見られず、マルチインタープリターとの併用が必要であることが結果にも現れました。
IOバウンドな処理の性能向上
ローカルにDockerで立てたHTTPBinを利用して遅いHTTPリクエストをシミュレートするプログラムを用意しました。
def do_http_request(_: int) -> str:
url = "http://localhost:8080/delay/3"
with urlopen(url) as res:
data = res.read().decode("utf-8")
return data
このHTTPリクエストを各手法で並行に動かして実行時間を計測します。
条件は次の通りです。
- 並行度(リクエスト数): 4
- 1リクエストの遅延時間: 3秒
- 計測回数: 5回
計測結果は次の通りです。
| 並行化の手法 | 1回目 | 2回目 | 3回目 | 4回目 | 5回目 |
|---|---|---|---|---|---|
| デフォルト | 12.049秒 | 12.056秒 | 12.057秒 | 12.053秒 | 12.042秒 |
| マルチスレッド | 3.027秒 | 3.028秒 | 3.030秒 | 3.031秒 | 3.033秒 |
| マルチプロセス | 3.068秒 | 3.067秒 | 3.070秒 | 3.069秒 | 3.065秒 |
| マルチインタープリター (with マルチスレッド) |
3.061秒 | 3.063秒 | 3.058秒 | 3.061秒 | 3.063秒 |
こちらも前述のようにマルチスレッドだけでも性能向上が見られました。
まとめ
PythonにGILという概念があり、それがためにマルチスレッドだけではCPUバウンドな処理の性能を向上させられないことが理解できました。
マルチスレッドにマルチインタープリターを組み合わせることでCPUバウンドな処理でも性能を向上させられることも理解できました。
マルチインタープリター(with マルチスレッド)とマルチプロセスはどちらもCPUバウンドな処理の性能向上に効果的ですが、それぞれのメリット・デメリットや使い分けに関しては整理できておらず、今後の宿題だと思いました。
また、IOバウンドな処理の性能向上にも言及しましたが、これに関しては前回の記事で言及した非同期IOを使用するのが素直かもしれないと考えています。
実験に使用したコードの全量は次の場所に置いてあります。
Discussion