自動化無しに生活無し

WEB開発関係を中心に備忘録をまとめています

非同期処理(async、await)とマルチスレッド(threading)とマルチプロセス(multiprocessing)の違い

thumbnail

FastAPIやWebSocketで非同期処理を扱うようになったため、非同期処理とマルチスレッドとマルチプロセスの違いをまとめる。

この違いは非常に複雑で難解。

まずはPythonのGILについて解説する。

GIL とは?

Global Interpreter Lock の略。pythonのCPython実装時に追加された、スレッドの排他制御メカニズムのこと。

このGILの仕組みにより、複数のスレッドがPythonのオブジェクトにアクセスできないようにしている。(整合性確保のため)

GILによりCPUバウンドの処理に制約が課せられる。マルチスレッドにしても同時にPythonオブジェクトにアクセスできないため、処理速度は向上しない。

ちなみに、I/Oバウンドの処理は、GILの影響は受けない。ファイルの読み書きはOSのカーネルに任される。GILの影響はPythonのオブジェクトに限定される。故に、I/Oバウンドの処理には影響はない。

このI/Oバウンドの処理速度は、非同期処理やマルチスレッドによって向上する。

一方、CPUバウンドの処理速度は、マルチプロセスにより向上する。各プロセスのメモリは独立しているためである。これで、GILの影響を回避できる。

CPUバウンドの処理速度は、非同期処理やマルチスレッドでは向上できない。メモリが共有されているためでGILの影響を受ける。

I/Oバウンドの処理とは?

I/Oバウンドの処理とは、以下のようなものが当てはまる

  • ファイルの読み書き
  • データベースのクエリ
  • ネットワーク通信
  • BluetoothやUSB接続デバイスとの通信
  • キャッシュサーバーの操作

いずれも整合性が必要な処理である。

「ファイルの読み書き」は言わずもがな、「データベースのクエリ」の整合性確保は重要だ。

「ネットワーク通信」とはHTTP通信だけではない。SMTPやIMAP、FTPなども当てはまる。そのため、ウェブスクレイピングやメール送信、ストレージへのファイルアップロードも全てI/Oバウンドのタスクである。

また、高速なイメージのあるキャッシュサーバーの操作も、I/Oバウンドに当てはまる。ネットワーク通信が必要であり、メモリの読み書きをしているからだ。

バウンドとは?

bound (訳: 制約される、束縛される)のバウンドである。

I/Oバウンドと言うと、入出力操作に束縛されるという意味になる。

整合性確保のため、処理速度が制約されてしまうのが、I/Oバウンド。

一方、CPUバウンドは、CPUの計算能力によって処理速度が制約される。

非同期処理

非同期処理を使えば、I/Oバウンドの処理を高速化できる。

GILによる入出力待ちが発生すれば、別のタスクを実行することで解決している。

ただし、非同期処理で動いているスレッドは1つ。故に、入出力待ちの処理が並列(同時)に実行されているわけではない。

各処理のメモリも共有されている。よって、CPUバウンドなタスクには向いていない。

import asyncio

# 非同期関数
async def task(name, delay):
    print(f"Task {name} starting")
    await asyncio.sleep(delay)  # I/Oバウンドな操作(時間待ち)
    print(f"Task {name} completed after {delay} seconds")

async def main():
    # 非同期にタスクを並行実行
    tasks = [
        task("A", 2),
        task("B", 3),
        task("C", 1)
    ]
    await asyncio.gather(*tasks)

# 非同期処理を実行
asyncio.run(main())

async で非同期関数を定義できる。 非同期関数内で非同期処理をする場合、await を使う。グローバルスコープではawait は直接使えない。

グローバルスコープで非同期関数を動作させるには、asyncio.run(main()) とする。これで非同期関数 main の処理が終了するまで待機できる。mainを渡すのではなく、main() を渡す点に注意。

戻り値も受け取れる。

asyncio.gather(*tasks) とすることで、複数の非同期関数を並行して実行できる。これにより、I/Oバウンドな処理速度を向上できる。

注意: 非同期関数内の処理は、全て非同期処理にしないと、他の非同期関数に処理が移らない

async def task(name, delay):
    print(f"Task {name} starting")
    await asyncio.sleep(delay)  # I/Oバウンドな操作(時間待ち)
    print(f"Task {name} completed after {delay} seconds")

この非同期関数の内、 asyncio.sleep(delay) は非同期処理である。しかし、print文は同期処理である。

故に、仮に同期処理のprint文で時間がかかった場合、他の非同期関数に処理が移ることはない。

    tasks = [
        task("A", 2),
        task("B", 3),
        task("C", 1)
    ]
    await asyncio.gather(*tasks)

このようにまとめてtaskを実行しているが、もし、print文に時間がかかってしまった場合、直列的に実行され、printの処理待ちで別のtaskに移らない。

よって、非同期関数内に、時間のかかる同期処理が含まれる場合は、非同期処理にする必要がある。

ただし、その時間のかかる同期処理がCPUバウンドの場合、非同期処理にしたところで処理速度は向上しない。そんなときはマルチプロセスを使う。

マルチスレッド

マルチスレッドも、I/Oバウンドの処理を高速化できる。

複数のスレッドを使って、並列処理を行うため、入出力待ちが発生することはなくなる。

ただし、CPUバウンドな処理には向いていない。

非同期処理と同様、マルチスレッドはメモリが共有されており、ここでGILの制約が発生。処理速度の向上は期待できない。

import threading
import time

# スレッドで実行するタスク
def task(name, delay):
    print(f"Task {name} starting")
    time.sleep(delay)  # I/Oバウンドな操作(時間待ち)
    print(f"Task {name} completed after {delay} seconds")

def main():
    # スレッドの作成
    threads = [
        threading.Thread(target=task, args=("A", 2)),
        threading.Thread(target=task, args=("B", 3)),
        threading.Thread(target=task, args=("C", 1))
    ]
    
    # スレッドの開始
    for thread in threads:
        thread.start()

    # スレッドの終了を待機
    for thread in threads:
        thread.join()

# マルチスレッド処理を実行
main()

マルチプロセス

非同期処理やマルチスレッドと違い、複数のプロセスで処理が動作される。

このプロセスごとでメモリは独立している。そのためGILの排他制御の影響を受けない。

結果、CPUバウンドの処理速度は向上する。

import multiprocessing
import time

# プロセスで実行するタスク
def task(name, delay):
    print(f"Task {name} starting")
    time.sleep(delay)  # I/Oバウンドな操作(時間待ち)
    print(f"Task {name} completed after {delay} seconds")

def main():
    # プロセスの作成
    processes = [
        multiprocessing.Process(target=task, args=("A", 2)),
        multiprocessing.Process(target=task, args=("B", 3)),
        multiprocessing.Process(target=task, args=("C", 1))
    ]
    
    # プロセスの開始
    for process in processes:
        process.start()

    # プロセスの終了を待機
    for process in processes:
        process.join()

# マルチプロセス処理を実行
main()

非同期処理とマルチスレッドとマルチプロセスの違いのまとめ

比較項目 非同期処理 マルチスレッド マルチプロセス
どんな処理に有効か? I/Oバウンドの処理 I/Oバウンドの処理 CPUバウンドの処理
並列処理なのか? 入出力待ちの時間があれば別の処理に切り替える。
そのため厳密には並列処理ではない
複数のスレッドを使うため、並列処理 複数のプロセスを使うため並列処理
メモリはどうなる 共有する 共有する プロセスごとに独立する
構文 asyncとawait threading multiprocessing

どの処理に何を使えばよいか?

非同期処理

  • I/Oバウンドの処理
  • I/O待機が頻繁に起こる場合
  • スレッドのオーバーヘッドがない場合

非同期処理は、1つの処理内で何度も何度もI/O待機が発生する場合に非常に有効。IO待機が発生するたび、別の非同期処理に移れる。

マルチスレッドでも並列処理でI/O待機をある程度は有効活用できるが、非同期処理ほどフレキシブルではない。

非同期処理はあくまでも、並行処理なので、スレッドのオーバーヘッドがある場合、高速化は期待できない。

マルチスレッド

  • I/Oバウンドの処理
  • それほど頻繁にI/O待機が発生しない場合
  • スレッドのオーバーヘッドがある場合

非同期処理ほど頻繁にIO待機が発生しない場合に有効。

スレッドのオーバーヘッドが発生しているか否かは、シングルスレッドでの動作で処理時間が削減されない時にそう判断すると良いだろう。

スレッド数を徐々に増やし、処理時間が削減される場合、スレッドのオーバーヘッドが発生していると言える。

マルチプロセス

  • CPUバウンドの処理
  • I/Oバウンドの処理がほとんど無い場合

GILの影響を受ける、CPUバウンドの高度な計算処理の場合に有効。

マルチプロセスは並列実行している特性上、一見、マルチスレッドと同じようにIO待機をある程度効率的に処理できそうに見える。

だが、マルチプロセスは、プロセスごとにメモリは独立している。故に、マルチスレッドよりも非効率なI/O処理になってしまう可能性がある。

結論

まとめると

  • PythonにはGILというスレッドの排他制御メカニズムがある。
  • GILにより、CPUバウンドの処理は、複数のスレッドを使っても処理速度は向上しない
  • I/Oバウンドの処理は、OSのカーネルが行うため、GILの影響は受けない。
  • I/Oバウンドの処理は、非同期処理やマルチスレッドで高速化できる。(IO待機時間、別のタスクを実行できるため)
  • 特にI/Oバウンドの処理が頻繁に起こる場合、非同期処理が有効。
  • ただし、スレッドのオーバーヘッドがある場合は、マルチスレッドが有効。
  • CPUバウンドの処理は、マルチプロセスで高速化できる。(プロセスごとにメモリは独立、GILの影響は受けない)

以上を踏まえ、状況に応じて、「非同期処理」「マルチスレッド」「マルチプロセス」を使い分ける必要がある。

特に、I/Oバウンドの処理がある場合は非同期処理、マルチスレッドのいずれかで良い。

その頻度が高ければ非同期処理、スレッドのオーバーヘッドが発生している場合は、マルチスレッド。

その中でCPUバウンドの処理がある場合、部分的にマルチプロセスにしていく必要がある。

更にまとめると

CPUバウンドの場合

マルチプロセス > マルチスレッド > 非同期処理

I/Oバウンドの場合

非同期処理 > マルチスレッド > マルチプロセス

スポンサーリンク