ExceptionGroupとTaskGroupのサポートで例外処理を効率的に扱う[3.11]
Python 3.11では、複数の例外をまとめるExceptionGroupが導入されています。これと同時に、複数のタスクの終了と例外の発生を管理するTaskGroupが導入されました。ExceptionGroupの目的の一つとして、このTaskGroupにおける例外のハンドリングがあります。
例外をまとめるExceptionGroup
まずはExceptionGroupを紹介します。ExceptionGroupはその名の通り例外グループで、複数の例外オブジェクトを1個の例外グループにまとめます。例外オブジェクトの他に、例外グループをまとめるという入れ子も可能です。
exceptions = [UserException1("ユーザ例外1"), UserException2("ユーザ例外2")]
raise ExceptionGroup("2つのユーザ例外です。", exceptions)
実行すると以下のように例外が発生し、ExceptionGroupオブジェクトと2つのサブ例外オブジェクトが表示されます。
+ Exception Group Traceback (most recent call last):
| File "exceptiongroup.py", line 11, in <module>
| raise ExceptionGroup("2つのユーザ例外です。", exceptions)
| ExceptionGroup: 2つのユーザ例外です。 (2 sub-exceptions)
+-+---------------- 1 ----------------
| UserException1: ユーザ例外1
+---------------- 2 ----------------
| UserException2: ユーザ例外2
+------------------------------------
このように、ExceptionGroupの動きは非常にシンプルですが、用途は以下のような局面での複数の例外のハンドリングという幅広いものです。
- 後述する並列処理で発生する全ての例外
- socket.create_connection関数などで処理の再試行で発生する例外
- ユーザーコールバック関数やファイナライザで発生するそれぞれの例外
- Hypothesisライブラリなどで複雑な計算処理の過程で発生する全ての例外
複数の例外をキャッチするtry~except*ブロック
ExceptionGroup自体も例外オブジェクトであり、try~exceptブロックで捕捉できます。ただ、例外グループのいずれかの例外が発生したということしか分からず、個別の例外の種類はisinstanceメソッドで逐一判別したり、subgroupメソッドで個別の例外を取り出したりする必要があるなど、その利用は若干煩雑です。そこで、個別の例外を直接捕捉できるtry~except*ブロック(「*」が付いている)がPython 3.11で同時に導入されました。これを使うと、上記の例外は以下のリストのように捕捉できます。
try:
exceptions = [UserException1("ユーザ例外1"), UserException2("ユーザ例外2")]
raise ExceptionGroup("2つのユーザ例外です。", exceptions)
except* UserException1 as e:
print(repr(e))
except* UserException2 as e:
print(repr(e))
# 実行結果:
ExceptionGroup('2つのユーザ例外です。', [UserException1('ユーザ例外1')])
ExceptionGroup('2つのユーザ例外です。', [UserException2('ユーザ例外2')])
ここで注目すべきは、複数の例外に対して複数のexcept*ブロックが実行されている点です。通常のtry~exceptブロックでは、最初に捕捉されたexceptブロックのみが実行されるので、大きな違いです。さらに、except*ブロックにおいて例外を変数eで受け取っていますが、それはExceptionGroupオブジェクトであり個別の例外オブジェクトではありません。このExceptionGroupオブジェクトには、except*ブロックで捕捉された例外オブジェクトのみが含まれており、例外の内容に基づく処理を実行できます。なお、一つのtryブロックにexceptブロックとexcept*ブロックを混在させることはできません。
タスクをまとめるTaskGroup
前述したように、ExceptionGroupは非同期で動作するタスクにおいて発生する例外を扱うときに特に有用です。同期処理では発生する例外も常に1つなので特別な考慮は不要ですが、非同期処理では複数のタスクで個別に例外が発生する可能性があるので、それらを全て捕捉しようとすると特別な仕組みが必要です。そこでPython 3.11で導入されたのがTaskGroupです。
TaskGroupは非同期コンテキストマネジャーと呼ばれ、そのcreate_taskメソッドで登録された全てのタスクの実行の終了と例外の発生を管理します。例外がいずれかのタスクで発生すると、それはExceptionGroupオブジェクトにまとめられて送出され、他の実行中のタスクは終了させられます。以下のリストは、ランダムに例外を発生させる非同期関数を3つTaskGroupに登録し、例外の発生がなくなるまで繰り返す例です。
# 1~6の乱数を発生させ、1~3なら例外、4~6なら関数の戻り値とする
async def coroutine():
value = random.randint(1, 6)
match(value):
case 1:
raise UserException1("ユーザ例外1")
case 2:
raise UserException2("ユーザ例外2")
case 3:
raise UserException3("ユーザ例外3")
case _:
return value
# 呼び出し回数を表示してcoroutineタスクを3個実行。それぞれの戻り値の総計を返す
async def call(count):
print(f"{count} 回目の呼び出しです。")
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(coroutine())
task2 = tg.create_task(coroutine())
task3 = tg.create_task(coroutine())
return task1.result() + task2.result() + task3.result()
# callタスクを無限に呼び出す。例外が運良く発生しなければ終了
async def main():
count = 0
while True:
count = count + 1
try:
print(f"{await call(count)} が返りました。")
break
except* (UserException1, UserException2, UserException3) as e:
print(repr(e))
await asyncio.sleep(1)
asyncio.run(main())
実行すると、例えば以下のように例外の発生を確認できます(乱数を使っているので実行結果は毎回変化します)。各タスクで発生した例外は、'unhandled errors in a TaskGroup'と名付けられたExceptionGroupオブジェクトにまとめられて送出されるのが分かります。
ExceptionGroup('unhandled errors in a TaskGroup', [UserException2('ユーザ例外2')])
ExceptionGroup('unhandled errors in a TaskGroup', [UserException3('ユーザ例外3'), UserException3('ユーザ例外3')])
2 回目の呼び出しです。
ExceptionGroup('unhandled errors in a TaskGroup', [UserException3('ユーザ例外3')])
…略…
8 回目の呼び出しです。
15 が返りました。
