+ 作るログイン
図を読み込んでいます…
1 / 21
JS から来た人の Python ― どこから来て、なぜ動くのか / 全19章

Python 16:非同期:JS との一番大きな違い

発行
アニメモ公式
版
第1版
改訂
コマ
全21

JS のコードはいつもイベントループの中で動きますが、Python は既定では1行ずつ順番に動き、ループは asyncio.run(…) の間だけ存在します。async def を呼んでも中身が動かない理由(コルーチン)、await で手放したタスクをループがどう切り替えるかを5段の図で追い、asyncio.gather と Promise.all、time.sleep や requests がループを止める落とし穴、httpx での書き方、どこで await できるか、スレッド・プロセスとの使い分けと GIL、FastAPI ではループをフレームワークが回すことまでを、tenki で3都市の天気を並べて取りながら説明します。

3都市ぶん取ると、待ち時間がそのまま積み重なる

第13章で、tenki は requests を使って Open-Meteo(天気の API)から本物の気温を取れるようになりました。第15章では型ヒントや道具も整えました。次は、東京・大阪・札幌の3都市の天気をまとめて表示したくなります。

素直に書くと、for で1都市ずつ取ることになります(fetch は1都市ぶんを requests で取ってくる関数です)。

python
for city in CITIES:
    print(*fetch(city))

これは動きます。ただ、図の上の帯のように、待ち時間が積み重なります。1都市の問い合わせに1秒かかるなら、3都市で約3秒です。東京の返事が来るまで、大阪への問い合わせは始まりません。

JS を書いてきた人なら、ここで Promise.all を思い浮かべるはずです。3つの fetch を同時に出して、全部そろうのを待てば、約1秒で済みます。

Python にも async と await があると聞いて、JS と同じつもりで書いてみると、いきなりつまずきます。

SyntaxError: 'await' outside function

JS なら(ES モジュールでは)ファイルの一番外側でも await が書けたのに、Python では書けません。

この章の疑問はこうです。

疑問答えの場所
なぜ関数の外で await できない?ループがあるかどうか(台所のたとえ・JS と Python のループ)
async def を呼んだのに何も起きないのはなぜ?コルーチン
Promise.all はどう書く?ループの中の5段と gather
requests のままでは速くならないのはなぜ?ループごと止める関数

この章は、第15章までの内容(import・pip・venv・pyproject.toml・uv)を前提にします。httpx というライブラリを uv add で入れて使います。

※2026年9月時点(Python 3.13)の情報です。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 1 / 21
  • 3都市ぶん取ると、待ち時間がそのまま積み重なる
  • 東京
  • 返事を待つ
  • 大阪
  • 返事を待つ
  • 札幌
  • 返事を待つ
  • 約3秒
  • 0秒
  • 1秒
  • 2秒
  • 3秒
  • for city in CITIES:
  • r = await client.get(URL)
  • print(*fetch(city)) # requests.get
  • SyntaxError: 'await' outside function
  • 疑問:Python で Promise.all はどう書く? なぜ関数の外に await と書くとエラー?
  • ひらめき
  • requests で1都市ずつ取ると、前の返事が来るまで次を頼まない。JS なら Promise.all で並べるのに。

JS の台所は最初から開いている。Python は自分で開ける

非同期の話は、イベントループという係がいるかどうか、で全部説明できます。

レストランの台所でたとえます。料理人は1人です。

JSPython(既定)
台所最初から開いている閉まっている
料理人の動き方鍋を火にかけたら、煮える間に次の鍋の準備をする。煮えた鍋から仕上げる1つの鍋を、煮えるまでじっと見ている。煮えてから次の鍋
何皿も同時に作るには何もしなくてよい(最初からそう動く)asyncio.run(…) で台所を開ける

この「煮える間に他の鍋を見る料理人」がイベントループです。「待っている仕事」を覚えておき、返事が来たものから順に続きを実行する係です。料理人は1人なので、同時に手を動かすのは1つだけです。でも、待つのは何件でも同時にできます。

ネットの問い合わせは、ほとんどが「向こうの返事を待つ時間」です。料理でいえば、煮込んでいる時間です。だから、1人の料理人でも待ち時間を重ねるだけで、3皿を約1皿分の時間で出せます。

JS のコードは、ブラウザでも Node.js でも、いつもこのイベントループの中で動いています。だから JS の人は、ループの存在を意識せずに await を書けました。

Python は違います。既定ではループがありません。Python のコードは上から1行ずつ、待つときはその場で止まって待ちます。非同期にしたいときは、自分でループを作って、その上で動かす必要があります。これが JS との一番大きな違いで、この章の全部がここから出てきます。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 2 / 21
  • JS の台所は最初から開いている。Python は自分で開ける
  • JS(Node.js・ブラウザ)
  • Python(既定)
  • 更新
  • 台所は最初から開いていて、
  • 料理人(ループ)が1人、
  • バツ
  • 料理人は1つの鍋を
  • 煮えるまでじっと見ている。
  • 鍋が煮える間に次の鍋を見る。
  • 何皿も同時に作りたいなら、
  • イベントループ
  • コードはいつもこの中で動く。
  • ループは無い
  • asyncio.run で台所を開ける。
  • イベントループ:「待っている仕事」を覚えておき、返事が来たものから順に続きを実行する係。1人で何件も同時に待てる
  • ひらめき
  • JS はいつもイベントループの中で動く。Python には既定でループが無く、asyncio.run で自分で作る。

Python は既定で「同期」:1行ずつ、待つ間も止まって待つ

まず、ふつうの Python(同期のコード)で何が起きているかをはっきりさせます。

requests.get(URL) の行を実行すると、Python は問い合わせを送り、返事が届くまでその行で止まります。返事が来てから、やっと次の行に進みます。こうして処理が止まることをブロックすると言います。

図の帯の中身は、ほとんどが「ネットの向こうの返事を待つ時間」です。Python の側でやる仕事(頼みを送る、届いた JSON を読む)は、ほんの一瞬です。つまり、3秒のうちほとんどの時間、CPU は何もせずに待っているだけです。

実際に測ってみました。第13章の requests の fetch で3都市を順に取ると、本物の Open-Meteo 相手に約2秒でした(回線や混み具合で変わります)。1都市ずつの待ちが、そのまま足し算になっています。

これは悪いことではありません。

同期のよいところ同期の困るところ
上から順に動くので、読みやすく追いかけやすい待ち時間が足し算になる
エラーが起きた行がはっきりしている待っている間、他のことが何もできない
1回か2回問い合わせるだけの小さな道具なら十分何十・何百件の問い合わせでは遅すぎる

Python の既定が同期なのは、たいていのスクリプトにはこれで足りるからです。「待ちが積み重なって困る」ときに、はじめて非同期の道具を持ち出します。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 3 / 21
  • Python は既定で「同期」:1行ずつ、待つ間も止まって待つ
  • 東京
  • 返事を待つ
  • 大阪
  • 返事を待つ
  • 札幌
  • 返事を待つ
  • 約3秒
  • 0秒
  • 1秒
  • 2秒
  • 3秒
  • CPU
  • 帯の中身はほとんど「ネットの向こうの返事を待つ時間」。Python の側でやる仕事(頼む・受け取る)は一瞬。
  • でも同期のコードは、1つ待ち終わるまで次を始められない。これを「ブロックする」と言う。
  • これは悪いことではない。1つずつ順に動くので、読みやすく、追いかけやすい。Python の既定がこれ
  • ひらめき
  • requests.get(…) の行は、返事が来るまで次の行へ進まない。その間 CPU は何もしていない。

JS:スクリプトの外側に、いつもイベントループがある

比べるために、JS(Node.js)の動き方を確認しておきます。

node main.js と打つと、図の番号の順に進みます。

順番起きること
1main.js を上から実行する。fetch() や setTimeout() は「頼む」だけで、待たずに次の行へ進む
2ファイルの最後まで来ても終わらず、イベントループが回り続ける。返事が来た fetch の続き、時間が来た setTimeout などを順に実行する
3待っているものが1つも無くなったら、プロセスが終わる

図の左下のコードを実際に動かすと、console.log(p) は Promise { <pending> }(まだ結果が無い約束)と表示します。fetch の返事を待たずに先へ進んだ証拠です。返事は、ファイルを最後まで実行した後で、ループが受け取って処理してくれます。

JS では、ループはあなたのコードより先に存在していて、あなたのコードはその中で動くのです。だから JS の関数はいつでも「待つ仕事をループに預けて先へ進む」ことができ、await はどこでも使える前提で言語が作られています。ES モジュール(.mjs や "type": "module")では、ファイルの一番外側でも await が書けます(top-level await)。

ブラウザも同じです。ページを開いた瞬間からループが回っていて、クリックやタイマーや通信の返事を順に処理しています。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 4 / 21
  • JS:スクリプトの外側に、いつもイベントループがある
  • ターミナル
  • node main.js
  • 書類
  • main.js を上から実行
  • 更新
  • fetch() は頼むだけで先へ進む
  • イベントループ
  • 返事が来た fetch の続き
  • const p = fetchCity("東京");
  • 時間が来た setTimeout
  • console.log(p); // Promise { <pending> }
  • …を順に実行
  • // ファイルの最後まで来ても、ループが返事を待ってくれる
  • OK
  • 待つものが無くなったら終了
  • JS では「待つ仕事はループに預けて先へ進む」が最初から当たり前。だからどこでも await が使える前提で書ける
  • ひらめき
  • node main.js は、ファイルを上から実行した後もループが回り続け、待っている仕事の続きを実行する。

Python:ループは、asyncio.run(…) の間だけ存在する

Python で python3 main.py と打つと、ファイルを上から1行ずつ実行して、最後の行が終わればそのまま終了します(第1章)。途中にループはありません。

非同期で動かしたいときは、標準ライブラリの asyncio を使い、図の下のように書きます。

python
import asyncio

async def main():
    ...   # ここでは await が使える

asyncio.run(main())

`asyncio.run(main())` の1行が、図の上の青い区間です。ここでやっていることは3つです。

順番asyncio.run がやること
1新しいイベントループを作る
2main() をそのループの上で、最後まで動かす
3ループを閉じて、main() の戻り値を返す

公式ドキュメントにも「この関数は必ず新しいイベントループを作り、最後に閉じる」と書かれています。つまり Python のループは、`asyncio.run` の行を実行している間だけ存在するのです。その前後は、いつもの1行ずつの世界です。

await が async def の中でしか書けないのは、このためです。async def の関数はループの上で動く前提で作られますが、ファイルの一番外側はループの外なので、「待つ仕事を預ける先」がありません。だから SyntaxError: 'await' outside function になります。

JSPython
ループはいつある?プロセスが始まってから終わるまで、ずっとasyncio.run(…) の間だけ
ループを作るのは実行環境(Node.js・ブラウザ)あなた(asyncio.run)
await を書ける場所async 関数の中と、ES モジュールの一番外側async def の中だけ

asyncio は Python に最初から付いてくる標準ライブラリなので、pip install や uv add は要りません。asyncio.run は Python 3.7 から使えます。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 5 / 21
  • Python:ループは、asyncio.run(…) の間だけ存在する
  • ターミナル
  • python3 main.py
  • 1行ずつ実行
  • 更新
  • asyncio.run(main())
  • この間だけループがある
  • 1行ずつ → 終了
  • import asyncio
  • asyncio.run がやること:
  • async def main():
  • ① 新しいイベントループを作る
  • ... # ここでは await が使える
  • ② main() をその上で最後まで動かす
  • ③ ループを閉じる
  • asyncio.run(main()) # ループを作る → main を実行 → 閉じる
  • asyncio は標準ライブラリなので pip install 不要。asyncio.run は 3.7 から使える
  • ひらめき
  • python3 main.py は上から1行ずつ実行して終わる。asyncio.run(main()) の行の間だけ、ループが動く。

async def を呼んでも、中身は動かない

JS から来た人が次につまずくのが、ここです。図の左右は、同じ形の関数を Python と JS で書いて、実際に動かしたものです。

JS の async function は、呼んだ瞬間に動き始めます。最初の await に着くまでは、ふつうの関数と同じように走ります。だから fetchCity("東京") と呼んだだけで「東京: 取りに行く」が表示され、その後で Promise { <pending> } が返ってきます。

Python の async def は違います。呼んでも中身は1行も動きません。返ってくるのは コルーチン(coroutine)という物だけです。

python
>>> c = fetch("東京")
>>> c
<coroutine object fetch at 0x7f49ef3eaa80>

「取りに行く」は表示されていません。コルーチンは「この関数を、この引数で実行する」という予約券のようなもので、次のどちらかをされるまで眠っています。

コルーチンが動き出すきっかけ例
await されるr = await fetch("東京")
ループに渡されるasyncio.run(fetch("東京"))、asyncio.gather(…)、asyncio.create_task(…)

await を付け忘れると、何も起きずに通り過ぎてしまいます。Python は最後に警告だけ出します。

RuntimeWarning: coroutine 'fetch' was never awaited

これはエラーではなく警告なので、プログラムは止まりません。「何も起きないし、エラーも出ない」という、気づきにくいバグになります。この警告を見たら、await の付け忘れを疑ってください。

JS の async 関数Python の async def
呼ぶと最初の await まで走る何も動かない
返るものPromiseコルーチン
await し忘れると処理は走るが、結果やエラーを受け取れないそもそも処理が走らない
  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 6 / 21
  • async def を呼んでも、中身は動かない
  • Python
  • JS
  • async def fetch(city):
  • print(f"{city}: 取りに行く")
  • async function fetchCity(city) {
  • await asyncio.sleep(1)
  • console.log(`${city}: 取りに行く`);
  • return f"{city} 晴れ"
  • await sleep(1000);
  • return `${city} 晴れ`;
  • c = fetch("東京")
  • }
  • print(c)
  • const p = fetchCity("東京");
  • # <coroutine object fetch at 0x…>
  • console.log(p);
  • # 「取りに行く」は出ない
  • // 東京: 取りに行く ← 先に出る
  • // Promise { <pending> }
  • JS の async 関数は、呼んだ瞬間に最初の await まで走り出す。Python のコルーチンは「実行の予約券」で、
  • await されるまで眠っている。await し忘れると、RuntimeWarning: coroutine 'fetch' was never awaited
  • ひらめき
  • async def を呼ぶと「コルーチン」が返るだけ。await されるまで1行も動かない。

ループの中 ①:asyncio.run が main() を並べる

ここからは、tenki の3都市を並べて取るコードを実行したとき、ループの中で何が起きるかを5段で追います。コードの全体は後で見せます。骨組みはこうです。

python
async def main():
    results = await asyncio.gather(
        fetch("東京"), fetch("大阪"), fetch("札幌"))

asyncio.run(main())

図の舞台は3つの場所です。

場所意味
実行できる(左の黄色)すぐに続きを動かせる仕事の列。ループはここの先頭から1つずつ動かす
イベントループ(中央)1度に1つの仕事だけを動かす係。中の緑の札が「いま実行中」
待っている(右の青)await で何かを待っている仕事。返事が来たら左の列に戻る

最初の段では、asyncio.run(main()) がループを作り、main() のコルーチンをタスクにして、左の列に並べました。

タスクは、ループが面倒を見る仕事の単位です。コルーチンを包んで、「動かす」「await で中断したら脇に置く」「返事が来たら再開する」を管理します。コルーチンが予約券なら、タスクはループの受付に出して番号札をもらった状態です。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 7 / 21
  • ループの中 ①:asyncio.run が main() を並べる
  • 実行できる(順番待ち)
  • 更新
  • 待っている(await 中)
  • main()
  • イベントループ
  • await
  • 作られたばかり
  • 次
  • 終わった:
  • asyncio.run(main())
  • タスク:ループが面倒を見る仕事の単位。
  • # main: await asyncio.gather(fetch(東京), …)
  • コルーチンを包んで、実行・中断・再開を管理する。
  • ひらめき
  • asyncio.run はループを作り、main() のコルーチンを「タスク」にして、実行できる列に並べる。

ループの中 ②:main が動き、3つの仕事を並べる

ループは左の列の先頭から main() を取り出して、動かし始めます。中央の緑の札が main() になりました。

main() の最初の行は asyncio.gather(fetch("東京"), fetch("大阪"), fetch("札幌")) です。

fetch("東京") などを呼んだ時点では、前に見たとおり、コルーチン(予約券)ができるだけで、中身は動いていません。`asyncio.gather` は、受け取ったコルーチンをそれぞれタスクにしてループに預けます。図の左の列に、東京・大阪・札幌の3つの札が並びました。

この時点では、まだどれも動いていません。ループが動かしているのは、あくまで main() です。ループは1度に1つの仕事しか動かさないので、main() が手を離すまで、3つの札は列で順番を待ちます。

JS の Promise.all([fetchCity(…), …]) との違いもここに表れています。JS では fetchCity(…) を呼んだ時点で、それぞれ最初の await まで走り出しています(問い合わせはもう送られています)。Python では、ループに預けられて順番が来るまで、問い合わせすら送られていません。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 8 / 21
  • ループの中 ②:main が動き、3つの仕事を並べる
  • 実行できる(順番待ち)
  • 更新
  • 待っている(await 中)
  • 東京
  • イベントループ
  • await
  • 大阪
  • main を実行中
  • 札幌
  • main()
  • 次
  • 終わった:
  • results = await asyncio.gather(
  • gather は渡されたコルーチンをタスクにして
  • fetch("東京"), fetch("大阪"), fetch("札幌"))
  • ループに預ける。まだどれも動いていない。
  • ひらめき
  • main が gather を呼ぶと、3都市の fetch がそれぞれタスクになって、実行できる列に並ぶ。

ループの中 ③:await で手放すと、ループは次を動かす

main() の行は results = await asyncio.gather(…) でした。`await` は、「この結果が出るまで、わたしは脇で待つ。その間ループは他の仕事をしてよい」という意味です。

main() は、3つがそろうのを待つために、自分から手放しました。図では右の「待っている」に移っています。

手が空いたループは、左の列の先頭から次の仕事を取ります。東京の fetch です。中央の緑の札が「東京」になりました。

東京の fetch は、httpx で問い合わせを組み立てて送り、r = await client.get(URL, …) の行に来ます。ここでまた await です。返事が届くまで、東京も手放します。

ここが非同期の核心です。`await` は、ただ待つのではなく「ループに手を返す」場所です。同期のコードの requests.get は、返事が来るまでその場で止まり続けました。await client.get は、送ったらすぐにループへ手を返します。

書き方待っている間
requests.get(URL)(同期)その場で止まる。他の仕事は誰も動かせない
await client.get(URL)(非同期)ループに手を返す。ループは他の仕事を動かせる

ループは、1つのタスクを途中で無理やり止めることはありません。タスクが自分から await で手放したときだけ、次の仕事に切り替えます。だから、await の無い長い計算を書くと、その間ループは他の仕事を一切動かせません。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 9 / 21
  • ループの中 ③:await で手放すと、ループは次を動かす
  • 実行できる(順番待ち)
  • 更新
  • 待っている(await 中)
  • 大阪
  • イベントループ
  • await
  • main()(gather 待ち)
  • 札幌
  • 東京を実行中
  • 東京
  • 次
  • 終わった:
  • async def fetch(client, city):
  • 東京は頼みを送って、返事を await する。
  • r = await client.get(URL, params=…)
  • ここでまた手放し、ループは大阪へ移る。
  • ひらめき
  • main は gather の完了を await して、自分から手放す。ループは列の先頭の「東京」を動かす。

ループの中 ④:3つとも返事待ち。ループは見張るだけ

東京が手放すと、ループは大阪を動かします。大阪も問い合わせを送って await で手放し、次に札幌も同じように手放します。

図の右に、4つの札が並びました。main() は gather の完了を、東京・大阪・札幌はそれぞれネットの返事を待っています。左の列は空で、中央で実行中の仕事もありません。

このときループは、「どれかの返事が届いたら教えて」と OS に頼んで、待機しています。CPU はほとんど使っていません。

ここで、3つの問い合わせはほぼ同時にネットへ出ています。東京の返事を待つ時間、大阪の返事を待つ時間、札幌の返事を待つ時間が、重なっているのです。1つ1秒かかっても、全部で約1秒で済むのはこのためです。

言葉を整理しておきます。

言葉意味この図では
並行(concurrency)複数の仕事を、切り替えながら同じ時間帯に進めるループは1つずつしか動かさないが、待ちは重なっている
並列(parallelism)複数の仕事を、本当に同じ瞬間に実行する(CPU の芯が複数要る)asyncio はこれではない

asyncio がやっているのは並行です。料理人は1人のままで、煮込み時間を重ねているだけです。JS のイベントループもまったく同じ仕組みです。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 10 / 21
  • ループの中 ④:3つとも返事待ち。ループは見張るだけ
  • 実行できる(順番待ち)
  • 更新
  • 待っている(await 中)
  • イベントループ
  • await
  • main()(gather 待ち)
  • どれかの返事が来るまで待機
  • 東京(返事待ち)
  • 次
  • 大阪(返事待ち)
  • 札幌(返事待ち)
  • 終わった:
  • # 3つの頼みが、ほぼ同時にネットへ出ている
  • ループが動かしているのは1度に1つだけ。
  • # 1つ1秒かかっても、全部で約1秒
  • それでも「待つ」は同時にできる。これが並行。
  • ひらめき
  • 東京・大阪・札幌が次々に頼みを送り、全部が返事待ちになった。3つの待ち時間が重なっている。

ループの中 ⑤:返事が来たものから続きを動かし、全部そろう

やがて返事が届き始めます。たとえば大阪の返事が先に届けば、大阪の札が左の列に戻り、ループは大阪の fetch の await の続きから動かします。r.json() で気温を取り出し、return して、大阪のタスクは終わります。

返事が届く順番は、頼んだ順とは限りません。どの順で届いても、届いたものから続きを動かします。

3つとも終わると、gather の結果がそろいます。待っていた main() が左の列に戻り、ループが再開させます。図は、東京・大阪・札幌が「終わった」に並び、main() が実行中になった状態です。

gather の戻り値は、渡した順の list です。返事が届いた順ではありません。だから results[0] はいつも東京です。

python
results = await asyncio.gather(…)   # [("東京", 18.6), ("大阪", 21.9), ("札幌", 14.6)]
for city, temp in results:
    print(city, temp)

main() の最後の行まで進んで終わると、asyncio.run はループを閉じて、次の行へ進みます。ここから先は、また1行ずつの同期の世界です。

5段を1枚の表にまとめます。

段実行中起きたこと
①―asyncio.run がループを作り、main を並べる
②maingather が3つのタスクを並べる
③東京main が await で手放す。東京が動いて、送って手放す
④―3つとも返事待ち。待ち時間が重なる
⑤main届いたものから続きを動かし、そろったら main が再開
  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 11 / 21
  • ループの中 ⑤:返事が来たものから続きを動かし、全部そろう
  • 実行できる(順番待ち)
  • 更新
  • 待っている(await 中)
  • イベントループ
  • await
  • main を再開
  • main()
  • 次
  • 終わった:
  • 東京
  • 大阪
  • 札幌
  • results = await asyncio.gather(…) # 3つの結果の list
  • main が終わると asyncio.run はループを閉じ、
  • for city, temp in results: print(city, temp)
  • 次の行へ進む。ここから先はまた同期の世界。
  • ひらめき
  • 返事が来たタスクから列に戻って続きを実行し、終わる。3つそろうと gather が終わり、main が再開する。

並べて待つ:asyncio.gather は Promise.all

ループの中の動きを、時間の帯で見直すと図のようになります。最初の図では右へずれて並んでいた3本の帯が、左へ寄って重なりました。3都市で約3秒だったものが、約1秒になります。

`asyncio.gather` は、JS の `Promise.all` に当たります。

JSPython
並べて待つawait Promise.all([a, b, c])await asyncio.gather(a, b, c)
渡し方配列1つ引数を並べる(list なら * で広げる)
結果渡した順の配列渡した順の list
1つが失敗したら最初の失敗で reject最初の例外がそのまま投げられる(残りは取り消されない)

Python 3.11 からは、もう1つの書き方 `asyncio.TaskGroup` も使えます。

python
async with asyncio.TaskGroup() as tg:
    t1 = tg.create_task(fetch("東京"))
    t2 = tg.create_task(fetch("大阪"))
print(t1.result(), t2.result())

async with のブロックを抜けるときに、中で作ったタスクが全部終わるまで待ってくれます。公式ドキュメントでは、gather より安全な方法として紹介されています。どれか1つが失敗したら、残りのタスクを取り消してくれるからです。gather は1つが失敗しても、残りは動き続けます。

実際に動かすと、TaskGroup で2都市を取っても約1秒でした。どちらを使っても、待ち時間が重なる仕組みは同じです。この本では、Promise.all と形が近い gather を使います。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 12 / 21
  • 並べて待つ:asyncio.gather は Promise.all
  • 東京
  • 返事を待つ
  • 大阪
  • 返事を待つ
  • 札幌
  • 返事を待つ
  • 約1秒
  • 0秒
  • 1秒
  • 2秒
  • 3秒
  • Python
  • JS
  • results = await asyncio.gather(
  • fetch("東京"), fetch("大阪"), fetch("札幌"))
  • const results = await Promise.all([
  • fetchCity("東京"), fetchCity("大阪"), fetchCity("札幌")]);
  • 3.11 からは asyncio.TaskGroup も使える(1つ失敗したら残りを取り消す)
  • ひらめき
  • 待ち時間が重なるので、3都市で約3秒だったのが約1秒になる。結果は渡した順の list で返る。

await できるもの・できないもの

await の右には何でも置けるわけではありません。Python の公式ドキュメントは、await できる物(awaitable)を3種類に分けています。

await の右に置くものできる?例
コルーチンできる。その場で動かして、終わるまで待つawait fetch("東京")
タスクできる。もう動いている仕事の終わりを待つtask = asyncio.create_task(fetch("東京")) の後で await task
Futureできる。「いつか値が入る箱」。主にライブラリの中で使われるふだん自分では作らない
ふつうの値・関数の戻り値できない。TypeErrorawait requests.get(URL)

最後の行を実際に試すと、こう言われます(Response は requests の戻り値の型です)。

TypeError: object Response can't be used in 'await' expression

requests.get(URL) は同期の関数なので、行を実行した時点で返事を待ち終わり、ふつうの Response の物を返しています。それを後から await しても意味がありません。

JS との対応はこうです。

JSPython
PromiseFuture・タスク(「いつか結果が出るもの」)
async 関数の呼び出し(走り出した Promise)タスク
―コルーチン(まだ走っていない予約券)

JS では非同期のものはほぼ全部 Promise ですが、Python では「まだ走っていないコルーチン」と「もう走っているタスク」が別の物である点が違います。asyncio.create_task(コルーチン) と書くと、コルーチンをタスクにしてループに預け、すぐに動き始めてもらえます。JS の「呼んだら走り出す」に近い形です。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 13 / 21
  • await できるもの・できないもの
  • await の右に置くもの
  • できる?
  • 例
  • コルーチン
  • できる(その場で動かして待つ)
  • await fetch("東京")
  • タスク
  • できる(もう動いている仕事を待つ)
  • await asyncio.create_task(…)
  • Future
  • できる(いつか値が入る箱)
  • ライブラリの中で使われる
  • ふつうの値・関数の戻り値
  • できない → TypeError
  • await requests.get(URL)
  • JS
  • Python
  • 違い
  • Promise
  • Future / タスク
  • JS はほぼ全部これ
  • ひらめき
  • await できるのはコルーチン・タスク・Future の3種類。ふつうの関数の戻り値は await できない。

time.sleep と requests は、ループごと止める

非同期で一番多い失敗がこれです。async def の中で、同期の、止まる関数を呼んでしまうことです。

python
async def fetch(city):
    time.sleep(1)          # ループが止まる
    requests.get(URL)      # これも止まる

time.sleep(1) や requests.get(…) は、await の無いふつうの関数です。ループに手を返さず、その場で止まります。ループは1人の料理人なので、料理人自身が鍋の前で寝てしまうのと同じで、他のタスクは誰も動かせません。

実際に、1秒の time.sleep をする async def を3つ gather で並べて測ると、3.0秒かかりました。async def と gather を使っているのに、時間の帯は図のように順番に並んだままです。しかも、エラーも警告も出ません。

止まる(使わない)手放す(こちらを使う)
time.sleep(1)await asyncio.sleep(1)
requests.get(URL)await client.get(URL)(httpx の AsyncClient)
open(…).read()(大きなファイル)別のスレッドで読む(asyncio.to_thread。後述)

JS では、こういう「止まる関数」はほとんどありません。ファイルも通信もタイマーも、最初から非同期の API しか無いか、同期版には Sync と名前に付いているからです。Python では、同期の関数のほうが標準で、非同期版は別に用意されています。何を呼ぶと止まるのかを、自分で意識する必要があります。

どうしても同期のライブラリしか無いときは、`asyncio.to_thread` で別のスレッドに逃がせます。

python
await asyncio.gather(*(asyncio.to_thread(fetch_sync, c) for c in CITIES))

1秒止まる同期の関数 fetch_sync(中で time.sleep(1) するもので試しました)でも、3つが約1秒で終わりました(asyncio.to_thread は Python 3.9 から)。スレッドについては、少し後で説明します。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 14 / 21
  • time.sleep と requests は、ループごと止める
  • 東京
  • ループごと止まる
  • 大阪
  • ループごと止まる
  • 札幌
  • ループごと止まる
  • 約3秒
  • 0秒
  • 1秒
  • 2秒
  • 3秒
  • async def fetch(city):
  • time.sleep(1) # ループが止まる
  • async def fetch(city):
  • requests.get(URL) # これも止まる
  • await asyncio.sleep(1) # 手放す
  • await client.get(URL) # httpx
  • ループは1人の料理人。料理人自身が鍋の前で寝てしまう(time.sleep)と、他の鍋も誰も見られない
  • ひらめき
  • async def の中でも、止まる関数を呼ぶとループが動けない。待ち時間は重ならず、また約3秒になる。

httpx:await できる HTTP ライブラリ

requests は同期専用です。await できる版はありません。そこで非同期では、httpx というライブラリを使います。requests とほとんど同じ書き方ができるように作られていて、同期版(httpx.Client)と非同期版(httpx.AsyncClient)の両方を持っています。

tenki のフォルダで、第13章と同じように入れます。

bash
$ uv add httpx

pyproject.toml の dependencies に httpx が書き足され、uv.lock が更新され、.venv の site-packages に httpx とその依存(httpcore、anyio など)が入ります。pip で入れるなら、venv を有効にして pip install httpx です(第11・12章)。

書き方を比べます。

requests(同期)httpx(非同期)
準備不要async with httpx.AsyncClient() as client:
問い合わせr = requests.get(URL, params=params)r = await client.get(URL, params=params)
JSON を読むr.json()r.json()(同じ)
書ける場所どこでもasync def の中

`async with` は、第6章の with の非同期版です。with が __enter__ と __exit__ を呼んだように、async with は __aenter__ と __aexit__ を await しながら呼びます。ブロックを抜けるときに、開いた接続をきちんと閉じてくれます。

AsyncClient を1つ作って3都市で使い回しているのにも理由があります。同じ相手(api.open-meteo.com)への接続を使い回せるので、都市ごとに接続し直すより速くなります。

JS の fetch は最初から非同期でしたが、Python では「非同期で HTTP を使いたいなら、非同期に対応したライブラリを選ぶ」必要があります。これは HTTP に限りません。データベースのドライバなども、非同期版を別に選ぶことが多いです。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 15 / 21
  • httpx:await できる HTTP ライブラリ
  • $ uv add httpx
  • httpx(非同期)
  • # pyproject.toml の dependencies に httpx が載る
  • # (第13章。pip なら pip install httpx)
  • import httpx
  • async def main():
  • requests(同期)
  • async with httpx.AsyncClient() as client:
  • r = await client.get(URL, params=params)
  • r = requests.get(URL, params=params)
  • data = r.json()
  • data = r.json()
  • async with は、非同期版の with(第6章)。
  • 終わるときに接続をきちんと閉じる。
  • client を使い回すと、同じ相手への接続も使い回せる。
  • ひらめき
  • requests は同期専用。httpx はほぼ同じ書き方で、await できる AsyncClient も持つ。

tenki に入れる:3都市を並べて取る

ここまでの内容で、tenki を書き直します。第13章の requests で1都市を取っていた処理を、httpx の非同期版にしたものです。URL は第13章と同じ Open-Meteo の住所で、都市ごとの緯度・経度は辞書 CITIES({"東京": (35.68, 139.77), …})にまとめました。

python
import asyncio
import httpx

async def fetch(client, city):
    lat, lon = CITIES[city]
    params = {"latitude": lat, "longitude": lon,
              "current": "temperature_2m"}
    r = await client.get(URL, params=params, timeout=10)
    return city, r.json()["current"]["temperature_2m"]

async def main():
    async with httpx.AsyncClient() as client:
        results = await asyncio.gather(
            *(fetch(client, c) for c in CITIES))
    for city, temp in results:
        print(city, temp)

asyncio.run(main())

*(fetch(client, c) for c in CITIES) は、都市ごとのコルーチンを作って、gather の引数として広げる書き方です(第5章の *args)。

速さの違いを、はっきり確かめるために、1回の問い合わせに必ず1秒かかる、手元の練習用サーバーを相手に測りました。

書き方かかった時間
requests で1都市ずつ(同期)3.01 秒
httpx と gather(非同期)1.11 秒

待ち時間が重なった分だけ、ほぼ3分の1になりました。都市が10個、100個と増えるほど、差は大きくなります(ただし相手のサーバーに一度に大量の問い合わせを送るのは迷惑になるので、実際には同時に送る数を絞ります)。

1つ注意があります。第14章で、[project.scripts] に tenki = "tenki.cli:main" と書きました。tenki コマンドは、この main をふつうの関数として呼ぶだけです。main が async def だと、呼ばれてもコルーチンが返るだけで、何も動きません(コルーチンの話そのままです)。

そこで、コマンドの入口の main は同期の関数のままにしておき、非同期の本体には別の名前を付けて、main の中で asyncio.run します。

python
async def fetch_all():
    ...   # 上の main の中身

def main():
    asyncio.run(fetch_all())

こうすれば pyproject.toml は変えずに、uv run tenki で3都市が並べて取れます。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 16 / 21
  • tenki に入れる:3都市を並べて取る
  • async def fetch(client, city):
  • 1回1秒かかる相手で測ると
  • lat, lon = CITIES[city]
  • params = {"latitude": lat, "longitude": lon,
  • # requests で1都市ずつ
  • "current": "temperature_2m"}
  • 3.01 秒
  • r = await client.get(URL, params=params, timeout=10)
  • return city, r.json()["current"]["temperature_2m"]
  • async def main():
  • # httpx と gather
  • async with httpx.AsyncClient() as client:
  • 1.11 秒
  • results = await asyncio.gather(
  • *(fetch(client, c) for c in CITIES))
  • for city, temp in results:
  • *(… for c in CITIES) は、
  • print(city, temp)
  • 都市ごとのコルーチンを並べて
  • asyncio.run(main())
  • gather の引数に広げる書き方(第5章)。
  • ひらめき
  • fetch を async def にして httpx で取り、main で gather。最後の asyncio.run が入口。

どこで await できるか

await を書ける場所をまとめます。ルールは1つだけで、「そこでループが回っているか」です。

場所関数の外で await理由
python3 main.py(ふつうのスクリプト)できないループが無い。SyntaxError
async def の中できるループの上で動いている
python -m asyncio(非同期の REPL)できるREPL 自身がループを回している
Jupyter ノートブックできるノートブックがループを回している
JS の ES モジュールできるtop-level await

python -m asyncio と打つと、最初からループが回っている REPL が起動します(Python 3.8 から)。ちょっと試すのに便利です。

$ python3 -m asyncio
>>> await asyncio.sleep(1)

逆に、もうループが回っている所で `asyncio.run` を呼ぶと、エラーになります。asyncio.run は「新しいループを作る」関数ですが、1つのスレッドで同時に回せるループは1つだけだからです。

RuntimeError: asyncio.run() cannot be called from a running event loop

実際に async def の中から asyncio.run を呼んで確かめると、このメッセージが出ました。Jupyter ノートブックで asyncio.run(main()) と書いて同じエラーになるのも、同じ理由です。ループの中にいるなら、asyncio.run は要りません。await main() と書けば済みます。

いる場所書き方
ループの外(スクリプトの一番外側)asyncio.run(main())
ループの中(async def・ノートブック・非同期の REPL)await main()
  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 17 / 21
  • どこで await できるか
  • 場所
  • 関数の外で await
  • 理由
  • python3 main.py(ふつうのスクリプト)
  • できない
  • ループが無い → SyntaxError
  • async def の中
  • できる
  • ループの上で動いている
  • python -m asyncio(非同期の REPL)
  • できる
  • REPL がループを回している
  • Jupyter ノートブック
  • できる
  • ノートブックがループを回している
  • JS の ES モジュール(.mjs)
  • できる
  • top-level await が使える
  • asyncio.run() cannot be called from a running event loop ← ループが回っている所で asyncio.run を呼ぶと
  • ひらめき
  • await は async def の中だけ。ただし、ループが最初から回っている場所では外でも書ける。

スレッド・プロセスとの使い分けと、GIL の一言

「同時に進める」道具は、asyncio のほかにスレッドとプロセスがあります。ここでは使い分けの入口だけ押さえます。

道具向いている仕事Python の書き方JS で近いもの
asyncioネットやファイルの待ちが多い。何百件でも軽いasync def / awaitPromise・async
スレッド待ちが多いが、止まるライブラリしか無いasyncio.to_thread(f)、threadingほぼ無い
プロセス計算が重い(CPU を使い切る)concurrent.futures.ProcessPoolExecutorworker_threads、子プロセス

スレッドは、1つのプログラムの中で、同時に動ける流れを複数持つ仕組みです。asyncio のように await で手放さなくても、OS が勝手に切り替えてくれます。だから requests のような止まる関数でも並べられます。

ここで出てくるのが GIL(Global Interpreter Lock)です。ふつうの Python(CPython)は、Python のコードを同時に実行できるスレッドを1本に限る鍵を持っています。スレッドを4本作っても、Python の計算を同時に進められるのは1本だけです。

ただし、ネットの返事やファイルを待っている間は、鍵を手放します。だから、待ちの多い仕事ならスレッドでも速くなります(前に見た asyncio.to_thread の例が約1秒になったのはこのためです)。逆に、計算そのものが重い仕事は、スレッドでは速くなりません。その場合は、鍵ごと別々のプロセス(別の Python)を立てて、CPU の芯を複数使います。

Python 3.13 では、この鍵を外した実験的な版(free-threaded ビルド)が用意されました。3.14 で正式にサポートされましたが、既定の版ではなく、別に入れる必要があります。ふだん使う Python には、まだ GIL があると考えてください。

迷ったら、まずこう考えれば十分です。待つのが多いなら asyncio、止まるライブラリしか無いならスレッド、計算が重いならプロセス。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 18 / 21
  • スレッド・プロセスとの使い分けと、GIL の一言
  • 道具
  • 向いている仕事
  • Python の書き方
  • JS で近いもの
  • asyncio
  • ネット・ファイルの待ちが多い
  • async def / await
  • Promise・async
  • スレッド
  • 止まるライブラリを並べたい
  • asyncio.to_thread(f)
  • 無い
  • プロセス
  • 計算が重い(CPU を使い切る)
  • ProcessPoolExecutor
  • worker_threads
  • 錠
  • GIL:ふつうの Python(CPython)は、同時に Python のコードを実行できるスレッドを1本に限る鍵を持つ。
  • だから計算の並列化にはプロセスを使う。待つ間は鍵を手放すので、待ちの多い仕事ならスレッドでも速くなる。
  • 3.13 には鍵を外した実験的な版(free-threaded)もあるが、既定の版ではない。
  • ひらめき
  • 待つのが多いなら asyncio、止まるライブラリしか無いならスレッド、計算が重いならプロセス。

FastAPI では、ループはフレームワークが回してくれる

次の第17章では、tenki を FastAPI の Web API にします。そこでは、asyncio.run を自分で書きません。

図の番号の順に、役割を見ます。

順番担当やること
1uvicorn(Web サーバー)イベントループを作り、プログラムが終わるまで回し続ける。届いた要求を FastAPI に渡す
2FastAPI(フレームワーク)要求の URL を見て、どの関数を呼ぶか選ぶ
3あなたの async defループの上で呼ばれる。中で await してよい

つまり、Web サーバーを起動した時点で、JS と同じ「最初からループがある世界」になります。あなたは async def weather(city: str): のような関数を書いて FastAPI に渡すだけで、ループの上で呼んでもらえます。ループの中なので、ここで asyncio.run を書くと、前に見た RuntimeError になります。

1つの要求で天気の返事を待っている間に、同じループが別の人からの要求を受け付けます。これが、1つのプロセスで何人もの相手をできる理由です。

逆に、async def の中で requests や time.sleep のような止まる関数を呼ぶと、ループごと止まり、他の全員が待たされます。第17章の「よくある間違い」でも、この話がもう一度出てきます。

「あなたが呼ぶ」のではなく「フレームワークが、あなたの関数を呼ぶ」という関係は、第10章で見たフレームワークの定義そのものです。ループを回すことも、フレームワーク側の仕事に含まれている、というわけです。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 19 / 21
  • FastAPI では、ループはフレームワークが回してくれる
  • サーバー
  • uvicorn
  • パズル
  • FastAPI
  • コード
  • あなたの async def
  • ループを作り、回し続ける
  • 届いた要求で関数を選ぶ
  • 中で await httpx… してよい
  • @app.get("/weather/{city}")
  • asyncio.run を自分で書かない。
  • async def weather(city: str):
  • ループはもう回っているので、
  • async with httpx.AsyncClient() as client:
  • 書くと RuntimeError になる。
  • r = await client.get(URL, params=…)
  • 1つの要求の返事を待つ間に、
  • return r.json()
  • 同じループが別の要求を受け付ける。
  • ひらめき
  • Web サーバー(uvicorn)がループを回す。あなたは async def を書いて渡すだけ(第17章)。

よくある間違いと、そのときのメッセージ

非同期のつまずきは、ほとんどが「await の忘れ」と「止まる関数を async def の中で呼ぶ」の2つに集まります。どれも、この章の中で実際に出したメッセージです。

出るもの原因直し方
RuntimeWarning: coroutine '…' was never awaitedasync def の関数を呼んで await し忘れたawait を付ける
SyntaxError: 'await' outside functionasync def の外で await したasync def main() に入れ、asyncio.run(main())
RuntimeError: asyncio.run() cannot be called from a running event loopループの中で asyncio.run を呼んだそこでは await するだけ
エラーは出ないが、並べても速くならないtime.sleep・requests を async def の中で使ったasyncio.sleep・httpx
<coroutine object …> と表示される結果の代わりにコルーチンを print したawait した結果を使う
TypeError: object Response can't be used in 'await' expression同期の関数の戻り値を await した非同期版の関数を使う

一番怖いのは4つ目です。エラーも警告も出ず、ただ遅いだけなので、気づかないまま使い続けてしまいます。「async def の中で呼んでいる関数は、await 付きで呼んでいるか」を見直す癖をつけましょう。await の付いていない関数呼び出しで、時間がかかりそうなもの(通信・ファイル・sleep)があれば、それがループを止めています。

1つ目と5つ目は、JS の感覚のままだと起きやすい間違いです。JS では async 関数を呼べば走り出すので、await を忘れても処理自体は行われます。Python では、await を忘れると処理そのものが行われません。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 20 / 21
  • よくある間違いと、そのときのメッセージ
  • 出るもの
  • 原因
  • 直し方
  • RuntimeWarning: coroutine '…' was never awaited
  • async def を呼んで await し忘れた
  • await を付ける
  • SyntaxError: 'await' outside function
  • async def の外で await
  • async def main() に入れる
  • cannot be called from a running event loop
  • ループの中で asyncio.run
  • そこでは await するだけ
  • エラーは出ないが並べても速くならない
  • time.sleep・requests を使った
  • asyncio.sleep・httpx
  • <coroutine object …> と表示される
  • 結果の代わりにコルーチンを print
  • await した結果を使う
  • object Response can't be used in 'await'
  • 同期の関数の戻り値を await
  • 非同期版の関数を使う
  • ひらめき
  • 非同期のつまずきは、ほぼ「await の忘れ」と「止まる関数を async def の中で呼ぶ」の2つ。

まとめ:ループがあるかどうかが、JS との一番の違い

この章の内容を、JS と並べてまとめます。

JSPython
イベントループいつもあるasyncio.run(…) の間だけ
async 関数を呼ぶと最初の await まで走る。Promise が返る何も動かない。コルーチンが返る
関数の外で awaitES モジュールならできるスクリプトではできない
並べて待つPromise.allasyncio.gather・TaskGroup
HTTPfetch(最初から非同期)requests(同期)/ httpx(両方)
待つ間に止まる関数ほとんど無いtime.sleep・requests など多い
計算を並列にworker_threadsプロセス(GIL のため)

覚えておきたいのは3つです。Python には既定でループが無く、`asyncio.run` で作ったときだけある。 `async def` を呼んだだけでは動かず、`await` かループに渡されて初めて動く。 `await` は「待つ」ではなく「ループに手を返す」場所で、手を返さない関数(`time.sleep`・requests)はループごと止める。

この3つがわかっていれば、JS の async/await の感覚のうち、何がそのまま使えて、何が違うのかを自分で判断できます。

tenki は、3都市の天気を並べて取れるようになりました。でも、まだ自分のパソコンのコマンドです。これを Web API にして、ブラウザや他の人のプログラムから `/weather/東京` のように呼べるようにしたい。 そのとき、あなたの関数を呼ぶのは誰で、@app.get と書くと何が起きるのか。次の第17章では、FastAPI を使って、フレームワークに「呼ばれる」側に回ります。

  • JS から来た人の Python 16 ― 非同期:JS との一番大きな違い
  • 21 / 21
  • まとめ:ループがあるかどうかが、JS との一番の違い
  • JS
  • Python
  • イベントループ
  • いつもある
  • asyncio.run の間だけ
  • async 関数を呼ぶと
  • 最初の await まで走る。Promise が返る
  • 何も動かない。コルーチンが返る
  • 関数の外で await
  • ES モジュールならできる
  • スクリプトではできない
  • 並べて待つ
  • Promise.all
  • asyncio.gather・TaskGroup
  • HTTP
  • fetch(最初から非同期)
  • requests(同期)/ httpx(両方)
  • 待つ間に止まる関数
  • ほとんど無い
  • time.sleep・requests など多い
  • 計算を並列に
  • worker_threads
  • プロセス(GIL のため)
  • ひらめき
  • 次の疑問:Web API にして他の人から呼ばれるようにしたい。フレームワークに呼ばれるとは? → 第17章。

0 件のコメント

読み込み中…

権利侵害・不適切な内容の連絡: [email protected]