Câu hỏi thực tế nhất khi viết code song song: dùng bao nhiêu luồng? Có hai câu trả lời "hiển nhiên" đối lập nhau — "càng nhiều càng nhanh" (của người mới) và "đặt bằng số lõi (nproc)" (của người đã đọc vài bài blog). Cả hai đều sai một nửa, và cái nửa sai tùy thuộc một điều mà nhiều người bỏ qua: bản chất công việc. Tôi đo hai loại việc trái ngược, và số luồng tối ưu của chúng khác nhau cả bậc độ lớn.
Hai loại việc, hai câu trả lời
Chia công việc song song ra hai cực. Việc thuần CPU (CPU-bound): luồng luôn bận tính — nhân ma trận, nén dữ liệu, băm mật khẩu. Mỗi luồng cần một lõi để chạy, và khi tính nó chiếm lõi đó liên tục. Việc chờ-bound (I/O-bound hay blocking): luồng phần lớn thời gian chờ — đọc file, gọi mạng, chờ phản hồi cơ sở dữ liệu, ngủ. Khi chờ, luồng không dùng CPU; hệ điều hành cho nó ngủ và chạy luồng khác trên lõi đó.
Sự khác biệt này quyết định số luồng tối ưu. Với việc CPU-bound, quá số lõi thì thừa luồng chỉ tranh nhau lõi. Với việc chờ-bound, một lõi có thể phục vụ nhiều luồng đang chờ, nên số luồng tối ưu vượt xa số lõi. Tôi đo cả hai trong container gcc:13 (nproc = 10 lõi).
Đo (a): việc CPU-bound chững ở số lõi
Một khối tính toán cố định, chia cho NT luồng, đo speedup (lấy min của 5 lần để bớt nhiễu):
NT | speedup
1 | 1,00×
2 | 1,96×
4 | 3,81×
8 | 5,30×
10 | 5,81× <- quanh nproc
20 | 5,87× (phẳng)
40 | 5,91× (phẳng)
Speedup tăng đẹp tới quanh nproc = 10 (đạt 5,81×), rồi phẳng ra — 20 luồng, 40 luồng đều quanh 5,9×, không nhích thêm. Lý do đơn giản: chỉ có 10 lõi vật lý, và việc CPU-bound cần lõi để chạy. Khi đã có 10 luồng bận trên 10 lõi, CPU đã bão hòa — thêm luồng thứ 11, 20, 40 chỉ khiến chúng xếp hàng chờ tới lượt được lõi, tổng sức tính không tăng. Đây chính là lý do heuristic "đặt số luồng ≈ số lõi" đúng cho việc CPU-bound: quá điểm đó là lãng phí.
(Chú ý speedup đỉnh chỉ ~5,8× chứ không phải 10× — đúng như định luật Amdahl và các chi phí ẩn như băng thông bộ nhớ mà ta đã đo; nhưng điểm ở đây là hình dạng đường cong: nó chững ở nproc.)
Đo (b): việc chờ-bound vượt xa số lõi
Bây giờ loại việc ngược lại: mỗi tác vụ chủ yếu chờ — tôi mô phỏng bằng nanosleep 1 ms mỗi tác vụ (ít CPU, chủ yếu ngủ), tổng 2000 tác vụ, chia cho NT luồng:
NT | throughput (task/s)
1 | 766
8 | 6.322
10 | 7.932 <- nproc
20 | 15.789 (gấp đôi!)
40 | 30.991 (gấp đôi nữa!)
80 | 61.071 (vẫn gấp đôi!)
Đây là bức tranh hoàn toàn khác: throughput tiếp tục tăng gấp đôi mỗi khi tăng gấp đôi số luồng, vượt xa nproc. 80 luồng (gấp 8 lần số lõi) đạt 61.071 task/s — gấp gần 8 lần mức 10 luồng. Vì sao thoát được giới hạn 10 lõi? Vì các luồng phần lớn thời gian đang ngủ trong nanosleep, và luồng ngủ không chiếm lõi. Trong khi luồng A ngủ chờ, hệ điều hành cho luồng B, C, D... chạy trên cùng lõi đó. Với việc chờ-bound, cái chặn throughput không phải số lõi mà là thời gian chờ — và bạn giấu thời gian chờ bằng cách có nhiều luồng chờ song song. Số luồng tối ưu ở đây là hàng chục, thậm chí hàng trăm, chứ không phải 10.
Một lần tôi đo hớ: "nproc" chỉ đúng cho một loại việc
Tôi vào đo với hai niềm tin đối chọi. Niềm tin ngây thơ: "nhiều luồng thì luôn nhanh hơn". Sai cho việc CPU-bound — quá 10 luồng là phẳng, thêm luồng vô ích. Niềm tin "đã đọc sách": "luôn đặt số luồng = nproc". Cũng sai cho việc chờ-bound — nproc (10) chỉ cho 7.932 task/s, trong khi 80 luồng cho 61.071, gấp gần 8 lần. Nếu tôi cứng nhắc đặt 10 luồng cho một server I/O, tôi đã bỏ phí 87% thông lượng.
Nhưng tôi cũng đo hớ một nuance mà tôi tưởng sẽ thấy: tôi kỳ vọng việc CPU-bound quá luồng sẽ chậm hẳn đi (như thread pool phần 15 thấy 32 worker chậm hơn 16). Đo ra nó chỉ phẳng, không chậm rõ — 40 luồng ≈ 10 luồng. Vì sao khác? Vì việc ở đây độc lập hoàn toàn, không có hàng đợi chung hay khóa nào; oversubscription chỉ thêm chi phí chuyển ngữ cảnh nhỏ, mà tổng việc CPU cố định nên wall-time gần như không đổi. Oversubscription thật sự làm chậm khi các luồng thừa còn tranh một tài nguyên chung (khóa, hàng đợi) — lúc đó chúng vừa không thêm sức tính, vừa gây tranh chấp. Bài học đo lường: "quá nhiều luồng" hại theo mức độ khác nhau tùy có chia sẻ hay không — độc lập thì phẳng, có tranh chấp thì chậm hẳn. Đúng như tinh thần đo lường, tôi phải sửa lại kỳ vọng "oversubscribe luôn chậm" thành "oversubscribe phẳng, trừ khi có chia sẻ".
Sự thật cốt lõi: số luồng tối ưu không phải một hằng số — nó phụ thuộc bản chất việc. CPU-bound → quanh nproc. Chờ-bound → cao hơn nproc rất nhiều, tùy tỉ lệ thời gian chờ. Không có con số vàng; chỉ có đo trên chính workload của bạn.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: phân loại việc trước khi chọn số luồng. Hỏi: luồng của tôi phần lớn thời gian tính hay chờ? Nếu tính (xử lý ảnh, tính toán khoa học), đặt số luồng ≈ số lõi — thêm nữa là phí. Nếu chờ (server web gọi database, crawler tải trang), đặt nhiều hơn số lõi — có khi gấp nhiều lần — để giấu độ trễ chờ. Đây là lý do các web server (nhiều I/O) chạy hàng trăm luồng/kết nối trên máy vài lõi, còn thư viện tính toán (CPU) đặt pool đúng bằng số lõi.
Hệ quả thứ hai: đừng thờ mặc định vào nproc. nproc là điểm khởi đầu tốt cho việc CPU-bound, nhưng nó sai cho việc chờ-bound (quá thấp) và cần điều chỉnh cho việc lai (một phần tính, một phần chờ). Với việc lai, số luồng tối ưu nằm giữa, và cách duy nhất tìm ra là đo đường cong throughput của bạn và chọn điểm bão hòa — như mọi phần của sê-ri này.
Hệ quả thứ ba là tinh thần đo lường: không có số luồng vàng — đo bản chất việc. Con số mang theo: số luồng tối ưu phụ thuộc BẢN CHẤT việc: việc THUẦN CPU chững ở ~nproc (speedup 5,81× ở 10 lõi, thêm luồng 20/40 chỉ phẳng ~5,9× — vô ích vì CPU đã bão hòa); việc CHỜ-bound (I/O, ngủ, chặn) tối ưu VƯỢT xa nproc — 80 luồng vẫn cho 61k task/s (gấp ~8× mức 10 luồng) vì luồng chờ không chiếm lõi. 'Đặt luồng = nproc' chỉ đúng cho CPU-bound; oversubscribe việc độc lập thì phẳng, nhưng chậm hẳn khi có tranh chấp chung.
Thử ba mươi giây
Nghĩ về một chương trình đa luồng bạn có và phân loại nó: các luồng phần lớn thời gian đang tính hay đang chờ (chờ mạng, đĩa, khóa, người dùng)? Nếu đang tính, thử đặt số luồng bằng số lõi rồi thử gấp đôi — bạn sẽ thấy gấp đôi không nhanh hơn, chỉ tốn bộ nhớ và chuyển ngữ cảnh. Nếu đang chờ (ví dụ một script tải 1000 URL), thử tăng số luồng từ số lõi lên gấp 4, gấp 8 lần — bạn sẽ thấy throughput tiếp tục tăng rất lâu, vì trong lúc một luồng chờ mạng, lõi chạy luồng khác. Ba mươi giây phân loại "tính hay chờ" đó cho bạn câu trả lời đúng cho câu hỏi tưởng đơn giản "dùng bao nhiêu luồng" — và nhắc rằng nproc không phải câu thần chú, mà chỉ là điểm khởi đầu cho đúng một loại việc.