Python asyncioコルーチン、イベントループ、async awaitの深掘り:基盤の解説
Emily Parker
Product Engineer · Leapcell

はじめに
今日の相互接続された世界では、アプリケーションは、ネットワークリクエスト、データベースクエリ、ファイルI/Oなどの外部リソースの応答を待機する必要があるシナリオに頻繁に遭遇します。従来、これらの操作をブロックすることは、非効率的なリソース利用と低いユーザーエクスペリエンスにつながっていました。非同期プログラミングは、プログラムが操作を開始し、最初の操作が完了するのを待っている間に別のタスクに切り替えることができるパラダイムシフトである非同期プログラミングです。Pythonのasyncio
ライブラリは、単一スレッドを使用して並行コードを記述するための堅牢でエレガントなフレームワークを提供し、開発者が非常にスケーラブルで応答性の高いアプリケーションを構築できるようにします。この記事では、asyncio
の基礎となる要素、つまりコルーチン、イベントループ、そしてasync/await
構文について深く掘り下げ、それらの内部動作を解き明かし、協力的なマルチタスクをどのように調整するかを実証します。
コアコンセプトの解説
asyncio
のメカニズムを探索する前に、その基本的な構成要素を明確に理解しましょう。
- コルーチン: コルーチンは、本質的に、一時停止して再開できる特別な関数です。呼び出されると完了まで実行される通常の関数とは異なり、コルーチンは制御を呼び出し元に
yield
して他のタスクの実行を許可し、その後、中断した箇所から正確に処理を再開できます。Pythonでは、コルーチンはasync def
を使用して定義されます。 - イベントループ: イベントループは
asyncio
の中央オーケストレーターです。イベント(例:I/Oの準備完了、タイマー、完了したタスク)を継続的に監視し、それらを適切なコルーチンにディスパッチします。それは単一スレッドのスケジューラとして機能し、すべての非同期タスクの実行フローを管理します。 - タスク: タスクはコルーチンの抽象化であり、Futureのようなオブジェクトでラップします。コルーチンがイベントループで実行するためにスケジュールされると、それはタスクになります。タスクにより、イベントループはコルーチンのライフサイクル(キャンセルや完了を含む)を管理できます。
- Future:
Future
オブジェクトは、非同期操作の最終的な結果を表します。これは、結果または例外を取得するためにawait
できる低レベルのオブジェクトです。タスクは、Future上に構築された高レベルの抽象化です。 async
とawait
: これらのキーワードは、コルーチンを記述し、非同期操作との対話をより自然にするためのシンタックスシュガーです。async
は関数をコルーチンとして定義し、await可能にします。await
は、現在のコルーチンの実行を、awaitされた「awaitable」(別のコルーチン、Task
、またはFuture
)が完了するまで一時停止し、イベントループが他のタスクに切り替えることを許可します。
Asyncioの内部動作
asyncio
の力は、イベントループによって調整される協調的なスケジューリングに由来します。これらのコンポーネントがどのように連携するかを分解してみましょう。
演算を待機するコルーチン
ネットワークからデータを取得する典型的な同期的関数を考えてみましょう。
import time def fetch_data_sync(url): print(f"Fetching data synchronously from {url}...") time.sleep(2) # Simulate network latency print(f"Finished fetching data from {url}.") return {"data": f"content from {url}"} # Synchronous execution start_time = time.time() data1 = fetch_data_sync("http://example.com/api/1") data2 = fetch_data_sync("http://example.com/api/2") end_time = time.time() print(f"Synchronous execution time: {end_time - start_time:.2f} seconds")
この同期的例では、fetch_data_sync("http://example.com/api/2")
は、fetch_data_sync("http://example.com/api/1")
がシミュレートされた2秒間の遅延を含めて完全に完了した後にのみ開始されます。
次に、async def
とawait
を使用してasyncio
でこれがどのように変換されるかを見てみましょう。
import asyncio import time async def fetch_data_async(url): print(f"Fetching data asynchronously from {url}...") await asyncio.sleep(2) # Non-blocking sleep, yields control print(f"Finished fetching data from {url}.") return {"data": f"content from {url}"} async def main(): start_time = time.time() # Schedule both coroutines to run concurrently task1 = asyncio.create_task(fetch_data_async("http://example.com/api/1")) task2 = asyncio.create_task(fetch_data_async("http://example.com/api/2")) # Await their completion data1 = await task1 data2 = await task2 end_time = time.time() print(f"Asynchronous execution time: {end_time - start_time:.2f} seconds") print(f"Data 1: {data1}") print(f"Data 2: {data2}") if __name__ == "__main__": asyncio.run(main())
非同期バージョンでは:
async def fetch_data_async(url):
はfetch_data_async
をコルーチンとして宣言します。await asyncio.sleep(2)
が重要な部分です。実行がこの行に達すると、2秒間ブロックする代わりに、fetch_data_async
コルーチンは一時停止し、制御をイベントループにyieldします。- イベントループは次に実行可能な他のタスクを探します。
main
関数では、task1
とtask2
という2つのタスクを作成しました。 task1
がawaitした後、イベントループはtask2
に切り替えることができ、task2
は実行を開始し、最終的にasyncio.sleep(2)
をawaitします。- 両方のコルーチンが「スリープ」している(awaitしている)間、イベントループはタイマーを監視します。2秒後、
task1
のasyncio.sleep(2)
が完了したというシグナルを受け取ります。その後、中断した箇所から処理を再開するようにtask1
を再開し、「Finished fetching data...」と表示して結果を返すことができます。 - 同様に、
task2
もスリープ完了時に再開されます。 - 最後に、
main
のawait task1
とawait task2
は、それぞれの結果を取得します。
重要な点は、await
がプログラム全体をブロックするのではなく、現在のコルーチンのみをブロックし、イベントループが他のタスクを並行して処理し続けることを可能にするということです。これは協調的なマルチタスクです。コルーチンは明示的に制御を譲渡します。
イベントループの役割
イベントループは、(Linuxではepoll
、macOSではkqueue
、WindowsではIOCP
などの)プラットフォーム固有のメカニズムや、しばしばselectors
のような低レベルの構造を使用して実装され、複数のI/O操作をブロックすることなく効率的に監視します。
asyncio.run(main())
を呼び出すと、通常は次のようになります。
- イベントループインスタンスが作成されます(現在のスレッドでまだ実行されていない場合)。
main()
コルーチンがこのイベントループでスケジュールされます。- イベントループはメイン実行サイクルを開始します。
- 実行可能(つまり、現在 await 中でない)なタスクを選択します。
await
式に遭遇するまでそのタスクを実行します。await
にヒットすると、現在のタスクは一時停止され、その状態が保存されます。- イベントループは、完了したI/O操作、期限切れのタイマー、またはその他のキューに入れられたイベントをチェックします(例:
asyncio.sleep
やネットワーク読み取りのようなawaitされた操作が完了すると、対応する一時停止されたタスクは再開可能としてマークされます)。 - イベントループは次に別の実行可能タスクを選択し、サイクルを続行します。
- すべてのスケジュールされたタスクが完了するまで、このサイクルは続行されます。
aiohttp
を使用した実践的な応用
aiohttp
(asyncio互換のHTTPクライアントライブラリ)を使用して複数のHTTPリクエストを並行して行う一般的なユースケースで例を示します。
import asyncio import aiohttp import time async def fetch_url(session, url): async with session.get(url) as response: return await response.text() async def main_http(): urls = [ "https://www.example.com", "https://www.google.com", "https://www.bing.com", "https://www.python.org" ] start_time = time.time() async with aiohttp.ClientSession() as session: tasks = [] for url in urls: task = asyncio.create_task(fetch_url(session, url)) tasks.append(task) # Gather all results concurrently responses = await asyncio.gather(*tasks) end_time = time.time() print(f"Fetched {len(urls)} URLs in {end_time - start_time:.2f} seconds") # print first 100 chars of each response for i, res in enumerate(responses): print(f"URL {urls[i]} content snippet: {res[:100]}...") if __name__ == "__main__": asyncio.run(main_http())
この例では:
aiohttp.ClientSession()
は非同期コンテキストマネージャーであり、適切なリソース管理を保証します。- 各
url
について、fetch_url
がコルーチンとして呼び出されます。await session.get(url)
とawait response.text()
の呼び出しは非ブロッキングです。session.get
がネットワークリクエストを開始すると、制御をyieldしてイベントループが次のリクエストを開始できるようにします。 asyncio.gather(*tasks)
は強力なユーティリティであり、複数のawaitable(ここではtasks
)を受け取り、それらを並行して実行します。それらすべてが完了するまで待機し、タスクが渡された順序で(あるいはパスされた引数の順序で)、それらの結果を返します。
これは、asyncio
がI/O操作をオーバーラップさせ、各URLを順次取得する場合と比較して、総実行時間を大幅に短縮できる方法を示しています。
結論
コルーチン、イベントループ、およびasync/await
構文というコアコンセプトを備えたasyncio
は、効率的な並行Pythonアプリケーションを記述するための強力で直感的な方法を提供します。コルーチンが協調的に制御をyieldする方法と、イベントループがそれらの実行を調整する方法を理解することで、開発者は単一スレッドの非同期プログラミングの可能性を最大限に活用して、非常に応答性が高くスケーラブルなシステムを構築できます。asyncio
は単なるライブラリではありません。それはPythonにおける並行処理への、より効率的でモダンなアプローチへの根本的なシフトです。