concurrent.futures

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

【Python】並行・並列処理、IOバウンド・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のマルチスレッドとマルチプロセスがどのような違いがでるかを見てみましょう。

なお、マルチスレッドとマルチプロセスの実装では、threadingmultiprocessingではなく、より簡単に実装ができるconcurrent.futuresモジュールを使用します。以降の説明では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にかかる負荷が変わりますのでお使いのPCのスペックに合わせて調整してください。

main関数の中では、io_bound_process関数を2回呼び出す処理を、1)順次実行、2)マルチスレッド、3)マルチプロセスで実行し、処理時間を出力しています。なお、マルチスレッド、マルチプロセスでは、max_worker=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.
順次処理 7.4642

cpu_bound_process done.
cpu_bound_process done.
マルチスレッド 7.47714

cpu_bound_process done.
cpu_bound_process done.
CPU数: 16
マルチプロセス 4.13000

上記のcpu_bound_process関数は、CPUバウンドな処理となっています。数値を加算していくだけの非常に単純な計算処理ですが、繰り返し回数を非常に大きな値としています。数字によりPCにかかる負荷が変わりますのでお使いの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におけるマルチスレッド、マルチプロセスの関係性を理解し、効率的に開発できるようになってもらいたいと思います。