3 ポイント 投稿者 GN⁺ 2024-05-09 | 1件のコメント | WhatsAppで共有
  • Pythonの asyncio はI/O-boundなプログラムを扱うためのツールであり、この記事ではそれをジェネレーター上の単純な実装として作り直し、動作原理を示す
  • 核心は、複数のタスクを持つ イベントループ がタスクを実行し、yieldで制御を受け取り、次のタスクへ進む構造にある
  • sleepは、時間が経過するまでyieldする サブジェネレーター として作ることができ、yield fromは下位ジェネレーターが終わった後に元のタスクを続行させる
  • async/awaitに置き換えると、Task.__await__()が完了前までイベントループへ制御を渡し、create_taskrunが実際の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している間、タスクは停止状態になる
    • 十分な時間が過ぎると、sleepwhileループが終わる
    • それ以上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を呼び出す形を確認できる

単純なTaskcreate_taskrunの実装

  • カスタム実装はリストの代わりにQueueをイベントループとして使う
    • タスクをループに追加・削除する動作を定数時間で処理しようとする構造である
  • Taskクラスはジェネレーターオブジェクトと完了状態を保存する
    • self.iterにジェネレーターオブジェクトを保存する
    • self.finishedFalseで開始する
    • ジェネレーターがStopIterationを発生させると完了したものとして処理する
    • done()は完了状態を返す
  • Task.__await__()は、タスクが終わっていない間ずっとyield selfを呼び出す
    • この動作がイベントループに制御を返す
  • create_task(generator)はジェネレーターをTaskで包み、イベントループキューに入れてから返す
    • タスクをイベントループにスケジューリングする役割である
  • run(main)は実際のasyncio.run()に似た形でイベントループを開始する
    • 最初に受け取ったmainTaskで包んでキューに入れる
    • キューが空でない間、次のタスクを取り出す
    • 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)ジェネレーターへ移す
    • _sleepは一定時間が過ぎるまでyieldする
  • async def sleep(seconds)_sleep(seconds)でタスクを作り、そのタスクをawaitする
    • await taskTask.__await__()を呼び出す
    • タスクが終わっていなければ、yieldでイベントループに制御を渡す
  • 完成したカスタムファイルjacobio.pyには次の要素が含まれる
    • イベントループキュー
    • _sleep
    • async sleep
    • Task
    • create_task
    • run
  • 使用例では既存のyield fromawaitに置き換え、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件のコメント

 
GN⁺ 2024-05-09
Hacker News のコメント
  • Asyncio は、イベントループを自作の実装に差し替えることができます。
    Temporal Python では、ワークフローをカスタムの耐久性のある asyncio イベントループとして表現しており、asyncio.sleep のようなものも耐久性のあるタイマーになります。つまり、コードを別のマシンで再開できるため、数週間 sleep することも可能です。
    実装方法はこの記事で説明されています: https://temporal.io/blog/durable-distributed-asyncio-event-l...
    asyncio の最大の問題は、Python では同期呼び出しで asyncio のスレッドをブロックできてしまい、システム全体を簡単に停止させられる点です。Python には、呼び出しグラフを作って、async def の中でスレッドをブロックする呼び出しが直接または間接的に呼ばれていないか検出する静的解析ツールが切実に必要です。

    • 悪い考えかもしれませんが、現在のようにすべての呼び出しがデフォルトで同期的である方式ではなく、逆にしたらどうかと思います。
      算術演算やデータ構造へのアクセスのような基本動作は高速な同期呼び出しとして許可リストに入れ、イベントループが疑わしいほど速く回っている場合には、同期であるべき他の処理も検出できるのではないでしょうか。
    • Temporal は本当に素晴らしいです。
      asyncio イベントループをどう実装しているのかを知ったときは、本当に目からうろこが落ちる瞬間でした。
    • なぜスレッドを使わないのか気になります。
      Python が本当に並行処理に向いた言語なのか、今でも理解しようとしているところです。Asyncio はいつも何とか持ちこたえている感じで、私の基準では C# の非同期実装のほうがきれいです。
    • ワークフローの例では、Purchaser.purchasedo_purchase であるべきではないですか?
    • それを静的に検出するのは簡単ではないでしょう。
      単純な read() でも、ディスクリプタが何で、どう設定されているかによってブロックする場合もしない場合もあります。これをどうやって静的解析で検出するのでしょうか?
  • この実装は sleep するときにビジーウェイトしているように見えます。つまり、現在実行可能なタスクがなくてもイベントループが回り続けます。
    以前、別のおもちゃ実装を見た記憶がありますが、そこではタスクの次に実行可能になる時刻をソートされた順序で追跡し、現在実行可能なタスクがなければイベントループ自体を sleep させていました。実際の asyncio もこのように動作すると理解しています。
    その後、次に実行可能になる条件が単純な壁時計時刻だけでなく、ソケットのようなものへの依存も持てるように拡張され、select をタイムアウト付きで使えるようになります。

    • asyncio 自体でも、標準提供の実装以外にカスタムイベントループ実装を使えます。
      有名な実装の一つが uvloop(https://github.com/MagicStack/uvloop)で、基本的に libuv を使ってループを実装し、説明されている select のような処理を libuv が扱います。
    • これは SimPy [1] / Simpy.io [2] とかなり似て聞こえますが、SimPy は asyncio より何年も前に登場しています。
      [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...
      ただし、型シグネチャは学術的な関心として触れたもので、実際の型注釈では、より単純な IterableAwaitable を好みます。
    • 実際の問題は何ですか? それとも単に言語の純粋性についての話ですか?
    • これを悲劇と見る必要はありません。
      開発者体験の意味論を、言語の純粋性のようなものより優先したように聞こえます。async 関数が非同期関数であることのせいで混乱したことは一度もありません。
    • Python のより大きな問題は、そもそも関数に def を使わなければならず、JS のアロー関数や C++ のラムダのようにインライン関数を作れない点です。
      これは言語の純粋性の問題ではなく、単に不便なのです。
    • デコレーターの話ですか? Python の文脈で何を言っているのか把握するのがかなり混乱します。