MicroPython的午睡(13) ラズパイPico、マルチコアの排他制御など

Joseph Halfmoon

前回、MicroPythonからでもラズパイPicoの2つのCPUコアを使えることが分かりました。今回は複数コアを使う際にはまず避けて通れないことの一つ、排他制御をやってみます。また、RP2040にはコア間の制御をやりやすくするための専用ハードが搭載されているので、そちらが使われているのかどうかも探ってみます。

※「MicroPython的午睡」投稿順 Indexはこちら

_Threadモジュールの関数を使って別Threadを起動することで、そのThreadがメインThreadのコア(core0)とは別なコア(core1)に割り当てられていることを前回目にしました。ただし同時に複数のThreadは起動できないような注釈も目にしました。ま、RP2040の2は2コアの2だそうなので、コア=Threadであるのであれば当然といえば当然。しかし、元々MicroPythonの_Threadモジュールでは、シングルコア機であっても複数Threadを起動できるのが普通だと思います(GILが効いているので同時に走るのは通常1つですが起動はできる。)

疑問1 Threadの2個目起動したらどうなるか?

答え1 ThonnyIDE上で実行してみたところ、ManagementError エラーが報告される。

前回のコードのthread起動部分を以下のように変更し、実行してみました。

_thread.start_new_thread(task0, (100, 0.5))
_thread.start_new_thread(task1, (100, 0.5))

どうも、2個目の起動を掛けたところでエラーが発生します。よって、現在使用しているラズパイPico上のMicroPython実装では、

Thread = 物理コア

と想定してしまって良いようです。

さて、本題の排他制御です。「フル」PythonではThreadingのような「高水準」なモジュールを使えますが、ラズパイPico上の現MicroPython実装で使えるのは「低水準」な_threadモジュールです。

疑問2 Thread(core0とcore1)間の排他制御はlock使えば良いのか?

答え2 lockが使える

まず参照用のドキュメントを見てみるとMicroPythonご本家の _thread モジュールについての説明は以下のようにつれないです。

_thread — マルチスレッドサポート (MicroPython)

ということで、「フル」Pythonのドキュメントを見るしかありません。

_thread — 低水準のスレッドAPI

ただし、「フル」Pythonのドキュメントどおりに実装されているとは限りません。まずは実機に聞いてみます(最初に import _thread を忘れずに。)

>> help(_thread)
object <module '_thread'> is of type module
__name__ -- _thread
LockType -- <class 'lock'>
get_ident -- <function>
stack_size -- <function>
start_new_thread -- <function>
exit -- <function>
allocate_lock -- <function>

_threadモジュール内のオブジェクトは上記のような「フル」版のサブセットです。lockオブジェクトを生成するための allocate_lock()メソッドが存在することが分かります。実際に lock オブジェクトで使えるメソッドにどんなものがあるのか調べてみます。

>>> help(_thread.LockType)
object <class 'lock'> is of type type
acquire -- <function>
release -- <function>
locked -- <function>
__enter__ -- <function>
__exit__ -- <function>

lockを獲得するための acquire() と開放するための release() があることは確認できました。これらを使えば、とりあえず2Thread間で排他制御、待ち合わせなど実現できそうです。しかしそこでちょっと気になることがあります。

疑問3 RP2040にはハードウエアのSPINLOCKが装備されているがMircoPythonで使われている?

答え3 使われている(みたい。)しかしMicroPythonの_Threadモジュールのlockでは使われていない?(ぽい。)

ドキュメントには捗々しいことが書いてないので、結局、先週のサンプルプログラムをチョイ直し、lockを使って待合するコードを挿入した上で、

SPINLOCK_STレジスタを覗き見するコード

を何か所か仕込んでみました。実際に走らせたコード全文を末尾に掲げました。

SPINLOCK_STレジスタは32ビットのステータスレジスタで、それぞれのビットが32個あるSPINLOCKレジスタのステータス(LOCKされているか否か)を反映するデバッグ用のレジスタです。このレジスタのどこかに1が立っていれば「ハードウエアのSPINLOCKが使われている」こととなります。

末尾のプログラムの動作はこんな感じです。

  1. task0は、新Thread(core1)に割り当てられて起動される。
  2. task0は、起動するなり一つだけある lock オブジェクトを獲得する。
  3. task1は、メインタスクで動作する。 lock 獲得を試みる。しかしtask0がlockを持っている間は待つ(開放されるまで永久に)
  4. task0は、先週作った0.5秒周期のLチカ(コア毎LED色変)する
  5. task0は、2回Lチカしたところで lock を開放する。
  6. task1は、lockが得られたのでようやくLチカ開始する

ということで、同一回数、同一周期のマルチコアLチカですが、task0がLチカ2回先行し、task1は2回遅れて開始し、終了することになります。

末尾のプログラムの実行結果
task0 : 00000000
task0 locked.
task0 : 00000000
task0 locked.
task0 : 00000000
task0 locked.
task1 : 00020000
task1 : 00020400
task1 : 00020000
task1 : 00020000
task1 : 00020000
task1 : 00000000
task1 : 00000000
task1 : 00000000
task1 : 00000000
task1 : 00000000

task0がtask1の実行をlockで抑えている間、task1は止まっており、解放されると動き始めます。task0がlockしている最中に、SPINLOCK_STレジスタの内容をダンプしているのですが、オール0のままです。よって_threadモジュールのlockオブジェクトとSPINLOCK_STレジスタが直接対応しているということは無さそうです。しかし、解放された後、task1が(task0と並行に)動作を始めると何やらSPINLOCK_STレジスタのビットが立ちます。それも2箇所。これが何を意味しているのかは不明ですが、MicroPython内部の制御のためにハードウエアのSPINLOCKレジスタが使われていると推測されます。

まあ、多少、分かったことは増えましたが、まだまだ謎は多いな。

MicroPython的午睡(12) ラズパイPico、簡単!マルチコアでLチカを へ戻る

MicroPython的午睡(14) ラズパイPico、7セグ4桁、とりあえずタイマ駆動 へ進む

Lockを含むマルチコア・テスト用コード(前回の改造版)
import time, machine, _thread

def task1(n, delay):
    global lock
    led1RED  = machine.Pin(17, machine.Pin.OUT)
    led1BLUE = machine.Pin(16, machine.Pin.OUT)
    led1STAT = False
    for i in range(n):
        cpuid = machine.mem32[0xd0000000]
        if led1STAT:
            if cpuid == 0:
                led1RED.high()
            else:
                led1BLUE.high()
            led1STAT = False
        else:
            led1RED.low()
            led1BLUE.low()
            led1STAT = True
        lockP = lock.acquire(1, -1) #wait forever
        time.sleep(delay)
        if not lockP:
            print("task1 can not get the lock.")
        else:
            spinlock_ST = machine.mem32[0xd000005c]        
            print("task1 : {0:08x}".format(spinlock_ST))
            lock.release()
    led1RED.low()
    led1BLUE.low()

def task0(n, delay):
    global lock
    led0RED  = machine.Pin(14, machine.Pin.OUT)
    led0BLUE = machine.Pin(15, machine.Pin.OUT)
    led0STAT = False
    lockP = lock.acquire(1, 1.0) #wait 1 second
    if not lockP:
        print("task0 can not get the lock.")
    for i in range(n):
        cpuid = machine.mem32[0xd0000000]
        if led0STAT:
            if cpuid == 0:
                led0RED.high()
            else:
                led0BLUE.high()
            led0STAT = False
        else:
            led0RED.low()
            led0BLUE.low()
            led0STAT = True
        time.sleep(delay)
        if i == 3:
            lock.release()
        elif i < 3:
            spinlock_ST = machine.mem32[0xd000005c]        
            print("task0 : {0:08x}".format(spinlock_ST))
            print("task0 locked.")
    led0RED.low()
    led0BLUE.low()
    _thread.exit()

lock = _thread.allocate_lock()

_thread.start_new_thread(task0, (10, 0.5))
task1(10, 0.5)