Hình dung một gian bếp có 10 đầu bếp (10 lõi). Có hai kiểu món. Món thứ nhất phải đứng đảo liên tục — chảo trên lửa, rời tay là cháy: một đầu bếp bị món đó giữ chặt từ đầu tới cuối. Với kiểu này, 10 đầu bếp làm được đúng 10 món cùng lúc; gọi thêm đầu bếp thứ 11 cũng vô ích vì hết chỗ đứng bếp, họ chỉ đứng chờ. Món thứ hai thì cho vào lò rồi bỏ đi — nướng 20 phút, đầu bếp không cần đứng canh: đặt xong là quay sang bắt đầu món khác. Với kiểu này, vài đầu bếp có thể giữ hàng chục món đang nướng cùng lúc, và càng nhiều món "đang chờ trong lò" thì càng nhiều món ra lò mỗi giờ. Câu hỏi "dùng bao nhiêu đầu bếp" không có một đáp án — nó tùy món.

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 (món phải đứng đảo). 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 đó (món cho vào lò rồi bỏ đ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 (món đang nướng không giữ đầu bếp). 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ôi trường đo: container gcc:13 trên host ARM AArch64, nproc=10, việc CPU-bound lấy min 5 lần, việc chờ-bound mô phỏng bằng nanosleep 1 ms × 2000 tác vụ. Con số tuyệt đối đổi theo máy; điều bền vững là hình dạng: CPU-bound chững ở ~nproc, chờ-bound tăng vượt xa nproc.)

Hai niềm tin đối nghịch, cùng sai

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. Nên 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ẻ" — đúng tinh thần đo lường: đo rồi sửa niềm tin, đừng giữ khư khư.

Sự thật cốt lõi, gom cả hai niềm tin sai 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ề đến code hằng ngày

Điều đầ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 để giấu độ trễ chờ. Có hẳn một công thức kinh điển từ Java Concurrency in Practice của Brian Goetz: số luồng ≈ số lõi × (1 + thời-gian-chờ / thời-gian-tính) — việc càng chờ nhiều thì hệ số càng lớn, đúng cái tôi đo.

Điều thứ hai: đừng thờ mặc định vào nproc. Nó là điểm khởi đầu tốt cho việc CPU-bound, nhưng sai cho việc chờ-bound (quá thấp) và cần điều chỉnh cho việc lai. 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.

Chỗ này đáng biết là cả ngành đang dịch chuyển đúng theo bài học này, và mỗi runtime đóng gói nó một kiểu. Java ForkJoinPool.commonPool() mặc định cỡ số lõi — hoàn hảo cho việc CPU-bound (parallel streams, tính toán), nhưng sai nếu bạn nhét việc blocking vào (một lời gọi mạng chặn sẽ ghim một luồng pool, bỏ phí song song). Đó chính là lý do Java 21 ra virtual thread (Project Loom): một luồng ảo bị chặn không ghim luồng OS nền, nên bạn có thể tạo hàng triệu luồng ảo cho việc I/O-bound — đúng câu chuyện "80 luồng vẫn tốt" của tôi, đẩy tới cực hạn. Go làm điều tương tự từ đầu: GOMAXPROCS = số lõi giới hạn song song CPU, nhưng bạn cứ thoải mái spawn hàng nghìn goroutine cho I/O vì runtime park goroutine bị chặn ra khỏi luồng OS. Ở tầng khác, sizing connection pool (HikariCP) không phải "càng nhiều càng tốt" mà quanh số lõi × 2 — vì công việc DB là lai (một phần chờ đĩa/mạng, một phần CPU), và quá nhiều kết nối lại gây tranh chấp ở phía database. Node.js thì đi hẳn hướng một-luồng-event-loop cho I/O. Mẫu số chung: "tính hay chờ" là câu hỏi đầu tiên quyết định kiến trúc luồng, và các nền tảng hiện đại đang tách bạch "song song CPU" (giới hạn ở số lõi) khỏi "đồng thời chờ" (có thể rất lớn).

Muốn tự soi thì 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. Vài phút 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.