CircuitPythonのasyncioを使った協調型マルチタスク1 基礎編

AdafruitのLearning Guideの非公式日本語訳です。
英語独特の言い回しを可能な限り日本語的な表現に直していますが、不自然に感じる部分も残っていますことを何卒ご容赦ください。

このガイドは開発中です。今後、内容は進化していきます。
不足している部分もあることをご了承ください。

このガイドでは、asyncioライブラリとasyncおよびawaitキーワードを使って、CircuitPythonで協調的マルチタスクを行う方法を説明します。asyncioライブラリはPythonのホストコンピュータ版であるCPythonに含まれています。MicroPythonもasyncioのバージョンを提供しており、CircuitPythonではそのバージョンを使用しています。

asyncio を使用するには CircuitPython 7.1.0 以降を使用してください。asyncio は、SAMD21 ボードではサポートされていません(RAM 容量が不足しているため)。

よくある質問

どうしてCircuitPythonはプリエンプティブなハードウェア割り込み(irq)をサポートしていないの?

私たちはMicroPythonがどのようにハードウェア割り込みをサポートしているかを調べましたが、制限があるため、より良い完全なasyncioの体験を提供するよりも、使いにくく、エラーが起こりやすいと判断しました。プリエンプティブな割り込みはいつでも入ってくる可能性があり、インタプリタ言語では制御が困難です。MicroPythonでは、割り込みハンドラでメモリを確保することはできませんが、Pythonにはメモリを確保するものがたくさんあります。また、ガベージコレクタがあるので、割り込みのレイテンシを保障できるわけではありません。

その代わりに、asyncioとGPIOピンの変化/下降/上昇を追跡する「バックグラウンドタスク」を使用して、割り込みを捕捉し、処理する準備ができたときに使用できると考えています。当社にはcountioとkeypadという2つのネイティブモジュールがあり、バックグラウンドでピンの状態変化を追跡することができます。

また、Feather M4やCircuitPlaygroundsのようなボードやRaspberry PiやデスクトップのPythonコンピュータでサンプルが実行できるように、CircuitPythonのコードをCPythonコードの真のサブセットとして維持したいと思っています。

どうしてthreadをサポートしてくれないの?

CircuitPythonの開発者はthreadに着手したことがありますが、 threadは共有メモリを使って複数のタスクを協調して実行するのに適した方法ではないとすぐに判断しました。asyncioは、安全に同じメモリ空間を共有することができる同時実行タスクを持つためのより良い方法だと考えています。

CircuitPythonに対応しているマイクロコントローラーの大半はシングルコアですが、Espressifチップセットのようにデュアルコアのものは、WiFi処理などの特定のバックグラウンドタスクを、処理のバランスを手動でとるのではなく、別のコアに固定した方が良いでしょう。

協調型マルチタスク(ノンプリエンプティブマルチタスク)

協調型マルチタスクとは、複数のタスクを交互に実行するプログラミングのスタイルです。各タスクは、何かを待つ必要があるまで、あるいは十分に実行したので他のタスクを実行させるべきだと判断するまで実行します。

どのタイミングで他のタスクに制御を委ねるかは、各タスクの判断に委ねられており、これが協調性の理由です。タスクは、行儀が悪ければ他のタスクをフリーズさせてしまいます。これはプリエンプティブ・マルチタスクとは対照的で、他のタスクを実行させるためにタスクが知らないうちに中断されます。Pythonのthreadやmultiprocessingはプリエンプティブなマルチタスクの例です。

協調型マルチタスクは、2つのタスクが文字通り同時に実行される並列性を意味するものではありません。タスクは並行して実行されます。タスクの実行はインターリーブされており、同時に複数のタスクを実行することができます。

協調型マルチタスクでは、スケジューラがタスクを管理します。一度に実行されるタスクは1つだけです。あるタスクが制御を放棄して待ち始めると、スケジューラは実行可能な別のタスクを開始します。スケジューラは公平で、準備ができているすべてのタスクに実行のチャンスを与えます。スケジューラはイベントループを実行し、イベントループに割り当てられたすべてのタスクに対して、このプロセスを繰り返します。イベントループについてはすでにおなじみですが、この用語は知らないかもしれません。ほとんどのCircuitPythonプログラムの主要部分であるwhile Trueループは、ボタンが押されたことを監視したり、単にスケジュールに沿って定期的にコードを実行するなど、イベントループとして機能することがよくあります。また、Arduinoのプログラムで必須となっているloop()ルーチンも、イベントループとして使われます。

タスクは、スリープ期間の完了、ネットワークやファイルのI/O操作、タイムアウトなどを待つことができます。また、ピンの状態が変化するなど、外部の非同期イベントを待つこともできます。

下の図は、イベントループを実行しているスケジューラが、3つのタスクを抱えている様子を示しています。タスク1は実行中、タスク2は実行準備が整い、タスク1が制御を放棄するのを待っている状態、タスク3は他のものを待っていて、まだ実行できる状態ではありません。

コルーチン

タスクはコルーチンの一種です。コルーチンは、コードの途中で停止して、呼び出し元に戻ることができます。コルーチンは、コードの途中で停止して呼び出し元に戻り、再び呼び出されたときには、中断したところからスタートします。

Pythonのジェネレータをご存知かもしれませんが、これもコルーチンの一種です。ジェネレータには、1つ以上のyield文が含まれているので、それで見分けることができます。コルーチンは、yield文に到達すると呼び出し元に戻り、オプションで値を返します。コルーチンが再び呼び出されると、yieldの次のステートメントから始まります。Pythonの初期の協調型マルチタスクシステムではジェネレータのメカニズムを利用し、タスクが制御を放棄するタイミングをyieldで示していました。

その後、Python は協調的マルチタスクをサポートするために async と await キーワードを追加しました。コルーチンはキーワード async で宣言され、キーワード await はその時点でコルーチンが制御を放棄することを示します。

下の図は、タスクとして使用される2つのコルーチンf()とg()を示しています。タスク1は起動すると、何かを待つ必要のあるawait文に到達するまで実行します。その時点で制御を放棄します。スケジューラは、実行する別のタスクを探し、タスク2を選択します。タスク2は、自分のawaitに到達するまで実行されます。その時までに、タスク1が待っていたものは何でも起こったと仮定しましょう。スケジューラは、タスク1が再び実行できる状態になったことを確認し、中断していたf()を起動します。

このガイドでは、async、await、およびasyncioライブラリでの使用について、簡単な例を挙げながら詳しく説明します。

asyncioライブラリ

このガイドのほとんどの例では、CircuitPythonバージョンのasyncioライブラリを必要とします。このライブラリはCircuitPythonに組み込まれているわけではなく、使用するにはCIRCUITPYにコピーする必要があります。asyncioライブラリはCircuitPython Library bundleに含まれており、GitHubからも入手可能です。また、circupツールを使って、ライブラリを取得し、最新の状態に保つことができます。

asyncioライブラリは、内部でadafruit_ticksライブラリを使用しています。
asyncioを手作業でインストールする場合は、adafruit_ticksもインストールしてください。circup ツールはこの処理を自動的に行います。

ハードウェア

asyncとawait(つまりasyncio)はほとんどのCircuitPythonボードで使用できますが、Trinket M0、Metro M0、Feather M0などのSAMD21(”M0″)ボードには十分なフラッシュやRAMがありません。また、内蔵フラッシュのみのいくつかのnRFボードもフラッシュが十分ではありません。

すべてのCircuitPythonボードがマルチタスクを使用するためのリソースを持っているわけではありません。必要に応じて適切なリソースを持つボードを選んでください。

今後の機能強化

CircuitPython asyncioと関連するライブラリやモジュールは時間をかけて強化されます。詳細はAdafruitのasyncioライブラリのイシューリストをご覧ください。countioについては、この問題を含め、その問題を参照してください。

コンカレントタスク

協調的なマルチタスクを実証するためには、1つまたは2つの独立した点滅するLEDを実装する簡単な例から始める必要があります。最初にasyncioを使わずに例をコーディングし、次にasyncioのタスクを使って同じ例をコーディングする方法を紹介します。

これらの例を自分で試してみて、少し修正してみるのもいいでしょう。この例では、2つのピンに抵抗付きのLEDを接続するだけです。後ほどタクトスイッチも必要になります。ここでは、Adafruit Metro M4 Expressボードの典型的な配線図を示していますが、ほとんどのボードを使用することができます。

asyncioを使わずにLチカ

LED 1個の場合

asyncioを使わずに、1つのLEDを点滅させたいとします。以下のプログラムはそのためのものです。このプログラムは、これまで見てきた最も単純な点滅の例よりも洗練されています。

この例ではboard.D1ピンを使用していますが、これは後の例で別のLED用のピンを使用するためです。しかし、board.LED(ボードにLEDがある場合)など、好きなピンを使ってください。

このプログラムでは、def blink内のループから抜けた時にDigitalInOutを自動的にdeinit()するためにwithを使用しています。LEDを10回点滅させた後、”done “と表示しています。

import board
import digitalio
import time

def blink(pin, interval, count):
    with digitalio.DigitalInOut(pin) as led:
        led.switch_to_output(value=False)
        for i in range(count):
            led.value = True
            time.sleep(interval)
            led.value = False
            time.sleep(interval)

def main():
    blink(board.D1, 0.25, 10)
    print("done")

main()

LED 2個の場合

ここで、もう1つのLEDを追加して、異なる速度で点滅させ、10回ではなく20回点滅させたいとします。2つのLEDは同時に点滅を開始するはずですが、点滅速度は異なります。

しかし、上のプログラムを安易に拡張すると、うまくいきません。1回目の後に、2回目のblink()を呼び出したとします。1回目のblink()は、time.sleep()でスリープがコントロールを持っていて動作し続けます。2つ目のLEDのための2回目のblink()は、1回目のblink()がすべて終わった後にのみ実行されます。これではうまくいきません。

# これは正しく機能しません
import board
import digitalio
import time

def blink(pin, interval, count):
    with digitalio.DigitalInOut(pin) as led:
        led.switch_to_output(value=False)
        for i in range(count):
            led.value = True
            time.sleep(interval)
            led.value = False
            time.sleep(interval)

def main():
    blink(board.D1, 0.25, 10)
    # DOESN'T WORK
    # Second LED blinks only after the first one is finished.
    blink(board.D2, 0.1, 20)

main()

asyncioを使わずに2つのLEDを同時に点滅させるには、かなり複雑なことがわかりました。以下の例は、この問題を解決するための一つの方法に過ぎず、他にも様々な方法があります。このプログラムでは、LEDの状態が変化する時間を確認するために、常に時間をチェックする必要があります。必要な状態を保つためにクラスを追加しました。要するに、この例のためだけに、特別な目的のタスクとイベントループのメカニズムを作ったのです。

import board
import digitalio
import time

class Blinker:
    def __init__(self, led, interval, count):
        self.led = led
        self.interval = interval
        # Count both on and off.
        self.count2 = count * 2
        self.last_transition = 0

    def blink(self):
        """Lチカが終わったらFalseを返す"""
        if self.count2 <= 0:
            return False
        now = time.monotonic()
        if now > self.last_transition + self.interval:
            self.led.value = not self.led.value
            self.last_transition = now
            self.count2 -= 1
        return True

def main():
    with digitalio.DigitalInOut(board.D1) as led1, digitalio.DigitalInOut(
        board.D2
    ) as led2:
        led1.switch_to_output(value=False)
        led2.switch_to_output(value=False)

        blinker1 = Blinker(led1, 0.25, 10)
        blinker2 = Blinker(led2, 0.1, 20)
        running1 = True
        running2 = True
        while running1 or running2:
            running1 = blinker1.blink()
            running2 = blinker2.blink()
        print("done")

main()

ご覧のように、2つのタスクを調和させて行うことは、思ったほど簡単ではありません。asyncioがどのようにこれを簡単に実現するかを見てみましょう。

asyncioを使ってLチカ

次に、上の例と同じ例を、今度はasyncioを使って試してみましょう。

LED 1個の場合

1つのLEDを点滅させるのに、asyncioは必要ありませんが、この例はasyncioを使うためのスタイルで書かれています。この例は、上記の非asyncioのLED1個の例と似ていますが、大きな違いがあることに注目してください。

  • time.sleep()の呼び出しは、await asyncio.sleep()に置き換えられています。
  • blink()とmain()関数は単なるdefではなくasync defとして定義されています。
  • タスクオブジェクトが作成され(実行が開始される)、await asyncio.gather()でタスクが完了するのを待ちます。
  • 単にmain()を呼び出すのではなく、asyncio.run(main())を呼び出します。
import asyncio
import board
import digitalio

async def blink(pin, interval, count):     # blinkをasync defとして定義します
    with digitalio.DigitalInOut(pin) as led:
        led.switch_to_output(value=False)
        for i in range(count):
            led.value = True
            await asyncio.sleep(interval)  # awaitするのを忘れないこと
            led.value = False
            await asyncio.sleep(interval)  # awaitするのを忘れないこと

async def main():                          # メインについてもasync defとして定義します
    led_task = asyncio.create_task(blink(board.D1, 0.25, 10))
    await asyncio.gather(led_task)         # awaitするのを忘れないこと
    print("done")

asyncio.run(main())

では、どんなふうになっているかを見てみましょう。まず、awaitを含むすべての関数やメソッドは、コルーチンであることを示すために、async defとして定義しなければなりません。次に、非同期のコードから非同期の関数を直接呼び出すことはできません。代わりにasyncio.run()や同様の特別な関数を使って、非同期コード(code.pyのメインラインコード)と非同期コードの間のギャップを埋める必要があります。

ところで、awaitとは何を意味するのでしょうか?コードの中で、実行中のコルーチンやタスクがスケジューラーに制御を譲り、他の非同期ルーチンが完了するのを待つポイントを示します。 awaitは、「何かを待つ必要があるので、再開の準備ができるまで他のタスクを実行させてください」という意味です。上のblink()では、await asyncio.sleep()を使っています。コードがスリープ状態になると、別のタスクが実行できるようになります。sleep()が終わったら、このコルーチンは再開します。

main()では、まずタスクを作成します。blink()コルーチンを必要な引数で呼び出してインスタンス化し、そのコルーチンをasyncio.create_task()に渡します。 create_task()はコルーチンをタスクにラップし、タスクを「すぐに」実行するようにスケジュールします。”すぐに “というのは、他の既存のタスクが制御を放棄したらすぐに実行の順番が回ってくるということです。

そして、プログラムはawait asyncio.gather()を使い、渡されたすべてのタスクが終了するのを待ちます。この場合は、待つべきタスクは1つだけです。

タスクとコルーチンはどちらもAwaitableオブジェクトであり、waitすることが可能であることを意味します。実際、これらを実行させるには、awaitする必要があります。

LED 2個の場合

次の例は、2つのLEDを点滅させるもので、やはりasyncioを使用しています。このコードは、上記の1つのLEDの例とほとんど同じであることに注意してください。blink()関数は全く同じです。await asyncio.gather()が使われますが、1つではなく2つのタスクが渡されます。

import asyncio
import board
import digitalio

async def blink(pin, interval, count):
    with digitalio.DigitalInOut(pin) as led:
        led.switch_to_output(value=False)
        for _ in range(count):
            led.value = True
            await asyncio.sleep(interval)  # awaitするのを忘れないこと
            led.value = False
            await asyncio.sleep(interval)  # awaitするのを忘れないこと


async def main():
    led1_task = asyncio.create_task(blink(board.D1, 0.25, 10))
    led2_task = asyncio.create_task(blink(board.D2, 0.1, 20))

    await asyncio.gather(led1_task, led2_task)  # awaitするのを忘れないこと
    print("done")


asyncio.run(main())

この例を実行して、2つのタスクが同時に開始されることを確認してください。両方が完了するまで実行されると、「done」と表示されます。

まとめ

これらの例から、覚えておくべき重要なことはこちらです。

  • async defでコルーチンを定義する。
  • await でコルーチンの制御を放棄する。
  • await asyncio.sleep(interval)でコルーチンの中でスリープさせる。
  • asyncio.create_task(some_coroutine(arg1, arg2, …)) で、即時実行されるタスクを作成する。
  • await asyncio.gather(task1, task2, …) でタスクが終了するのを待つ。
  • awaitを忘れないでください。

Follow me on Twitter