Một khóa (mutex) bảo vệ vùng dữ liệu chung khỏi bị nhiều luồng giẫm chân nhau. Ai cũng "biết" mutex có cái giá của nó, nhưng cái giá đó cụ thể là bao nhiêu, và nằm ở đâu? Trên Linux, mutex của pthread dựng trên một nguyên thủy tên futex — fast userspace mutex — và chính cái tên đó gợi ý câu trả lời: khi không có tranh chấp, khóa và mở khóa xảy ra hoàn toàn trong userspace, không cần nhờ nhân. Bài này đo trực tiếp cái giá của khóa khi rảnh và khi bị tranh — và phát hiện cái giá của tranh chấp không nằm ở nơi tôi đinh ninh, sau khi strace phơi bày sự thật.
futex: khóa nhanh trong userspace
Ý tưởng cốt lõi của futex là tách trường hợp nhanh khỏi trường hợp chậm. Trạng thái của khóa (đang rảnh hay đang giữ) nằm trong một biến số nguyên ở bộ nhớ người dùng. Khi một luồng muốn khóa và khóa đang rảnh, nó chỉ cần một phép so-sánh-và-đổi nguyên tử (compare-and-swap, CAS) lên biến đó — một lệnh CPU, không hề gọi hệ thống, không phiền tới nhân. Mở khóa cũng vậy: một phép ghi nguyên tử. Đây là "đường nhanh", và nó nhanh vì nhân hoàn toàn không tham gia.
Nhân chỉ được gọi tới ở "đường chậm": khi một luồng muốn khóa nhưng khóa đang bị giữ, nó cần ngủ chờ cho tới khi khóa được nhả — và việc ngủ/đánh thức một luồng phải qua nhân, bằng lời gọi hệ thống futex(). Lời gọi này đắt: nó là một syscall, và thường kéo theo một cú chuyển ngữ cảnh (luồng đi ngủ, luồng khác chạy). Nên trực giác thông thường là: không tranh chấp thì rẻ (userspace), có tranh chấp thì đắt (syscall + ngủ). Tôi muốn đo xem "rẻ" và "đắt" cụ thể là bao nhiêu.
Đo: 5 ns khi không tranh, 23 ns khi tranh
Tôi viết một chương trình dùng pthread_mutex bảo vệ một biến đếm, mỗi luồng lặp lock → counter++ → unlock mười triệu lần, rồi đo thời gian mỗi thao tác khóa/mở khóa. Container thấy 10 CPU. Với một luồng (không ai tranh) so với tám luồng (tranh nhau cùng một khóa), trung vị 3 lần:
| Tình huống | ns mỗi lock/unlock |
|---|---|
| 1 luồng (không tranh) | 5,4 ns |
| 8 luồng (tranh chấp), tiết đoạn ngắn | 23 ns |
Không tranh chấp, một cặp khóa/mở khóa chỉ tốn 5,4 nano giây — đúng như hứa hẹn của futex: đây là chi phí một phép CAS nguyên tử, không hơn. Để so sánh, một lời gọi hệ thống rẻ nhất cũng tốn hàng trăm nano giây; con số 5,4 ns này nhỏ hơn thế cả trăm lần, xác nhận rằng khóa không tranh không hề chạm tới nhân.
Khi tám luồng tranh nhau, chi phí lên 23 ns — chậm hơn khoảng 4 lần. Chậm hơn thật, nhưng... chỉ 4 lần? Nếu mỗi lần tranh chấp phải trả một syscall futex() và một cú chuyển ngữ cảnh (cỡ vài microsecond mỗi cái), con số phải lớn hơn thế cả trăm lần mới đúng. Có gì đó không khớp với trực giác của tôi, và đó là lúc phải đào.
Một lần tôi đo hớ: tranh chấp mà nhân gần như không dự
Tôi vào bài với niềm tin chắc nịch: "tranh chấp mutex nghĩa là hàng loạt syscall futex() đắt đỏ vào nhân", và định lấy một con số chậm khủng khiếp làm điểm nhấn. Nhưng 23 ns — chỉ 4 lần chậm hơn — không khớp với hình dung đó. Con số quá nhỏ so với "mỗi lần tranh là một syscall + ngủ". Khi số đo mâu thuẫn với mô hình trong đầu, hoặc mô hình sai, hoặc tôi đang đo nhầm — nên tôi bật strace để đếm chính xác bao nhiêu lời futex() thật sự rơi vào nhân.
Kết quả lật ngược giả định của tôi. Với một luồng, mười triệu lần khóa/mở khóa mà strace chỉ ghi nhận một lời gọi futex() (của lúc dọn dẹp cuối) — đúng nghĩa không syscall. Với tám luồng tranh nhau, mười triệu thao tác nhưng chỉ 13.600 lời futex() — tức 0,14%. Nói cách khác, 99,86% số lần khóa dưới tranh chấp vẫn không hề chạm tới nhân.
Vì sao? Vì glibc không đi ngủ ngay khi thấy khóa bận. Nó spin — quay bận một vòng ngắn trong userspace, kiểm lại biến khóa vài trăm lần — với hy vọng luồng đang giữ sẽ nhả ra ngay. Với một tiết đoạn gang cực ngắn (chỉ counter++), luồng giữ khóa nhả gần như tức thì, nên kẻ chờ hầu như luôn giành được khóa trong lúc spin, chẳng bao giờ phải gọi futex() để ngủ. Cái làm 23 ns chậm hơn 5,4 ns không phải syscall, mà là cache bouncing: biến khóa (và biến đếm) bị các nhân CPU giành nhau, dòng cache của nó nảy qua nảy lại giữa các lõi, mỗi lần nảy tốn vài chục nano giây.
Vậy khi nào đường futex() vào nhân mới thật sự áp đảo? Khi tiết đoạn gang đủ dài để kẻ chờ spin mãi không được, đành bỏ cuộc mà đi ngủ. Tôi đo lại với một tiết đoạn gang dài cỡ 2 microsecond: giờ khoảng một nửa số lần khóa rơi vào futex() ngủ thật, và chi phí mỗi lock vọt lên 3476 ns. Đây mới là bức tranh "tranh chấp đắt đỏ" mà tôi tưởng sẽ thấy ngay từ đầu.
Bài học đo lường: "mutex tranh chấp chậm bao nhiêu" không có một đáp án — nó phụ thuộc độ dài tiết đoạn gang, một biến ẩn tôi chưa kiểm soát. Đo với tiết đoạn ngắn, tôi thấy tranh chấp "nhẹ nhàng" (spin, cache bouncing, 4 lần); đo với tiết đoạn dài, tôi thấy nó "tàn khốc" (ngủ, syscall, hàng trăm lần). Nếu chỉ đo một chế độ rồi ngoại suy, tôi đã kết luận sai theo cả hai hướng. Và một lần nữa, strace — nhìn thẳng vào syscall thật — mới bóc được sự thật mà con số thời gian đơn thuần che giấu.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là đừng sợ mutex khi tranh chấp thấp. Một cặp khóa/mở khóa không tranh chỉ tốn ~5 ns — rẻ ngang một phép toán số học, và hoàn toàn trong userspace. Nỗi lo "mutex đắt, phải tránh khóa" thường bị thổi phồng: nếu các luồng của bạn hiếm khi chạm cùng một khóa cùng lúc, cái giá gần như bằng không. Tối ưu bằng cách gỡ khóa ở nơi không có tranh chấp là tối ưu một thứ vốn đã rẻ.
Hệ quả thứ hai là giữ tiết đoạn gang càng ngắn càng tốt — đó mới là đòn bẩy thật. Phép đo cho thấy chi phí tranh chấp không do "có khóa" mà do luồng khác phải chờ, và thời gian chờ đó bằng thời gian bạn giữ khóa nhân với mức tranh. Giữ khóa lâu (đọc tệp, cấp phát bộ nhớ, gọi hàm phức tạp trong vùng gang) đẩy kẻ chờ từ chỗ spin rẻ sang chỗ ngủ đắt, và biến 23 ns thành hàng nghìn ns. Nguyên tắc vàng: làm việc nặng ngoài khóa, chỉ giữ khóa cho đúng cái thao tác cập nhật ngắn nhất. Đừng gọi I/O hay syscall khi đang giữ mutex.
Hệ quả thứ ba là chọn đúng công cụ cho đúng mức tranh, và đo trước khi đổi. Nếu tiết đoạn cực ngắn và tranh chấp cao, đôi khi một biến nguyên tử (atomic, không cần mutex) hay một thiết kế không-khóa lại tốt hơn — nhưng chỉ khi bạn đo được rằng cache bouncing hay việc ngủ đang là nút thắt thật. Đừng nhảy sang lock-free chỉ vì nghe nói nhanh; nó khó đúng hơn nhiều. Con số mang theo: khóa mutex không tranh tốn ~5 ns thuần userspace (không syscall); tranh chấp với tiết đoạn ngắn chỉ chậm ~4 lần và vẫn gần như không chạm nhân (glibc spin, cái đắt là cache bouncing) — chỉ khi tiết đoạn gang đủ dài để kẻ chờ đi ngủ thì syscall futex() mới áp đảo và giá vọt lên hàng nghìn ns. Mutex rẻ hay đắt tùy bạn giữ nó bao lâu, không phải tùy bạn có dùng nó hay không.
Thử ba mươi giây
Nếu bạn nghi một chương trình đa luồng đang tốn nhiều thời gian vào khóa, hãy để strace đếm giúp: strace -f -c -e trace=futex ./chuong-trinh in ra tổng số lời gọi futex() và thời gian ngồi trong đó. Số futex() thấp nghĩa là khóa của bạn hầu như không tranh (hoặc tiết đoạn đủ ngắn để spin giải quyết) — mutex không phải nút thắt. Số futex() cao và cột thời gian lớn nghĩa là các luồng đang thật sự ngủ chờ nhau — lúc đó hãy tìm xem tiết đoạn gang nào bạn đang giữ quá lâu và rút ngắn nó, hoặc giảm mức tranh. Con số syscall đó cho bạn biết mình đang ở chế độ "spin rẻ" hay "ngủ đắt" mà bài này đo — trước khi đoán mò.