pythonでマルチスレッド
pythonが並列処理を苦手としている理由について以前以下の記事にまとめました。
今回はその原因となっているGIL(Global Interpreter Lock)が外れたpythonがリリースされたので、使ってみようと思います。
また、pythonがどのように新しくプロセスを作成するかの挙動についても、色々実験していきます。
導入
pyenvを使うと簡単に導入できます。
$pyenv install 3.14t-dev
$python % pyenv shell 3.14t-dev
$python -VV
Python 3.14.0a2+ experimental free-threading build (heads/main:31c9f3c, Dec 7 2024, 09:12:18) [Clang 15.0.0 (clang-1500.3.9.4)]
python -VV
で、experimental free-threading build
と書いてあれば、GILなし版を使用できます。
$python -c "import sys; print(sys._is_gil_enabled())"
False
このように表示されていれば、GILが無効化された状態です。ちなみにPYTHON_GIL=1
を指定すればGILが有効なpythonを使用することもできます。
$PYTHON_GIL=1 python -c "import sys; print(sys._is_gil_enabled())"
True
GILがあるとどうなるのか
詳しくは以下にまとめています。
GILがない言語だと、一つのプロセスに対して複数のスレッドを建てて、
それぞれのスレッドで処理を行うことができますが、GILがあると一つのプロセスで一つのスレッドしか建てることができないので、並列で処理できません。
色々実験してみる
ここから、従来のpythonとGILを無効化したpythonを比べていきたいと思います
以下のようなcpu計算を行う処理を用いて比べます。渡した数値までの、素数の数を出力する関数です。
この素数計算を2回行い、その時間を比較します。
# CPUバウンド処理のテスト関数 (例: 素数計算)
def cpu_bound_task(n):
print(f"Starting CPU-bound task to calculate primes up to {n}")
primes = []
for i in range(2, n):
if all(i % p != 0 for p in range(2, int(math.sqrt(i)) + 1)):
primes.append(i)
print(f"Finished CPU-bound task up to {n}")
return len(primes)
if __name__ == '__main__':
print(sys._is_gil_enabled())
# スレッド1つでの実行
print("\n--- Testing with 1 thread ---")
start = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
nums = [500000, 500000] # 同じタスクを2回実行
results = list(executor.map(cpu_bound_task, nums))
finish = time.perf_counter()
print(f"Results: {results}")
print(f"Finished with 1 thread in {round(finish - start, 2)} second(s)")
# スレッド2つでの実行
print("\n--- Testing with 2 threads ---")
start = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(cpu_bound_task, nums))
finish = time.perf_counter()
print(f"Results: {results}")
print(f"Finished with 2 threads in {round(finish - start, 2)} second(s)")
従来のpythonの場合(GILあり)
以下の様な出力になります。
--- Testing with 1 thread ---
Starting CPU-bound task to calculate primes up to 500000
Finished CPU-bound task up to 500000
Starting CPU-bound task to calculate primes up to 500000
Finished CPU-bound task up to 500000
Results: [41538, 41538]
Finished with 1 thread in 2.34 second(s)
--- Testing with 2 threads ---
Starting CPU-bound task to calculate primes up to 500000
Starting CPU-bound task to calculate primes up to 500000
Finished CPU-bound task up to 500000
Finished CPU-bound task up to 500000
Results: [41538, 41538]
Finished with 2 threads in 2.36 second(s)
どちらも2.3秒ほどで、スレッドが一つの場合でも、二つの場合でもさほど変わっていません。
GILなしPython
以下の様な出力になりました。
--- Testing with 1 thread ---
Starting CPU-bound task to calculate primes up to 500000
Finished CPU-bound task up to 500000
Starting CPU-bound task to calculate primes up to 500000
Finished CPU-bound task up to 500000
Results: [41538, 41538]
Finished with 1 thread in 2.36 second(s)
--- Testing with 2 threads ---
Starting CPU-bound task to calculate primes up to 500000
Starting CPU-bound task to calculate primes up to 500000
Finished CPU-bound task up to 500000
Finished CPU-bound task up to 500000
Results: [41538, 41538]
Finished with 2 threads in 1.21 second(s)
スレッドが一つの場合に比べて、二つの場合の時間が、半分ほどになっているのがわかると思います。
なぜこのような結果になるのか。
GILがある場合、以下の図のように一つのプロセスの中でスレッドを同時に一つしか建てることができません。
よって、一つ目のスレッドの処理が終わってからもう一つのスレッドの処理が始まります。
よって、実行時間がスレッド一つの時と、スレッドが二つの時で時間が変わりませんでした。
GILがない場合は、他の言語と同じように、同時にスレッドをいくつか建てることができます。
よって並列で処理ができ、時間が半分になりました。
従来のPythonでの並列処理
では、どのようにGILがある状態で並列処理を行うのでしょうか。
一つのプロセス内で、同時に一つのスレッドしか処理することができないため、プロセス自体を複数建てることで並列処理を実現します。
先ほどのプログラムに以下の実装を追加します。
# プロセス2つでの実行
print("\n--- Testing with 2 processes ---")
start = time.perf_counter()
with concurrent.futures.ProcessPoolExecutor(max_workers=2) as executor:
results = list(executor.map(cpu_bound_task, nums))
finish = time.perf_counter()
print(f"Results: {results}")
print(f"Finished with 2 processes in {round(finish - start, 2)} second(s)")
実行結果
--- Testing with 2 processes ---
Starting CPU-bound task to calculate primes up to 500000
Starting CPU-bound task to calculate primes up to 500000
Finished CPU-bound task up to 500000
Finished CPU-bound task up to 500000
Results: [41538, 41538]
Finished with 2 processes in 1.33 second(s)
1.3秒ほどで、確かに並列で処理した時と同じくらいの時間で処理できています。
以下の図のように、プロセス自体が並列に動作するため短い時間で処理ができています。
ただ、この様なプロセスでの並列化は、スレッドでの並列化といくつか違いもあり、注意が必要です。
なぜpythonにはGILがついているのか
初期の設計の名残や、メモリ管理を単純化するためだそうです。
プロセスはメモリ空間を共有しませんが、スレッドは同じプロセス内に存在するため、メモリ空間を共有します。
GILなし版のpythonで複数のスレッドから同じ変数にアクセスしてみます。
import concurrent.futures
x = 0 # 共有変数
def increment(thread_id):
global x
print(f"Thread {thread_id} started.")
for _ in range(100000):
x += 1
print(f"Thread {thread_id} finished.")
if __name__ == '__main__':
# スレッド2つでの実行
print("\n--- Testing with 2 threads (No Lock) ---")
x = 0 # 共有変数の初期化
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
executor.map(increment, range(2))
print(f"x = {x}")
このように共通の変数を一つ定義し、その値に100000を足す様なプログラムです。
従来のpython(GILあり)の場合は以下の様になります。
--- Testing with 2 threads (No Lock) ---
Thread 0 started.
Thread 0 finished.
Thread 1 started.
Thread 1 finished.
x = 200000
出力を見ると、GILがある場合、一つのプロセスで一つのスレッドしか実行できないためスレッド0が終わってからスレッド1が実行されていることがわかります。
よって、出力結果もそれぞれのスレッドで100000ずつプラスされるため、200000という出力になっています。
次に、GILをオフにした場合のPythonで同じプログラムを実行してみます。
--- Testing with 2 threads (No Lock) ---
Thread 0 started.
Thread 1 started.
Thread 0 finished.
Thread 1 finished.
x = 103818
同じプロセス内でスレッドを複数建てることができるため、並列で実行されていることがわかります。
変数を見てみると、同じ様に100000を2つのスレッドから足したはずなのに、先ほどとは出力結果が異なっていて、同じリソースに対して、複数のプログラムからアクセスがあったことにより、競合状態
になってしまっています。
この様な問題が起こらない様にするには、mutex
などで一つのスレッドからしかアクセスできないようにしたり、様々な対処が必要です。
この様に、マルチスレッド
ではメモリセーフ
を実現するために、様々な手間がかかってしまうため、pythonではGILを用いてメモリの安全性
を確保しています。
pythonでの並行処理
先ほど、従来のpythonで複数スレッド処理をしましたが、並列で処理ができずに時間が短縮されませんでした。
実は並列処理ではなく、並行処理になっています。
並行処理は,先ほど検証したような計算などのcpu計算ではなく、io処理などcpuを使わない処理で効果を発揮します。
def io_task(seconds):
print(f"Sleeping {seconds} second(s)...")
time.sleep(seconds) # 模擬的なIO処理
return f"Done Sleeping...{seconds}"
このような指定した時間だけ待機する処理を、一つのスレッド、二つのスレッドで実行します。
この処理は厳密にはio処理ではありませんが、今回はcpuリソースを使わないという意味で擬似的なio処理に見立てて検証を行いたいと思います。
# スレッド1つでの実行
print("\n--- Testing with 1 thread ---")
start = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
nums = [2, 3] # 2秒と3秒のタスクを実行
results = list(executor.map(io_task, nums))
finish = time.perf_counter()
print(f"Results: {results}")
print(f"Finished with 1 thread in {round(finish - start, 2)} second(s)")
# スレッド2つでの実行
print("\n--- Testing with 2 threads ---")
start = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(io_task, nums))
finish = time.perf_counter()
print(f"Results: {results}")
print(f"Finished with 2 threads in {round(finish - start, 2)} second(s)")
先ほどの関数を、一つのスレッドで2回実行した場合と、従来のpythonで二つのスレッドを使用して実行した場合を比較します。
まず、一つのスレッドで2秒と3秒待機させます。
--- Testing with 1 thread ---
Sleeping 2 second(s)...
Sleeping 3 second(s)...
Results: ['Done Sleeping...2', 'Done Sleeping...3']
Finished with 1 thread in 5.02 second(s)
当然ですが、5秒程度実行に時間がかかっています。
従来のpythonで実行
次に従来のpythonでスレッドを二つ立てて実行します。
--- Testing with 2 threads ---
Sleeping 2 second(s)...
Sleeping 3 second(s)...
Results: ['Done Sleeping...2', 'Done Sleeping...3']
Finished with 2 threads in 3.01 second(s)
このように、3秒ほどで実行が完了しています。
IO処理では、cpuリソースを使用しないため、並行処理でも、処理の完了を待たずに次の処理を行うことができます。
まとめ
pythonのGILあり、なしを比較することで、並行処理、並列処理についての理解を深めました。
これまでpythonのマルチスレッドについてあまり検証する機会もななかったので、いい経験になりました。