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).
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.
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 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:
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)
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.
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
}
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ì):
Bình luận