Pythonで書いたプログラムが、ただ待っているだけの時間で遅くなっていると感じたことはありませんか。
APIを10回呼ぶ処理。1回1秒でも、順番に呼べば10秒かかります。
このとき、CPUはほとんど働いていません。相手からの返事を、じっと待っているだけです。
その待ち時間を重ねて短くするための仕組みが、Python標準のasyncioです。読み方はエイシンクアイオー、非同期入出力という意味の略語になります。
この記事では、asyncioが何をしてくれるのか、async/awaitという書き方が何を表しているのかを、実際に動かしたコードと実行結果を並べながら解説していきます。
asyncioは何のためにあるのか¶
まずは目的をはっきりさせましょう。asyncioは、待ち時間の多い処理をまとめて進めるための標準ライブラリです。
ここで言う待ち時間とは、通信やファイル読み込みのように、相手の応答を待つ時間のことを指します。CPUが計算で忙しいわけではなく、ただ手が空いている状態です。
公式ドキュメントによると、asyncioの中心にあるのはイベントループという仕組みです。イベントループは一度に1つのタスクを実行し、そのタスクが結果を待ち始めたら、その間に別のタスクを進めます。
料理にたとえるとわかりやすいかもしれません。お湯が沸くのを鍋の前で見ているのではなく、その間に野菜を切る。やっていることはこれと同じです。
なお、asyncioは複数のCPUコアを使って計算を速くする仕組みではありません。ここは最初に押さえておきたい大事な境界線です。
順番に処理するとどうなるか¶
ありがたみは、使わない場合と並べるといちばんよくわかります。まずは普通のPythonコードで、2秒かかる処理を3回繰り返してみます。
import time
def download(name, seconds):
print(f"{name} 開始")
time.sleep(seconds)
print(f"{name} 完了")
start = time.time()
download("A", 2)
download("B", 2)
download("C", 2)
print(f"合計 {time.time() - start:.1f} 秒")
手元のPython 3.11で実行すると、こうなりました。
A 開始
A 完了
B 開始
B 完了
C 開始
C 完了
合計 6.0 秒
2秒 × 3回で、きっちり6秒です。Aが終わるまでBは始まりません。
でも、待っている6秒のあいだ、プログラムは何も計算していないのです。もったいないと思いませんか。
async/awaitの書き方を覚える¶
では、同じ処理をasyncioで書き直してみます。新しく出てくるキーワードは2つだけです。
async defで作るのはコルーチン¶
関数の頭にasyncを付けると、その関数はコルーチンになります。途中で止まったり、あとから再開したりできる関数だと考えてください。
このasync/awaitという書き方は、PEP 492という提案書によってPython 3.5で追加されました。それ以前は、yieldを使った古い書き方が使われていた歴史があります。
【関連記事】ジェネレータ(yield)っていつ使うの?巨大なデータをメモリ節約して扱う方法
awaitは待つ場所の目印¶
awaitは、ここで結果を待つという印です。同時に、待っているあいだは他のタスクに順番を譲るという合図でもあります。
つまりawaitの付いた行こそが、他の処理が動き出せる切り替えポイントになるわけです。ここを理解すると、asyncioの動きが一気に読めるようになります。
書き換えたコードがこちらです。time.sleepではなく、asyncio.sleepを使っている点に注目してください。
import asyncio
import time
async def download(name, seconds):
print(f"{name} 開始")
await asyncio.sleep(seconds)
print(f"{name} 完了")
async def main():
await asyncio.gather(
download("A", 2),
download("B", 2),
download("C", 2),
)
start = time.time()
asyncio.run(main())
print(f"合計 {time.time() - start:.1f} 秒")
実行結果は次のとおりです。
A 開始
B 開始
C 開始
A 完了
B 完了
C 完了
合計 2.0 秒
6秒が2秒になりました。A、B、Cの開始が先にまとめて並んでいるのが見えるでしょうか。
Aがawaitで待ちに入った瞬間、Bが動き出しています。3つの待ち時間が重なった結果、全体では2秒で済んだわけです。
ここで使ったasyncio.runは、Python 3.7で追加された入口の関数です。イベントループの用意と後片付けをまとめて引き受けてくれます。
非同期の処理は、必ずこの関数の中から始まると覚えておくと迷いません。async defで書いた関数は、単体では動かせないからです。
同時に走らせる書き方を比べる¶
まとめて動かす方法はひとつではありません。用途によって使い分けます。
代表的な3つを並べてみます。
| 書き方 | 何をするか | 向いている場面 |
|---|---|---|
await コルーチン() |
1つを実行して終わるまで待つ | 順番に処理したいとき |
asyncio.gather() |
複数をまとめて動かし結果を一覧で受け取る | 独立した処理を並べたいとき |
asyncio.TaskGroup() |
複数をまとめて動かし失敗時に全体を止める | 失敗したら全部やめたいとき |
gatherは結果をリストで返してくれるので、戻り値がほしい場面で手軽に使えます。順番は渡した順のまま保たれます。
一方でgatherには弱点があります。1つのタスクが失敗しても、残りのタスクは止まらずに動き続けるのです。
Python 3.11から使えるTaskGroup¶
その弱点を埋めるために用意されたのがTaskGroupです。公式ドキュメントによると、Python 3.11で追加されました。
新しく書くコードでは、create_taskやgatherを直接使うよりもTaskGroupが推奨されています。理由は安全性にあります。
どれか1つのタスクが例外で失敗すると、TaskGroupは残りのタスクをキャンセルしてくれます。gatherがやってくれないのは、まさにここです。
実際に書くと、async withという形になります。
import asyncio
async def fetch(name, seconds):
await asyncio.sleep(seconds)
return f"{name} の結果"
async def main():
async with asyncio.TaskGroup() as tg:
task_a = tg.create_task(fetch("A", 1))
task_b = tg.create_task(fetch("B", 2))
print(task_a.result())
print(task_b.result())
asyncio.run(main())
実行すると、A の結果とB の結果が順に表示されます。withブロックを抜けるときに、中のタスクが全部終わるのを待ってくれる仕組みです。
async withは、非同期版のwith文だと考えてください。使い終わったあとの後片付けを自動でやってくれる点は、通常のwith文と同じです。
【関連記事】pythonのコンテキストマネージャーって何?詳しく解説します!
なお、TaskGroupの中で複数のタスクが失敗した場合、それらはExceptionGroupという形にまとめられて送出されます。1つの例外に押し込めず、起きたことをすべて残す設計になっています。
向いている処理、向いていない処理¶
ここまで読んで、何でもasyncioにすれば速くなりそうだと感じたかもしれません。残念ながら、そうはいきません。
効くのは待ち時間がある処理だけです。計算そのものが重い処理では、まったく効果がありません。
試しに、asyncio.sleepの代わりに計算で2秒ふさぐコードを動かしてみます。gatherで2つ並べても、結果は4.0秒でした。
理由はシンプルで、awaitで譲る隙がないからです。イベントループは1つのスレッドで動いているので、誰かが手を離さない限り次へ進めません。
このあたりの背景には、PythonのGILという仕組みも関わっています。
【関連記事】PythonのGIL(グローバルインタプリタロック)とは?仕組みを詳しく解説
処理の種類ごとに、どれを選べばよいかを整理しておきます。
| 処理の種類 | 具体例 | 選ぶもの |
|---|---|---|
| 通信やファイルの待ち | API呼び出し、Webスクレイピング | asyncio |
| 重い計算 | 画像処理、数値計算、集計 | multiprocessing |
| 既存の同期ライブラリを並べる | 対応していないDBドライバなど | スレッド |
計算が重い処理を速くしたいときは、複数のプロセスを使う方法が向いています。
【関連記事】Pythonのマルチプロセシングとマルチスレッドの違いとは?
つまずきやすいポイント¶
私は10年ほどエンジニアとして開発に関わってきましたが、asyncioで最初に踏んだのは呼び出し忘れという単純な失敗でした。
async defで定義した関数は、そのまま呼んでも実行されません。試しにprintしてみると、こう表示されます。
<coroutine object hello at 0x7f6edb7f6980>
RuntimeWarning: coroutine 'hello' was never awaited
コルーチンオブジェクトができただけで、中身は一度も動いていないのです。エラーではなく警告なので、気づかず素通りしてしまいます。
このメッセージが出たら、awaitの付け忘れを疑ってください。原因の9割はそこにあります。
もうひとつ厄介なのが、非同期の中に同期処理を混ぜてしまう事故です。requestsのような同期ライブラリをasync関数の中で呼ぶと、その瞬間にイベントループ全体が止まります。
私も以前、非同期にしたはずのバッチ処理がまったく速くならず、原因を探して半日を溶かしたことがあります。犯人は、中で呼んでいた同期のHTTPクライアントでした。
非同期で通信するなら、aiohttpやhttpxのように非同期に対応したライブラリを選ぶ必要があります。
どこから手をつければいいか¶
いきなり全部を非同期にする必要はありません。まずは自分のコードの中で、待っているだけの箇所を探してみてください。
APIを繰り返し呼ぶ処理や、複数のURLを順番に取得している処理。そこがasyncioの出番です。
学ぶ順番としては、asyncio.runとawaitで1本動かすところから始めるのがおすすめです。次にgatherで並べ、最後にTaskGroupへ進むと無理がありません。
公式ドキュメントには、asyncioの考え方を解説したA Conceptual Overview of asyncioというページが追加されています。仕組みまで踏み込みたくなったら、ここを読むと理解が深まります。
待ち時間を重ねるという発想は、一度身につくと他の言語でも役に立ちます。焦らず、小さなコードで手を動かしながら慣れていきましょう。
ここまでお読みいただきありがとうございました。