10月15日-16日でPyCon JP 2021が開催されました。
いち参加者として楽しみつつ、スピーカーとして「実装で知るasyncio -イベントループの正体とは-」という発表をしてきたので、それについての補足と振り返りです。
--- 2022/05/02 追記[ここから] ---
後日談ですが、参加者アンケートの集計結果が公開されて、この発表がPyCon JP 2021 でのベストトークになっていました。 聴講してくださった参加者の方、スタッフおよび関係者の方々、改めて貴重な場を設けていただきありがとうございました。
--- 2022/05/02 追記[ここまで] ---
PyCon JP
今年のPyCon JPはオンサイトとオンラインのハイブリッド開催でした。 まだまだコロナ禍ではありますが、近頃は少し落ち着いてきたためタイミング的に良かったと思います。 ギリギリまで決断を遅らせたスタッフの方々の努力が実って本当に良かったです。
ただ、わたし自身は広島の尾道に住んでいるため、オンライン参加を選択し、発表もオンラインで行いました。 ハイブリッド開催という選択はとても大変だったと想像しますが、スタッフの方々が細かいところまで尽力してくださっていたおかげで、参加者の観点からもスピーカーの観点からもとても上手く回っていたと思います。
関係者の方々、とても楽しい時間をありがとうございました。
各セッションは動画で改めて見ます!
1日目は仕事があり、2日目はたまたま自分が最終セッションになっていたので一日中ソワソワしてました。 そのため各セッションは流していても全然頭に入ってこなかったので、改めて後日動画で確認しようと思います。 PyCon JPは毎年必ず動画配信をしてくれるのでとても助かりますね。
発表内容とその補足
実は今回の発表はこちらのツイートにあるように、わたし自身が知りたかったことを題材にしました。 レベルを Advanced にしていたこともあり、プレッシャーのなかでのインプットと準備は大変でしたが、自分の理解が深まっていく感覚はとても楽しく刺激的でした。
発表で使った資料はrhoboro/eventsに置いています。 スライドには、時間やストーリーの都合で泣く泣く削ったページや没になったページもも非表示にした状態でなるべく残しているので、良かったら確認してみてください。
いただいた質問と回答
セッションの最後や Ask The Speaker の時間にいただいた質問がいくつかあったので、こちらで回答とともに共有します。
Q. 中断後にfuture.set_resut() が呼ばれるのはなぜ?
await future を実行する前にいい感じに登録しています。 スライドで用いた図の場合は、await future の直前でcall_later()を使って一定時間後に future.set_result() が実行されるようスケジューリングしています。
ただし、call_later()を使っているのはasyncio.sleep()を非同期処理の例として使ったためです。
実用的なプログラムでは非同期I/Oを使うことが多いと思いますが、非同期I/Oの場合はBaseSelectorEventLoop._add_reader()や_add_writer()にて、self._selector.register()で登録しています。
このときのhandleが最終的にはfuture.set_result()の呼び出しに繋がります。
def _add_reader(self, fd, callback, *args):
...
self._selector.register(fd, selectors.EVENT_READ, (handle, None)))
このhandleはloop._run_once()の下記部分において、ファイルディスクリプタfdの準備ができたタイミングでself._ready に追加されます。
def _run_once(self):
...
# fdが利用可能になったときにeventが取得できる
event_list = self._selector.select(timeout)
# eventからhandleを取得してself._readyに追加
self._process_events(event_list)
...
# self._ready の中身を実行
...
また、loop.run_in_executor()で別スレッドや別プロセスを使う際にはwrap_future(future, *, loop=None)および_chain_future(source, destination)によって、concurrent.futures.Futureの完了状態がasyncio.Futureにもコピーされるようになっています。
この中断から再開の部分に関しては、asyncioの処理の流れを改めて補足しておきます。 ちょっと読み辛いですが、一部強調するともっと読み辛くなったので強調やめました(笑)
- Task.__init__() で loop.call_soon(self.__step) を呼び出し、taskの実行をスケジュールする
- task.__step() が実行され、 result = self._coro.send(None) でコルーチンの実行が開始
- コルーチンの中の await を辿っていくと await future に辿り着く
- await future の手前で非同期処理の実行をスケジュールしている。この時、非同期処理の完了時に future.set_result() が実行されるようにいい感じにセットしておく(上で記載した部分)。
- await future の行が実行され、 future.__await__() が実行される
- yield self でコルーチンの実行が中断する
- task.__step() に戻ってきて result = self._coro.send(None) の result が future になっている
- result.add_done_callback(self.__wakeup) を実行し、非同期処理の完了時の future.set_result() 呼び出し時に、task.__wakeup() が実行されるようにセットしておく
- 非同期処理の完了で future.set_result() が呼ばれ、 future._result に非同期処理の結果が格納される
- task.__weakup() が実行され、その中で task.__step() が実行され、result = self._coro.send(None) がもう一度実行される
- future.__await__() が再開し、return self.result() で await future の戻り値として future._result が返ってくる
- ほかに await している箇所がなければ StopIteration が送出される
- try:except: で StopIteration を捕捉して、super().set_result(exc.value) を呼び出して task に結果がセットされる
Q. loop._ready はキューですか?
FIFOキュー(先入れ先出しのキュー)になっています。
_readyの実体はcollections.deque()で、ハンドルの追加はself._ready.append(handle)で末尾(右側)に追加し、ハンドルを取り出して実行する際はself._ready.popleft()で左側から取得しています。
Q. 処理を追う時はCPythonをデバッグビルドして追ったのですか?
セッション準備全体のなかではCPythonをデバッグビルドしてブレークポイント貼って調査した箇所もありました。
しかし、asyncioの処理の流れ自体はPythonのソースコードだけでも完結します。 それにはasyncioのコードをほんの少しだけ改造する必要があります。
というのも、C拡張として_asyncio.Taskや_asyncio.Futureが定義されており、通常はPython実装のTaskやFutureよりも優先的にC実装版が使われるようになっているためです。 具体的には_asyncioをインポートできた場合はTaskを差し替える実装やFutureを差し替える実装が入っています。 処理の流れ自体は一緒なので、動かしながら処理を追う際にはこれらをコメントアウトしてPython実装版で追うのがおすすめです。
余談ですが、上記コメントアウトのほかで特に役立ったTipsは下記2点でした。
- loop._ready の差し替え
- collections.dequeをサブクラス化
- append()やpopleft()をオーバーライドし、ハンドルの追加や取得時にログを出すようにして差し替えた
- 後処理タスクのコメントアウト
- asyncio.run()で常に追加される後処理タスクがログを汚すのでコメントアウト
Q. 「フレームオブジェクト」を初めて聞いたので何か資料があれば教えて欲しい
Ask The Speaker の場にいた清水川さんが石本さんの記事「クロージャのひみつ」を紹介してくださいました。
また、フレームの各属性に関しては標準ライブラリのinspectモジュールのページにまとまっています。
感想とか
自分自身が知りたかったことですし、発表には入れられなかった周辺の話も含め、わたし自身が一番勉強させてもらいました。 なので、準備は大変でしたがリターンはそれ以上にとても大きかったと実感しています。
前半のコルーチンの説明に関しては、自分だからこそあのような説明になったと思います(仮にほかの人が同タイトルでセッションをしてもおそらく詳しく説明されない部分)。 「説明の意図が明確なので、ストーリーがわかりやすい」「いままでで一番わかりやすいコルーチンの説明だった!」というコメントなどもいただけて嬉しかったです(ここは会社で練習させてもらって改善できたところなので二重の意味で嬉しい)。 send() も yield from もおそらく使ったことない人のほうが多い機能ですが、知っていると時には強力な武器になり得るものです。 知らなかった人はぜひ触って動きを理解してもらえればと思います。
ちなみに無意識でも似た内容になってしまうことを怖れてソースコードやPEPなどの一次情報以外(特に日本語の記事)は極力目に入れないようにしていましたが、CPython InternalsとFluent Pythonにはとても助けられました。 どちらもおすすめですので、興味のある方はぜひ(Fluent Python は async / await 構文が入る前のものなのでちょっと古くなってますが...)。