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 のスケジューラはこの規則を例外なく守る。
正確に書くと、こうなる。
同じ優先度が複数いる場合は、そのうち 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 にすると、アイドルタスクと競合する。
- アイドルタスクの仕事(削除済みタスクの回収)が遅れる
- 省電力フック(
vApplicationIdleHook)が呼ばれにくくなる
アプリケーションのタスクは優先度 1 以上にすること。
configMAX_PRIORITIES を大きくするコスト
| コスト | 内容 |
|---|---|
| RAM | pxReadyTasksLists[] が優先度の数だけ必要。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このとき、切り替えは次のタイミングでしか起きない。
- タスクがブロックする API を呼んだとき
- タスクが
taskYIELD()を明示的に呼んだとき
高優先度タスクが 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 リストの実装を見る。