Có một thứ tôi mang theo sau bốn mươi lăm bài này: sự khiêm tốn. Tôi vào mỗi bài với một dự đoán nghe rất hợp lý, và gần như lần nào con số cũng vả vào mặt tôi. Đồng thời là mảnh đất mà trực giác — thứ vốn phục vụ ta tốt ở code tuần tự — trở nên phản chủ: cái "chắc chắn nhanh hơn" hóa ra chậm hơn, cái "gần như miễn phí" hóa ra đắt gấp mấy chục lần. Bài kết này không đo gì mới; nó lùi lại nhìn toàn cảnh và rút ra điều mà cả sê-ri, mỗi bài một cách, đều nói: đo, đừng đoán. Đó là câu ngắn nhất tôi có thể gói mọi thứ vào.
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 lần tôi đoán sai rồi bị số liệu sửa lại. Ba tầng bài học nổi lên.
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ì. 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).
Điều tôi học được sau 45 lần đoá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à đó chính là lý do phải đo. 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). Mỗi lần, con số kéo tôi về thực tế.
Và có một tầng sâu hơn khiến tôi rợn người: 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 cả kết luận — suýt khiến tôi 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). Nếu có một điều tôi tin chắc hơn tất cả sau sê-ri này, thì là: một phép đo ẩu còn tệ hơn không đo, vì nó cho bạn một niềm tin sai và sự tự tin để hành động theo nó; chỉ một quy trình đo tử tế mới biến con số thành thứ đáng tin.
Về đến code hằng ngày
Điều đầ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.
Điều 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.
Và đây là tinh thần của cả sê-ri, gói 45 bài vào một chỗ: 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 = 27×, CAS-loop ~119ns, false sharing ~4×); scale gần như luôn dưới tuyến tính (10 lõi ~4,65×, không phải 10×); 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 bốn 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.
Muốn bắt đầu ngay thì thế nà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: mình đã đo nó bao giờ chưa, hay chỉ đoán? Nếu chưa, 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.