Bốn mươi lăm bài, mỗi bài một phép đo thật trong container gcc:13 (10 lõi, ARM), mỗi bài một "đo hớ" nơi tôi đoán sai rồi bị số liệu sửa lại. Đây là bài kết — không đo gì mới, mà lùi lại nhìn toàn cảnh: sau tất cả, sê-ri này nói gì về việc viết code đồng thời? Câu trả lời gói trong đúng một câu, cũng là tựa đề: đo, đừng đoán. Với đồng thời, trực giác gần như luôn sai — và cách duy nhất biết sự thật là đặt một con số lên đồng hồ.
Tầng một: mọi thứ đều có giá
Bài học đầu tiên, lặp lại suốt sê-ri: mọi nguyên hàm đồng thời đều có giá, và giá đó thường khác xa trực giác. Tạo một luồng tốn ~47 µs — nghe nhỏ, nhưng nhân với 20.000 việc thành gần một giây, đó là lý do thread pool tồn tại. Một lần chuyển ngữ cảnh (và một lần cond_wait đánh thức) tốn ~8,5 µs. Một mutex lúc nhàn chỉ ~20 ns, nhưng khi bị tranh chấp thì đắt hơn nhiều lần. Và bất ngờ nhất với nhiều người: một atomic "lock-free" không miễn phí — khi bị tranh chấp nó chậm 27 lần so với lúc nhàn, ngang hoặc hơn cả mutex.
Đắt nhất mà kín nhất là false sharing: hai luồng ghi vào hai biến khác nhau nhưng tình cờ nằm chung một dòng cache 64 byte cũng chậm ~4 lần, dù về logic chúng chẳng chia sẻ gì. Bài học: giá của đồng thời không nằm ở dòng code bạn viết, mà ở tầng phần cứng bên dưới — và chỉ đo mới thấy.
Tầng hai: scale gần như luôn dưới tuyến tính
Bài học thứ hai đóng khung bằng định luật Amdahl và đo đi đo lại: thêm lõi không cho tốc độ tỉ lệ. Đo thẳng, 10 lõi chỉ đạt ~4,65 lần, không phải 10 — vì một phần tuần tự nhỏ (Amdahl), vì băng thông bộ nhớ chung bão hòa, và vì tranh chấp. Đường cong speedup luôn cong xuống rồi bão hòa; kỳ vọng tuyến tính là kỳ vọng sai.
Kèm theo là cái bẫy baseline: nếu đo speedup so với bản song song chạy một luồng (thay vì bản tuần tự tốt nhất), con số bị thổi phồng — ở một phép đo, đúng 6,2 lần. Và vector hóa vs đa luồng thêm một chiều: SIMD cho 2,49 lần trên một lõi nếu compute-bound, nhưng chỉ 1,12 lần (vô ích) nếu memory-bound. Hệ quả thực hành: muốn nhanh hơn, giảm phần tuần tự và dữ liệu di chuyển — đừng chỉ ném thêm lõi.
Tầng ba: nút thắt hay trốn
Bài học thứ ba tinh vi nhất: nút thắt thật thường trốn ở nơi bạn không nhìn. Nó trốn trong thư viện chuẩn — rand() có khóa ẩn khiến 8 luồng chậm hơn 1 luồng, dù bạn không viết một mutex nào. Nó trốn sau hàm "nóng" của profiler phẳng — hàm chạy trong vùng găng nổi lên nóng nhất, trong khi thủ phạm thật (thời gian chờ khóa) vô hình vì luồng chờ không tiêu CPU. Và nó trốn trong hành vi không xác định: một đua tranh dữ liệu là UB, và -O2 có thể gập hoặc hoist nó đi mất, khiến bug ẩn trong test rồi hiện ở production.
Hai câu ngắn gói cả tầng này: volatile không phải atomic (volatile chặn gập/hoist nhưng không làm phép tính nguyên tử — vẫn mất 50% cập nhật khi đua), và "thread-safe" không phải "scale" (một hàm khóa toàn cục an toàn hoàn hảo mà tuần tự hóa hoàn hảo).
Một lần tôi đo hớ: trực giác luôn sai
Nếu phải rút một điều từ cả 45 phần, đây: với đồng thời, trực giác của tôi gần như luôn sai — và đó là lý do phải đo. Tôi vào mỗi bài với một dự đoán "hợp lý", và số liệu sửa tôi gần như mỗi lần. Tôi tưởng atomic lock-free là rẻ (đo ra 27 lần khi tranh chấp). Tôi tưởng sẽ quan sát được NUMA và livelock (máy ảo một node, glibc công bằng — không tái hiện được, và tôi báo trung thực thay vì bịa). Tôi tưởng 10 lõi cho 10 lần (chỉ 4,65). Tôi tưởng volatile đủ cho đa luồng (mất một nửa cập nhật).
Và tầng sâu nhất: tôi còn đo hớ về chính việc đo. -O2 gập vòng lặp làm cuộc đua biến mất khỏi microbenchmark, khiến một bug UB trông "an toàn". Một lần chạy đơn lẻ trúng nhiễu đảo ngược kết luận — nói một tối ưu nhanh 4,36 lần là "chậm 2,9 lần". Đó là vì sao mọi con số trong sê-ri đều phải qua bốn rào: warmup, lặp nhiều lần, lấy min (không phải trung bình — min lọc nhiễu cộng), và chặn trình biên dịch tối ưu mất phần đang đo (đọc volatile mỗi vòng). Bài học đo lường tổng kết: một phép đo ẩu tệ hơn không đo, vì nó cho niềm tin sai; chỉ quy trình đo đúng mới làm số liệu đáng tin.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: khi tối ưu code đồng thời, đo trước khi tin bất cứ điều gì — kể cả bài viết này. Con số trên máy của bạn, với workload của bạn, mới là sự thật; con số của tôi (ARM, 10 lõi) chỉ là hình dạng để bạn biết chờ đợi gì. Đặt một đồng hồ quanh đoạn nghi ngờ, chạy nhiều lần, lấy min, và để số liệu quyết định.
Hệ quả thứ hai: học cách đọc đúng tín hiệu. Song song hiệu quả = CPU tổng / wall (thấp nghĩa là đang chờ, không thiếu CPU). Scale dưới tuyến tính là bình thường, không phải lỗi. Một hàm "nóng" có thể là nạn nhân, không phải thủ phạm. Những cách đọc này, học một lần, áp cho mọi bài toán đồng thời về sau.
Hệ quả thứ ba là tinh thần của cả sê-ri: đo, đừng đoán. Con số mang theo, gói 45 bài: mọi nguyên hàm đồng thời đều có giá (tạo luồng ~47µs, chuyển ngữ cảnh/cond_wait ~8,5µs, mutex nhàn ~20ns, atomic tranh chấp ~44ns=27x, CAS-loop ~119ns, false sharing ~4x); scale gần như luôn dưới tuyến tính (10 lõi ~4,65x, không phải 10x); nút thắt hay trốn (libc rand() sụp, sau hàm nóng của profiler, trong UB mà -O2 gập/hoist); volatile != atomic, thread-safe != scale; và mọi con số phải qua 4 rào (warmup, lặp, lấy min, chặn -O2) vì trực giác gần như luôn sai và một phép đo ẩu tệ hơn không đo. Cảm ơn bạn đã theo hết sê-ri — giờ hãy đi đo code của chính mình.
Thử ba mươi giây
Chọn một niềm tin bạn đang giữ về hiệu năng đồng thời trong code của mình — "lock này chắc không sao", "thêm luồng chắc nhanh hơn", "atomic thì miễn phí", "vòng này chắc song song tốt". Rồi hỏi: tôi đã đo nó bao giờ chưa, hay chỉ đoán? Nếu chưa đo, ba mươi giây tới hãy đặt một clock_gettime (hay System.nanoTime) quanh nó, chạy mười lần, lấy con số nhỏ nhất — và xem niềm tin đó có sống sót không. Nếu sê-ri này để lại đúng một thói quen, tôi mong là thói quen ấy: trước khi tin một điều về đồng thời, hãy đặt nó lên đồng hồ. Đo, đừng đoán.