Hình dung một vị khách VIP (H) cần vào một căn phòng, nhưng chìa khóa đang nằm trong tay anh lao công (L). Bình thường lao công trả chìa trong nháy mắt. Trớ trêu là ngay lúc đó ông quản lý (M) cứ liên tục kéo anh lao công đi làm việc vặt — mà quản lý thì có quyền sai bảo lao công. Thế là VIP đứng chôn chân chờ chìa khóa, bị chặn gián tiếp bởi một người đáng ra dưới cơ mình, chỉ vì người đó cứ giành mất anh giữ chìa. Đây là priority inversion (đảo ưu tiên) — và nó chính là bug đã suýt giết tàu Mars Pathfinder năm 1997.

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 tình huống trên phá vỡ điều đó — 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. 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.

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. Đúng ông quản lý giành mất anh lao công. 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. Đúng như tạm cho anh lao công cái thẻ VIP để ông quản lý hết đường giành.

(Môi trường đo: container gcc:13 trên host ARM AArch64, ba luồng SCHED_FIFO ghim chung 1 lõi, mốc thời gian tính từ lúc bắt đầu. Con số tuyệt đối đổi theo máy và theo độ dài vùng găng/việc của M; cái cần nhìn là hình dạng: không PI thì H chờ cả phần M chiếm CPU, có PI thì H chỉ chờ đúng vùng găng của L.)

Hai niềm tin đối nghịch, cùng sai

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". Con số 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.

Còn một niềm tin ngược, tinh vi hơn, mà tôi thấy nhiều lập trình viên rơi vào khi đã biết chuyện này: "tác vụ quan trọng bị trễ hả? Cứ nâng ưu tiên của nó lên nữa là xong". Sai, và đôi khi còn phản tác dụng. 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út thắt nằm ở L bị M cướp CPU. Tệ hơn, nâng ưu tiên bừa bãi chính là thứ tạo ra kịch bản này ngay từ đầu (một luồng cao chặn trên khóa của một luồng thấp). Cách chữa đúng là priority inheritance, hoặc thiết kế lại để luồng cao không phải chờ khóa của luồng thấp — chứ không phải vặn to con số ưu tiên.

Về đến code hằng ngày

Điều đầ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.

Điều thứ hai: nghĩ tới toàn bộ chuỗi phụ thuộc, không chỉ con số ưu tiên của riêng một luồng. Muốn một tác vụ chạy đúng hạn, hãy hỏi: nó chờ khóa nào, ai giữ, ai có thể cướp CPU của người giữ? 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, chứ đo lúc máy rảnh thì mọi thứ trông hoàn hảo.

Chỗ này không chỉ là chuyện tàu vũ trụ. Linux futex hỗ trợ priority inheritance ở tầng nhân đúng để giải bài này cho PTHREAD_PRIO_INHERIT; ngoài PI còn có priority ceiling protocol (nâng sẵn ai giữ khóa lên trần ưu tiên của khóa) mà RTOS công nghiệp hay dùng. Java real-time (RTSJ) đưa priority inheritance vào chuẩn vì synchronized thường không có nó. Và một họ hàng gần của priority inversion xuất hiện ở tầng cao mà ít ai gọi đúng tên: lock convoy trong cơ sở dữ liệu và server — một transaction ngắn kẹt sau một transaction dài đang giữ khóa hàng, kéo tụt cả throughput; cách chữa cũng cùng tinh thần (rút ngắn vùng giữ khóa, giảm phạm vi khóa, hoặc dùng MVCC để đọc khỏi chờ ghi). Cùng một bài học VIP-chờ-chìa-khóa, lặp lại từ nhân hệ điều hành tới tầng database.

Muốn tự thấy thì 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. Vài phút đó 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.