Khi nhiều luồng cùng sửa một dữ liệu chung, chúng phải đồng bộ — nếu không, hai luồng đọc cùng một giá trị, cùng cộng một, cùng ghi lại, và một lần tăng biến mất. Cách quen thuộc là khóa (mutex). Cách "hiện đại" được nhắc tới như nhanh hơn là không khóa (lock-free): dùng lệnh nguyên tử của phần cứng thay cho khóa. Bài trước đã thấy đua tranh đặt trần cho song song; bài này đo xem lock-free có tháo được cái trần ấy không — và câu trả lời làm tôi phải sửa lại điều mình tin.
Khóa, không khóa, và không chia sẻ
Một mutex (khóa loại trừ) cho đúng một luồng vào "vùng nguy hiểm" tại một thời điểm: luồng vào khóa lại, làm việc, mở khóa; luồng khác phải chờ. Đơn giản và đúng, nhưng chờ đợi là chi phí, và mỗi lần khóa/mở khóa cũng tốn.
Cách không khóa (lock-free) bỏ mutex, dùng thẳng những lệnh nguyên tử mà CPU đảm bảo chạy trọn vẹn không bị chen ngang: fetch_add (đọc-cộng-ghi trong một lệnh), hay compare-and-swap (CAS — "nếu giá trị vẫn là X thì đổi thành Y, đồng thời báo có thành công không"). Không ai phải chờ ai giữ khóa; mỗi luồng cứ thử thao tác nguyên tử của mình. Nghe hẳn phải nhanh hơn mutex.
Còn một cách thứ ba, quê mùa hơn: không chia sẻ gì cả. Nếu mỗi luồng có bản đếm riêng và ta chỉ gộp lại một lần ở cuối, thì chẳng cần đồng bộ trong lúc chạy. Tôi đo cả ba trên cùng một bài toán: tăng một bộ đếm tổng cộng 50 triệu lần, chia cho 1, 2, 4, 8, 10 luồng (máy có 10 lõi).
Đo: atomic nhanh hơn mutex, nhưng cả hai đều xấu đi
Bảng dưới là thông lượng (triệu lần tăng mỗi giây — càng cao càng tốt), kiểm đúng đắn trước: cả ba cách đều ra đúng tổng 50 triệu, không mất cập nhật.
Tăng 50 triệu lần, thông lượng (triệu/giây):
luồng | MUTEX | ATOMIC (biến chung) | ĐẾM RIÊNG (không chia sẻ)
1 | 226 | 627 | 1.236
2 | 77 | 309 | 2.127
4 | 46 | 174 | 3.603
8 | 36 | 51 | 6.004
10 | 33 | 34 | 6.358
Nhìn cột đầu tiên (1 luồng): atomic đạt 627 triệu/giây, mutex chỉ 226 — atomic nhanh hơn mutex 2,8 lần khi không có đua tranh. Đúng như tiếng đồn. Nếu dừng ở đây, tôi sẽ kết luận "dùng lock-free thay mutex là thắng". Đó là chỗ tôi đo hớ.
Một lần tôi đo hớ: biến atomic chung vẫn scale âm
Hãy đọc bảng theo chiều dọc, khi tăng số luồng. Cả mutex lẫn atomic đều chậm đi, không nhanh lên. Mutex tụt từ 226 xuống 33 triệu/giây; atomic tụt từ 627 xuống 34. Thêm lõi mà thông lượng giảm — đây là scale âm, điều nghịch lý nhất trong lập trình song song: bỏ thêm tài nguyên vào lại chậm hơn.
Và cú sốc lớn hơn: ở 10 luồng, atomic (34) gần bằng mutex (33). Toàn bộ lợi thế "nhanh 2,8 lần" của lock-free đã bốc hơi dưới đua tranh cao. Lock-free không tháo được cái trần — nó chỉ dời chỗ nghẽn đi.
Vì sao? Một biến chung — dù được sửa bằng lệnh nguyên tử — vẫn nằm trong một dòng cache. Để một lõi thực hiện fetch_add, nó phải giành quyền độc chiếm dòng cache đó: kéo dòng cache về cache riêng của mình ở trạng thái "được sửa", khiến bản trong cache của mọi lõi khác bị vô hiệu. Lõi kế tiếp muốn tăng lại phải kéo dòng cache đó về phía mình. Với 10 lõi cùng đập một biến, dòng cache duy nhất ấy bị kéo qua lại liên tục giữa các lõi — gọi là cache-line ping-pong. Mỗi thao tác nguyên tử giờ tốn cả một chuyến đi qua hệ thống nhớ liên-lõi, đắt hơn nhiều so với chính phép cộng. Lệnh nguyên tử không loại bỏ đua tranh; nó chỉ chuyển đua tranh từ "chờ khóa phần mềm" thành "giành dòng cache phần cứng" — và cái sau vẫn tuần tự hóa mọi thứ, đúng như một phần tuần tự trong luật Amdahl.
Cách nhanh thật: đừng chia sẻ
Cột cuối cùng kể một câu chuyện khác hẳn. Đếm riêng — mỗi luồng có bộ đếm của mình (đệm ra một dòng cache riêng để tránh false sharing, tức hai biến khác nhau vô tình nằm chung một dòng cache) — tăng đều theo số luồng: 1.236 → 6.358 triệu/giây. Ở 10 luồng, nó nhanh hơn cả mutex lẫn atomic khoảng 180 lần. Lý do đơn giản: không có dòng cache nào bị tranh, mỗi luồng chạy trong góc riêng của nó, và việc gộp 10 con số ở cuối là không đáng kể.
Đây là bài học đo hớ trọn vẹn: câu hỏi đúng không phải "khóa hay không khóa", mà "có chia sẻ hay không". Cách nhanh nhất không phải một cấu trúc lock-free tinh vi hơn, mà là thiết kế lại để khỏi chia sẻ. Và cần thành thật thêm: lock-free không chỉ thường không nhanh hơn dưới đua tranh, nó còn cực khó viết đúng — dính những cạm bẫy như vấn đề ABA (giá trị đổi từ A sang B rồi về A, CAS tưởng không có gì đổi) và thứ tự bộ nhớ (memory ordering) mà một dòng đặt sai là lỗi chỉ hiện ra một lần trong triệu lần chạy, trên đúng một loại CPU.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: giảm chia sẻ trước, chọn cơ chế đồng bộ sau. Khi một biến chung là điểm nóng, đừng vội hỏi "mutex hay atomic" — hãy hỏi "có cách nào để mỗi luồng làm việc trên dữ liệu riêng rồi gộp không". Bộ đếm phân mảnh (sharded counter), biến cục bộ luồng (thread-local) gộp cuối, chia không gian dữ liệu theo luồng — những kỹ thuật "chia để khỏi tranh" này thắng mọi cơ chế khóa dưới đua tranh cao, thường tới hai ba bậc độ lớn như số 180 lần ở đây.
Hệ quả thứ hai: atomic đáng dùng ở đua tranh THẤP, không phải như liều thuốc vạn năng. Một cờ nguyên tử được đọc nhiều ghi ít, một bộ đếm bị chạm thưa thớt, một con trỏ đổi hiếm khi — ở đó atomic nhanh, gọn, đúng chỗ. Nhưng đặt một biến atomic vào tâm một vòng nóng mà mọi luồng đập vào mỗi vòng lặp thì bạn đã dựng một nút cổ chai phần cứng, và nó không nhanh hơn mutex là bao. Đo thông lượng theo số luồng sẽ cho thấy ngay đường cong đi xuống đó.
Hệ quả thứ ba là bài học đo lường bao trùm: "lock-free nhanh hơn" là một câu đúng có điều kiện, và điều kiện là mức đua tranh. Con số mang theo: atomic fetch_add nhanh hơn mutex 2,8 lần khi một luồng, nhưng một biến atomic chung scale âm — thông lượng tụt từ 627 xuống 34 triệu/giây ở 10 luồng vì cache-line ping-pong, gần bằng mutex; còn đếm riêng không chia sẻ tăng đều lên 6.358 triệu/giây, nhanh gấp ~180 lần cả hai. Đua tranh trên một dòng cache là kẻ thù thật; công cụ đồng bộ chỉ quyết định bạn trả giá cho nó theo cách nào, còn thắng nó thì phải thôi chia sẻ. Đo đường cong theo số luồng, đừng tin một điểm một luồng.
Thử ba mươi giây
Nếu bạn viết đa luồng trong bất kỳ ngôn ngữ nào, thử một thí nghiệm: cho N luồng cùng tăng một biến dùng chung (kiểu atomic hay có khóa) tới tổng cố định, đo thời gian với N = 1, 2, 4, 8. Bạn gần như chắc chắn thấy thời gian tăng khi thêm luồng — scale âm ngay trước mắt. Rồi đổi sang cho mỗi luồng một biến riêng và cộng lại ở cuối, đo lại: lần này thời gian giảm theo luồng. Khoảng cách giữa hai lần đo chính là cái giá của việc chia sẻ một dòng cache — và là lý do vì sao câu thần chú của lập trình song song hiệu năng cao không phải "khóa tốt hơn" mà là "chia sẻ ít hơn".