Hình dung dòng xe trên đường. Nếu bạn thêm làn — mỗi xe chạy làn riêng, không vướng ai — thì càng nhiều làn, càng nhiều xe qua mỗi giờ, tới khi hết chỗ mở làn. Nhưng nếu mọi xe buộc phải dừng ở một trạm thu phí duy nhất, thêm xe chẳng làm dòng chảy nhanh hơn — chúng chỉ xếp hàng dài thêm trước trạm; và quá một ngưỡng, xe dồn tới chật cứng cả đường dẫn đến mức ít xe qua được hơn so với lúc đường vắng. Đó là "sụp đổ vì tắc nghẽn". Thêm luồng vào một chương trình song song cũng đúng hai kịch bản đó: nếu công việc độc lập thì như thêm làn, nếu mọi luồng cùng ghi một chỗ thì như dồn vào một trạm thu phí — và tôi đo được cả hai đường cong trái ngược đến mức làm tan vỡ trực giác "thêm luồng thì nhanh hơn".
Năm phần đầu của sê-ri đo từng chi phí riêng lẻ: tạo luồng, chuyển ngữ cảnh, khóa, atomic, false sharing. Giờ ta gộp lại thành câu hỏi thực tế nhất: khi tôi thêm luồng, chương trình có nhanh hơn không, và nhanh hơn bao nhiêu? Trực giác nói "thêm luồng thì nhanh hơn, càng nhiều càng nhanh". Tôi đo hai loại workload để xem đường cong tăng tốc thật — và chúng trái ngược nhau.
Hai đường cong, một câu hỏi
Tôi đo thông lượng (throughput — số thao tác mỗi giây) khi tăng số luồng từ 1 lên 16, cho hai workload trong container gcc:13 (10 lõi). Độc lập: mỗi luồng làm việc riêng của nó (cộng dồn vào một biến cục bộ, không chia sẻ ghi với ai) — mỗi xe một làn. Tranh chấp: mọi luồng cùng atomic_fetch_add vào một biến chung — tất cả dồn một trạm thu phí. Với mỗi mức luồng, tôi tính speedup = thông lượng(T) / thông lượng(1 luồng).
ĐỘC LẬP (không chia sẻ):
T=1 : 3684 Mop/s (1,00×) T=2 : 7177 (1,95×)
T=8 : 14296 (3,88×) T=16: 17911 (4,86×)
TRANH CHẤP (một atomic chung):
T=1 : 625 Mop/s (1,00×) T=2 : 333 (0,53×!)
T=8 : 59 (0,09×) T=16: 38 (0,06×)
Hai đường cong đi hai hướng hoàn toàn khác nhau.
Độc lập scale lên, tranh chấp scale xuống
Workload độc lập tăng tốc khi thêm luồng — nhưng dưới tuyến tính: 1,95× ở 2 luồng (gần lý tưởng), rồi 3,88× ở 8, và 4,86× ở 16 luồng. Nó không đạt 10× hay 16× vì máy chỉ có 10 lõi (thêm luồng quá số lõi không thêm sức tính), và vì băng thông bộ nhớ cùng tần số CPU (giảm khi nhiều lõi bận) cũng giới hạn. Đường cong: leo dốc rồi chững quanh số lõi. Đây là scale "tốt" — thêm luồng có ích, tới một trần.
Workload tranh chấp thì ngược hẳn: thêm luồng làm nó chậm đi. Ở 2 luồng, thông lượng đã tụt xuống 0,53× — một nửa so với một luồng. Ở 16 luồng, chỉ còn 0,06× — tức 16 luồng làm việc chậm hơn 16 lần so với một luồng làm một mình. Đây là scale âm (negative scaling): mỗi luồng thêm vào không mang thêm việc, mà chỉ mang thêm tranh chấp — dòng cache của biến chung nảy giữa nhiều lõi, các luồng dẫm chân nhau. Xe dồn tới chật cứng trạm thu phí, ít xe qua hơn là lúc đường vắng.
(Môi trường đo: container gcc:13 trên host ARM AArch64, 10 lõi, workload độc lập (biến cục bộ) so tranh chấp (một atomic chung), speedup so với một luồng, lặp lấy tốt nhất sau warmup. Con số tuyệt đối đổi theo máy; điều bền vững là hai hình dạng đối nghịch: độc lập leo rồi chững, tranh chấp đi xuống.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin gần như phổ quát, thật ra là hai vế: "thêm luồng thì nhanh hơn" và "song song scale tuyến tính — 8 luồng nhanh gấp 8". Đo lật tan cả hai. Ngay cả workload tốt nhất (hoàn toàn độc lập) cũng chỉ đạt 4,86× trên ~10 lõi, không phải 16× — song song hoàn hảo là hiếm, nên vế "tuyến tính" sai. Và workload tranh chấp không những không nhanh hơn, mà chậm đi 16 lần khi thêm luồng — nên vế "thêm luồng thì nhanh" cũng sai.
Hai vế gặp nhau ở một sự thật, và là bài học của bài: song song không tự động nhanh — nó chỉ nhanh khi công việc thật sự ĐỘC LẬP. Cùng một số luồng, cùng một máy, hai workload cho hai kết quả trái ngược, và khác biệt duy nhất là có chia sẻ ghi hay không. Đây là lý do bạn không thể giả định một chương trình sẽ tăng tốc khi thêm luồng; bạn phải đo đường cong scale thật của nó. Nếu tôi tin "thêm luồng = nhanh" mà không đo, tôi đã có thể ném 16 luồng vào một điểm nóng tranh chấp và làm chương trình chậm đi 16 lần — một "tối ưu" phản tác dụng hoàn toàn.
Vượt số lõi: chững, rồi có thể xấu đi
Một chi tiết trong bảng đáng dừng lại: workload độc lập đạt 3,88× ở 8 luồng, 4,86× ở 16 luồng — nhưng máy chỉ có 10 lõi. Vì sao 16 luồng vẫn hơn 8 một chút dù chỉ 10 lõi? Vì một phần thời gian mỗi luồng chờ bộ nhớ, nên 16 luồng lấp được vài khoảng trống đó — nhưng lợi ích cận biên nhỏ dần và sẽ chững hẳn rồi có thể xấu đi khi số luồng vượt xa số lõi (oversubscription), vì hệ điều hành phải liên tục chuyển ngữ cảnh để chia thời gian — mỗi lần ~8,5 µs bị đốt cho việc quản lý thay vì tính. Với workload nặng CPU thuần, số luồng tối ưu thường quanh số lõi; nhiều hơn chỉ thêm chi phí lập lịch.
Và như mọi phần của sê-ri, đường cong scale này chỉ đáng tin khi đo đúng cách: lặp nhiều lần lấy giá trị tốt nhất (nhiễu lịch của máy ảo rất lớn ở đa luồng), làm nóng trước, và đo đủ lâu để lấn nhiễu đồng hồ. Một lần đo scale đơn lẻ dễ cho một con số lệch khiến bạn kết luận sai về khả năng song song của chương trình.
Về đến code hằng ngày
Điều đầu tiên: đo đường cong scale trước khi tăng số luồng. Đừng đoán "thêm luồng sẽ nhanh hơn"; chạy với 1, 2, 4, 8 luồng và vẽ speedup. Nếu đường cong chững sớm hoặc đi xuống, bạn có một điểm nóng tranh chấp — thêm luồng chỉ đốt tài nguyên. Con số scale thật cho bạn biết chương trình thực sự dùng được bao nhiêu lõi, thường ít hơn nhiều so với số lõi có.
Điều thứ hai: thiết kế để độc lập. Cách duy nhất để đạt đường cong scale tốt là làm công việc thật sự song song — mỗi luồng chạm dữ liệu riêng, không chia sẻ ghi. Khi buộc phải tổng hợp, dùng mẫu "mỗi luồng một bản cục bộ, gộp một lần ở cuối" (như atomic tranh chấp và false sharing đã chỉ). Biến một điểm nóng chung thành N phép tính riêng là cách duy nhất để đường cong đi lên thay vì đi xuống.
Chỗ này cái hình dạng "leo rồi quay đầu đi xuống" có hẳn một mô hình toán mô tả nó, và nó áp cả ở quy mô cụm máy chứ không chỉ đa luồng. Universal Scalability Law (USL) mở rộng Amdahl: ngoài phần tuần tự (làm speedup chững), USL thêm một số hạng cho chi phí đồng bộ/coherence giữa các bên — chính là tranh chấp — và số hạng đó khiến throughput không chỉ chững mà đạt đỉnh rồi giảm, đúng cái đường cong âm tôi đo. Đây là lý do một quan sát phản trực giác trong vận hành: connection pool tới database thường nên nhỏ (HikariCP khuyên cỡ vài lần số lõi, không phải hàng trăm) — thêm kết nối quá điểm tối ưu làm database tranh chấp nội bộ và tổng thông lượng giảm, y như thêm luồng vào điểm nóng. Ở tầng database, một hot row mà mọi transaction cùng ghi (một biến đếm, một sequence) là trạm thu phí kinh điển: thêm writer không tăng throughput mà làm nó sụp. Go gói triết lý này thành câu châm ngôn "đừng chia sẻ bộ nhớ để giao tiếp, hãy giao tiếp để chia sẻ bộ nhớ" — tránh điểm ghi chung ngay từ thiết kế. Mẫu số chung, từ 10 lõi tới 1000 node: thứ quyết định bạn scale được bao xa là có bao nhiêu chia sẻ ghi, và câu trả lời luôn là cắt nó (shard, per-core, bản cục bộ) chứ không phải thêm phần cứng.
Muốn tự thấy thì lấy một tác vụ song song bạn có và chạy nó với 1, 2, 4, rồi 8 luồng, ghi thời gian mỗi lần. Chia thời gian một luồng cho thời gian T luồng để ra speedup, rồi nhìn đường cong: nó tiến gần T (tốt), chững lại (giới hạn tài nguyên), hay đi xuống (tranh chấp)? Nếu đi xuống hoặc chững sớm, tìm điểm nóng chia sẻ — một biến, một khóa, một dòng cache mà mọi luồng cùng đụng. Vài phút đó thay câu hỏi sai "tôi nên dùng bao nhiêu luồng" bằng câu hỏi đúng "chương trình của tôi scale được tới đâu, và cái gì đang chặn nó" — và chỉ đồng hồ mới trả lời được.