Mở đầu: khi "hợp tác" không còn đủ nhanh

Cooperative scheduler của Bài 13 có một giả định ngầm: mọi task đều TỰ GIÁC chạy nhanh rồi trả CPU lại. Điều đó đủ tốt cho phần lớn sản phẩm — nhưng một số tình huống không chấp nhận được sự chờ đợi đó: một sự kiện khẩn cấp (cảm biến báo nguy, lệnh dừng khẩn) cần được xử lý NGAY, dù task đang chạy có "tham" tới đâu. Đây là lúc preemption bước vào: thay vì tin tưởng task tự nhường, scheduler được trao quyền CƯỚP CPU bất kỳ lúc nào nếu có việc quan trọng hơn xuất hiện.

Sức mạnh này đi kèm một cái giá thẳng thắn: độ phức tạp tăng vọt. Race condition của Bài 8 — trước đây chỉ xảy ra giữa ISR và main — giờ có thể xảy ra giữa BẤT KỲ 2 task nào, bất kỳ lúc nào. Bài này mổ xẻ cơ chế context switch đứng sau preemption, các trạng thái một task có thể ở, và 2 pitfall kinh điển của lập lịch ưu tiên: đặt sai ưu tiên gây round-robin vô nghĩa, và tệ hơn — starvation (chết đói).


📚 Điều kiện tiên quyết
Bắt buộc: Bài 12 (stack — mỗi task một stack riêng dùng lại đúng kỹ thuật canary/watermark), Bài 13 (khái niệm task/scheduler), Bài 8 (race condition — tái vũ trang ở quy mô task↔task).

1. Vì sao cần preemption

Với cooperative scheduler, một task dài (dù đã chẻ thành FSM ở Bài 13) vẫn phải đợi tới LƯỢT của nó trong vòng quét mới được kiểm tra lại — không có gì đảm bảo một sự kiện khẩn cấp được xử lý trong một khung thời gian CỐ ĐỊNH. Preemptive scheduler giải quyết bằng nguyên tắc đơn giản: tại bất kỳ thời điểm nào, CPU luôn thuộc về task READY có độ ưu tiên cao nhất — nếu một task ưu tiên cao hơn vừa trở nên sẵn sàng trong khi task ưu tiên thấp hơn đang chạy, task thấp bị cướp CPU NGAY LẬP TỨC, không cần đợi nó tự nguyện.

Đánh đổi: giờ đây HAI task bất kỳ có thể "chen" vào giữa lượt của nhau — chính xác kịch bản race condition ISR↔main của Bài 8, nhưng KHÔNG còn giới hạn ở "ISR chen main" nữa mà là "bất kỳ task nào chen bất kỳ task nào". Công cụ tắt ngắt của Bài 8 (critical section) vẫn dùng được nhưng ngày càng thô bạo hơn ở quy mô lớn — Bài 15 sẽ giới thiệu công cụ mịn hơn: mutex và semaphore.

2. Context switch mổ xẻ

Để "cướp CPU" của một task rồi sau đó CHO NÓ CHẠY TIẾP đúng chỗ dang dở, RTOS phải lưu lại toàn bộ "ngữ cảnh" (context) của task đó trước khi chuyển sang task khác. Hai thành phần cốt lõi:

  • Mỗi task một stack RIÊNG — không như cooperative scheduler (mọi task dùng chung 1 stack vì chạy tuần tự, không bao giờ chồng lên nhau), preemptive scheduler có thể dừng task A giữa chừng một hàm đang gọi lồng sâu — ngữ cảnh đó (địa chỉ trở về, biến cục bộ) chỉ có thể được bảo toàn nếu MỖI task có vùng stack của riêng nó. Đây chính là lúc kỹ thuật cấp phát tĩnh + canary/watermark của Bài 12 "trả nợ đúng hạn".
  • TCB (Task Control Block) — một struct nhỏ lưu mọi thứ cần để "nhớ" một task: con trỏ ngăn xếp hiện tại (nơi ngữ cảnh vừa được lưu), độ ưu tiên, trạng thái.

Trình tự context switch tóm gọn: lưu các thanh ghi CPU hiện tại vào ĐỈNH stack của task đang chạy → cập nhật con trỏ stack lưu trong TCB của nó → nạp con trỏ stack từ TCB của task SẮP chạy → khôi phục thanh ghi từ đỉnh stack của task đó → tiếp tục chạy đúng chỗ nó dừng lại lần trước.

💡 Outlook: PendSV trên Cortex-M thật
Trên phần cứng Cortex-M thật, trình tự lưu/khôi phục thanh ghi ở trên không chạy tuỳ tiện — nó chạy bên trong một ngắt đặc biệt tên PendSV, được thiết kế RIÊNG cho việc context switch (ưu tiên thấp nhất có thể để không chen vào giữa các ISR quan trọng khác). Chi tiết triển khai nằm ngoài phạm vi series này — biết tên và vai trò của nó là đủ để không bỡ ngỡ khi đọc mã nguồn RTOS thật (FreeRTOS, Zephyr...).

3. Task states

Một task trong hệ preemptive luôn ở đúng MỘT trong các trạng thái sau tại mọi thời điểm:

Trạng thái Ý nghĩa Tốn CPU?
Ready Sẵn sàng chạy, đang chờ tới lượt (có task ưu tiên cao hơn đang chiếm CPU) Không
Running Đang thực sự chiếm CPU ngay lúc này Có (đây chính là task đang dùng CPU)
Blocked Đang chờ một sự kiện/thời gian, KHÔNG cần kiểm tra liên tục Không — điểm mấu chốt của cả mục này

Điểm mấu chốt cần khắc sâu: Blocked hoàn toàn khác busy-wait của Bài 4. Một task blocked "ngủ chờ sự kiện" — CPU được giao hẳn cho task khác trong lúc đó, không có gì bị lãng phí. Đây là lý do RTOS xử lý được nhiều task hơn hẳn một vòng lặp liên tục kiểm tra cờ.

4. Chính sách lập lịch

Chính sách chuẩn của RTOS nhúng: priority preemptive kết hợp round-robin giữa các task CÙNG mức ưu tiên (để không task nào ở cùng mức "ăn hết" CPU của các task ngang hàng). Verify trên mini-RTOS thật: 2 task cùng ưu tiên, không bao giờ ngủ → chạy xen kẽ tuyệt đối A, B, A, B, A, B, ... — mỗi task được nhường đúng 1 lượt trước khi tới lượt bên kia.

⚠️ Pitfall: starvation (chết đói)
Nếu một task ưu tiên CAO không bao giờ blocked (không bao giờ "ngủ", luôn có việc để làm ngay), mọi task ưu tiên THẤP hơn sẽ KHÔNG BAO GIỜ được chạy — dù chúng có việc cần làm cấp bách tới đâu. Verify trên mini-RTOS: task thấp chờ suốt 50 tick mà KHÔNG được chạy một lần nào khi task cao không hề ngủ. Cách chữa: buộc task cao phải blocked định kỳ (chờ dữ liệu/sự kiện thay vì busy-loop) — verify: chỉ cần cho nó ngủ 5 tick sau mỗi lần chạy, task thấp lập tức có cơ hội chạy trở lại nhiều lần trong cùng 50 tick đó.

Pitfall liên quan: đặt MỌI task ưu tiên cao "cho chắc ăn" — nếu tất cả task đều cùng một mức ưu tiên tối đa, hệ thống tự động rơi về round-robin thuần, VÔ HIỆU HOÁ toàn bộ mục đích thiết kế phân cấp ưu tiên. Ưu tiên chỉ có ý nghĩa khi nó THỰC SỰ phản ánh mức độ khẩn cấp tương đối giữa các task.

5. Thực hành: mini-RTOS visualizer

Demo dưới đây chạy mini-RTOS preemptive thật: xem preemption xảy ra ngay khi một "ngắt" kích hoạt task ưu tiên cao, và tự tay tái hiện + chữa starvation:

🧵 Mini-RTOS Preemptive Visualizer — VMCU thật

1. Preemption Timeline — task_L (ưu tiên thấp, 10 đơn vị việc) vs task_H (ưu tiên cao, bắt đầu ngủ)

2. Thí nghiệm Starvation (50 tick)

Đang tải…

Task thấp luôn ưu tiên 5, task cao luôn ưu tiên 0. Bấm 2 kịch bản để so sánh số lần task thấp được chạy trong đúng 50 tick.

tcb_context_switch.c
typedef enum { READY, RUNNING, BLOCKED } task_state_t;

typedef struct {
    uint32_t *sp;          // con tro ngan xep HIEN TAI cua task nay (Bai 12)
    uint8_t priority;      // so nho hon = uu tien cao hon (giong NVIC Bai 7)
    task_state_t state;
    uint32_t wake_at_tick; // chi dung khi state == BLOCKED
} tcb_t;

// Goi tu ben trong PendSV_Handler (outlook Muc 2) - KHONG goi truc tiep tu C thuong
void context_switch(tcb_t *from, tcb_t *to) {
    save_registers_to_stack(from->sp);   // luu thanh ghi vao DINH stack cua task cu
    from->sp = get_current_sp();
    from->state = READY;                 // bi cuop CPU, van con READY (chua mat gi)

    to->state = RUNNING;
    set_current_sp(to->sp);
    restore_registers_from_stack(to->sp); // khoi phuc dung ngu canh da luu lan truoc
}
rtos_demo.js (đúng logic đang chạy ở tab Xem trước)
import { MiniRTOS } from './vmcu.js';

const rtos = new MiniRTOS();
rtos.addTask('task_L', 5, 10, 1000);          // uu tien THAP, 10 don vi viec
rtos.addTask('task_H', 0, 2, 1000, true);     // uu tien CAO, bat dau BLOCKED

for (let i = 0; i < 3; i++) rtos.tick();       // task_L chay 3 tick dau
rtos.wakeTask('task_H');                        // mo phong 1 "ngat" xay ra dung luc nay
for (let i = 0; i < 7; i++) rtos.tick();       // task_H CUOP CPU ngay, roi task_L chay tiep

console.log(rtos.timeline.filter(e => e.event === 'run').map(e => e.taskName));
// ['task_L','task_L','task_L','task_H','task_H','task_L','task_L','task_L','task_L','task_L']

Tóm lược

  • Preemptive: scheduler được phép cướp CPU của task đang chạy bất kỳ lúc nào một task ưu tiên cao hơn trở nên READY — verified: task thấp bị cướp CPU ngay khi "ngắt" xảy ra giữa chừng, rồi tự động tiếp tục đúng chỗ dang dở.
  • ✅ Context switch cần mỗi task một stack riêng (Bài 12 trả nợ) + TCB lưu con trỏ stack/priority/state; trên Cortex-M thật chạy trong ngắt PendSV.
  • ✅ 3 task states: Ready/Running/Blocked — Blocked KHÔNG tốn CPU, khác hẳn busy-wait.
  • ✅ Priority preemptive + round-robin cùng mức — verified xen kẽ tuyệt đối A,B,A,B,A,B. Pitfall starvation verified: task cao không ngủ khiến task thấp không chạy lần nào trong 50 tick; chữa bằng cách buộc task cao phải blocked định kỳ.

Trắc nghiệm ôn tập

Câu 1

Khác biệt cốt lõi giữa cooperative scheduler (Bài 13) và preemptive scheduler (bài này) là gì?

Câu 2

Vì sao preemptive scheduler cần MỖI task có một stack RIÊNG, không thể dùng chung 1 stack như cooperative scheduler?

Câu 3

Vì sao trạng thái Blocked được coi là "KHÔNG tốn CPU", trong khi busy-wait (Bài 4) lại tốn CPU liên tục dù về bản chất cả hai đều là "chờ"?

Câu 4

Verified: task ưu tiên cao không bao giờ ngủ khiến task ưu tiên thấp không được chạy MỘT LẦN NÀO trong 50 tick (starvation). Cách chữa nào ĐÚNG theo bài học, không cần đổi kiến trúc RTOS?

Tải file code thực hành minh họa bài học

File JavaScript VMCU — engine MCU ảo dùng xuyên suốt cả 16 bài, Bài 14 vừa thêm mini-RTOS preemptive (MiniRTOS, RtosTask) với priority scheduling, round-robin, và preemption thật, kèm self-test đối chiếu đúng mọi hành vi trong bài (chạy node vmcu.js, không cần cài thêm gì):

Tải về vmcu.js

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 13: Super-loop vs Cooperative Scheduler Bài 15: Đồng bộ RTOS: mutex, semaphore, queue & priority inversion Quay lại Lộ trình Series Hệ Thống Nhúng

Bình luận