Pythonは2つ以上のスレッドが同時に実行される時、複数のスレッドが1つのリソースを同時にアクセスするときに発生しうる問題を防止するためにGIL(Global Interpreter Lock)というものを導入しています。
つまり、スレッドが実行されるとき、プログラム内のリソース全体のロックがかかります。
結局、Pythonプログラムでは同時にいくつかのスレッドが実行されても、GILによって1つのスレッドだけしか実行されません。
マルチスレッドの場合、ContextSwitch に必要なリソースまで考慮すると、単一スレッドよりも性能が落ちることが確認できます。
そのため、マルチスレッドによる分散処理は Python では意味がなく、分散処理を通じて性能の利益を得るためにはマルチプロセスを使用しなければなりません。
しかし、プロセスを追加に生成することはOSとしては非常に費用がかかることです。
Python 3.2 から追加されたconcurrent.futures
モジュールは別途のスレッドオブジェクトを作成せずに、関数の呼び出しをオブジェクト化して他のスレッドやプロセスでこれを実行できるようにしてくれます。
このとき中心的な役割を果たすのがExecutor
クラスです。
Executorクラスは再びThreadPoolExecutor
とProcessPoolExecutor
に分かれます。
両クラスの違いは同時性作業をマルチスレッドで処理するかマルチプロセスで処理するかの方法の違いがあるだけで、ほぼ同じ機能を提供します。
Thread vs Process
1つのCPUで行われる作業をはprocessと言います。
1つのprocessはメモリを共有する複数のthreadを持つことができます。
1つのprocessが複数のthreadを実行することをConcurrent execution
と言います。
複数のprocessを同時に実行させることをParallel execution
と言います。
つまり、multithreadingは複数のthreadを同時に実行すること(concurrency)
multiprocessingは複数のprocessを同時に実行することを言います(parallelism)
どちらを使えば良いか
これは現在の作業がどのようなものかによって異なります
結論から言いますと、multithreadingはI/O intensive tasks
にメリットがあります。
multiprocessingはCPU intensive tasks
にメリットがあります。
Case 1. CPU使用率が低いI/O作業
読み書き作業が多いときです。
この場合は、各作業をthreadで結んだら効率的です。
例えば、読むファイルが10個ほどあると想像してみましょう。
CPUを使う作業ではないので、10個の作業を同時に行っても負荷は少ないと思います。
すると、multiprocessingよりはmultithreadingの方が有利でしょう。
また、読んだファイルのデータをすべて集めることが必要になったら、memoryを共有するmultithreadingがより早い・安全だとも言えます。
Case 2. CPU使用率が高い計算作業
ある計算を何度もしなければならない時は、multiprocessingを使います。
CPU使用率が高いので、複数のCPUコア(プロセッサ)を使用するほうが有利でしょう。
例えば、大きい自然数に対して素数かどうかを確認する作業です。
import math
import timeit
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
PRIMES = [348729384737891,
348729384737869,
498428372819303,
904782734827291,
230484820348297,
2381923484739137]
def is_prime(n):
if n % 2 == 0:
return False
sqrt_n = int(math.floor(math.sqrt(n)))
for i in range(3, sqrt_n + 1, 2):
if n % i == 0:
return False
return True
def main():
t1 = timeit.default_timer()
for number, prime in zip(PRIMES, map(is_prime, PRIMES)):
print('%d is prime: %s' % (number, prime))
print(f'Single Thread - {timeit.default_timer() - t1:.3f} secends')
t2 = timeit.default_timer()
with ThreadPoolExecutor(max_workers=2) as executor:
for number, prime in zip(PRIMES, executor.map(is_prime, PRIMES)):
print('%d is prime: %s' % (number, prime))
print(f'Thread pool - {timeit.default_timer() - t2:.3f} secends')
t3 = timeit.default_timer()
with ProcessPoolExecutor(max_workers=2) as executor:
for number, prime in zip(PRIMES, executor.map(is_prime, PRIMES)):
print('%d is prime: %s' % (number, prime))
print(f'Process pool - {timeit.default_timer() - t3:.3f} secends')
if __name__ == '__main__':
main()
実行結果を確認します。
348729384737891 is prime: False
348729384737869 is prime: True
498428372819303 is prime: True
904782734827291 is prime: True
230484820348297 is prime: True
2381923484739137 is prime: True
Single Thread - 8.528 secends
348729384737891 is prime: False
348729384737869 is prime: True
498428372819303 is prime: True
904782734827291 is prime: True
230484820348297 is prime: True
2381923484739137 is prime: True
Thread pool - 7.653 secends
348729384737891 is prime: False
348729384737869 is prime: True
498428372819303 is prime: True
904782734827291 is prime: True
230484820348297 is prime: True
2381923484739137 is prime: True
Process pool - 5.865 secends
ThreadPoolExecutor
クラスを活用した結果を見ると、単一スレッドの実行結果に比べてあんなに早くなったとは言えません。
これはパイソンのGILにより、複数のスレッドを並列に実行することができないからです。
また、ThreadPoolExecutor
を実行する時、演算に使用するスレッドプールをあらかじめ生成し、スレッド間通信に追加で時間がかかるからです。
しかし、ProcessPoolExecutor
クラスを利用した結果を見ると、かなり飛躍的なパフォーマンスの向上が確認できます。
ProcessPoolExecutor
クラスは次のような手順で動作します。
- 入力データから入ってきた
map
メソッドに渡されたPRIMES
の各要素を取ります。 - 1番で得た要素を
pickle
モジュールを通じて二進データに直列化し、子プロセスのインタープリタに直列化したデータをコピーします。 - 子プロセスは
pickle
モジュールを使用してデータをPythonオブジェクトに逆直列化しながらis_prime
関数を呼び出します。 - 子プロセスは、入力データに対して
is_prime
関数を他の子プロセスと並列で実行します。 -
is_prime
関数の結果を二進データで直列化し、親プロセスで直列化したデータを返します。 - 親プロセスは、データをパイソンオブジェクトに逆直列化し、複数の子プロセスが返した結果をマージして一つのlistにします。
ProcessPoolExecutor
クラスを利用したmultiprocessingモジュールは親プロセスと子プロセスの間でデータが交換される際、pickle
モジュールを利用した直列化と逆直列化が起きなければならないので追加費用が発生します。
したがって、プログラムの他の部分と状態を共有する必要がない、親子の間で交換するデータの大きさは小さいけど子プロセスの演算量が非常に大きい数学的アルゴリズムに適しています。
特に計算が大きくなければ、子プロセス通信による付加費用により並列化しても速度はそれほど速くなりません。
注意
multiprocessingを使用する際に注意すべき点は、メインプロセスが実行されるファイルモジュールが明確に定義されなければならない点です。
つまりif__name__=='__main___'
構文の下で実行しなければならないことです。
これは子プロセスで実行されるworker
が作業に必要な情報を親プロセスからもたらし、親プロセスは結果を取りまとめなければならないため、__main__モジュールとその他モジュールは明確に構文されなければなりません。
したがってREPL環境ではマルチプロセッシングが動作しません。
当たり前ですが、multithreadingは関係なく動作します。