concurrent.futures

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

【Python】並行・並列処理、IOバウンド・CPUバウンドを理解する
naoki-hn

並行処理や並列処理、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 の使い方の基本は「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 バウンドな処理では、マルチプロセスよりもマルチスレッドの方が効果的である場合があり、十分検討する必要があります。

注意点

I/O バウンドな処理では、コンピュータの I/O 状況や実行環境に依存する部分があるため、必ずしも上記のような傾向になるとは限りません。
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 にて公開しています。参考にしていただければと思います。

あわせて読みたい
【Python Tech】プログラミングガイド
【Python Tech】プログラミングガイド
ABOUT ME
ホッシー
ホッシー
システムエンジニア
はじめまして。当サイトをご覧いただきありがとうございます。 私は製造業のメーカーで、DX推進や業務システムの設計・開発・導入を担当しているシステムエンジニアです。これまでに転職も経験しており、以前は大手電機メーカーでシステム開発に携わっていました。

プログラミング言語はこれまでC、C++、JAVA等を扱ってきましたが、最近では特に機械学習等の分析でも注目されているPythonについてとても興味をもって取り組んでいます。これまでの経験をもとに、Pythonに興味を持つ方のお役に立てるような情報を発信していきたいと思います。どうぞよろしくお願いいたします。

※キャラクターデザイン:ゼイルン様
記事URLをコピーしました