Q1async def hello(): return 'Hi'という関数をhello()と呼んだときに返るのはどれですか?
async / await の基礎 — 待ち時間を活用して処理を高速化する
コルーチン・イベントループ・asyncio.sleepの仕組みから、async def/awaitでI/O待ち時間を別タスクに回し処理を高速化する書き方までを学べます。
async / awaitは、I/O(Input/Output、ファイル読み書き・ネットワーク通信・DB アクセスなど待ち時間が長い処理)の待ちの間に別の処理を進めるための仕組みです。Web API を 100 件叩くような待ち時間が支配的な処理を、1 つのスレッドのまま高速化できます。本記事ではコルーチン・イベントループ・asyncio.sleepの 3 つを整理します。
本環境での実行について
async / await は時間軸の挙動が肝心ですが、本環境のランナーはprintの出力をスクリプト完走後に一括表示する実装のため、リアルタイムな経過や体感速度は実機と同じには見えません。本記事は図で内部動作をわかりやすく解説しますが、printの流れや所要時間を実感したい場合はローカルの Python 環境(asyncio.runで実行)で動かすのがおすすめです。
プロセスとスレッド — async / await の前提知識
async / await の話に入る前に、プロセスとスレッドという用語を簡単に整理します。
プロセス(OS が起動した 1 つのプログラム)は、自分専用のメモリ空間を持つ独立した実行単位です。Python スクリプトを実行すれば 1 つのプロセスが立ち上がり、別のターミナルでもう 1 つ動かせばその数だけプロセスが並びます。
スレッド(プロセス内でコードを実際に動かす実行の流れ)は、プロセスの中でCPU を使ってコードを進める単位です。1 つのプロセスは複数のスレッドを持てますが、普通の Python プログラムは 1 プロセス + 1 スレッドで動いていて、time.sleepのような待ちが入るとそのスレッドが止まります。
- OS から見た独立した実行単位
- 自分専用のメモリ空間を持つ
- プロセス内で実際にコードを動かす単位
- 普通の Python は1 つだけ動いている
- 1 つのスレッド内で切り替えながら動く
async / awaitで何個でも作れる
threading (スレッドを増やす) やmultiprocessing (プロセスを増やす) との大きな違い。async / awaitが扱うのは一番内側のコルーチンだけです。同じスレッドの中でコルーチン同士を切り替えながら動かすので、スレッドや CPU を増やしているわけではありません — 「I/O 待ちで CPU が動いていない時間」を別のコルーチンに切り替えるだけのシンプルな仕組みです。スレッドそのものを並行に動かしたいときはthreading、プロセスそのものを増やしたいときはmultiprocessingを使います(次回 / 次々回の記事で扱います)。
コルーチンとは — async / await の主役
コルーチン(async defで定義され、内部のawait地点で一時停止できる関数 / オブジェクト)は async / await の中心です。普通の関数は呼ばれると最後まで一気に走り切るのに対して、コルーチンは内部のawaitのたびに中断 → イベントループが再開という動きを繰り返せます。これが async の土台です。
async defで定義した関数を呼んでも本体は実行されず、コルーチンオブジェクトが返るだけです (例: coro = hello())。await coroかasyncio.run(coro)に渡して初めて実行が始まります。
import asyncio
async def hello():
return "Hi"
# 呼んでも本体は実行されない
coro = hello()
print(coro) # <coroutine object hello at 0x...>
# ↑ 0x... はメモリアドレス。
# 「コルーチンが生成されただけ」のサイン
# await で初めて実行される
print(await coro) # Hi
awaitに当たると一時停止 → イベントループが再開、を繰り返す。await some_io()と書いた地点でコルーチンを一時停止して他のタスクに切り替わり、待っている間に他のコルーチンが動ける — これが async の最大の特徴です。
イベントループとは — コルーチンを切り替えるスケジューラ
イベントループ(コルーチンを順番に切り替えながら実行するスケジューラ)は、asyncioが裏で動かす仕組みです。キューから 1 つ取り出して実行 → awaitで次に切り替え → 待ちが終わったらキューに戻すを延々と繰り返します。
import asyncio
async def main():
print("開始")
await asyncio.sleep(0) # 0 秒の sleep は
# 「待たないが、ここで切り替わってOK」
# というマーカー
print("終了")
# asyncio.run でループ起動 → main 実行 → 終わったらループも閉じる
asyncio.run(main())
# 出力:
# 開始
# 終了
この切り替えは 1 つのスレッドの中で起きるため、CPU コアは増やさず、「CPU が動いていない待ち時間」を別のコルーチンに切り替えるだけです。本環境のブラウザ上の Python は既にループが動いているので、asyncio.run(...)を呼ばなくてもトップレベルで直接awaitが書けます。
なぜ async / await が必要か — 待ち時間を別のタスクに切り替える
同期 (sync) 版(1 行ずつ順番に実行する普通の書き方)でtime.sleep(1)のような待ちが入ると、その間CPU が動いていないのにプログラムは止まったままです。Web API・DB クエリ・ファイル I/O の完了待ちも同じ。
async / awaitは「待つ」と書いた地点で別のタスクに切り替える仕組みで、同じスレッドのまま待ちが入った瞬間に他へ切り替えます。
# requests = 同期版 HTTP クライアント (普通の get で 1 件ずつ順番)
# httpx = async 対応 HTTP クライアント (await で並行に呼べる)
import requests, asyncio, httpx
# 同期版: API を 3 件 順番に呼ぶ → 合計 3 秒
def fetch_users_sync():
r1 = requests.get("https://api.example.com/users/1") # ← 1 秒待ち
r2 = requests.get("https://api.example.com/users/2") # ← さらに 1 秒
r3 = requests.get("https://api.example.com/users/3") # ← さらに 1 秒
return [r1.json(), r2.json(), r3.json()]
# async 版: 3 件を並行に呼ぶ → 最も遅い 1 件と同じ 1 秒で完了
async def fetch_users_async():
async with httpx.AsyncClient() as client:
r1, r2, r3 = await asyncio.gather(
client.get("https://api.example.com/users/1"),
client.get("https://api.example.com/users/2"),
client.get("https://api.example.com/users/3"),
)
return [r1.json(), r2.json(), r3.json()]
asyncio.gather の詳細は次回
ここで使ったasyncio.gather(...)は複数のコルーチンを並行に走らせて、全部の結果が揃うまで待つ関数です。詳しい使い方・戻り値・例外処理は次回 asyncio 応用 で扱います。
並列ではなく並行
async / await は CPU 自体を増やさないので、計算で CPU を 100% 使うような処理は速くなりません。「I/O 待ち / ネットワーク待ち / sleep」のようなCPU が動いていない時間を別のタスクに切り替えるだけです — これを並行(concurrent)と言い、真の並列(parallel)ではありません。並列を実現するには threading や multiprocessing が必要で、次回 / 次々回の記事で扱います。
async def と await の基本
async defで定義した関数はコルーチン関数と呼ばれ、呼んでも本体は実行されず、コルーチンオブジェクトが返るだけです。動かすにはawaitで待つかasyncio.run()に渡します。
await xは「x の完了を待つ。間は他のタスクに切り替える」という意味です。xに書けるのはコルーチン(今この記事でやっているもの)と、Task / Future(次の記事で扱う Task オブジェクトと、その内部で使われる完了通知オブジェクト)の 3 種類です — 通常使うのはほぼコルーチンか Taskの 2 つです。
import asyncio
# async def でコルーチン関数を定義
async def hello():
return "Hi"
# 呼び出すだけではコルーチンオブジェクトが返るだけ (本体は実行されない)
print(hello()) # <coroutine object hello at 0x...>
# await で実行する (本環境のブラウザ上ではトップレベルで直接書ける)
result = await hello()
print(result) # Hi
# 実機の Python では asyncio.run() で囲む
# print(asyncio.run(hello())) # Hi
| 要素 | 意味 | 備考 |
|---|---|---|
| async def f(): | コルーチン関数を定義 | 呼んでも本体は実行されない |
| f() | コルーチンオブジェクトを生成 | 実行するには await が必要 |
| await f() | 完了を待ち、その間に他へ切り替え | async 関数の中でだけ書ける |
| asyncio.sleep(N) | N 秒待つ(切り替えながら) | time.sleep と違ってブロックしない |
| asyncio.run(f()) | トップレベルから動かす | 実機 Python の標準的な書き方 |
await を書かないとコルーチンが実行されない
hello()のようにコルーチン関数を呼んだだけでは本体が走らず、<coroutine object hello at 0x...>のような警告がコンソールに出ます。必ず await hello() のように待つか、asyncio.run(hello())で実行してください。「呼ぶ」と「実行する」は別物という async の重要なポイントです。
並行実行で待ち時間を共有する — asyncio が嬉しいポイント
asyncio.sleep(秒数)はasync 版の sleepで、待つ間に他のタスクに切り替わる性質があります。後で扱うasyncio.gatherで複数のコルーチンを同時に走らせると、全 sleep が並行に進むので、合計時間は「最大の sleep 1 つ分」で済みます(time.sleepだと合計時間になる)。
内部では「N 秒後にこのコルーチンを再開して」とループに予約を入れて、即座に他のタスクへ切り替わる動きをしています。一方time.sleep(N)はOS 関数で CPU を完全にブロックするため、ループも止まって他のコルーチンが動けません — async 関数の中でtime.sleepを使うと async の意味が消えるので注意してください。
理解度チェック
まずは1問ずつ答えてみましょう。
Q2async 関数の中で「N 秒待つ間に他のタスクに切り替える」ために使うのはどれですか?
Q3async / awaitで速くなる処理として最も適しているのはどれですか?