Hình dung một buồng điện thoại công cộng chỉ một người vào được. Có ba tình huống. Buồng đang trống: bạn bước vào, đóng cửa, dùng — gần như tức thì, không phải chờ ai. Có người đang gọi năm giây: bạn đứng chờ ngay cửa một nhịp rồi vào — rẻ. Có người đang gọi mười phút: đứng chờ thì phí, nên bạn ghi tên vào danh sách rồi về bàn làm việc khác, khi buồng trống có người gọi bạn quay lại — và nếu đông người thì thành một hàng dài chờ. Cái tốn của "buồng điện thoại" không phải chuyện có một cái buồng, mà là nó bận bao nhiêu và mỗi cuộc gọi dài bao lâu. Mutex vận hành y hệt, và tôi đo được ba tình huống đó cho ba con số hoàn toàn khác nhau.

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 (buồng trống). 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ỉ là gạt cái chốt cửa). 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 (cuộc gọi năm giây):

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ủ (đứng chờ ngay cửa một nhịp). 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 — cuộc gọi mười phút):

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ôi trường đo: container gcc:13 trên host ARM AArch64, 10 lõi, pthread_mutex (futex), vùng tới hạn ngắn counter++ và dài ~2 µs, 20 triệu thao tác. Con số tuyệt đối đổi theo máy; điều bền vững là ba chế độ: không tranh chấp ~ns, tranh chấp CS ngắn spin ~chục ns, tranh chấp CS dài ngủ ~nghìn ns.)

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

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ự). Điều cần nhớ: đừ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í — buồng bận bao nhiêu, và mỗi cuộc gọi dài bao lâu.

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 (đứng nán ở cửa trước khi quyết định về bàn). 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ề đến code hằng ngày

Điều đầ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ẻ. Đây cũng là lý do quy tắc kinh điển ở database: đừng bao giờ giữ một khóa (hay để mở một transaction) trong lúc chờ I/O hay chờ người dùng nhập — giữ cuộc gọi ngắn thì hàng chờ mới không dài.

Điều 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 (lock striping — đúng cách ConcurrentHashMap của Java chia map thành nhiều segment), 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í.

Chỗ này cái mô hình "rẻ khi rảnh, spin khi giành ngắn, ngủ khi giành lâu" không phải chuyện riêng của pthread — nó là cách các runtime khác cũng làm, nên hiểu nó áp thẳng qua mọi ngôn ngữ. JVM có cả một bậc thang khóa: một synchronized không tranh chấp là thin lock (chỉ một CAS, rẻ như futex fast path), và chỉ phình thành fat lock (monitor của hệ điều hành, ngủ) khi thật sự bị giành lâu — đúng ba chế độ tôi đo; và mọi sách Java đều dạy "giữ khối synchronized ngắn" vì cùng lý do. Go sync.Mutex cũng có fast path bằng một CAS, rồi quay vài vòng, rồi mới park qua semaphore (kèm starvation mode) — y hệt adaptive spinning. Ở database, khóa mức dòng (row-level) thay vì mức bảng chính là sharding của khóa để giảm tranh chấp, và "transaction ngắn" là "vùng tới hạn ngắn" ở quy mô lớn. Mẫu số chung: một khóa rảnh gần như miễn phí ở mọi nơi, nên đừng sợ khóa — hãy sợ giữ khóa lâu và nhiều bên cùng giành một khóa, và chữa bằng vùng-tới-hạn-ngắn cộng chia-nhỏ-khóa.

Muốn tự thấy thì 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.