except」まとめこんにちは、福田(@JunyaFff)です。3.11に追加されるasyncioの新機能asyncio.TaskGroupを紹介します。3.11ですが、今回取り上げるのは非同期I/Oで並行処理を実現する標準ライブラリasyncioの新機能asyncio.TaskGroupです。asyncio.TaskGroupは複数のタスクを並行処理する高レベルAPIになります。同様の既存機能(asyncio.gather()やasyncio.wait())と大きく違う2つの特徴があります。例外発生時のタスクのキャンセルこの特徴によって、発生した例外を漏らさずに記録でき、制御しやすい並行処理の実装がより簡単にできるようになりました。私個人としては、かゆいところに手が届くイチオシの新機能です。Selivanov氏も興奮の連投ツイートをされていました。3.11のasyncio.TaskGroupを試してみよう!と思っていただけると幸いです。3.11.0rc2で動作確認をしています。公式ドキュメントにあるサンプルコードを見てみましょう。asyncio.TaskGroupはコンテキストマネージャーを利用し、その中で複数のタスクを同時に実行します。サンプルコードではsome_coro()とanother_coro()という非同期関数(中身の実装はありません)を同時に実行しています。コンテキストマネージャーを抜けるタイミングでタスクが完了しています。documentationしかし、複数のタスクを同時に処理するのであれば、既存機能のasyncio.gather()やasyncio.wait()でも同様の書き方ができます。now.")違いは例外の扱いにあります。「⁠同時に発生した例外の捕捉」について詳しく見てみましょう。3.11のasyncio.TaskGroupで、それぞれ比較してみましょう。3.10以前の例外処理について見てみましょう。いくつか方法がありますが、ここではasyncio.gather()の利用例を紹介します。asyncio.gather()では最初の例外を送出するか、すべてのタスクを実行した後に結果をリストで取得するしかありませんでした。asyncio.gather()での例外処理return_exceptions指定なし:最初の例外を送出return_exceptions=True指定あり:すべてのタスクの完了を待ち、例外を含む結果をリストで取得return_exceptions指定なしの場合、try-exceptで囲い捕捉します。return_exceptions=Trueの場合、戻り値のリストをチェックする必要があります。実際にコードにして動きを見てみましょう。同時に実行するための簡易な非同期関数を3つ用意します。うち2つは、例外を送出します。return_exceptionsを指定せずにasyncio.gather()で動かしてみます。print(f"{err=}")asyncio.run(main())実行すると次のような結果になります。最初の例外のみを捕捉できます。TypeErrorは捕捉できません。asyncio_gather_except.pyerr=ValueError()次にasyncio.gather()のreturn_exceptions=Trueを指定します。return_exceptions=Trueはすべてのタスクを実行し、例外を含むすべてのタスクの結果をリストで取得します。print(f"{results=}")これを実行すると、次のようになります。TypeError()]この場合、例外を処理するには、戻り値のリストをチェックする必要があります。print("TypeError")asyncio.gather()ではすべてのタスクを実行しないと、すべての例外を捕捉できないことがわかります。10個のHTTPリクエストを同時に行う処理があるとします。最初のタスクに例外が発生した場合、残りの失敗するかもしれない9つが完了するまで待つことになります(結果10個すべてが同じエラーになったとしてもです⁠)⁠。asyncio.gather()での例外処理には、以下の2つの課題があります。asyncio.gather()の例外処理での課題return_exceptions指定なし:同時に発生した例外を1つしか捕捉できないreturn_exceptions=True指定あり:戻り値のリストをチェックする必要がある。すべてのタスクを実行しなければならないこれらの課題を解決し読みやすく書けるのがasyncio.TaskGroupです。3.11で追加された新しい構文except*を利用することで、逐次関数の例外処理と同様に書けます。見慣れないexcept*については後述します。asyncio.TaskGroupで同時に発生する例外を捕捉してみましょう。先ほどのasyncio.gather()の例をasyncio.TaskGroupで書き換えてみます。ExceptionGroupオブジェクトになります。実際に発生した例外は、exceptions属性にタプルで格納されています。print(f"{err.exceptions=}")実行すると次のような結果になります。複数のタスクから同時に発生する例外を捕捉できました。asyncio_taskgroup_except.pyerr.exceptions=(ValueError(),)err.exceptions=(TypeError(),)同期処理を書くように、直感的に実装できるようになっています。タスクのキャンセルさて、asyncio.TaskGroupのもう1つの特徴である、タスクのキャンセルについて見てみましょう。3.11のasyncio.TaskGroupをそれぞれ比較してみましょう。タスクがキャンセルされずに残ってしまうケースasyncioで複数のタスクを同時に処理する際に例外が送出されると、完了していないタスクが残り意図せず動作してしまっている場合があります。asyncio.gather()を利用し、タスクが残ることを確認してみましょう。非同期関数のcoro_long()を追加しています。asyncio.sleep()を利用して出力します。それぞれのタスクの状態を、Taskオブジェクトのインスタンス属性の_stateで確認します。_stateは「PENDING」「⁠FINISHED」「⁠CANCELLED」が設定され、作られた時点で「PENDING⁠」⁠、終了が「FINISHED⁠」⁠、そしてキャンセル完了の「CANCELLED」を示します。1.5秒待つことでException発生後にcoro_log()が動いていることを確認できるasyncio.run(main())実行すると次のような結果になります。追加した非同期関数のcoro_long()がPENDINGの状態で残っており、"完了していないタスクが出力しています"が出力されていることから、例外発生後にcoro_long()が動作しているとわかります。task3._state='PENDING'完了していないタスクが出力していますこのように例外が発生すると、タスクが残ってしまう場合があります。タスクが残ってしまうと、意図せず後続処理の間に動作が完了したり、実装によってはI/Oのコネクションが閉じられないというような可能性が残り、処理の予測が困難です。3.10以前のタスクのキャンセル上記のサンプルコードを修正し、タスクのキャンセル処理を追加します。修正のポイントは次の3つです。追加する完了していないタスクに対しキャンセルを指示するキャンセルの完了を待つまず、キャンセルする非同期関数のcoro_long()へasyncio.CancelledErrorの捕捉とキャンセル時の処理を追加します。次にasyncio.gather()を実行しているmain()にタスクの完了を判定するTask.done()と、タスクのキャンセルを指示するTask.cancel()を追加します。最後にキャンセル処理の完了を待つ必要があるため、ここではasyncio.sleep()を利用し完了を待ちます。re-raised」(⁠必ず再送出する)とあります。再送出されるとタスクのキャンセル処理完了となり、タスクが「CANCELLED」になります。キャンセルされたタスクはasyncio.CancelledErrorを捕捉し、そこでロールバックなどの処理を行います。CancelledError内の処理が完了後、キャンセルになる実行すると次のような結果になります。"完了していないタスクが出力しています"が出力されず、代わりに"キャンセルされたタスクが出力しています"が出力されます。タスクの状態もtask.cancel()実行直後は「PENDING」ですが、asyncio.sleep()を利用しキャンセル処理の完了を待つと「CANCELLED」になり、キャンセルされていることがわかります。再度実行しなければなりません。return_exceptions=True)例外発生時にタスクが残ってしまうことを避けるため、キャンセル処理は必要です。サンプルコードと出力からわかるように、asyncio.gather()では終了していないタスクをキャンセルする処理、そしてキャンセル完了を待つ処理の実装が必要です。asyncio.TaskGroupによるタスクのキャンセルasyncio.TaskGroupでは、コンテキストマネージャーを抜けるタイミングで未完了のタスクをキャンセルします。例外が発生した場合に開発者は、タスクがキャンセルされたか、またキャンセル処理が完了しているか、を意識する必要がなくなります。非常にシンプルで読みやすい実装になります。{task3._state=}")実行すると、キャンセル処理が実行され、またタスクも「CANCELLED」になっていることがわかります。asyncio.TaskGroupから離れ、except*について見てみましょう。654にて追加されました。Newでは次のように紹介されています。simultaneously(無関係な複数の例外を同時に処理する⁠)⁠」とあります。except*」ではMotivation(動機)に4つのケースが挙げられています。errors(同時に発生するエラー⁠)⁠」です。exceptions.(複数のタスクが送出する例外を処理する良い方法は、今のところありません⁠)⁠」とあり、またasyncio.gather()の課題が記載されています。構文は非同期処理のために実装された、と言っても過言ではないようです。Langa氏が話されています。気になる方はぜひご覧ください。まとめasyncioはI/Oバウンドな処理を効率的に実行できるため、データベースや外部APIの実行によく使われます。外部リソースに依存するため、エラーも発生しやすいです。利用すると、例外処理や例外発生時のキャンセルを非常に読みやすく書けるようになっています。利用して「予測可能で、より安全」に非同期処理を実装しましょう。world"というトークです⁠)⁠。Ltd.ページ内容の全部あるいは一部を無断で利用することを禁止します⁠。個別にライセンスが設定されている記事等はそのライセンスに従います。