ESP32のFreeRTOS入門 その3 マルチタスク

概要

前回はタスクの作成を説明しました。今回は複数のタスクを動作させるマルチタスクについて説明したいと思います。

マルチタスクとは?

複数のタスクを同時に動作させることです。同時に動作させる方式もいろいろあり、複数のことを同時に実行できるマルチタスクは便利ですが、いろいろ気にしてつかわないとハマりやすいです。

複数のタスクを同時に実行する方法は複数あり、一つがタスクを短い時間実行しては、他のタスクに切り替えながら動かすスレッドという方法と、複数のプロセッサで個別にタスクを動かすマルチコアです。

スレッドとは?

短い時間で実行するタスクを切り替えながら、擬似的に同時に動いているようにするのがスレッドです。1つのプロセッサコアでは、その瞬間に動いているタスクは1つだけです。

FreeRTOSがタスクの切り替えを自動で行ってくれますので、切り替えについてはあまり意識しなくても大丈夫です。

マルチコアとは?

複数のプロセッサコアを搭載することで、1つのコアに対して1つのタスクを動かせるので、コアの数だけタスクを同時実行することができます。

ESP32は通常デュアルコアなので、2つのタスクまでであればスレッドを使わなくても動かすことができます。

スレッドとマルチコアの違い

こちらもFreeRTOSが管理してくれるので、あまり使い方に違いはありません。タスク作成時にCore0(PRO_CPU)とCore1(APP_CPU)を指定したと思いますが、loop()関数はCore1(APP_CPU)で動いているので、Core1(APP_CPU)に新規タスクを作成するとマルチスレッドで動いていることになります。同じようにCore0(PRO_CPU)に新規タスクを作成するとマルチコアになります。

作り方に関しては気にしなくてもいいのですが、使い方については気をつけて使い分ける必要があります。

Core0(PRO_CPU)は無線を利用する場合には、無線関係のタスクが動くことになります。それなりに重い処理になるので、さらに重いタスクを追加しようとすると、無線通信にも影響がでてしまいます。

同じようにCore1(APP_CPU)にたくさんタスクを詰め込んでも、CPUパワーが足りなくなりますので、空いているコアを選んでタスクを作成する必要があります。

また、スレッドの場合、タスクが切り替わりながら実行されるので、厳密にその瞬間に動いているタスクは1つのはずです。そのためグローバル変数などを変更しても、他のタスクから同時に変更されることなどは起こりません。

デュアルコアの場合には、その瞬間に2つのタスクが動いているので、同時にグローバル変数などを変更してしまう可能性があります。

厳密にはスレッドでも、タスクの切り替わる瞬間などにより、意図しない変更などが発生する可能性がありますので、排他制御と呼ばれる処理が必要になります。

排他制御はいろいろな種類があり、数回にわけて今後説明していきたいと思います。

タスクの切り替え時間

FreeRTOSのタスクはすべてスレッドとして、短い時間で切り替えながら動いています。時間を分割するので、タイムスライスとFreeRTOSではよんでいます。

タイムスライスで時間を分割する単位をTickとよびます。このTickは時計の針が動く音などをあらわしています。Tickの間隔なのですが、環境によりことなります。標準的な環境では10ミリ秒です。CPUの速度が遅い場合には10ミリ秒だと、ほとんど処理ができない環境であれば、もっと大きな間隔に変更することができます。

ESP32の1Tickは1ミリ秒が標準でArduino環境の場合には変更できないので、1ミリ秒間隔のタイムスライスで動いていると思ってください。

タスクの基本構造

void loopTask(void *pvParameters)
{
    setup();
    for(;;) {
        if(loopTaskWDTEnabled){
            esp_task_wdt_reset();
        }
        loop();
    }
}

また、loop()関数を見てみます。不要な部分を削ってみます。

void task(void *pvParameters)
{
    for(;;) {
    }
}

上記のような構造になっていますね。このプログラムはfor文を利用した無限ループで終了しません。タスクを作る場合には無限ループにして終了させてはいけません。終了する場合にはタスクを終了させる関数を使う必要があります。

さて、Arduinoのloop()関数はfor文の無限ループですが、while文のループを個人的に使いますんので、例文は以下の形をベースとします。

void task(void *pvParameters)
{
    while (1) {
    }
}

タスクの動作確認

バラバラ出力

void task1(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("1");
    }
    Serial.print("\n");
  }
}
void task2(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("2");
    }
    Serial.print("\n");
  }
}
void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait
  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );
  xTaskCreateUniversal(
    task2,
    "task2",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );
}
void loop() {
}

最初に極端な例をだします。2つのタスク作成していて、50個文字を出力したら改行するを無限ループするタスクです。

ともにCore1のAPP_CPUで、優先度1で動かしています。どのような出力結果になるでしょうか?

1111111111111111111111111111112222222222
2222222222222222222222211111111111111111111
11111111111111222222222222222222222222222
2222221111111111111111111111111111111111122222222222222222222222222222222221
1111111111111111111111111111111112222222222
22222222222222222222222211111111111111111
111111111111111122222222222222222222222222
22222222111111111111111111111111111111111122222222222222222222222222222222222
1111111111111111111111111111111112222222
22222222222222222222222222211111111111111111
1111111111111111122222222222222222222222
2222222222111111111111111111111111111111111
1222222222222222222222222222222222211111111111111111111111111111111111222222
22222222222222222222222222211111111111111

一部抜粋ですが、上記のような実行結果になります。意外だったでしょうか?

ばらばらな文字の出力ですが、出力している途中で別タスクに処理が切り替わっています。

一括出力

void task1(void *pvParameters) {
  while (1) {
    Serial.println("111111111111111111111111111111111111111111111111111111111111111111111111111111");
  }
}
void task2(void *pvParameters) {
  while (1) {
    Serial.println("222222222222222222222222222222222222222222222222222222222222222222222222222222");
  }
}
void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait
  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );
  xTaskCreateUniversal(
    task2,
    "task2",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );
}
void loop() {
}

今度は、出力部分を1行にまとめてみました。

111111111111111111111111111111111111111111111111111111111111111111111111111111
222222222222222222222222222222222222222222222222222222222222222222222222222222
111111111111111111111111111111111111111111111111111111111111111111111111111111
222222222222222222222222222222222222222222222222222222222222222222222222222222
111111111111111111111111111111111111111111111111111111111111111111111111111111
222222222222222222222222222222222222222222222222222222222222222222222222222222
111111111111111111111111111111111111111111111111111111111111111111111111111111
222222222222222222222222222222222222222222222222222222222222222222222222222222
111111111111111111111111111111111111111111111111111111111111111111111111111111
222222222222222222222222222222222222222222222222222222222222222222222222222222
111111111111111111111111111111111111111111111111111111111111111111111111111111

今度はきれいに出力されました。さすがにSerial.println()関数の途中で切り替えは発生しないみたいです。

別コア

void task1(void *pvParameters) {
  while (1) {
    Serial.println("111111111111111111111111111111111111111111111111111111111111111111111111111111");
  }
}
void task2(void *pvParameters) {
  while (1) {
    Serial.println("222222222222222222222222222222222222222222222222222222222222222222222222222222");
  }
}
void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait
  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );
  xTaskCreateUniversal(
    task2,
    "task2",
    8192,
    NULL,
    1,
    NULL,
    PRO_CPU_NUM
  );
}
void loop() {
}

さて、次にtask2をCore0のPRO_CPUで動かしてみます。

222222222222222222222222222222222222222222222222222222222222222222222222222222
222222222222222222222222222222222222222222222222222222222222222222222222222222
222222222222222222222222222222222222222E (10301) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
E (10301) task_wdt:  - IDLE0 (CPU 0)
E (10301) task_wdt: Tasks currently running:
E (10301) task_wdt: CPU 0: task2
E (10301) task_wdt: CPU 1: loopTask
E (10301) task_wdt: Aborting.
abort() was called at PC 0x400d359b on core 0
Backtrace: 0x4008b560:0x3ffbe170 0x4008b78d:0x3ffbe190 0x400d359b:0x3ffbe1b0 0x400845ed:0x3ffbe1d0 0x400d1a51:0x3ffb5f50 0x400d0d09:0x3ffb5f70 0x400d0ece:0x3ffb5f90 0x400d0ef5:0x3ffb5fb0 0x400d0ba9:0x3ffb5fd0 0x40088215:0x3ffb5ff0
Rebooting...
222222222222222222222222222222222222222222222222222222222222222222222222222222
222222222222222222222222222222222222222222222222222222222222222222222222222222
222222222222222222222222222222222222222222222222222222222222222222222222222222

すると今度は2しか出力されません。そしてよく見るとエラーが出力されていて、リブートされていました。

エラーを見てみると「core 0」でWatchdogトリガーが発生しています。

ウォッチドッグとは?

システムがハングアップしていなかを監視する仕組みです。無限ループなどにはまっていないかを確認してくれる機能になります。

定期的に生きていますよと報告をして、報告が一定時間なくなると自動的に再起動する仕組みになります。

一定時間(ESP32は3秒)でリセットがかかるので、一般的にはウォッチドッグタイマ(WDT)と呼ばれており、生存報告のことをウォッチドッグタイマをリセットすると表現します。

ウォッチドッグの初期値

コアコア名WDT
Core 0PRO_CPUWDT有効
Core 1APP_CPUWDT無効

ESP32は2つのコアがありますが、loop()関数が動いているAPP_CPUはWDTの初期値が無効になっています。これはloop()関数などで無限ループにして、再起動させちゃう人が多いためかな?

APP_CPUはWDTが有効なので、3秒以上ウォッチドッグタイマをリセットしないと再起動が動いてしまいます。

ウォッチドッグタイマのリセット方法

delay()関数を1以上で呼び出すことでリセットが可能です。vTaskDelay()関数などを呼び出すと書いてあるサイトなどがありますが、ESP32のArduino環境ではdelay()関数のほうが適していると思います。

void delay(uint32_t ms)
{
    vTaskDelay(ms / portTICK_PERIOD_MS);
}

とはいえ、delay()関数の中身はvTaskDelay()関数です。

#define portTICK_PERIOD_MS			( ( TickType_t ) 1000 / configTICK_RATE_HZ )
#define configTICK_RATE_HZ				( CONFIG_FREERTOS_HZ )
#define CONFIG_FREERTOS_HZ 1000

delay()関数にあったportTICK_PERIOD_MSを追っていくと、(1000/1000)なので1です。1Tickが1ミリ秒なので、delay()関数の引数msのままvTaskDelay()関数を呼び出しています。

ちなみにdelay(0)で呼び出すと、vTaskDelay()関数側で処理をしないようになっているので、1以上を指定する必要があります。

この辺の情報はESP32ではなく、ESP8266の情報が混ざっている人が多く、ESP8266とはESP32が結構違うので注意してください。

正しいタスクの最小構成

void task(void *pvParameters)
{
    while (1) {
        delay(1);
    }
}

無限ループにして、ループの最後にdelay(1)を入れるのが正しいです。

ちなみにloop()関数の中身にもdelay(1)を本来は入れたほうがいいです。入れるのと入れないのでは消費電力の違いがでます。APP_CPUなのでWDTは無効ですが、delay(1)がないと、WDTが発動する条件になります。

とくに別タスクを起動して、そちらで処理を行うからいいやとloop()関数を空にしておくと無駄にCPUパワーを無限ループで使われていることになります。

ウォッチドッグタイマの設定

Core 0有効化enableCore0WDT()
Core 0無効化disableCore0WDT()
Core 1有効化enableCore1WDT()
Core 1無効化disableCore1WDT()

あまり使うことのない関数ですが、上記の関数でWDTの状態を変更することができます。

Core0のPRO_CPUで、SDカードなどのファイルデバイスにアクセスしようとした場合などに、時間がかかってWDTが発動してしまう場合などに、一時的に無効にするなどの用途で利用することができます。

基本的には初期設定から変更しないほうがこのましいので、WDTが発動してしまうような処理はなるべくCore1のAPP_CPUで実行するか、処理の途中でdelay(1)を追加するなどの工夫をしてください。

優先度の設定

void task1(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("1");
    }
    Serial.print("\n");
    delay(1);
  }
}
void task2(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("2");
    }
    Serial.print("\n");
    delay(1);
  }
}
void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait
  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );
  xTaskCreateUniversal(
    task2,
    "task2",
    8192,
    NULL,
    0,
    NULL,
    APP_CPU_NUM
  );
}
void loop() {
}

さて、タスクの優先度の実験もしたいと思います。タスクにdelay(1)を追加して、task2の優先度を1から0に落としました。

この状態で動かすと、タスク1しか実行されません!

びっくりですね。実はloop()関数も優先度1の無限ループで動いているので、タスク1がdelay(1)で処理をあけわたしても、その分loop()関数がループしてCPUをフル回転させています。そのため優先度が低いタスク2が実行されないのです。

loop()関数にdelay追加

void task1(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("1");
    }
    Serial.print("\n");
    delay(1);
  }
}
void task2(void *pvParameters) {
  while (1) {
    for ( int i = 0 ; i < 50 ; i++ ) {
      Serial.print("2");
    }
    Serial.print("\n");
    delay(1);
  }
}
void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait
  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );
  xTaskCreateUniversal(
    task2,
    "task2",
    8192,
    NULL,
    0,
    NULL,
    APP_CPU_NUM
  );
}
void loop() {
  delay(1);
}

これを実行してみると以下の出力になりました。

2211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111
211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111
2211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111
2211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111
211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111
2211111111111111111111111111111111111111111111111111
11111111111111111111111111111111111111111111111111

タスク2も少ないですが、実行されていますね。ちなみにタスクのdelay(1)だと処理時間が短すぎるので、delay(10)などにするときれいに出力されます。

11111111111111111111111111111111111111111111111111
22222222222222222222222222222222222222222222222222
11111111111111111111111111111111111111111111111111
22222222222222222222222222222222222222222222222222
11111111111111111111111111111111111111111111111111
22222222222222222222222222222222222222222222222222

最低限delay(1)は必要ですが、可能であればなるべく長めのdelay()関数を呼んであげてください。大抵の処理は100ミリ秒とか500ミリ秒間隔の実行でも構わない事が多いと思います。

ワンショットタスク

void task1(void *pvParameters) {
  for(int i = 3 ; i >= 0 ; i--){
    Serial.println(i);
    delay(1000);
  }
  // NULLだと自分自身を削除
  vTaskDelete(NULL);
  // 削除以降の処理は実行されない!
}
void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait
  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    NULL,
    APP_CPU_NUM
  );
}
void loop() {
   delay(1);
}

最後にタスクの終了の仕方です。本来は作成時にタスクハンドルを保存しておき、そのハンドルに対して終了を呼ぶのが正しい動作です。

ただし、タスク内部のループで終了条件を設定して終了することもできます。vTaskDelete()関数にタスクハンドルではなく、NULLを渡した場合には自分自身のタスクを終了することになります。

ためしに、vTaskDelete()関数を消してみると、エラーがでて再起動がかかると思います。

3
2
1
0
E (4104) FreeRTOS: FreeRTOS Task "task1" should not return, Aborting now!
abort() was called at PC 0x40088233 on core 1
Backtrace: 0x4008b560:0x3ffb3fa0 0x4008b78d:0x3ffb3fc0 0x40088233:0x3ffb3fe0
Rebooting...

このようにタスク関数はたんに終了するとエラーがでますので、必ずタスクを削除してから終了してください。

資料

日本語リファレンス

関連ブログ

まとめ

マルチタスクを使う場合には優先度とどちらのコアで動かすのかを考えてから作成しましょう。delay()は必ずいれて、なるべく大きな数字にしたほうが安全です。

次回は割り込みと、排他制御を予定しています。

続編

コメント