mizzsugar’s blog

日々感じていることや学んだことを書きます。エンジニアリング以外にも書くかもしれません。

「実装で知るasyncio」を聴いてコルーチンを全く分かっていないことに気づいた

本記事は「PyCon JP 2021 Advent Calendar」に5日目に掲載する記事です。

qiita.com

PyConJP2021で「実装で知るasyncio -イベントループの正体とは- 」を拝聴しました。

https://2021.pycon.jp/time-table/?id=272959

正直、「何も分からん」という感想でした。例の成長曲線の「完全に理解した」の次のやつではなく、完全に理解する前の段階です。 しかし、発表中に「もし難しく感じたらラッキー。それこそが PyCon JP に参加する価値です」というお言葉をいただきました。せっかくいただいたお言葉「この発表難しかった」で終わるのはもったいないと思い、この記事を執筆するに至ります。この記事を書きながら、コルーチンとは何かを説明出来るくらい理解することが目的です。

※この記事では、asyncioについてはまったく扱いません。(最後の方で少ししか)

まとめ

  • コルーチンとは、プログラムの呼び出し元と呼び出し先を行ったり来たりする仕組み
  • Pythonでは、行ったり来たりするのにgeneratorを使う
  • generatorを使ったコルーチンは、generatorオブジェクト.send()が呼び出されるまで処理を待つ仕組みである
  • generatorのコルーチンとは別にasync/awaitを使ったnative coroutinePythonにはあるが、上述のコルーチンのように行ったり来たりの処理はできない。処理を待つという点ではgeneratorコルーチンと同じ特徴を持つ。

コルーチンとは(全プログラミング言語共通の概念として)

コルーチンは英語で書くとco-routineです。

ルーチンは、「ルーチンワーク」のルーチンですね。 weblioには「きまりきった手続きや手順、動作など。また、日常の仕事。日課」と書いています。

ルーチンとは何? Weblio辞書

例えば、ウェイターの仕事で「ルーチン」を表してみると、下記の3つが決まりきった仕事になります。

注文を取る
料理を運ぶ
食器を下げる

「ルーチン」はコンピュータプログラムの文脈では「特定の処理を実行するための一連の命令群」という意味だそうです。

ウェイターの仕事をPythonで書いてみて、「ルーチン」を表してみます。

python3
>> print("注文を取る")

>> print("料理を運ぶ")

>> print("食器を下げる")

print文は「この文字列を標準出力してください」という命令です。 ...毎回print文を書くのは面倒ですね。

そこで、関数にまとめて仕事を表してみます。

def work():
    print("注文を取る")
    print("料理を運ぶ")
    print("食器を下げる")

work()を呼び出したら、出力される文字列はこのようになります。

>> work()
注文を取る
料理を運ぶ
食器を下げる

work()関数は、ウェイターの仕事を表す「ルーチン」となります。 3つのprint文を書くことは決まりきったことなので、関数にすると楽ですね。 ウェイターの仕事をするために、print文という命令をまとめているので、関数はルーチンと言えます。

さて、コンピュータプログラムには、ルーチンには2種類あります。「サブルーチン」と「コルーチン」です。 この2つの違いはなんでしょう。

先程のwork()関数はサブルーチンです。

サブルーチンは一度呼び出されたら処理が終了するまで呼び出し元には戻らないルーチンです。

それに対してコルーチンは、呼び出し元と呼び出し先を行ったり来たりするルーチンです。

ウェイターの仕事は、ノンストップで「注文を取る」「料理を運ぶ」「食器を下げる」をするわけではなく、 以下のようになっていると思います。

f:id:mizzsugar:20211203174142p:plain サブルーチンだと「料理が出来る」を待たずに「料理を運ぶ」が実行されるイメージです。

ウェイターだけでなく、料理人やお客さんと相互のやり取りをしながら仕事を進めるのがコルーチンです。

Pythonのコルーチン

pythonでは、相互のやり取りを表すためにyieldを使います。 ウェイターの仕事だと、下記のようのなイメージです。

def work():
    print("仕事開始")
    x = yield
    print(x)
    print("注文を取る")
    y = yield
    print(y)
    print("料理を運ぶ")
    z = yield
    print("食器を下げる")

work()が返すオブジェクトをgeneratorと呼びます。 work()を呼び出すとこのように文字列が出力されます。

>> w = work()
>> w.send(None)  # 生成されたばかりのgeneratorには、Noneを渡してsend()を実行しないといけない
仕事開始
>> w.send("コーヒーを頼む")  # xに「コーヒーを頼む」という文字列が代入され、次の行が実行される
コーヒーを頼む
注文を取る
>> w.send("コーヒーが出来上がる")  # yに「コーヒーが出来上がる」という文字列が代入され、次の行が実行される
コーヒーが出来上がる
料理を運ぶ
>> w.send("コーヒーを飲み終わる")  # zに「コーヒーを飲み終わる」という文字列が代入され、次の行が実行される。行の終わりになるのでStopIterationという例外が発生する。
コーヒーを飲み終わる
食器を下げる
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

この時、send()が呼び出されるまでは次の行の処理に移りません。

例えば、①の後にsend()によってコーヒーが頼まれるまでは注文を取りません。

処理の終わりに到達するので、w.send("コーヒーを飲み終わる")StopIterationという例外が発生しています。

また、send()よりも前の処理によってなされた状態を保っています。

入力された数値の平均値を求める関数を例に見てみます。

def averager():
    print("開始")
    count = 0
    sum = 0
    while True:
        sum += yield
        count += 1
        print(sum/count)

先程のウェイターの仕事では仕事を終えたらStopIterationエラーになりましたが、このプログラムでは任意のタイミングでコルーチンを止めるようにしました。

>> a = averager()
>> a.send(None)  # next(a)でも可
開始
>> a.send(2)  # sumは2で、countが1
2.0
>> a.send(3)  # sumは5で、countが2
2.5
>>a.close()  # generatorオブジェクト.close()でコルーチンを終了させる
>>a.send(4) # コルーチンは終了しているのでStopIterationエラーになる
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

この処理では、呼び出し元と呼び出し先を行ったり来たりしている間に、generatorがsumとcountの状態を保っていることが分かります。 前に実行した処理を引き継いだまま、send()を実行しています。

Pythonのasyncioはコルーチン?

Pythonでは、上記のコルーチンとは別で、native coroutineと呼ばれるコルーチンがあります。native coroutineはasync/awaitを使ったコルーチンです。 PEP492で、上記のgeneratorを使ったコルーチンとは別のとしてnative coroutineが定義されています。

natavie coroutineとgeneratorを使ったコルーチンの違いが分からなかったところ、図解「generator・native coroutine・with」 〜 関心やコードを分離する文法と、処理順序・構造 という記事に解説がありました。

import asyncio
import random

async def main():
    print('first')
    await asyncio.gather(
        native_coroutine(1),
        native_coroutine(2),
        native_coroutine(3),
    )


async def native_coroutine(x):
    await asyncio.sleep(
        random.random())
    print(x)


asyncio.run(main())
まず、asyncの付いている関数定義は、generatorと同じように、呼び出しをしても直ちに実行はされない関数になります。generatorの場合はsend()を都度実行するのでしたが、native coroutineの場合はasyncio.run()やasyncio.gather()などによって実行します。

asyncio.runでmain()を実行すると、まずfirstと出力されますが、次のawaitでnative(1),native(2),native(3)の結果が返されるまで処理を待つようになります。
native(1),native(2),native(3)は"同時に"実行されます。その事を模式的に表現したのが三本の矢印たちです。

ところで、このフローを見ると、(カタカナ表記の)コルーチンのような行ったり来たりする構造がありません。
どういうことでしょうか。

実は、await asyncio.gather()をすると、その引数のnative coroutine達の処理が終了するか、またはタイムアウトするまで待ち続けてしまう ので、コルーチンにおける行ったり来たりという処理ができないのです。ですが、Pythonではこれを(native) coroutineと呼んでいます。

図解「generator・native coroutine・with」 〜 関心やコードを分離する文法と、処理順序・構造 より引用

generatorコルーチンがsend()が呼び出されるまで実行されないように、async/awaitawaitの処理が終わるまで待っているという点では同じようです。 しかし、行ったり来たり出来ない点ではnative coroutineは、Python以外での文脈で使われる「コルーチン」とは違うようです。

参考

zenn.dev

www.oreilly.co.jp