Python応用(2)~コルーチンと非同期処理~

現代のプログラミングでは、効率的な処理が求められる場面が増えています。

特に、ネットワーク通信やファイル入出力といった遅延が発生するタスクを非同期に処理することで、プログラム全体のパフォーマンスを向上させることが可能です。

Pythonでは、asyncとawaitを使って簡単に非同期プログラミングを実現できます。

この記事では、Pythonにおける非同期処理の基本的な実装方法と、マルチスレッド、マルチプロセッシングとの違いについて解説します。

1. 非同期処理とは?

非同期処理とは、あるタスクが完了するまで待つ必要がなく、他のタスクを同時に実行できる処理のことを指します。

通常の同期処理では、1つのタスクが完了するまで次のタスクに進むことができませんが、非同期処理ではこの制限を回避し、プログラム全体の効率を向上させることができます。

イベントループとは?

非同期処理において、タスクの管理は「イベントループ」という仕組みが担っています。

イベントループは、タスクの進行状況を監視し、待機中のタスクを中断して他のタスクに切り替えることで、プログラム全体の効率を向上させます。

Pythonのasyncioライブラリがこのイベントループを提供しており、asyncio.run()によってイベントループが開始され、全体の非同期処理が動作します。

非同期処理が有効な場面:

非同期処理が特に有効なのは、ネットワーク通信やファイルの読み書きなど、I/Oバウンドなタスクです。

これらのタスクは、実際の処理自体は軽量であっても、待機時間が長くなることが多いため、非同期処理を使ってその待機時間を他の処理で埋めることができます。

例えば、以下のような場面で非同期処理が役立ちます。

  • ウェブアプリケーションにおける複数のリクエストの同時処理。
  • 複数のAPIに対する並列リクエストの送信。
  • ファイルの読み書きを行いつつ、同時に他のデータ処理を進める。

非同期処理の限界:

非同期処理が万能というわけではありません。

例えば、CPUバウンドなタスク(数値計算などCPUを集中的に使う処理)においては、非同期処理は効果を発揮しません。

その場合は、マルチスレッドやマルチプロセッシングの方が適しています。

2. Pythonのasyncとawaitの使い方

asyncとawaitはPython 3.5以降で導入された機能です。

これらを使うことで、イベントループを利用した非同期処理が可能になります。

import asyncio

async def example_task():
    print("Task started")
    await asyncio.sleep(2)  # 非同期に2秒待機
    print("Task finished")

async def main():
    await asyncio.gather(example_task(), example_task())

# イベントループを実行
asyncio.run(main())

このコードでは、2つの非同期タスクを同時に実行し、ブロックすることなく処理を進めています。

3. マルチスレッド、マルチプロセッシング、非同期処理の違い

並行処理にはいくつかのアプローチがあり、Pythonでは主に「マルチスレッド」「マルチプロセッシング」、そして「非同期処理」の3つが選択肢として挙げられます。

それぞれの手法には、得意な分野や適したタスクが異なるため、タスクの性質に応じて使い分けることが重要です。

マルチスレッド

マルチスレッドは、1つのプロセスの中で複数のスレッドを生成し、並行してタスクを実行する手法です。

Pythonではthreadingモジュールを使ってマルチスレッドを実装しますが、Python固有の「GIL(グローバルインタプリタロック)」という制限により、CPUバウンドなタスクにはあまり向いていません。

  • 利点: スレッド間の通信や共有データの操作が容易です。スレッドが軽量であるため、システムリソースの消費が少ないという特長もあります。
  • 欠点: スレッド間のデータ共有による競合(デッドロックやレースコンディションなど)が発生しやすい点に注意が必要です。
    また、PythonのGILによって、CPUを集中的に使うタスクではスレッドの数に関係なく、実質的に1つのスレッドしか動かないという制約があります。
  • 最適な用途: I/Oバウンドなタスク(ファイル入出力、ネットワーク通信など)に向いています。

コード例(マルチスレッド):

import threading
import time

def thread_task():
    print(f'Task started by {threading.current_thread().name}')
    time.sleep(2)
    print(f'Task finished by {threading.current_thread().name}')

# 2つのスレッドを作成して同時に実行
thread1 = threading.Thread(target=thread_task, name='Thread-1')
thread2 = threading.Thread(target=thread_task, name='Thread-2')

thread1.start()
thread2.start()

thread1.join()
thread2.join()

マルチプロセッシング

マルチプロセッシングは、複数のプロセスを使用してタスクを並行処理する手法です。

各プロセスは独立してメモリ空間を持つため、GILの制約を受けることなく、CPUのコアを最大限に活用できます。

Pythonではmultiprocessingモジュールを使います。

  • 利点: 各プロセスが独立しているため、GILの制限がなく、CPUバウンドな処理(大規模な計算など)に効果的です。
    また、プロセスごとに独立したメモリ空間を持つため、スレッド間の競合による問題が起きにくいです。
  • 欠点: プロセス間でのデータ共有や通信にはオーバーヘッドが発生し、効率が低下する場合があります。
    また、プロセスの生成にはスレッドに比べてコストが高いため、大量のプロセスを生成するとメモリ消費が増えます。
  • 最適な用途: CPUバウンドなタスク(計算処理やデータの圧縮など)に向いています。

コード例(マルチプロセッシング):

import multiprocessing
import time

def process_task():
    print(f'Task started by {multiprocessing.current_process().name}')
    time.sleep(2)
    print(f'Task finished by {multiprocessing.current_process().name}')

# 2つのプロセスを作成して同時に実行
process1 = multiprocessing.Process(target=process_task, name='Process-1')
process2 = multiprocessing.Process(target=process_task, name='Process-2')

process1.start()
process2.start()

process1.join()
process2.join()

非同期処理

非同期処理は、イベントループを利用してI/Oバウンドなタスクを効率的に並行処理する手法です。

Pythonではasyncとawaitを使った非同期プログラミングがこれに該当します。

非同期処理では、シングルスレッド上でタスクが切り替えられ、タスクがブロックされている間に他のタスクを実行します。

  • 利点: 非同期処理はスレッドやプロセスを作成せずに、効率よくI/Oバウンドなタスクを並行処理できます。
    リソースの消費が少ないため、大規模なシステムでもスムーズに動作します。
  • 欠点: 非同期処理は、CPUバウンドなタスクには適していません。
    また、コードの構造が複雑になりやすく、デバッグや保守が難しくなることもあります。
  • 最適な用途: 非同期I/O処理、ネットワークリクエスト、ファイル入出力などに最適です。

コード例(非同期処理):

import asyncio

async def async_task():
    print('Async task started')
    await asyncio.sleep(2)  # 非同期で2秒待機
    print('Async task finished')

async def main():
    await asyncio.gather(async_task(), async_task())  # 複数タスクの同時実行

asyncio.run(main())

それぞれの使い分け:

  • マルチスレッドは、ネットワーク通信やファイル入出力といったI/Oバウンドなタスクが中心の場合に適しています。
    スレッド間のデータ共有が容易ですが、CPUバウンドなタスクには向いていません。
  • マルチプロセッシングは、大規模な計算やCPUバウンドなタスクを実行する場合に最適です。
    プロセス間の通信が難しい反面、GILの制約を受けないため、マルチコアを効果的に利用できます。
  • 非同期処理は、I/O操作が多いシステム(WebサーバーやAPI呼び出しなど)に非常に効果的です。シングルスレッド上で効率的に処理できるため、軽量で高速な処理が可能です。

4. どの方法を選ぶべきか?—タスクごとの最適な選択肢

並行処理の方法を選ぶ際には、タスクの種類やシステムの要件をよく理解することが重要です。

それぞれの方法には強みと弱みがあり、用途に応じた選択をすることで、プログラムの効率を最大限に引き出すことができます。

1. I/Oバウンドなタスクの場合

I/Oバウンドなタスクとは、主にネットワーク通信やファイルの読み書きなど、外部デバイスとのやり取りに時間がかかる処理を指します。

I/Oバウンドな処理では、タスクが完了するまでの待機時間が長いため、その待ち時間を有効に活用できる方法が求められます。

推奨方法: 非同期処理

非同期処理は、I/Oバウンドなタスクに最適です。asyncとawaitを使うことで、長時間の待機が発生するタスクでもブロックされずに他のタスクを同時に進行できます。

例えば、Webサーバーで複数のクライアントリクエストを処理する場合、非同期処理を使うことで一度に多くのリクエストを効率よくさばくことができます。

非同期処理が有効なシチュエーション:

  • Web APIの呼び出し: 複数のAPIリクエストを同時に処理する必要があるとき。
  • ファイル入出力: 大量のファイルを読み込んだり書き込んだりする場面で、他のタスクを並行して行いたいとき。
  • ネットワーク通信: 長時間のネットワーク接続を待機している間に、他のタスクも進めたいとき。

例: 非同期にWeb APIを呼び出してデータを取得し、他の処理と並行して行うケース。

import asyncio
import aiohttp

async def fetch_data(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.text()

async def main():
    urls = ["http://example.com", "http://example.org"]
    tasks = [fetch_data(url) for url in urls]
    await asyncio.gather(*tasks)

asyncio.run(main())

2. CPUバウンドなタスクの場合

CPUバウンドなタスクとは、計算量が多く、CPUの処理能力に依存するタスクを指します。例えば、大規模なデータ処理や機械学習モデルのトレーニング、暗号化・復号化処理などがこれに該当します。これらのタスクでは、複数のCPUコアを活用して並列処理することで、処理時間を短縮できます。

推奨方法: マルチプロセッシング

マルチプロセッシングは、CPUバウンドなタスクに最適です。

PythonのGIL(Global Interpreter Lock)はマルチスレッドのパフォーマンスを制約しますが、マルチプロセッシングではプロセスごとに独立したメモリ空間が与えられるため、GILの制約を受けずに複数のCPUコアを最大限に活用できます。

マルチプロセッシングが有効なシチュエーション:

  • 大規模データ解析: 膨大なデータセットを分割して並列に処理する場合。
  • 数値計算: 複雑な計算処理を行う場面で、複数のコアを活用することで処理時間を短縮したい場合。
  • 機械学習モデルのトレーニング: CPUリソースをフル活用して学習を高速化したいとき。

例: 大規模な数値計算をマルチプロセスで分散して行うケース。

import multiprocessing

def compute_factorial(number):
    result = 1
    for i in range(1, number + 1):
        result *= i
    return result

if __name__ == '__main__':
    with multiprocessing.Pool() as pool:
        numbers = [100000, 200000, 300000]
        results = pool.map(compute_factorial, numbers)
        print(results)

3. 軽量な並行処理の場合

I/Oバウンドな処理が軽量で、比較的小規模なタスクを並行して実行したい場合には、マルチスレッドが有効です。

スレッドはプロセスよりも軽量で、リソースの消費が少ないため、I/Oバウンドな処理を扱うときに効率よく利用できます。

ただし、PythonのGILの制約により、CPUバウンドな処理には向いていません。

推奨方法: マルチスレッド

I/Oバウンドかつ軽量なタスクが複数ある場合には、マルチスレッドが有効です。

ファイル入出力や軽量なネットワーク通信などでは、スレッドを使って簡単に並行処理を実現できます。

マルチスレッドでは、複数のスレッドが同一プロセス内で動作するため、スレッド間の通信やデータ共有が容易です。

マルチスレッドが有効なシチュエーション:

  • ファイル入出力の並列処理: 複数のファイルを並行して読み書きする場合。
  • ネットワーク接続のハンドリング: 軽量なネットワーク接続が多数発生する場合(チャットアプリケーションなど)。
  • GUIアプリケーションの応答性向上: ユーザーインターフェースの応答を維持しながら、バックグラウンドで処理を実行したいとき。

例: ファイルを複数のスレッドで並行して読み取るケース。

import threading

def read_file(file_path):
    with open(file_path, 'r') as file:
        print(f'Reading {file_path}: {file.read()}')

if __name__ == '__main__':
    files = ['file1.txt', 'file2.txt']
    threads = []

    for file in files:
        thread = threading.Thread(target=read_file, args=(file,))
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

まとめ

Pythonのasyncとawaitを使った非同期プログラミングは、効率的なI/O処理を実現するための強力なツールです。

マルチスレッドやマルチプロセッシングとの違いを理解し、タスクの種類に応じた最適な方法を選ぶことで、よりパフォーマンスの高いプログラムを作成できるようになります。

これからのプロジェクトで、ぜひこの知識を活用してみてください!

SHARE
採用バナー