asyncioとUvicorn
事前準備
スレッドとプロセス
Pythonのtime.sleepはスレッドを指定した時間停止させるようです.
Suspend execution of the calling thread for the given number of seconds. 1
スレッドとさらっと書いていますが, スレッドにはいくつかの種類があります. 特に重要なのはOSのカーネルが関係するかどうかです. カーネル・スレッドと呼ばれ実行の順番などはOSが管理するようです. ではPythonはどちらでしょうか?
This module provides low-level primitives for working with multiple threads (also called light-weight processes or tasks) — ...2
とあるのでどうもカーネル・スレッドのようです. 要はOSのシステム・コールかなんかを使うようです.3 別名LWP(Light-Weight Process)とも呼ばれますが, 何が軽量なのでしょうか?
マルチ・タスク
OSは複数のアプリケーションを同時に実行できるように見せていますが, 実際にはあるコアが実行できるプロセスは一つだけです. では何故同時に実行できるように見えるのかというと高速でプロセスを切り替えているからです. コンテクスト・スイッチといってプロセスの処理に必要な情報(コンテクス)を保存したり復元したりを繰り返し一つのプロセスがリソースを占有しないようにOSは気を配っているわけです. この切り替えがプロセスに比べて軽量なので軽量プロセス = スレッドという感じでしょうか.4 これはOSにより強制的に発生します.
こうした方式をプリエンプティブ・マルチタスキングと呼ぶようです. 一方sleepなどをシステムコールのタイミングで制御をOSにゆずりわたす方式は協調型マルチタスキングと呼ぶそうです. 56
方式 | 制御の主体 |
---|---|
Preemptive Multitasking | OS |
Cooperative Multitasking | アプリケーション |
ブロッキングとノンブロッキング
ようやく本題です. Web(というかHttpというかTCP)などのネットワークで通信する場合に発生するボトルネックの一つはI/Oバウンドです. 特にブロッキングという問題があります. Pythonでも標準入力はブロッキングな処理と言えそうです.
import sys
if __name__ == "__main__":
for line in sys.stdin:
print("> " + line)
ファイルやネットワークからの読み込みも同様に処理が終わるまで待つ必要があります. こうした待機中の状態を減らしたい, できれば無くしたいわけです.
マルチプロセス, スレッディング, そして非同期
ブロッキングI/Oに対する対策は3つあるらしいです.
方法 | 問題 |
---|---|
マルチプロセス | コンテクスト・スイッチのコスト, 生成に時間がかかる |
マルチスレッド | エラーがプロセス上の全スレッドに波及する? |
協調的マルチタスク | 制御が難しい? |
上二つはブロッキングな処理をスレッドとかプロセスで実行すれば, 並列に処理できるといことです. ブロッキングな通信を続けながら他の処理も実行できます. いわゆる並行処理によるマルチタスクです. JavaScriptもシングル・スレッドと言われますが, ブラウザ自体はUIスレッドとは別にネットワーク用のスレッドを持っているようです.7 シングルコアならスレッドを切り替えたり, マルチコアなら実際に完全に並列に実行すれば無駄が省けます. 協調的マルチタスクはcallbackやコルーティンを使う方法らしいです. Pythonの非同期処理はコルーティンを使うので協調的マルチタスクと言えるようです.8
コルーティンとyieldの意味
プリエンプティブ・マルチタスクではではスレッドやプロセスの制御をOSが強制的に取得して切り替えます.9 これに対して協調的マルチタスクではアプリケーション側で自発的に譲りあう事になります. つまりある時点で呼び出される側が行うらしいです.
In general, a coroutine (short for cooperative subroutine) is a function designed for voluntary preemptive multitasking: it proactively yields to other routines and processes, rather than being forcefully preempted by the kernel.10
これでyieldがreturnとどう違うかが分かったと思います. returnはまさに逐次的です. 処理をして何か結果を返すという一つの塊です. このような何か結果を返す関数のことを(サブ)ルーティンとか呼ぶわけです. coroutineはcooperative subroutineと呼ばれ, returnの代わりにyieldを使います. 値と一緒に制御も返します. コルーティンが互いに主導権を譲るわけです.11
Pythonのコルーティンはyieldを右辺に置くことで実現します.12 以下の例13では制御はgather関数を通じてmain関数側で行われています. Noneが送られるまで制御は継続しています.
def accumlate():
accumlator = 0
while True:
next = yield
if next is None:
return accumlator
accumlator += next
def gather(tallies):
while True:
tally = yield from accumlate()
tallies.append(tally)
def main():
tallies = []
accumlator = gather(tallies)
next(accumlator)
for i in range(4):
accumlator.send(i)
accumlator.send(None)
for i in range(5):
print(i)
accumlator.send(i)
accumlator.send(None)
print(tallies)
if __name__ == "__main__":
main()
これは一種の待ち状態(await)とも言えます. またスレッドともよく似ています. スレッドもコルーティンも処理のためのコンテクストを保存しています. スレッドの切り替えでコンテクストが保存されるように, コルーティンは次に再開される場所やその時の状態を保存しています. 違いは制御の切り替えをOSが行うかアプリケーション側で行えるかでしょう. グリーンスレッドもそうですがアプリケーション側で行う場合OSのコンテクスト・スイッチのよりも軽量に行うことができるそうです.
本来Pythonのコルーティンというと以下で説明するネイティブ・コルーティンを指すのだと思いますが, ここでは以下の記事に従ってyieldを使ったコードもコルーティンと表現します.
- Coroutine in Python
- PYTHON: GENERATORS, COROUTINES, NATIVE COROUTINES AND ASYNC/AWAIT
- An Introduction to Asynchronous Programming in Python
公式ドキュメントには以下のような文しか見つけられませんでした.
Generators also become coroutines, a more generalized form of subroutines. Subroutines are entered at one point and exited at another point. Coroutines can be entered, exited, and resumed at many different points.14
以後は使い分けのために一番下の記事にしたがって"yieldを使ったコルーティン"と呼ぶことにします.
yield-from構文の役割
yield-fromはyieldを使ったコルーティンを後ろに取ります. 面白いのはこのgather関数を通じてコルーティンを管理できるところです. つまりコルーティンを制御するコルーティンが作れるわけです.
ネイティブ・コルーティンの登場
yieldを使ったコルーティンを使うとジェネレータ・ベースのコルーティンというのを定義できます.15 coroutineデコレータ関数は結構面倒な処理が含まれているので調べていませんが, 将来的にはこのデコレータは廃止されるようです.
@asyncio.coroutine
def old_style_coroutine():
yield from asyncio.sleep(1)
async def main():
await old_style_coroutine()
asyncで定義された関数が生成するのオブジェクトをネイティブ・コルーティンと呼ぶのはcoroutineオブジェクトを返すからかなと思います. 上のyieldを使ったコルーティンはgeneratorオブジェクトを返します. これはcotouineデコレータを使って再定義しても結果は同じですがasyncを使った場合だけcoroutineオブジェクトが生成されます.
ここまでのまとめ
ひとまずまとめをしておきます.
- ブロッキングとノン・ブロッキング
- 協調的マルチタスキングとプリエンプティブ・マルチタスキング
- コルーティンとスレッド
- yield-fromとawait
asyncio
Asynchronous programming is different than classical “sequential” programming.16
コルーティン関数とコルーティン・オブジェクト
個人的な話ですがJavaScriptの非同期処理が比較的分かりやすいのに比べて, Pythonのasyncioはなんとなく掴み所がないと感じていました. これはasync/awaitをPromiseによって書き下せることが大きいと思います.17
Pythonは非同期処理をコルーティンベースで実現するのですが, Coroutineクラスを直接呼び出すわけではありません. コルーティンオブジェクトを生成する関数をコルーティン関数と呼びますが, asyncがついた関数はまさにコルーティン関数です. しかしyieldを使ったコルーティンとasyncの間にはかなり開きがあるように感じます.
yield-from構文を使うとコルーティン・マネジャが作れるのでした. これはコルーティンを待たせたり, データを送ったり, 実行させたりがコントロールできるようになるわけです.
コルーティンとは?
yieldは式です. つまり変数に束縛させることができました. notのような使い方といえば良いでしょうか.
def coroutine_like_func():
greeting = yield
try:
yield greeting
except GeneratorExit as e:
print("Exit")
def main():
coroutine_like = coroutine_like_func()
try:
next(coroutine_like)
print(coroutine_like.send("Hi"))
coroutine_like.close()
next(coroutine_like)
except StopIteration as e:
print("Stop Iteration")
finally:
print("End")
if __name__ == "__main__":
main()
このcoroutine_likeには以下のようなメソッドが存在します.
- __iter__
- __next__
- send
- close
- throw
上二つをイテレータ・プロトコルと呼び, これを実装しているものはiteratorと呼ぶらしいです.18 ジェネレータというのは, このプロトコルを実装したオブジェクトを返す便利関数だったわけです.
Python’s generators provide a convenient way to implement the iterator protocol.19
sendがあることでコルーティンにデータを渡すことができるようになしました. closeを呼び出すとGeneratorExit例外が発生します. coroutineはこれまでの処理の状態を維持したまま停止していますから, コルーティンを終了させる場合はcloseを使います.
def accumlate():
# context
accumlator = 0
print("Intialized")
try:
while True:
next = (yield) # Input
if next is not None:
print("Accumulate")
accumlator += next
else:
yield accumlator # Output
except GeneratorExit as e:
print('Exit From Generator!!')
def main():
try:
coroutine = accumlate()
coroutine.send(None) # proceed to the first yield
for i in range(1, 10):
coroutine.send(i)
result = coroutine.send(None)
for i in range(1, 10):
coroutine.send(i)
result = coroutine.send(None)
print(result)
coroutine.close()
next(coroutine)
except StopIteration as e:
print("Stop")
if __name__ == "__main__":
main()
throwはsendで渡す値がおかしいのでコルーティンの外側から例外を吐かせたいというときに使えるようです.20 これらをコルーティンの性質を踏まえてネイティブ・コルーティンに適用してみましょう.
ネイティブ・コルーティンにsendしてみる.
generator.pyをawaitで置き換えてみましょう. 面白いのはdefがasync defになってyield fromがawaitになったこと以外はほとんど同じように使えることです.21
class AwaitableAccumlator:
def __init__(self):
self.accumlator = 0
def __await__(self):
while True:
next = yield
if next is None:
print('result', self.accumlator)
return self.accumlator
self.accumlator += next
async def native_manager(tallies):
# list the yield expression infinitely...
while True:
tally = await AwaitableAccumlator() # (*)
tallies.append(tally)
def main():
tallies = []
accumlator = native_manager(tallies)
# next(accumlator) can't be executed for awaitables
# because they don't conform to the iterator protocols
accumlator.send(None)
for i in range(4):
accumlator.send(i)
result = accumlator.send(None)
for i in range(6, 10):
accumlator.send(i)
accumlator.send(None)
print(tallies)
accumlator.close()
accumlator.send(1)
if __name__ == "__main__":
main()
このコードから待つという状態は要するにyieldの位置で制御を返してコルーティンの処理を中断するということのようです.
Awaitableとは
awaitが取れるものはズバリawaitableです. awaitable.pyで定義したように, awaitに後置できるかは__await__というダンダー・メソッドがあるかで判断しているようです. つまりawaitableとは__await__が定義されたクラスということになります. ネイティブ・コルーティンに加えてFutureとTaskを取ることができます.22
|Awaitables|説明 |
|:--:|:--:|:--:|
|Coroutine|コルーティン関数が返すオブジェクト|
|Future|将来の実行結果が入るオブジェクト|
|Task|Coroutineを実行できるオブジェクト|
まとめ
- コルーティン
- awaitables