【Python】並行・並列処理、I/Oバウンド・CPUバウンドを理解する

並行処理や並列処理、I/O バウンドや CPU バウンドといった概念と Python におけるマルチスレッド、マルチプロセスとの関係性について解説します。
目次
並行・並列処理と I/O バウンド・CPU バウンド
マルチスレッドやマルチプロセスのプログラミングでは「並行処理」や「並列処理」、そして「I/O バウンド」や「CPU バウンド」といった用語が重要です。これらの概念をしっかりと理解することは、効率的なプログラムを設計するうえで不可欠です。
本記事では、これらの概念と Python におけるマルチスレッドやマルチプロセスのプログラミングとの関連性について説明します。
並行処理と並列処理
並行処理と並列処理は、よく混同されがちですが以下のような違いがあります。
- 並行処理は、複数タスクが交互に実行され、高速に切り替わることで同時に進行しているように見える処理です。並行処理は、リソースの効率的な使用に焦点を当てています。
- 並列処理では、マルチコアプロセッサを活用し、複数タスクが文字通り同時に実行される処理です。並列処理の目的は計算の高速化と処理能力の向上です。
例えば、OS が複数アプリケーションを管理したり、Web サーバーが複数リクエストを処理したりすることは並行処理の例と言えます。また、科学計算やビッグデータ分析などの高い計算処理速度が求められるタスクでは並列処理が重要な役割を果たします。
I/O バウンドと CPU バウンド
プログラムが直面する可能性のある主要なボトルネックとして「I/O バウンド」と「CPU バウンド」という言葉があります。
- I/O バウンドは、入出力操作に時間がかかることを言います。
- CPU バウンドは、計算処理に時間がかかることを言います。
I/O バウンドは、大量のファイル操作やネットワーク通信を含むタスク、CPU バウンドは、複雑な数学計算やデータ分析が含まれるタスクが該当します。これらのタスクの特性とプログラミング言語の相性を理解することで、パフォーマンスを大幅に向上させることが可能となります。
Python のマルチスレッドとマルチプロセス
Python では、マルチスレッドおよびマルチプロセスで並列処理を実装するためのモジュールが用意されています。マルチスレッドは、threading モジュール、マルチプロセスは、multiprocessingモジュールが該当します。それぞれの使い方の基本については、以下にまとめているので参考にしてください。
Python のマルチスレッドやマルチプロセスを理解するには Python 実装と GIL について理解が必要です。Python 実装で最も広く使われている CPython では、マルチスレッドを多くの状況で効果的に使用できない制限が存在し、グローバルインタープリタロック(GIL)として知られています。なお、GIL は、CPython 特有のもので、例えば、Java で実装された Jython には存在しません。
GIL では、Python オブジェクトにアクセスする処理は、1 つのスレッドに限定されます。そのため、Python で CPU バウンドな処理においてはマルチスレッドを利用しても、スレッドがシリアル化されて順次実行されるため、高速化が難しくなります。
この場合は、マルチスレッドではなく、マルチプロセスの利用が有効です。一方で、I/O バウンドな処理や C 拡張のモジュールを使う場合では、GIL の影響は軽減されることがあり、マルチスレッドでも効果を見込むことができます。
以降では、簡単な I/O バウンドな処理と CPU バウンドな処理を使って、Python のマルチスレッドとマルチプロセスがどのような違いがでるかを見てみましょう。
なお、マルチスレッドとマルチプロセスの実装では、threading や multiprocessing ではなく、より簡単に実装ができる concurrent.futures モジュールを使用できます。以降説明では concurrent.futures を使いつつ説明をしていきます。
I/O バウンドなタスク
I/O バウンドな処理に対してマルチスレッドとマルチプロセスでどのような違いがあるか以下の例で見てみます。
import concurrent.futures
import os
import time
def io_bound_process(file_name, text):
"""IOバウンドな処理を実行する関数"""
with open(file_name, "w+") as file:
# ファイルに書き込み
file.write(text)
# シーク位置を戻して読み込み
file.seek(0)
file.read()
os.remove(file_name)
return "io_bound_process done."
def main():
"""メイン"""
large_text = "test_string" * 10000000
# 通常の順番でのIO処理
start = time.time()
print(io_bound_process("./test1.txt", large_text))
print(io_bound_process("./test2.txt", large_text))
end = time.time()
print(f"順次処理 {end - start:.4f}\n")
# マルチスレッドで処理
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
start = time.time()
multi_thread1 = executor.submit(io_bound_process, "./test1.txt", large_text)
multi_thread2 = executor.submit(io_bound_process, "./test2.txt", large_text)
print(multi_thread1.result())
print(multi_thread2.result())
end = time.time()
print(f"マルチスレッド {end - start:.5f}\n")
# マルチプロセスで処理
with concurrent.futures.ProcessPoolExecutor(max_workers=2) as executor:
start = time.time()
multi_process1 = executor.submit(io_bound_process, "./test1.txt", large_text)
multi_process2 = executor.submit(io_bound_process, "./test2.txt", large_text)
print(multi_process1.result())
print(multi_process2.result())
end = time.time()
print(f"CPU数: {os.cpu_count()}")
print(f"マルチプロセス {end - start:.5f}\n")
if __name__ == "__main__":
main()【実行結果例】 io_bound_process done. io_bound_process done. 順次処理 1.6407 io_bound_process done. io_bound_process done. マルチスレッド 1.19107 io_bound_process done. io_bound_process done. CPU数: 16 マルチプロセス 1.39711
上記の io_bound_process 関数では、指定されたファイルへ受け取った文字列を書き込み、その後に読み込んだファイルを消すといった I/O に関わる操作をしています。書き込む文字列は、main 関数で「large_text = "test_string" * 10000000」という長い文字列を作成しています。お使いの PC スペックに合わせて長さは調整してください。
main 関数では、io_bound_process 関数を2回呼び出す処理を、1) 順次実行、2) マルチスレッド、3) マルチプロセスで実行し、処理時間を出力しています。なお、マルチスレッド、マルチプロセスでは、max_workers=2 で最大 2 並列としています。
結果を見てみると、順次実行に対してマルチスレッドもマルチプロセスも処理が速くなっていますが、マルチスレッドの方が効果が大きくなっていることが分かります。
マルチスレッドは、メモリ空間を共有するのに対して、マルチプロセスでは各プロセスで独立したメモリ空間を持ち、プロセス間通信が発生することがあるため、オーバーヘッドが発生するため、マルチプロセスの方が遅くなっていると考えられます。
マルチスレッドは非常に多くのスレッドを作ることができるのに対して、マルチプロセス数は実行環境の CPU 数に依存します。そのため、I/O バウンドな処理では、マルチプロセスよりもマルチスレッドの方が効果的である場合があり、十分検討する必要があります。
CPU バウンドなタスク
CPU バウンドな処理に対してマルチスレッドとマルチプロセスでどのような違いがあるか以下の例で見てみましょう。
import concurrent.futures
import os
import time
def cpu_bound_process():
"""CPUバウンドな処理を実行する関数"""
i = 0
while i < 100000000:
i = i + 1
return "cpu_bound_process done."
def main():
"""メイン"""
# 通常の順番でのCPU処理
start = time.time()
print(cpu_bound_process())
print(cpu_bound_process())
end = time.time()
print(f"順次処理 {end - start:.4f}\n")
# マルチスレッドで処理
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
start = time.time()
multi_thread1 = executor.submit(cpu_bound_process)
multi_thread2 = executor.submit(cpu_bound_process)
print(multi_thread1.result())
print(multi_thread2.result())
end = time.time()
print(f"マルチスレッド {end - start:.5f}\n")
# マルチプロセスで処理
with concurrent.futures.ProcessPoolExecutor(max_workers=2) as executor:
start = time.time()
multi_process1 = executor.submit(cpu_bound_process)
multi_process2 = executor.submit(cpu_bound_process)
print(multi_process1.result())
print(multi_process2.result())
end = time.time()
print(f"CPU数: {os.cpu_count()}")
print(f"マルチプロセス {end - start:.5f}\n")
if __name__ == "__main__":
main()【実行結果例】 cpu_bound_process done. cpu_bound_process done. 順次処理 5.4354 cpu_bound_process done. cpu_bound_process done. マルチスレッド 5.44025 cpu_bound_process done. cpu_bound_process done. CPU数: 16 マルチプロセス 2.86662
上記の cpu_bound_process 関数は、CPU バウンドな処理となっています。数値を加算していくだけの非常に単純な計算処理ですが、繰り返し回数を非常に大きな値としています。お使いのPCのスペックに合わせて繰り返し回数は調整してください。
main 関数の中では、cpu_bound_process 関数を2回呼び出す処理を、1) 順次実行、2) マルチスレッド、3) マルチプロセスで実行し、処理時間を出力しています。なお、マルチスレッド、マルチプロセスでは、max_worker=2 で最大 2 並列としています。
結果を見ると、順次実行が遅いのは理解できますが、マルチスレッドでも順次実行とほぼ同じ処理時間となってしまっています。これは、マルチスレッドが CPU バウンドな処理では GIL により結局は順次実行となってしまっているためです。
一方で、マルチプロセスでは、明確に処理するコアが異なるため、順次実行に比べて大幅に処理時間が早くなっています。なお、os.cpu_count() で使用環境の CPU 数を確認しています。
このように CPU バウンドな処理では、マルチスレッドの効果は出ない可能性があります。CPUバウンドな処理では、マルチプロセスの使用を検討するとよいでしょう。
まとめ
「並行処理」「並列処理」「I/O バウンド」「CPU バウンド」といった概念と Python におけるマルチスレッド、マルチプロセスのプログラミングとの関係性について解説しました。
並行処理と並列処理は、よく混同されますが厳密には異なる概念です。並行処理は、複数タスクが交互に実行され、高速に切り替わることで同時に進行しているかのように見える処理であるのに対して、並列処理はマルチコアプロセッサを活用して、複数タスクが文字通り同時に実行されます。
また、I/O バウンドは入出力操作に時間がかかること、CPU バウンドは計算処理に時間がかかることを意味しており、これらの違いを意識したプログラミングが必要です。
今回は、簡単な I/O バウンドな処理と CPU バウンドな処理で、Python のマルチスレッドとマルチプロセスの違いを確認しました。
Python では、GIL の影響で CPU バウンドな処理ではマルチスレッドの効果は少なく、マルチプロセスの方が適切な場合があります。一方で、I/O バウンドな処理では、マルチスレッドでも GIL の影響が軽減するため、多くのスレッドで実行が可能なマルチスレッドは、CPU数に依存するマルチプロセスよりも効果的な場合があります。
並列処理の実装では、今回紹介したような特徴をよく理解して検討することが非常に重要です。ぜひ、並行処理、並列処理、I/O バウンド、CPU バウンドといった概念と Python におけるマルチスレッド、マルチプロセスの関係性を理解し、効率的に開発できるようになってください。
上記で紹介しているソースコードについては GitHub にて公開しています。参考にしていただければと思います。


