- Pythonの asyncio はI/O-boundなプログラムを扱うためのツールであり、この記事ではそれをジェネレーター上の単純な実装として作り直し、動作原理を示す
- 核心は、複数のタスクを持つ イベントループ がタスクを実行し、
yieldで制御を受け取り、次のタスクへ進む構造にある
sleepは、時間が経過するまでyieldする サブジェネレーター として作ることができ、yield fromは下位ジェネレーターが終わった後に元のタスクを続行させる
async/awaitに置き換えると、Task.__await__()が完了前までイベントループへ制御を渡し、create_taskとrunが実際のasyncio APIに似た形を作る
- カスタムの
jacobio呼び出しをasyncioに置き換えると実際のパッケージを使うコードになるが、実際のasyncioは例よりはるかに複雑で、内部の流れも一部異なる
ジェネレーターで見るasyncioの基本構造
- asyncioはPythonで I/O-boundなプログラム を処理するために使われ、この記事ではそれをジェネレーター基盤で単純化して再構築する
- Python 3+の
rangeのように、ジェネレーターはシーケンス全体をメモリに保存せず、必要な値を1つずつ生成する
range(100_000_000)をリストのように作ると1億個の要素を保存する必要があるため、メモリ効率が大きく悪い
- ジェネレーターは値が必要になるたびに生成するため、シーケンス全体を保持しない
- ジェネレーター関数は通常の関数のように定義するが、
yieldを使う
- 関数呼び出し時に本文をすぐ実行せず、ジェネレーターオブジェクト を返す
next(iterator)を呼び出すと次のyieldまで実行される
- それ以上
yieldがなければStopIteration例外が発生する
yield fromは、ジェネレーターが サブジェネレーター やiterableオブジェクトを呼び出せるようにし、ジェネレーターチェーンを作れる
- ここで重要な特性は、関数の実行を停止し、状態を保持したまま再開できる点である
イベントループをリストとして単純化する
- イベントループ は、現在のタスクを実行・管理するasyncioの中核である
- 実際のasyncioイベントループはCで書かれているが、単純なモデルでは現在のタスクを入れるリストと見なせる
- 初期の例では、タスクはジェネレーターオブジェクトとして扱われる
- イベントループマネージャーがタスクリストを巡回する
- 各タスクに
next(task)を呼び出して実行する
- タスクはI/O-boundな作業のように待つ必要がある時点で
yieldにより停止し、イベントループに制御を返す
task1()とtask2()がそれぞれ出力後にyieldする例では、出力が交互に現れる
- 2つの関数が
while Trueループを持つため、実行は続き続ける
- 出力は
Task 1, Task 2, Task 1, Task 2のように繰り返される
yield fromでsleepを作る
sleep(seconds)は開始時刻を記録した後、指定時間が過ぎるまで継続してyieldするジェネレーターとして実装される
- タスク関数は
yield from sleep(1)またはyield from sleep(5)のように、sleepサブジェネレーター に実行を委譲する
sleepが継続してyieldしている間、タスクは停止状態になる
- 十分な時間が過ぎると、
sleepのwhileループが終わる
- それ以上
yieldがないためStopIterationが発生し、yield fromはタスク関数の次の行へ続く
- 例では
task1が1秒ごとに出力し、task2が5秒ごとに出力する
- 出力は
Task 1, Task 2の後にTask 1が何度も出てから再びTask 2が出る形になる
yieldからawaitへ置き換える
awaitを使うには、対象オブジェクトが __await__ メソッドを持つか、coroutineでなければならない
- asyncioでは通常、
asyncio.create_taskのような関数でTaskオブジェクトを扱う
TaskオブジェクトはasyncioのFutureオブジェクトを継承する
Futureオブジェクトには__await__メソッドがある
asyncキーワードが付いた関数を呼び出すとcoroutineオブジェクトが作られる
- coroutineはジェネレーター関数のように、実行を停止して再開できる
awaitは、追加の検証ルールがあるyield fromに近いものとして見られる
await objectは、オブジェクトインスタンスの__await__でyieldするか、別のcoroutineを待つ形になる
- asyncioの
Futureソースコードでも、FutureまたはTaskが完了していないとき、__await__が基本的にyieldを呼び出す形を確認できる
単純なTask、create_task、runの実装
- カスタム実装はリストの代わりに
Queueをイベントループとして使う
- タスクをループに追加・削除する動作を定数時間で処理しようとする構造である
Taskクラスはジェネレーターオブジェクトと完了状態を保存する
self.iterにジェネレーターオブジェクトを保存する
self.finishedをFalseで開始する
- ジェネレーターが
StopIterationを発生させると完了したものとして処理する
done()は完了状態を返す
Task.__await__()は、タスクが終わっていない間ずっとyield selfを呼び出す
create_task(generator)はジェネレーターをTaskで包み、イベントループキューに入れてから返す
- タスクをイベントループにスケジューリングする役割である
run(main)は実際のasyncio.run()に似た形でイベントループを開始する
- 最初に受け取った
mainをTaskで包んでキューに入れる
- キューが空でない間、次のタスクを取り出す
task.iter.send(None)でタスクを進める
StopIterationが発生するとtask.finished = Trueに設定する
- 例外がなければタスクを再びイベントループキューに入れる
next(task.iter)の代わりにtask.iter.send(None)を使うのは、async/awaitキーワードと一緒に動かすときの特性であり、ここでは同じ役割を果たす
async互換のsleepとjacobioの例
- 既存の
sleepはジェネレーター関数だったが、awaitはジェネレーター関数と直接組み合わせることができない
await対象は__await__を持つオブジェクトか、coroutine関数でなければならない
- 実際の待機ロジックは
_sleep(seconds)ジェネレーターへ移す
async def sleep(seconds)は_sleep(seconds)でタスクを作り、そのタスクをawaitする
await taskはTask.__await__()を呼び出す
- タスクが終わっていなければ、
yieldでイベントループに制御を渡す
- 完成したカスタムファイル
jacobio.pyには次の要素が含まれる
- イベントループキュー
_sleep
async sleep
Task
create_task
run
- 使用例では既存の
yield fromをawaitに置き換え、awaitを使う関数にasyncを付ける
task1は2回出力し、そのたびにjacobio.sleep(1)を待つ
task2は3回出力し、そのたびにjacobio.sleep(0)を待つ
mainは2つのタスクを作り、どちらもawaitした後にdoneを出力する
- 例の出力は
Task 1, Task 2, Task 2, Task 2, Task 1, doneの順である
実際のasyncioに置き換える
- カスタムの
jacobio例でjacobioをすべてasyncioに置き換えると、実際の asyncioパッケージ を使うコードになる
- 対応する関数は次のとおり
jacobio.sleep() → asyncio.sleep()
jacobio.create_task() → asyncio.create_task()
jacobio.run() → asyncio.run()
- 実際のasyncioは内部ではるかに多くのことを行う
- このイベントループマネージャーは可能な限り単純に作った実装なので、asyncioの基本アイデアは示すが、実際のパッケージの規模と複雑さのため、実際のソースコードの流れとは少し異なる
- 実際のasyncioを使う場合、2つのタスクをそれぞれ作って両方を
awaitする代わりに、asyncio.gather()のような関数で複数のタスクを処理できる
- 関連記事として handling asyncio tasks like a pro がリンクされている
1件のコメント
Hacker News のコメント
Asyncio は、イベントループを自作の実装に差し替えることができます。
Temporal Python では、ワークフローをカスタムの耐久性のある asyncio イベントループとして表現しており、
asyncio.sleepのようなものも耐久性のあるタイマーになります。つまり、コードを別のマシンで再開できるため、数週間 sleep することも可能です。実装方法はこの記事で説明されています: https://temporal.io/blog/durable-distributed-asyncio-event-l...
asyncio の最大の問題は、Python では同期呼び出しで asyncio のスレッドをブロックできてしまい、システム全体を簡単に停止させられる点です。Python には、呼び出しグラフを作って、
async defの中でスレッドをブロックする呼び出しが直接または間接的に呼ばれていないか検出する静的解析ツールが切実に必要です。算術演算やデータ構造へのアクセスのような基本動作は高速な同期呼び出しとして許可リストに入れ、イベントループが疑わしいほど速く回っている場合には、同期であるべき他の処理も検出できるのではないでしょうか。
asyncio イベントループをどう実装しているのかを知ったときは、本当に目からうろこが落ちる瞬間でした。
Python が本当に並行処理に向いた言語なのか、今でも理解しようとしているところです。Asyncio はいつも何とか持ちこたえている感じで、私の基準では C# の非同期実装のほうがきれいです。
Purchaser.purchaseはdo_purchaseであるべきではないですか?単純な
read()でも、ディスクリプタが何で、どう設定されているかによってブロックする場合もしない場合もあります。これをどうやって静的解析で検出するのでしょうか?この実装は sleep するときにビジーウェイトしているように見えます。つまり、現在実行可能なタスクがなくてもイベントループが回り続けます。
以前、別のおもちゃ実装を見た記憶がありますが、そこではタスクの次に実行可能になる時刻をソートされた順序で追跡し、現在実行可能なタスクがなければイベントループ自体を sleep させていました。実際の asyncio もこのように動作すると理解しています。
その後、次に実行可能になる条件が単純な壁時計時刻だけでなく、ソケットのようなものへの依存も持てるように拡張され、
selectをタイムアウト付きで使えるようになります。有名な実装の一つが uvloop(https://github.com/MagicStack/uvloop)で、基本的に libuv を使ってループを実装し、説明されている
selectのような処理を libuv が扱います。[1] https://simpy.readthedocs.io/en/latest/
[2] https://gitlab.com/team-simpy/simpy.io
イベントループは必ずしも繰り返す必要はなく、
mainを実行して開始し、それが終われば一緒に終わっても構いません。ソケットを待つwhile trueループがあるサーバーを起動し、終了条件や割り込みで終わってプログラムも終了する状況を思い浮かべればよいです。イベントループの観点ではビジーウェイトもなく、sleep やソケットを触る必要もありません。完了するまで実行する方式と、永遠に実行する方式の違いです。
おもちゃのループを作るなら、永遠に実行される場合はあえて扱わないほうがよいと思います。
David Beazley の asyncio 講演は素晴らしいです。
それを基に離散事象シミュレーションツールを作りました。asyncio を自分で実装し、システムクロックをシミュレーション時間に置き換えられる点はかなり面白いです。
本当に素晴らしい記事で、初めて読む人が退屈しそうな部分をうまく飛ばした高レベルの説明です。
最後に、実際の内部でどのように動作しているのかを説明する別の資料を追加するとさらに良いと思います。
記事が
yieldをまったく使わずに同じ内容を示していたら、もっと良かったと思います。正直、本当に魔法のように感じる部分はそこです。Python コルーチンについてのはるかに深い説明はこちらにあります: https://aosabook.org/en/500L/a-web-crawler-with-asyncio-coro...
poll()への言及がまったくないのですか? だとすれば、asyncio の動作方式とはまったく違います。Python が
defを横取りして、実際には関数ではないオブジェクトを作るために使うようにしたのは、完全に奇妙です。少なくとも別のキーワードくらい作れたはずです。
ジェネレーターやコルーチンを返す形で、型シグネチャはここで見られます [1]。言語レベルの構文糖でなくても、デコレーターのようなものを使えば純粋な Python でも似たことはできます。
[1] https://docs.python.org/3/library/typing.html#typing.Generat...
ただし、型シグネチャは学術的な関心として触れたもので、実際の型注釈では、より単純な
IterableとAwaitableを好みます。開発者体験の意味論を、言語の純粋性のようなものより優先したように聞こえます。
async関数が非同期関数であることのせいで混乱したことは一度もありません。defを使わなければならず、JS のアロー関数や C++ のラムダのようにインライン関数を作れない点です。これは言語の純粋性の問題ではなく、単に不便なのです。