asyncioのTaskに関する基礎知識

pythonのslackclientで非同期タスクを実行したらValueErrorになった話 で、たぶん asyncioのtaskを勘違いしてる気がするので簡単に解説。

要は asyncio.run にコルーチンではなくてTaskを渡したらエラーになった、という話ですが、このページでは、

Taskオブジェクト(asyncioが内部的にloopに委ねるときにwrapするオブジェクト)

と書かれていて、Taskオブジェクトをちょっと誤解している感じがします。このTaskの役割を理解したら納得がいくのではないかと思います。

まず、簡単に用語の解説。

コルーチン関数

async def で定義した関数を コルーチン関数 といいます。

In [6]:
import asyncio

async def coro_func():
    """coro_func()はコルーチン関数"""
    return 100

普通の関数なら、呼び出すと処理を実行して結果を返します。

In [7]:
def func():
    return 100

print(func())
100

普通ですね。しかし、コルーチン関数は呼び出しても実行されず、値も返しません。

In [8]:
print(coro_func())
<coroutine object coro_func at 0x7fba403461c0>
<ipython-input-8-af8de4e51631>:1: RuntimeWarning: coroutine 'coro_func' was never awaited
  print(coro_func())
RuntimeWarning: Enable tracemalloc to get the object allocation traceback

コルーチン関数を呼び出すと、結果を返すかわりに コルーチンオブジェクト という謎のオブジェクトが返ってきます。なんか警告メッセージも出てますが、これはとりあえず無視してください。

コルーチンオブジェクト

コルーチンオブジェクトは、普通の関数オブジェクトと同じように、コルーチン関数に書いた処理を実行できる「なんか」です。

ただし、関数オブジェクトと違って、お尻に () つけるだけでは呼び出せません。コルーチンオブジェクトを実行するには、asyncioのイベントループ に放り込む必要があります。

イベントループ

コルーチンオブジェクトを実行する時は、イベントループの create_task()メソッドでイベントループに登録します。

In [9]:
coro = coro_func()  # コルーチンオブジェクトを作成
loop = asyncio.get_event_loop() # イベントループを作成
task = loop.create_task(coro) # イベントループにコルーチンを登録
print(task)
<Task pending name='Task-3' coro=<coro_func() running at <ipython-input-6-ddc9151e3267>:3>>

create_task() メソッドは、コルーチンオブジェクトをイベントループに登録し、タスクオブジェクトを返します。

タスクオブジェクト

タスクオブジェクトはコルーチンオブジェクトの実行状態を管理するオブジェクトで、JavascriptのPromise や、Python/JavaのFutureオブジェクトによく似ています。単にコルーチンをラップしたもの、というのはちっと違うわけです。

コルーチンの処理結果を受け取る時は、タスクオブジェクトの add_done_callback()を使います。

In [10]:
def coro_done(task):
    print("The result is:", task.result())
task.add_done_callback(coro_done)
The result is: 100

RTMClient.start()はなにをやってるのか?

問題の RTMClient.start() も、タスクオブジェクトを返すようです。

つまり、RTMClient.start() 自体はコルーチン関数ではなく、どこかに本体のコルーチン関数があり、そのコルーチンをイベントループに登録してタスクオブジェクトを返す、という処理を行っているわけです。このとき、実行中のスレッドにイベントループが存在しなければ、あたらしくイベントループを作成して、コルーチンを登録している模様です。

slackapi/python-slackclientは使ったことがないので想像ですが…

loop.run_until_complete()

さて、ようやく 「なぜloop.run_until_complete() なら動くのに asyncio.run() では動かないのか?」 を解説できる感じになりました。

というかですね、そもそも loop.run_until_complete()asyncio.run() は、だいぶ意味合いの違う処理ですので、あっちで動いたからこっちでも動くはず、というものではありません。

loop.run_until_complete(task) は、イベントループのメソッドで、こんな処理をします。

  1. task で指定したタスクオブジェクトが完了するまで、イベントループを実行し続けます。
  2. task がタスクではなくコルーチンオブジェクトなら、loop.create_task() でイベントループに登録してから 1.を行います。

使い方はこんな感じです。

coro = coro_func() # コルーチンオブジェクトを作成
loop = asyncio.get_event_loop() # イベントループを作成
task = loop.create_task(coro) # イベントループにコルーチンを登録
loop.run_until_complete(task) # タスクが終了するまでイベントループを実行

loop.run_until_complete() は、同じループに対して何回でも使えます。

loop = asyncio.get_event_loop() # イベントループを作成

task = loop.create_task(coro_func()) # イベントループにコルーチンを登録
loop.run_until_complete(task) # タスクが終了するまでイベントループを実行

task2 = loop.create_task(coro_func()) # イベントループにコルーチンを登録
loop.run_until_complete(task2) # タスクが終了するまでイベントループを実行

loop.run_until_complete() の主な用途は、登録済みのタスクが終了するまでイベントループを回す、というものですので、問題の RTMClient.start() が作成したタスクの終了を待つ、という場合には loop.run_until_complete()は適切なわけです。おそらく、このライブラリの作者は、この使い方を想定しているのだと思います。

asyncio.run()

一方、asyncio.run() は、あたらしくイベントループを作成して、非同期処理を開始するための関数です。こんな処理をします。

  1. あたらしくイベントループを作成します。
  2. 指定されたコルーチンオブジェクトをイベントループに登録し、タスクオブジェクトを作成します。
  3. タスクオブジェクトが完了するまでイベントループを実行し続けます。

使い方はこんな感じです。

coro = coro_func() # コルーチンオブジェクトを作成
asyncio.run(coro) # イベントループを作成し、コルーチンオブジェクトが終了するまで実行

pythonのslackclientで非同期タスクを実行したらValueErrorになった話 では

なぜasyncio.run()ではnew loopをする?

と書かれていますが、asyncio.run()のドキュメント を見ると、

この関数は常に新しいイベントループを作成し、終了したらそのイベントループを閉じます。 この関数は非同期プログラムのメインのエントリーポイントとして使われるべきで、理想的には 1 回だけ呼び出されるべきです。

と書かれています。 そもそも asyncio.run() はあたらしくイベントループを用意して、非同期処理を開始するための関数なので、イベントループを作ってくれないと困ってしまうのです。loop.run_until_complete() のような、おなじイベントループで処理を継続するためのメソッドとは大きく違います。

asyncio.run() に指定できるのは、タスクオブジェクトではなく、コルーチンオブジェクトだけです。asyncio.run()は常にあたらしいイベントループを作成しますから、別のイベントループに登録したタスクオブジェクトを渡されると、一生ループを実行し続けても決して終了しません。

RTMClient.start() の場合も同じことで、RTMClient.start()がタスクを登録したイベントループは、asyncio.run() が上書きしてしまいますから、このタスクはもう絶対に動きません。RTMClient.start() は別のイベントループで処理を開始してしまっているのに、asyncio.run() であたらしくイベントループを作って、無理やりこっちのイベントループで実行しろ、と強要するのは無茶な話なのです。

これって「地獄」?

元記事では「あるいは遭遇しうる地獄」と表現していますが、このケースは単にライブラリ開発者が想定してしていない使い方をしてしまったというだけの話で、別に「地獄」とかというほどのものではないのではないかと思います。

通常のasyncioなプログラムは、最初に一つだけイベントループを作って、それを使い続けます。asyncio.run()は、前述のasyncio.run()のドキュメント にある通り、このために作られた関数です。

この関数は常に新しいイベントループを作成し、終了したらそのイベントループを閉じます。 この関数は非同期プログラムのメインのエントリーポイントとして使われるべきで、理想的には 1 回だけ呼び出されるべきです。

したがって、懸念されているような、イベントループがひょいひょい切り替わってしまうような事態は通常は発生しません。

このケースは、単にライブラリの使い間違いで、おそらく slackapi/python-slackclient さんが「ぼくがイベントループの初期化もしといてあげるね」と言っているのに、それを無視して「いや、こっちを使え」とasyncio.run()でイベントループを置き換えてしまったためにエラーになった、というだけだと思います。