順番に読み進めながら学べます

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 の使いどころ
Web API を100 件並行に叩くDB クエリを並行に発行ファイル I/O を並行に処理Web スクレイピング複数ページ取得
待ち時間が支配的な処理で効果を発揮する。CPU が動いていない時間(I/O 待ち / ネットワーク待ち / sleep)を別のタスクに切り替え、1 つのスレッドのまま全体を高速化する用途で使う。

プロセスとスレッド — async / await の前提知識

async / await の話に入る前に、プロセススレッドという用語を簡単に整理します。

プロセス(OS が起動した 1 つのプログラム)は、自分専用のメモリ空間を持つ独立した実行単位です。Python スクリプトを実行すれば 1 つのプロセスが立ち上がり、別のターミナルでもう 1 つ動かせばその数だけプロセスが並びます。

スレッド(プロセス内でコードを実際に動かす実行の流れ)は、プロセスの中でCPU を使ってコードを進める単位です。1 つのプロセスは複数のスレッドを持てますが、普通の Python プログラムは 1 プロセス + 1 スレッドで動いていて、time.sleepのような待ちが入るとそのスレッドが止まります。

プロセス・スレッド・コルーチンの入れ子
プロセス (1 つの Python アプリ)
  • OS から見た独立した実行単位
  • 自分専用のメモリ空間を持つ
スレッド (実行の流れ)
  • プロセス内で実際にコードを動かす単位
  • 普通の Python は1 つだけ動いている
コルーチン (async def 関数)
  • 1 つのスレッド内で切り替えながら動く
  • async / awaitで何個でも作れる
プロセスの中にスレッド、その中でコルーチンが動く 3 段の入れ子構造。async / await が動かしているのは一番内側のコルーチンだけで、スレッドや CPU 自体は増やさない — そこがthreading (スレッドを増やす) やmultiprocessing (プロセスを増やす) との大きな違い。

async / awaitが扱うのは一番内側のコルーチンだけです。同じスレッドの中でコルーチン同士を切り替えながら動かすので、スレッドや CPU を増やしているわけではありません — 「I/O 待ちで CPU が動いていない時間」を別のコルーチンに切り替えるだけのシンプルな仕組みです。スレッドそのものを並行に動かしたいときはthreading、プロセスそのものを増やしたいときはmultiprocessingを使います(次回 / 次々回の記事で扱います)。

コルーチンとは — async / await の主役

コルーチンasync defで定義され、内部のawait地点で一時停止できる関数 / オブジェクト)は async / await の中心です。普通の関数は呼ばれると最後まで一気に走り切るのに対して、コルーチンは内部のawaitのたびに中断 → イベントループが再開という動きを繰り返せます。これが async の土台です。

async defで定義した関数を呼んでも本体は実行されずコルーチンオブジェクトが返るだけです (例: coro = hello())。await coroasyncio.run(coro)に渡して初めて実行が始まります。

import asyncio

async def hello():
    return "Hi"

# 呼んでも本体は実行されない
coro = hello()
print(coro)             # <coroutine object hello at 0x...>
                        # ↑ 0x... はメモリアドレス。
                        #   「コルーチンが生成されただけ」のサイン

# await で初めて実行される
print(await coro)       # Hi
コルーチンの状態
async def f()定義f() 呼び出しコルーチン生成await f()→ 実行開始return で完了
async defで定義した関数は呼んでも本体が走らず、コルーチンオブジェクトが返るだけ。await を付けて初めて実行が始まり、最後にreturn で完了する。途中で内部の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())
# 出力:
# 開始
# 終了
イベントループの動き
キューから取り出すコルーチンを実行await で他に切替I/O 完了でキューに戻す
イベントループ「いま動かせるコルーチン」をキューから取り出して実行し、await で待ちに入ったら別のコルーチンに切り替える。I/O が完了すると再びキューに戻り同じ流れを繰り返す — 1 つのスレッドで延々と回し続ける。

この切り替えは 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 — 複数タスクが切り替わりながら進む様子
A 実行B 待機C 待機A: await→ 他に切替B 実行C 待機A 待機B: await→ 他に切替C 実行切り替え切り替え
3 つのコルーチンが同じスレッドの中で順番に CPU を使う。各タスクはawait で待ちに入ると他のタスクに切り替わり、次のタスクが動く。1 つのスレッドのまま待ち時間が並行に進むのが async の核。
並行 (concurrent) と並列 (parallel) の違い
並行 (concurrent)1 つの CPU で切り替えながら進めるasync / await(本記事の対象)並列 (parallel)複数 CPU コアで本当に同時に動くthreading /multiprocessing(次回 / 次々回)
並行 = 1 つの CPU で切り替えながら複数のタスクを進める(async / await が実現するもの)。並列 = 複数の CPU で本当に同時に動かす(threading / multiprocessing で実現)。CPU を 100% 使う計算には async は効かない。

並列ではなく並行

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 と await の関係
async def hello(): return 'Hi'hello() の結果=コルーチン(本体は未実行)await hello()→ 'Hi' が返る呼ぶだけawait
async defで関数を定義 → 関数を呼ぶだけではコルーチンオブジェクトが返るだけで本体は未実行 → await を付けて初めて実行されて結果が返る。これが async / await の最小ルール。
要素意味備考
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 の重要なポイントです。

API 呼び出しを再現する async 関数を書いて、awaitで実行してみます。asyncio.sleep(0.5)API のレスポンス待ち(0.5 秒)をシミュレートします。

import asyncioを書いてください

async def fetch_user(user_id):を定義してください — 中でf"user {user_id} 取得開始"を表示 → await asyncio.sleep(0.5)で 0.5 秒待つ → f"user {user_id} 取得完了"を表示 → f"User{user_id}"returnします

result = await fetch_user(1)で実行して結果を変数に入れてください(本環境ではトップレベルで await が使えます)

f"結果: {result}"の形で result を表示してください

(正しく実行できれば解説が表示されます)

Python エディタ

コードを実行してください

並行実行で待ち時間を共有する — asyncio が嬉しいポイント

asyncio.sleep(秒数)async 版の sleepで、待つ間に他のタスクに切り替わる性質があります。後で扱うasyncio.gatherで複数のコルーチンを同時に走らせると、全 sleep が並行に進むので、合計時間は「最大の sleep 1 つ分」で済みます(time.sleepだと合計時間になる)。

内部では「N 秒後にこのコルーチンを再開して」とループに予約を入れて、即座に他のタスクへ切り替わる動きをしています。一方time.sleep(N)OS 関数で CPU を完全にブロックするため、ループも止まって他のコルーチンが動けません — async 関数の中でtime.sleepを使うと async の意味が消えるので注意してください。

asyncio.sleep の内部動作
awaitasyncio.sleep(1)ループに「1 秒後に起こして」その間、別のコルーチンが動ける1 秒後→ コルーチン再開
asyncio.sleep(N)「N 秒後に再開できる」とイベントループに登録して他のタスクに切り替わる。残りの時間は他のコルーチンが動ける。N 秒経つとキューに戻され、コルーチンが再開する。
time.sleep と asyncio.sleep の違い
time.sleep(1)(同期版)CPU を 1 秒完全ブロック他の asyncタスクも止まるasyncio.sleep(1)(async 版)ループに 1 秒他に切替他のタスクが進める
time.sleepCPU を完全にブロックするので、その間に他の async タスクは動けない。asyncio.sleep他のタスクに切り替わるので、他のタスクが進める — async 関数の中では必ず asyncio.sleep を使う

前の演習と同じfetch_userを 3 件並行に呼び出し、合計 1.5 秒かかるはずの処理が0.5 秒で終わることを観察します。並行実行にはasyncio.gatherを使います(詳しい仕様は次回)。

import asyncioを書いてください

② 前の演習と同じasync def fetch_user(user_id):を定義してください(開始表示 → await asyncio.sleep(0.5) → 完了表示 → return)

results = await asyncio.gather(fetch_user(1), fetch_user(2), fetch_user(3))で 3 件を並行実行してください

f"結果: {results}"の形で結果リストを表示してください

Python エディタ

コードを実行してください
QUIZ

理解度チェック

まずは1問ずつ答えてみましょう。

Q1async def hello(): return 'Hi'という関数をhello()と呼んだときに返るのはどれですか?

Q2async 関数の中で「N 秒待つ間に他のタスクに切り替える」ために使うのはどれですか?

Q3async / awaitで速くなる処理として最も適しているのはどれですか?