Khi nhiều luồng cùng đụng vào một dữ liệu chia sẻ, ta dùng mutex (mutual exclusion) để bảo vệ: chỉ một luồng được vào vùng tới hạn (critical section) một lúc. Mutex là công cụ đồng bộ cơ bản nhất, và cũng bị mang tiếng oan nhiều nhất — "khóa làm chậm chương trình". Nhưng khóa đắt bao nhiêu, và đắt ở đâu? Tôi đo, và phát hiện cái đắt không nằm ở nơi người ta hay đổ lỗi.
Không tranh chấp thì gần như miễn phí
Trường hợp thường gặp nhất là không tranh chấp: một luồng khóa mutex, không ai khác đang giành. Tôi đo trong container gcc:13 (AArch64, 10 lõi), một luồng làm lock+unlock 20 triệu lần:
lock+unlock KHÔNG tranh chấp (1 luồng) : 3,3 ns/thao tác
3,3 nano giây — gần như miễn phí. Lý do: pthread_mutex hiện đại dùng futex (fast userspace mutex). Khi không có tranh chấp, việc khóa chỉ là một phép atomic (compare-and-swap) trên một biến trong không gian người dùng — không hề gọi vào nhân, không chuyển ngữ cảnh. Chỉ khi thật sự có tranh chấp, futex mới rơi xuống đường chậm và nhờ nhân can thiệp. Nên một mutex mà phần lớn thời gian không bị giành thì chi phí gần như không đáng kể.
Có tranh chấp: tùy vùng tới hạn ngắn hay dài
Giờ cho nhiều luồng cùng giành một mutex. Trước hết với vùng tới hạn ngắn — chỉ counter++ bên trong khóa:
T=1 luồng : 4,1 ns T=2 : 11,5 ns T=4 : 21,0 ns
T=8 luồng : 27,1 ns T=32 : ~25 ns (chựng lại)
Chi phí mỗi thao tác tăng từ 4 lên ~27 ns khi có tranh chấp — nhưng rồi chựng lại quanh 25-27 ns ngay cả với 32 luồng (vượt 10 lõi). Nó không nhảy lên micro giây. Vì sao? Vì vùng tới hạn quá ngắn: luồng thua chỉ quay (spin) vài vòng trong không gian người dùng rồi giành được ngay, chứ không ngủ. Chi phí thật ở đây là dòng cache của biến khóa (và counter) nảy qua lại giữa các lõi — vài chục ns, không phải chi phí ngủ.
Bây giờ tăng độ dài vùng tới hạn lên (một đoạn tính ~2 µs bên trong khóa):
T=1 : 2182 ns T=2 : 2995 ns T=4 : 3853 ns T=8 : 4014 ns
Giờ mọi thứ khác hẳn: mỗi thao tác (gồm cả công việc trong vùng tới hạn) tăng từ 2182 lên ~4000 ns khi thêm luồng. Vì vùng tới hạn dài, luồng thua không spin mãi được — nó thật sự ngủ, rơi vào đường chậm của futex, kéo theo một chuyển ngữ cảnh ~8,5 µs. Và vì vùng tới hạn là tuần tự, các luồng phải xếp hàng chờ nhau. Phần chênh ~1800 ns so với T=1 chính là chi phí serialize cộng ngủ/đánh thức.
Một lần tôi đo hớ: cái đắt là tranh chấp, không phải khóa
Tôi vào đo với hai niềm tin đối nghịch mà cùng lệch. Niềm tin thứ nhất: "khóa luôn đắt, tránh được thì tránh". Sai — lock/unlock không tranh chấp chỉ 3,3 ns, rẻ hơn nhiều phép tính thường. Niềm tin thứ hai (tôi rút ra sau khi đo chuyển ngữ cảnh): "mutex khi tranh chấp thì mỗi thao tác tốn ~8,5 µs vì luồng phải ngủ". Cũng lệch — với vùng tới hạn ngắn, tranh chấp chỉ ~25-27 ns, vì luồng thua spin chứ không ngủ; chi phí ngủ hàng µs chỉ xuất hiện khi vùng tới hạn dài.
Sự thật ở giữa và tinh tế hơn cả hai: cái đắt của mutex không phải bản thân việc khóa, mà là tranh chấp — và mức đắt tỉ lệ với cả mức tranh chấp lẫn độ dài vùng tới hạn. Một mutex không ai giành: 3,3 ns. Một mutex bị giành nhưng vùng tới hạn cực ngắn: ~25 ns (spin + cache nảy). Một mutex bị giành với vùng tới hạn dài: hàng nghìn ns (ngủ + xếp hàng tuần tự). Bài học đo lường: đừng đổ lỗi cho "cái khóa"; hãy hỏi nó bị giành nhiều không, và giữ nó bao lâu. Đó mới là hai trục quyết định chi phí.
Vì sao chi phí chựng lại ở vùng tới hạn ngắn
Chi tiết đáng dừng lại: với vùng tới hạn ngắn, chi phí mỗi thao tác chựng quanh 25-27 ns ngay cả khi tăng từ 8 lên 32 luồng — vượt xa 10 lõi. Nếu luồng thua phải ngủ, ta sẽ thấy chi phí leo thang theo số luồng (mỗi luồng thêm một lần chuyển ngữ cảnh ~8,5 µs). Việc nó chựng lại nói lên điều quan trọng: pthread_mutex mặc định có quay thích ứng (adaptive spinning) — luồng thua quay bận vài vòng trước khi chịu ngủ, đánh cược rằng khóa sẽ nhả nhanh. Với vùng tới hạn counter++ (dưới 5 ns), cược đó gần như luôn thắng: luồng giành được khóa sau vài vòng quay, không bao giờ vào nhân. Chi phí còn lại chỉ là dòng cache của biến khóa nảy giữa các lõi — một hiện tượng cache coherence mà ta sẽ đo riêng ở phần sau. Đây là lý do đo phải theo đúng kỷ luật: nếu chỉ đo một mức luồng, tôi đã bỏ lỡ chỗ đường cong chựng lại, và kết luận sai về cơ chế.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: giữ vùng tới hạn NGẮN. Vì chi phí tranh chấp bùng nổ khi luồng thua phải ngủ (vùng dài), và ở lại trong vùng cache-nảy rẻ khi vùng ngắn, quy tắc số một là làm ít nhất có thể bên trong khóa. Tính toán nặng, I/O, cấp phát bộ nhớ — đưa ra ngoài vùng tới hạn; chỉ giữ khóa đúng lúc chạm dữ liệu chia sẻ. Vùng tới hạn ngắn giữ tranh chấp trong vùng ns thay vì µs.
Hệ quả thứ hai: giảm tranh chấp quan trọng hơn tránh khóa. Đừng sợ khóa đến mức viết code lock-free phức tạp và dễ sai khi một mutex không tranh chấp đã 3,3 ns. Thay vào đó, giảm tranh chấp: chia dữ liệu thành nhiều phần mỗi phần một khóa (sharding), dùng khóa riêng cho từng vùng thay vì một khóa lớn, hay bố cục dữ liệu để các luồng chạm chỗ khác nhau. Ít luồng giành cùng một khóa thì khóa gần như miễn phí.
Hệ quả thứ ba là tinh thần đo lường: đo ở đúng chế độ bạn sẽ gặp. Con số mang theo: lock/unlock KHÔNG tranh chấp chỉ ~3,3 ns (futex fast path, không vào nhân); tranh chấp với vùng tới hạn NGẮN chỉ ~25-27 ns (luồng thua spin + dòng cache nảy giữa lõi, không ngủ); chỉ khi vùng tới hạn DÀI (~µs) luồng thua mới thật sự ngủ, kéo theo chuyển ngữ cảnh ~8,5µs và xếp hàng tuần tự (2182 -> 4014 ns). Cái đắt của mutex là tranh chấp nhân với độ dài vùng tới hạn, không phải việc khóa. Trước khi sợ một mutex, hãy đo nó bị giành bao nhiêu và giữ bao lâu.
Thử ba mươi giây
Viết một vòng lock+unlock một mutex 10 triệu lần từ một luồng và đo — bạn sẽ thấy mỗi lần chỉ vài nano giây, rẻ đến bất ngờ. Rồi cho 8 luồng cùng tăng một biến đếm dưới cùng mutex đó và đo lại thời gian mỗi thao tác — nó tăng lên vài chục ns (tranh chấp, nhưng vẫn ns). Cuối cùng, thêm một đoạn tính ~1 µs bên trong khóa và chạy 8 luồng — giờ mỗi thao tác vọt lên hàng nghìn ns, vì các luồng phải ngủ và xếp hàng. Ba lần đo đó cho bạn thấy bản đồ chi phí của mutex: rẻ khi rảnh, hơi đắt khi giành mà nhanh, rất đắt khi giành mà giữ lâu — và biết mình đang ở ô nào là chìa khóa để dùng khóa đúng.