Một xe cứu thương không chạy nhanh hơn định luật vật lý cho phép — động cơ của nó cũng như xe thường. Cái nó có là quyền ưu tiên: đèn hụ bật lên, mọi xe khác phải tấp vào lề nhường đường, nên nó tới đúng lúc dù phố đang kẹt cứng. Đó là toàn bộ ý nghĩa của "real-time" trong lập lịch — không phải nhanh hơn, mà đúng hạn hơn. Và cũng như một xe cứu thương chết máy giữa ngã tư sẽ chặn đứng cả dòng xe, cái quyền ưu tiên đó có một mặt tối chết người.
Phần 18 đo bộ lập lịch mặc định CFS: công bằng, chia thời gian đều, ai cũng có phần. Đó là điều tốt cho throughput chung, nhưng tệ cho một thứ khác — độ trễ đúng hạn. Một luồng cần phản ứng ngay (xử lý âm thanh, điều khiển động cơ, đọc cảm biến) không muốn "công bằng"; nó muốn được ưu tiên tuyệt đối. Đó là việc của lịch thời gian thực (real-time), mà lớp phổ biến nhất là SCHED_FIFO. Nhiều người nghĩ đặt luồng vào real-time nghĩa là nó "chạy nhanh hơn". Tôi đo trong container gcc:13 (cần quyền RT: --cap-add=SYS_NICE --ulimit rtprio=99), và con số cho thấy real-time không phải nhanh hơn — nó là đúng hạn hơn, và đi kèm một cái bẫy.
SCHED_FIFO: ưu tiên cứng, không công bằng
SCHED_FIFO là một lớp lập lịch ưu tiên cứng. Một luồng FIFO, khi được chạy, sẽ chạy cho tới khi nó tự block (chờ I/O, khóa, ngủ) hoặc bị một luồng RT ưu tiên cao hơn tước. Nó không bị time-slice bởi các luồng cùng hoặc thấp hơn — khác hẳn CFS, nơi mọi luồng luân phiên chia lõi. Với FIFO, luồng ưu tiên cao luôn thắng luồng thấp, tức thì, không nhân nhượng.
Điều đó cho một tính chất quý: độ trễ tất định. Khi hệ đang tải nặng (nhiều luồng tranh CPU), một luồng thường (CFS) muốn thức dậy đúng hạn sẽ phải chờ tới lượt nó trong vòng luân phiên — có thể trễ cả một timeslice. Một luồng FIFO ưu tiên cao thì tước CPU của mọi luồng thường ngay lập tức, nên nó thức đúng hạn bất kể hệ bận thế nào. Tôi đo chính điều này: một luồng định kỳ (thức mỗi 5ms, đo độ trễ so với mốc bằng kỳ hạn tuyệt đối phần 30), chạy chung một lõi với 4 luồng ngốn CPU, một lần là SCHED_OTHER và một lần là SCHED_FIFO.
Đo: jitter 3ms tụt còn 12µs
Luồng định kỳ (thức mỗi 5ms), chung 1 lõi với 4 luồng ngốn CPU:
SCHED_OTHER (công bằng, tranh với hog): trễ tb = 639 µs MAX = 3001 µs (~3 ms)
SCHED_FIFO(80) (tước hog, ưu tiên cao) : trễ tb = 3,4 µs MAX = 11,7 µs
-> độ trễ MAX giảm ~256 lần (jitter 3 ms -> 12 µs)
Là SCHED_OTHER, luồng định kỳ cạnh tranh công bằng với 4 luồng ngốn CPU. Nó thức dậy trễ trung bình 639µs, và tệ nhất trễ tới 3001µs (~3ms) — đúng bằng một timeslice CFS. Với một tác vụ cần phản ứng mỗi 5ms, một cú trễ 3ms là thảm họa. Chuyển đúng luồng đó sang SCHED_FIFO ưu tiên 80: giờ nó tước CPU của các hog ngay khi cần thức, nên trễ trung bình chỉ 3,4µs và tệ nhất 11,7µs. Độ trễ tệ nhất giảm 256 lần — jitter từ 3ms xuống 12µs.
Chú ý kỹ: cái đổi được là độ trễ, không phải tốc độ tính toán. Luồng định kỳ không làm việc gì nhanh hơn; nó chỉ được chạy đúng lúc. Các luồng ngốn CPU vẫn xử lý cùng khối lượng, chỉ bị chen ngang mỗi khi luồng RT cần. Real-time là chuyện khi nào, không phải nhanh cỡ nào.
Cái bẫy: một FIFO không block đói cả hệ
Sức mạnh "ưu tiên tuyệt đối" của FIFO cũng là con dao hai lưỡi. Vì một luồng FIFO chạy tới khi tự block, một luồng FIFO không bao giờ block (một vòng lặp tính toán, hay tệ hơn, một bug vòng vô hạn) sẽ chiếm lõi mãi mãi, bỏ đói mọi luồng ưu tiên thấp hơn. Tôi đo: một luồng FIFO(80) quay CPU trong 300ms trên một lõi, và một luồng thường (SCHED_OTHER) đếm tiến độ trên cùng lõi đó.
FIFO(80) busy 300ms trên CPU0, luồng OTHER cùng lõi đếm tiến độ:
Trong lúc FIFO chạy : OTHER tiến 0 vòng (ĐÓI hoàn toàn)
200ms sau khi FIFO xong: OTHER tiến 21901 vòng (bình thường trở lại)
Luồng thường tiến được 0 vòng suốt 300ms — nó bị đói tuyệt đối, không nhích một chút nào, cho tới khi luồng FIFO tự kết thúc. Trên máy thật, nếu một luồng RT ưu tiên cao vô tình rơi vào vòng lặp không block, nó có thể treo cả hệ — kể cả các tiến trình hệ thống. Đúng cái xe cứu thương chết máy giữa ngã tư. (Linux có một cơ chế throttle RT mặc định — giới hạn tổng thời gian RT được chiếm — làm lưới an toàn, nhưng đừng dựa vào nó.) Đây là lý do real-time là quyền lực phải dùng rất cẩn thận.
(Môi trường đo: container gcc:13 trên host ARM AArch64, quyền RT qua --cap-add=SYS_NICE --ulimit rtprio=99, luồng ghim 1 lõi, mỗi số lấy sau warmup. Con số tuyệt đối đổi theo máy; cái cần nhìn là hình dạng: FIFO cắt đuôi độ trễ hàng trăm lần nhưng không đổi tốc độ tính, và một FIFO không block đói sạch luồng thường.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin phổ biến nhất về từ "real-time": "đặt luồng vào lịch thời gian thực thì nó chạy nhanh hơn". Con số phá tan: SCHED_FIFO không làm luồng tính toán nhanh hơn một chút nào, không tăng throughput. Cái nó thay đổi là độ trễ và tính tất định — luồng RT thức và phản ứng đúng hạn, với jitter thấp (12µs thay vì 3ms), dưới tải nặng. "Real-time" trong khoa học máy tính chưa bao giờ nghĩa là "nhanh"; nó nghĩa là "đúng hạn, đoán trước được" — một hệ real-time là hệ mà bạn bảo đảm được thời hạn, dù thời hạn đó có thể chậm. Nếu tin "real-time = nhanh" và rải SCHED_FIFO lên các luồng tính toán để "tăng tốc", bạn không được một nano-giây nào nhanh hơn, mà lại tạo nguy cơ treo máy nếu một luồng nào đó không block đúng lúc.
Còn một niềm tin ngược cũng cần chỉnh: "real-time nguy hiểm thế thì tránh xa cho lành, cứ để CFS lo hết". Cũng không đúng — với tác vụ thật sự cần đúng hạn (âm thanh không được rớt buffer, vòng điều khiển chu kỳ cứng), CFS không thể cho bạn cái bảo đảm đó dưới tải, và cú trễ 3ms kia là lỗi thật sự nghe/thấy được. Vấn đề không phải "đừng dùng RT", mà là dùng đúng chỗ (tác vụ cần đúng hạn, không phải cần nhanh) và đúng cách (luồng block giữa các lần chạy, ghim lõi, giới hạn ưu tiên). Đo cho thấy giá trị thật của RT nằm ở phân phối độ trễ — đặc biệt phần đuôi (max/jitter), không phải tốc độ trung bình.
Về đến code hằng ngày
Điều đầu tiên: dùng real-time cho tác vụ cần đúng hạn, không phải cho tác vụ cần nhanh. Xử lý âm thanh (không được rớt buffer), điều khiển (chu kỳ cứng), đọc cảm biến định kỳ — đó là chỗ SCHED_FIFO tỏa sáng: nó bảo đảm luồng phản ứng đúng hạn dù hệ bận. Còn một tác vụ tính toán nặng muốn xong nhanh thì real-time không giúp gì; số lõi và thuật toán mới quyết định.
Điều thứ hai: luồng real-time phải block, và phải bị giới hạn. Một luồng RT nên làm một mẩu việc ngắn rồi ngủ/chờ sự kiện tiếp theo — không bao giờ quay CPU vô hạn. Hãy ghim nó vào một lõi riêng nếu có thể, đặt ưu tiên vừa đủ (không phải max), và để dành lõi cho các luồng hệ thống. Một vòng lặp bận ở ưu tiên RT là công thức treo máy.
Chỗ này lý giải rất nhiều lựa chọn kiến trúc ở tầng cao. Đây là lý do các ngôn ngữ có GC "stop-the-world" (Go, Java cổ điển) không hợp cho real-time cứng: một cú GC pause vài ms phá tan mọi thời hạn micro-giây — nên audio engine thường viết C/C++, và callback âm thanh có luật vàng "không được cấp phát, không được lấy khóa, không được làm gì có thể block". Java có cả một nhánh RTSJ (Real-Time Specification for Java) và JVM real-time riêng để giải đúng bài này. Linux có bản vá PREEMPT_RT biến gần như toàn nhân thành khả-tước cho robot/công nghiệp. Và cái bẫy "ưu tiên cao mà bị chặn" có một ca lịch sử nổi tiếng: tàu Mars Pathfinder năm 1997 liên tục reset vì priority inversion — một luồng ưu tiên thấp giữ khóa mà luồng RT cần, đúng họ hàng với cái bẫy đói ở trên. Cùng một bài học xe-cứu-thương: quyền ưu tiên là để tới đúng hạn, không phải để chạy nhanh, và ai cầm nó phải cực kỳ kỷ luật.
Muốn tự thấy thì tự hỏi về một tác vụ bạn định "ưu tiên cao": bạn cần nó nhanh hơn (xong sớm hơn) hay đúng hạn hơn (phản ứng đều, không trễ đột biến)? Nếu là nhanh hơn, real-time không phải câu trả lời — nó không tăng tốc gì, chỉ đổi thứ tự; hãy nhìn vào thuật toán và số lõi. Nếu là đúng hạn (một vòng lặp phải chạy đều mỗi X ms bất kể hệ bận), thì SCHED_FIFO có thể cắt jitter của bạn hàng trăm lần — nhưng chỉ khi luồng đó block giữa các lần chạy và bạn giới hạn nó cẩn thận. Vài phút tự hỏi "mình cần nhanh hay cần đúng hạn?" đó tách được hai thứ mà chữ "real-time" hay bị nhầm là một — và tránh cho bạn vừa không được tốc độ, vừa rước nguy cơ treo máy.