Hình dung mười người cùng phải ký vào một cuốn sổ đặt trên bàn. Cách thứ nhất: ai cần thì cầm sổ, ký, đặt xuống, người sau mới cầm — có lúc phải đứng chờ. Cách thứ hai "hiện đại" hơn: bỏ luật cầm-sổ, ai cũng chồm tới ký thật nhanh — nhưng vẫn chỉ một cây sổ, nên rốt cuộc họ vẫn giẫm chân nhau, chỉ khác là giẫm ở tay thay vì ở luật. Cách thứ ba, quê nhưng khôn: phát cho mỗi người một tờ giấy riêng, cuối buổi gom lại — không ai chờ ai. Ba cách ấy đúng là mutex, lock-free, và không chia sẻ. 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 đó không — và câu trả lời bắt tôi sửa lại điều mình vẫn 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" mỗi lúc: 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ờ 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 gói 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 chờ ai giữ khóa; mỗi luồng cứ thử thao tác nguyên tử của mình. Nghe thì hẳn phải nhanh hơn mutex.

Còn 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ì trong lúc chạy chẳng cần đồng bộ. 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).

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 (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". Và đó chính là chỗ tôi hố.

Biến atomic chung vẫn scale âm

Giờ đọ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 lại 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 mà chạy 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", làm bản trong cache của mọi lõi khác bị vô hiệu. Lõi kế muốn tăng lại phải kéo dòng cache đó về phía nó. Với 10 lõi cùng đập một biến, dòng cache duy nhất ấy bị kéo qua kéo 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 hẳn chính phép cộng. Lệnh nguyên tử không xóa 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 một 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 dòng cache nào bị tranh, mỗi luồng chạy trong góc riêng, và việc gộp 10 con số ở cuối là không đáng kể.

Đây là bài học 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 chẳ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ề đến code hằng ngày

Điều đầ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 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ư con số 180 lần ở đây.

Điều thứ hai: atomic đáng dùng ở đua tranh THẤP, không phải liều thuốc vạn năng. Một cờ nguyên tử đọ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ó chẳng nhanh hơn mutex là mấy. Đo thông lượng theo số luồng sẽ lộ ra ngay đường cong đi xuống đó.

Và cùng bài học này đội đúng những cái tên bạn gõ hằng ngày. Java LongAdder ra đời chính vì AtomicLong bị ping-pong dưới đua tranh cao — nó là bộ đếm phân mảnh đóng gói sẵn, mỗi luồng cộng vào ô riêng, sum() gộp cuối; JDK khuyến nghị dùng nó thay AtomicLong cho counter nóng đúng theo số 180 lần ở trên. Go có sync/atomic cho đua tranh thấp nhưng cũng khuyên tránh một biến chung nóng, và -race giúp bắt lỗi khi bạn định tự viết lock-free. Cơ sở dữ liệu vấp cùng bức tường ở tầng dòng: một hàng "bộ đếm" mà mọi giao dịch cùng UPDATE ... SET n = n+1 sẽ tuần tự hóa vì khóa hàng — mẫu hình quen thuộc để chữa cũng là sharding: tách thành nhiều hàng con rồi SUM khi đọc. Mẫu số chung ở mọi tầng: đua tranh trên một điểm chung là kẻ thù thật; công cụ đồng bộ chỉ quyết định bạn trả giá theo cách nào, còn thắng nó thì phải thôi chia sẻ.

Muốn tự thấy thì thử ngay: cho N luồng cùng tăng một biến dùng chung (kiểu atomic hay có khóa) tới một 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 hiện ra 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 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".