Bạn gán một luồng ưu tiên cao vì nó làm việc quan trọng, cần chạy trước mọi thứ. Trực giác nói: ưu tiên cao thì hệ điều hành luôn cho nó chạy trước luồng ưu tiên thấp. Nhưng có một tình huống kinh điển phá vỡ điều đó — priority inversion (đảo ưu tiên) — nơi luồng ưu tiên cao nhất bị kẹt sau một luồng ưu tiên trung bình, chỉ vì một cái khóa. Đây chính là bug đã suýt giết tàu Mars Pathfinder năm 1997. Tôi dựng lại đúng kịch bản đó và đo thời gian chờ, trong container gcc:13 với lịch thời gian thực (SCHED_FIFO) — rồi bật cơ chế chống, và đo lại.

Priority inversion

Ba luồng, một cái khóa, và một cú đảo

Kịch bản cần ba luồng với ba mức ưu tiên, và một cái khóa (mutex) chia sẻ. Để chúng thật sự tranh nhau, tôi ghim cả ba vào một lõi (affinity, phần 19) và dùng lịch SCHED_FIFO (ưu tiên cứng, khác nice mềm của phần 17 — với FIFO, luồng ưu tiên cao luôn chạy trước, không chia đều).

  • L (Low, ưu tiên thấp) giành được mutex và bắt đầu làm việc trong vùng găng.
  • H (High, ưu tiên cao nhất) cần chính mutex đó → nó chặn, ngủ chờ L nhả khóa.
  • M (Medium, ưu tiên trung) chạy một vòng lặp CPU thuần, không liên quan gì tới khóa.

Đây là chỗ cú đảo xảy ra: vì M ưu tiên cao hơn L, M cướp CPU của L. L không được chạy → không thể hoàn thành vùng găng → không thể nhả khóa. Mà H đang chờ chính cái khóa đó. Kết quả: H (cao nhất) bị kẹt sau M (trung bình) — dù M đáng ra thấp hơn H. Ưu tiên đã bị đảo: một luồng trung bình chặn một luồng cao cấp một cách gián tiếp. Tôi đo thời gian H phải chờ.

Đo: chờ 468ms, rồi tụt còn 128ms

Ba luồng SCHED_FIFO L(10) < M(20) < H(30), ghim chung 1 lõi;
vùng găng của L ~150ms công CPU, M chạy ~400ms công CPU:

KHÔNG priority inheritance:
  [t=55]  L giữ khóa, vào vùng găng
  [t=55]  H xin khóa -> CHẶN
  [t=61]  M chạy, CƯỚP CPU của L (M > L)
  [t=401] M xong (đã chiếm CPU ~340ms, L bị đói suốt)
  [t=523] L mới nhả được khóa
  -> H (cao nhất) chờ khóa = 468 ms   <- ĐẢO ưu tiên

H — luồng cao nhất — phải chờ 468 mili-giây để lấy một cái khóa, và phần lớn thời gian đó là vì M (một luồng thấp hơn H) đang chiếm CPU. Về danh nghĩa H ưu tiên tuyệt đối; về thực tế nó bị M chặn đứng. Đây là hiện tượng phản trực giác đến mức các kỹ sư JPL đã mất nhiều ngày mới chẩn ra nó trên Pathfinder.

Cách chữa kinh điển là priority inheritance (thừa kế ưu tiên): khi H chặn trên một khóa mà L đang giữ, hệ tạm nâng ưu tiên của L lên bằng H — vì L đang giữ thứ H cần. Bật nó (đặt PTHREAD_PRIO_INHERIT trên thuộc tính mutex) và đo lại:

CÓ priority inheritance:
  [t=523] L giữ khóa, vào vùng găng
  [t=523] H xin khóa -> CHẶN; L TẠM thừa kế ưu tiên H (=30)
  [t=651] L nhả khóa (M không cướp được CPU, L chạy thẳng ~128ms)
  -> H chờ khóa = 128 ms   (chỉ đúng bằng vùng găng của L)
  [t=651] M mới được chạy sau đó

Với priority inheritance, L tạm mang ưu tiên 30 khi giữ khóa — giờ L (30) cao hơn M (20), nên M không cướp được CPU của L nữa. L chạy thẳng vùng găng ~128ms rồi nhả khóa, H lấy được ngay. Thời gian H chờ tụt từ 468ms xuống 128ms — giảm 340ms, tức 73%. Và 128ms còn lại chính là thời gian không thể tránh: vùng găng của L phải chạy xong đã. Priority inheritance không xóa được việc H phải chờ L, nhưng nó xóa được phần M chen ngang.

Một lần tôi đo hớ: "ưu tiên cao thì luôn chạy trước"

Tôi vào đo với niềm tin thẳng thắn nhất: "đặt ưu tiên cao thì luồng đó luôn được ưu tiên tuyệt đối, không gì chặn nổi". Đo phá tan: H — ưu tiên cao nhất — bị chặn 468ms bởi M, một luồng thấp hơn nó. Không phải vì hệ lập lịch sai, mà vì một phụ thuộc gián tiếp qua khóa: H cần khóa → L giữ khóa → M chặn L. Ưu tiên của H chỉ đảm bảo nó được ưu tiên khi sẵn sàng chạy; nhưng khi nó đang chặn chờ một tài nguyên, ưu tiên đó vô nghĩa cho tới khi tài nguyên được nhả — và ai nhả lại phụ thuộc vào một cuộc tranh CPU mà H không kiểm soát.

Bài học đo lường: ưu tiên cao không bảo đảm chạy trước khi luồng đang chờ một khóa; một luồng ưu tiên thấp giữ khóa có thể bị luồng trung bình cướp CPU, kéo theo luồng cao bị chặn gián tiếp — và cách chữa là priority inheritance, không phải nâng ưu tiên của H. Nếu tôi tin "cứ đặt ưu tiên cao là xong", tôi đã không hiểu vì sao một tác vụ tối quan trọng thỉnh thoảng bị trễ khủng khiếp dưới tải — và tệ hơn, tôi có thể "sửa" bằng cách nâng ưu tiên H nữa (vô ích, vì vấn đề nằm ở L bị M cướp), thay vì bật cơ chế đúng.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: cẩn thận khi các luồng khác mức ưu tiên chia sẻ một khóa. Đây là công thức của priority inversion: một khóa mà cả luồng ưu tiên cao lẫn thấp cùng cần. Nếu bạn dùng ưu tiên thời gian thực (âm thanh, điều khiển, robot), hãy hoặc bật priority inheritance trên các mutex chia sẻ (PTHREAD_PRIO_INHERIT), hoặc thiết kế để luồng cao không phải chờ khóa mà luồng thấp giữ (ví dụ hàng đợi lock-free, hoặc tách dữ liệu). Đây là mặt trái của mutex mà một hệ ưu tiên phải tính tới.

Hệ quả thứ hai: nâng ưu tiên không phải liều thuốc vạn năng, đôi khi còn hại. Vấn đề của H không giải được bằng cách cho H ưu tiên cao hơn — nó đã cao nhất rồi. Nâng ưu tiên bừa còn dễ tạo ra chính kịch bản này (một luồng cao chặn trên khóa của một luồng thấp). Muốn một tác vụ chạy đúng hạn, hãy nghĩ tới toàn bộ chuỗi phụ thuộc của nó — nó chờ khóa nào, ai giữ, ai có thể cướp CPU của người giữ — chứ không chỉ con số ưu tiên của riêng nó.

Hệ quả thứ ba là tinh thần đo lường: đo độ trễ dưới tranh chấp, không chỉ ở trạng thái rảnh. Con số mang theo: ưu tiên cao KHÔNG bảo đảm chạy trước khi luồng đang chờ khóa — luồng cao H bị chặn GIÁN TIẾP bởi luồng trung M qua một khóa luồng thấp L giữ (M cướp CPU của L, L không nhả khóa); đo H chờ 468ms = priority inversion (bug Mars Pathfinder); bật PRIORITY INHERITANCE (L tạm thừa kế ưu tiên H, M không cướp được) thì H chờ tụt còn 128ms, giảm 73%. Một hệ nhìn "đúng ưu tiên" lúc rảnh có thể đảo lộn hoàn toàn khi ba luồng tranh một khóa — và chỉ đo dưới tranh chấp mới lộ ra.

Thử ba mươi giây

Hình dung (hoặc dựng) ba luồng ưu tiên thấp–trung–cao chia sẻ một mutex, ghim chung một lõi. Cho luồng thấp giữ khóa và làm việc; luồng cao xin khóa (chặn); luồng trung chạy một vòng CPU thuần. Hỏi: luồng cao phải chờ bao lâu? Trực giác nói "ngắn, vì nó ưu tiên cao"; thực tế nó chờ đúng bằng thời gian luồng trung chiếm CPU cộng vùng găng của luồng thấp — vì luồng thấp bị luồng trung cướp CPU nên không nhả khóa được. Rồi đặt PTHREAD_PRIO_INHERIT lên mutex và nghĩ lại: giờ luồng thấp mang ưu tiên của luồng cao khi giữ khóa, luồng trung không chen được, thời gian chờ tụt về đúng vùng găng. Ba mươi giây đó cho bạn thấy một sự thật mà con số ưu tiên che giấu: ưu tiên chỉ mạnh khi luồng sẵn sàng chạy — còn khi nó chờ một tài nguyên, số phận của nó nằm trong tay kẻ đang giữ tài nguyên đó, và cả kẻ có thể cướp CPU của người ấy.