FreeRTOS 06 · スケジューリング方針 — 常に最高優先度が走る

Chapter 06

スケジューリング方針 — 常に最高優先度が走る

この章がなぜ必要なのか——FreeRTOS の挙動は、たった 1 つの規則で決まる.

「どのタスクがいつ走るか」は、複雑そうに見えて、実は一行の規則に従っている。 その規則を正確に理解すると、 「なぜこのタスクが動かないのか」の 9 割は考えるだけで分かるようになる。

この章で使う既出の用語(定義は各リンク先). TCB(02 章 2 節)、スタック(02 章 2 節)、タスク(02 章 2 節)、スケジューラ(03 章 2 節)、Ready(04 章 1 節)、Running(04 章 1 節)、Suspended(04 章 1 節)、ラウンドロビン(04 章 3 節)

1. 唯一の規則

実行可能なタスクのうち、最も優先度の高いものが、必ず走っている。

これだけである。FreeRTOS のスケジューラはこの規則を例外なく守る。

正確に書くと、こうなる。

\[ \text{Running} = \arg\max_{t \in \text{Ready}} \text{priority}(t) \]

同じ優先度が複数いる場合は、そのうち 1 つ(ラウンドロビンで順番に)。

この方式を 固定優先度プリエンプティブスケジューリング (Fixed-Priority Preemptive Scheduling) と呼ぶ。

3 つの構成要素

要素意味
固定優先度優先度はプログラマが決める。OS が勝手に変えない
プリエンプティブ高優先度タスクが Ready になった瞬間に低優先度タスクを止める
(同一優先度は)ラウンドロビン同じ優先度どうしは順番に、タイムスライスで交代する

「固定優先度」の意味を誤解しないこと.

「固定」とは「OS が動的に調整しない」という意味である。 プログラマが vTaskPrioritySet() で変えるのは自由だし、 優先度継承(13 章)ではカーネルが一時的に上げる。

対義語は Linux の CFS(完全公平スケジューラ)のような 「実行時間の履歴を見て OS が優先度を計算する」方式である。 FreeRTOS はそういうことを一切しない。だから挙動が完全に予測できる。

2. 実際の動きを見る

スケジューリングのガントチャート

タスクの優先度・周期・実行時間を変えると、CPU がどう割り当てられるかを確かめられる。

3. プリエンプションの威力と危険

威力: 応答時間が優先度だけで決まる

高優先度タスクが Ready になれば、低優先度タスクの実行中でも即座に割り込む。

時刻 →
低優先度タスク: ████████░░░░░░░░████████
高優先度タスク: ────────████████────────
                        ↑
                    ここで Ready になった瞬間に切り替わる

このため、高優先度タスクの応答時間は、低優先度タスクが何をしていても悪化しない。 01 章で見たスーパーループの問題が、ここで完全に解決している。

危険 1: 低優先度タスクが永久に走らない(スタベーション)

void vHighTask(void *pv)
{
    for (;;) {
        do_work();       /* ← ブロックする API がない */
    }
}

このタスクが優先度 5 なら、優先度 4 以下のタスクは二度と走らない。 アイドルタスクも走らないので、削除済みタスクの後片付けもされない(04 章)。

これを「スタベーション(飢餓)」と呼ぶ.

Linux のような公平性重視のスケジューラでは、 低優先度タスクにも少しずつ CPU が回る仕組みがある(エイジング)。

FreeRTOS にはそれがない。 規則に例外がないからである。 「高優先度が走れるなら、必ず高優先度が走る」——低優先度が飢えても、規則は規則である。

これは欠陥ではなく設計思想である。 リアルタイムシステムでは、公平性より予測可能性が重要だからである。 「たまに低優先度に譲る」ような仕組みがあると、 高優先度タスクの最悪応答時間が計算できなくなる。

危険 2: 共有データが壊れる

プリエンプションはどの命令の間でも起きうる。

/* 共有構造体 */
struct { int x, y; } pos;

/* タスク A(低優先度) */
pos.x = 10;
/* ← ここで切り替わりうる */
pos.y = 20;

/* タスク B(高優先度)が pos を読むと、x=10, y=(古い値)を見る */

これが 19 章のテーマである。プリエンプションを有効にした瞬間、排他制御が必須になる。

4. 優先度の数と表現

#define configMAX_PRIORITIES  5

優先度は 0 から configMAX_PRIORITIES - 1 の範囲で、数値が大きいほど高優先度である。

これは OS によって逆である.

OS高優先度は
FreeRTOS数値が大きい
Zephyr数値が小さい(負値が協調的スレッド)
Linux (RT)sched_priority は大きいほど高
Linux (nice)nice 値は小さいほど高
µITRON / TOPPERS数値が小さいほど高

移植のときに必ず事故が起きるポイントである。

優先度 0 は特別

優先度 0 はアイドルタスクが使う。 アプリケーションのタスクを優先度 0 にすると、アイドルタスクと競合する。

アプリケーションのタスクは優先度 1 以上にすること。

configMAX_PRIORITIES を大きくするコスト

コスト内容
RAMpxReadyTasksLists[] が優先度の数だけ必要。1 つあたり約 20 バイト
探索時間ビットマップ最適化なし(configUSE_PORT_OPTIMISED_TASK_SELECTION = 0)なら、優先度の数に比例(07 章)
設計の複雑さ段数が多いと、どこに置くべきか迷う

実用上は 5〜8 段で足りることが多い.

「タスクごとに違う優先度を割り当てなければならない」という思い込みは捨てるべきである。 同じ優先度に複数のタスクを置くのは正常であり、むしろ推奨される。

典型的な段構成:

優先度用途
0アイドル(予約)
1低優先度のバックグラウンド(ログ、統計)
2通常のアプリケーションタスク(表示、UI)
3通信タスク(UART、SPI、ネットワーク)
4制御タスク(モータ、PID ループ)
5ソフトウェアタイマタスク(configTIMER_TASK_PRIORITY)
6緊急停止など、絶対に遅れてはいけないもの

ビットマップ最適化を使うなら、configMAX_PRIORITIES は 32 以下にすること (1 ワードのビットマップに収める必要があるため)。

5. configUSE_PREEMPTION = 0 — 協調的スケジューリング

プリエンプションを切ると、まったく違う OS になる。

#define configUSE_PREEMPTION  0

このとき、切り替えは次のタイミングでしか起きない。

高優先度タスクが Ready になっても、現在のタスクが自発的に譲るまで走らない。

協調的スケジューリングの比較

観点プリエンプティブ協調的
応答時間優先度で保証される他タスクの実行時間に依存する
排他制御必須ほぼ不要(切り替え点が分かっているため)
スタック使用量割り込みのネスト分が必要少なくて済む
デバッグ難しい(再現しないバグが出る)容易
バグの種類レースコンディション譲り忘れによるフリーズ

協調的スケジューリングを選ぶ理由はあるか.

ある。極端に RAM が少ない場合と、 既存のスーパーループコードを段階的に移行する場合である。

排他制御が不要になる利点は大きい。切り替え点が taskYIELD() と ブロック API だけなので、それ以外の場所は原子的に実行されるとみなせる。

ただしリアルタイム性は失われる。 「高優先度なのに遅い」ということが普通に起きる。 リアルタイム性が要るなら、プリエンプティブ一択である。

6. vTaskSwitchContext() の中身

これが「誰を次に走らせるか」を決める関数の全体である。

void vTaskSwitchContext( void )
{
    if( uxSchedulerSuspended != 0 ) {
        /* スケジューラが停止中 → 切り替えを保留する */
        xYieldPending = pdTRUE;
    }
    else {
        xYieldPending = pdFALSE;

        taskCHECK_FOR_STACK_OVERFLOW();          /* [17 章](17_スタックとオーバーフロー.md) */

        /* 実行時間統計の更新([21 章](21_デバッグとトレース.md#実行時間統計-誰が-cpu-を食っているか)) */
        #if ( configGENERATE_RUN_TIME_STATS == 1 )
            { ... }
        #endif

        taskSELECT_HIGHEST_PRIORITY_TASK();      /* ★ ここが本体([07 章](07_Readyリストの実装.md)) */

        /* newlib の再入対応 */
        #if ( configUSE_NEWLIB_REENTRANT == 1 )
            { _impure_ptr = &( pxCurrentTCB->xNewLib_reent ); }
        #endif
    }
}

本質は taskSELECT_HIGHEST_PRIORITY_TASK() の 1 行だけである。 残りはデバッグ支援と統計と標準ライブラリ対応である。

uxSchedulerSuspended — 切り替えを保留する

vTaskSuspendAll();
    /* この区間では、どんなに優先度の高いタスクが Ready になっても切り替わらない */
xTaskResumeAll();       /* ← ここで保留していた切り替えが実行される */

これは割り込みを止めずに切り替えだけを止める仕組みである。

手段割り込みタスク切り替え用途
taskENTER_CRITICAL()止まる止まる短い区間。ISR と共有するデータ
vTaskSuspendAll()止まらない止まる長い区間。タスク間だけで共有するデータ

vTaskSuspendAll() の方が「行儀がよい」.

割り込みを止めると、割り込みのレイテンシが悪化する。 通信の取りこぼしや、モータ制御の遅れにつながる。

タスク間の排他だけが目的なら、vTaskSuspendAll() を使うべきである。 ただし——この区間ではブロックする API を呼んではいけない。 切り替えができないので、ブロックしたらそこで永久に止まる。

7. taskYIELD() — 自発的に譲る

taskYIELD();      /* 同じ優先度の次のタスクに譲る */

同じ優先度のタスクがいれば、そちらに切り替わる。いなければ何も起きない (自分より低い優先度には絶対に譲らない。規則は規則である)。

長い計算をするタスクで、同じ優先度の他のタスクを飢えさせないために使う。

for (i = 0; i < 1000000; i++) {
    heavy_compute(i);
    if ((i & 0xFF) == 0) taskYIELD();    /* 256 回ごとに譲る */
}

taskYIELD() を「待つ」代わりに使ってはいけない.

while (!flag) { taskYIELD(); }     /* ← アンチパターン */

これはポーリングにコンテキストスイッチのコストを足しただけである。 しかも flag を立てるタスクが自分より低優先度なら、永久に立たない。

正しくはセマフォかタスク通知で待つ(12 章〜14 章)。 「待ちたい」と思ったら、必ずブロックする API を探すこと。

8. この章のまとめ

ポイント内容
唯一の規則実行可能な最高優先度タスクが、必ず走っている
方式固定優先度プリエンプティブ + 同一優先度ラウンドロビン
「固定」の意味OS が動的に調整しない。予測可能性を優先
スタベーション起きる。それが正しい動作である
優先度数値が大きいほど高。0 はアイドル専用
段数実用は 5〜8 段。同一優先度に複数タスクは正常
協調的排他制御がほぼ不要になるが、リアルタイム性を失う
vTaskSuspendAll割り込みを止めずに切り替えだけ止める。この区間でブロック禁止
taskYIELD同一優先度に譲る。待ちの代用にしてはいけない

次章では、「最高優先度のタスクを選ぶ」処理が なぜ O(1) でできるのか——Ready リストの実装を見る。