Ở bài mutex ta thấy khi một luồng thua khóa, nó ngủ — vào nhân, chuyển ngữ cảnh. Có một cách khác: thay vì ngủ, luồng thua cứ quay bận (busy-wait), liên tục thử giành khóa cho tới khi được. Đó là spinlock. Nó không gọi vào nhân, không chuyển ngữ cảnh, nên nghe có vẻ nhanh hơn mutex. Nhiều người tin "spinlock luôn nhanh hơn". Tôi đo, và phát hiện câu đó đúng đúng một nửa — nửa còn lại là một thảm họa.
Hai cách chờ khóa
Spinlock đơn giản đến mức viết được vài dòng: một cờ atomic, và while (test_and_set(cờ)) { /* quay */ }. Khi khóa bị giữ, luồng thua quay tại chỗ — đốt chu kỳ CPU liên tục kiểm cờ — cho tới khi chủ khóa nhả. Không syscall, không ngủ. Mutex thì khi thua sẽ ngủ: nó gọi vào nhân (futex), luồng bị đưa ra khỏi CPU cho tới khi được đánh thức. Cái giá của mutex là đường vào nhân; cái giá của spinlock là CPU bị đốt trong lúc quay.
Trực giác "spinlock nhanh hơn" dựa trên việc nó tránh được đường vào nhân. Nhưng nó bỏ qua cái giá của việc quay bận. Tôi đo cả hai với một vùng tới hạn cực ngắn (counter++), tăng số luồng từ 2 lên 32, trong container gcc:13 (10 lõi).
Đo: từ nhanh 1,7 lần đến chậm 36 lần
T | SPINLOCK | MUTEX | tỉ lệ (spin/mutex)
2 | 7,3 | 12,7 | 0,58×
4 | 27,5 | 21,0 | 1,31×
8 | 206,5 | 28,3 | 7,30×
16 | 411,3 | 27,0 | 15,2×
32 | 901,2 | 25,2 | 35,7×
Ở 2 luồng (tranh chấp thấp), spinlock đúng là nhanh hơn: 7,3 ns so với 12,7 ns của mutex — nhanh khoảng 1,7 lần. Vì ít khi phải quay, và khi được thì giành ngay không tốn syscall. Nhưng khi thêm luồng, câu chuyện đảo ngược hoàn toàn: ở 8 luồng spinlock đã chậm hơn mutex 7,3 lần, ở 16 luồng 15 lần, và ở 32 luồng — gấp hơn ba lần số lõi — spinlock chậm hơn mutex 35,7 lần. Đáng chú ý: mutex gần như phẳng (25-28 ns) suốt từ 4 đến 32 luồng, còn spinlock nổ tung từ 7 lên 900 ns.
Một lần tôi đo hớ: quay bận là con dao hai lưỡi
Tôi vào đo với niềm tin phổ biến trong giới tối ưu: "spinlock luôn nhanh hơn mutex vì nó không vào nhân". Đo ra niềm tin đó có điều kiện, và điều kiện rất hẹp. Spinlock chỉ nhanh hơn khi tranh chấp thấp và số luồng không vượt số lõi. Ngoài vùng đó, nó là thảm họa.
Vì sao spinlock nổ tung? Hai lý do. Thứ nhất, quay bận đốt CPU vô ích: mỗi luồng thua ngồi quay tại chỗ, chiếm trọn một lõi để... không làm gì, thay vì nhường lõi cho luồng khác làm việc thật. Thứ hai, và tệ hơn, khi số luồng vượt số lõi (oversubscription): một luồng đang giữ khóa có thể bị hệ điều hành hoãn lịch (đưa ra khỏi CPU) để nhường chỗ cho một luồng khác — mà luồng khác đó lại đang quay chờ chính cái khóa này. Kết quả: kẻ quay giữ lõi mà chủ khóa cần để chạy và nhả khóa, tạo tắc nghẽn — chủ khóa không chạy được, nên khóa không nhả, nên kẻ quay quay mãi vô ích cho hết lượt CPU của nó. Mutex tránh được điều này vì kẻ thua ngủ, tự nhường lõi cho chủ khóa chạy tiếp.
Bài học đo lường: một tối ưu "tránh chi phí X" có thể mang chi phí Y còn lớn hơn ở hoàn cảnh khác. Spinlock tránh được syscall (chi phí X) nhưng mang chi phí đốt-CPU-và-tắc-nghẽn (chi phí Y), và Y bùng nổ khi đông luồng. Không có cái luôn thắng — chỉ có cái thắng ở đúng hoàn cảnh, và đo là cách duy nhất biết mình đang ở hoàn cảnh nào.
Vì sao đường cong hai bên khác nhau đến vậy
Đáng dừng lại ở chi tiết mutex phẳng còn spinlock nổ tung, vì nó là bản chất của hai cơ chế. Với mutex, khi tranh chấp tăng, các luồng thua lần lượt ngủ; tại mỗi thời điểm chỉ có chủ khóa chạy vùng tới hạn còn những kẻ chờ không tiêu tốn gì (đang ngủ). Chi phí mỗi thao tác gần như chỉ là thời gian vùng tới hạn cộng một chuyển ngữ cảnh khi đến lượt — không phụ thuộc mấy vào tổng số luồng. Với spinlock, mọi kẻ chờ đều chạy hết công suất để quay, nên tổng lượng CPU bị đốt tăng theo số luồng chờ; và khi vượt số lõi, chính việc quay đó cạnh tranh lõi với chủ khóa, biến chậm-tuyến-tính thành chậm-thảm-họa. Đây cũng là một mặt của contention và scaling: thêm luồng vào một điểm nóng không thêm việc, mà spinlock còn khuếch đại điều đó bằng cách đốt CPU khi chờ. Và như mọi phần, con số này chỉ đáng tin khi đo đúng cách — lặp lấy min, warmup, và đo ở nhiều mức luồng để thấy cả đường cong, không chỉ một điểm.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: mặc định dùng mutex, spinlock chỉ ở chỗ rất hẹp. Mutex xuống dốc duyên dáng (graceful) khi tranh chấp tăng, và không bao giờ gây thảm họa oversubscription. Spinlock chỉ đáng khi bạn chắc chắn vùng tới hạn cực ngắn, tranh chấp thấp, và số luồng không vượt số lõi — điển hình là code trong nhân hệ điều hành, không phải code ứng dụng thông thường. Trong không gian người dùng với số luồng bạn không kiểm soát chặt, spinlock là quả bom hẹn giờ.
Hệ quả thứ hai: hiểu cái giá thật của mỗi lựa chọn đồng bộ. "Không vào nhân" nghe như luôn tốt, nhưng nó đổi lấy "đốt CPU khi chờ". Ở tải thấp, đổi này có lời; ở tải cao, nó lỗ nặng. Nhiều thư viện thực tế dùng mutex thích ứng (adaptive): quay một chút (như spinlock) rồi mới ngủ (như mutex) — lấy cái tốt của cả hai, đúng như pthread_mutex adaptive spinning mà ta đã thấy giữ chi phí thấp ở vùng tới hạn ngắn.
Hệ quả thứ ba là tinh thần đo lường: đừng chọn cơ chế đồng bộ theo khẩu hiệu. Con số mang theo: spinlock nhanh hơn mutex chỉ khi tranh chấp thấp và số luồng ≤ số lõi (T=2: nhanh 1,7 lần, không syscall); khi đông luồng hoặc oversubscribe (luồng > lõi), spinlock đốt CPU quay vô ích và giữ lõi mà chủ khóa cần — chậm hơn mutex 7 lần (T=8), 15 lần (T=16), 36 lần (T=32) — trong khi mutex phẳng ~25 ns vì kẻ thua ngủ nhường lõi. Không có cái luôn thắng; chọn theo đo ở đúng hoàn cảnh, không theo "spin nhanh hơn".
Thử ba mươi giây
Viết một spinlock mười dòng (atomic_flag với test_and_set) và cho 4 luồng cùng tăng một biến đếm dưới nó, đo thời gian; rồi thay bằng pthread_mutex và đo lại. Với 4 luồng (≤ số lõi máy bạn), có thể spinlock nhỉnh hơn hoặc ngang. Giờ tăng lên số luồng gấp 3-4 lần số lõi và đo lại — spinlock sẽ chậm thảm hại còn mutex gần như không đổi. Ba mươi giây đó cho bạn cảm nhận trực tiếp vì sao "spinlock nhanh hơn" là một khẩu hiệu nguy hiểm: nó đúng trong phòng thí nghiệm nhỏ, sai thảm ở tải thật — và chỉ đồng hồ ở đúng số luồng mới nói cho bạn biết ranh giới.