Hình dung một phòng họp chỉ một người vào được mỗi lúc, và bạn phải chờ tới lượt. Có hai cách. Cách "spinlock": đứng lì ngoài cửa, cứ vài giây lại giật thử tay nắm cho tới khi nó mở — bạn giành được phòng ngay giây nó trống, nhưng suốt lúc chờ bạn chẳng làm được việc gì, chỉ đứng giật cửa. Cách "mutex": ghi tên vào danh sách chờ rồi về bàn ngồi làm việc khác, khi phòng trống có người gọi bạn — mất một chút thời gian được gọi, nhưng trong lúc chờ bạn (và cái ghế của bạn) được giải phóng cho người khác dùng. Ở văn phòng vắng, đứng giật cửa nhanh hơn. Nhưng ở văn phòng đông mà ít bàn, cách đứng giật cửa biến thành thảm họa — và đó là điều tôi đo được.
Ở 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ờ (đứng giật tay nắm) — 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 (ghi tên rồi về ngồi). 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ôi trường đo: container gcc:13 trên host ARM AArch64, 10 lõi, vùng tới hạn cực ngắn counter++, 2-32 luồng, lặp lấy min sau warmup. Con số tuyệt đối đổi theo máy; điều bền vững là hình dạng: spinlock thắng ở ít luồng rồi nổ tung khi vượt số lõi, mutex phẳng.)
Hai niềm tin đối nghịch, cùng sai
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. Niềm tin đối nghịch — "spinlock nguy hiểm, đừng bao giờ dùng" — cũng sai: ở đúng vùng hẹp đó nó thật sự nhanh hơn, và nhân hệ điều hành dùng nó khắp nơi.
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ó (kẻ giật cửa chiếm mất cái ghế mà người giữ chìa khóa cần để làm xong và trả chìa). 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.
Hai niềm tin gặp nhau ở một bài học: 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ề đến code hằng ngày
Điều đầ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 (nơi Linux dùng spinlock vì nó kiểm soát được việc hoãn lịch, tắt preemption trong vùng tới hạn), 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ờ.
Điều thứ hai: hiểu cái giá thật của mỗi lựa chọn, và biết rằng thư viện đã lo giúp bạn. "Không vào nhân" nghe như luôn tốt, nhưng nó đổi lấy "đốt CPU khi chờ". Đây là lý do các nguyên hàm khóa thực tế đều là thích ứng (adaptive): quay một chút rồi mới ngủ — lấy cái tốt của cả hai. Go sync.Mutex quay vài vòng (kèm lệnh gợi ý CPU) rồi park qua semaphore, và còn có starvation mode chuyển sang hàng đợi công bằng nếu một luồng chờ quá 1 ms. Java synchronized/ReentrantLock cũng spin-rồi-park qua các tầng khóa. Vòng quay tử tế còn chèn lệnh PAUSE (x86) hay YIELD/ISB (ARM) để không đốt CPU quá phí. Vì thế lời khuyên thực tế gọn lại: đừng tự viết spinlock trong không gian người dùng — dùng mutex chuẩn, nó đã thích ứng sẵn.
Điều thứ ba là tinh thần đo lường: đừng chọn cơ chế đồng bộ theo khẩu hiệu. Tóm lại một câu: 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".
Tự thử nếu tò mò
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. Vài phút đó 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.