CPU-bound vs I/O-bound — chọn số thread pool thế nào
Hai loại workload đối lập: tính toán vs chờ I/O. Vì sao thread chờ I/O không tốn CPU, và công thức chọn số thread cho từng loại (N core vs N × hệ số chờ).
TL;DR: Workload chia làm hai loại đối lập. CPU-bound: thread gần như luôn tính toán, hiếm khi chờ — nút cổ chai là số core. Với loại này, số thread hợp lý xấp xỉ số core; thêm nữa chỉ tăng context switch mà không thêm sức tính (bài 02). I/O-bound: thread dành phần lớn thời gian blocked chờ mạng/đĩa, chỉ chạy một nhúm ngắn — nút cổ chai là độ trễ chờ, không phải CPU. Vì thread blocked không tốn CPU, bạn có thể có nhiều thread hơn số core: trong khi thread này chờ I/O, thread kia dùng CPU, giữ các core luôn bận. Công thức gợi ý (heuristic, không phải định luật): số_thread ≈ số_core × (1 + thời_gian_chờ / thời_gian_tính). Tỉ lệ chờ/tính càng lớn (I/O nặng), số thread tối ưu càng cao.
Bạn có một endpoint chậm. Nó gọi ba API bên ngoài, mỗi cái mất ~100 ms, rồi ghép kết quả. Thread pool đang để 10. Bạn tăng lên 200 — và throughput tăng vọt, đúng như kỳ vọng. Hào hứng, bạn áp cùng chiêu cho một service khác: service này băm mật khẩu (bcrypt) cho mỗi request, thuần tính toán, không gọi gì bên ngoài. Bạn tăng pool từ 8 lên 200 — và nó chậm hơn, p99 latency (độ trễ phần đuôi chậm nhất — xem tail latency bài 02) tệ đi. Cùng một hành động (thêm thread), hai kết quả ngược nhau.
Khác biệt không phải may rủi. Nó nằm ở bản chất workload: một cái chờ, một cái tính. Hiểu điều này, bạn thôi đoán mò khi cấu hình thread pool và biết chính xác vì sao thêm thread lúc là thuốc, lúc là thuốc độc.
Bài này gộp mọi thứ bạn đã học trong module — thread chia sẻ gì (bài 01), blocked không tốn CPU (bài 02), scheduler chia CPU (bài 03) — thành một kỹ năng thực dụng: nhìn một workload, phân loại nó, và chọn số thread có căn cứ.
1. Analogy — làm bài thi vs gọi điện đặt hàng
Hai kiểu công việc, hai cách tổ chức người làm khác nhau.
CPU-bound giống làm bài thi toán. Bạn dùng não liên tục từ đầu tới cuối, không có lúc nào "chờ". Nếu chỉ có một cái bàn (một core), thêm người vào phòng thi không làm bài xong nhanh hơn — họ vẫn phải thay nhau dùng bàn, và mỗi lần đổi người còn tốn công dọn bàn (context switch). Số người tối ưu = số bàn.
I/O-bound giống nhân viên gọi điện đặt hàng nhà cung cấp. Mỗi cuộc gọi: nói 10 giây yêu cầu (tính), rồi chờ máy 2 phút trong lúc bên kia kiểm kho (I/O). Trong 2 phút chờ đó, nhân viên không dùng não — họ rảnh. Nếu chỉ có một nhân viên, phần lớn thời gian anh ta ngồi chờ máy, phí. Cho nhiều nhân viên dùng chung một cái đầu (một core): trong khi người này chờ máy, người kia nói yêu cầu. Với 2 phút chờ trên 10 giây nói, một core có thể "nuôi" cả chục nhân viên gọi điện cùng lúc mà không ai phải xếp hàng lâu.
Chìa khoá: người làm bài thi chiếm tài nguyên suốt; người gọi điện nhả tài nguyên trong lúc chờ. Số lượng người tối ưu phụ thuộc tỉ lệ thời gian chờ trên thời gian dùng não.
| Đời thường | Khái niệm |
|---|---|
| Làm bài thi (dùng não suốt) | Tác vụ CPU-bound |
| Gọi điện rồi chờ máy 2 phút | Tác vụ I/O-bound |
| Cái bàn / cái đầu | CPU core |
| Đổi người ở bàn, dọn bàn | Context switch |
| Nói yêu cầu 10 giây | Thời gian tính (compute) |
| Chờ máy 2 phút | Thời gian chờ I/O (blocked) |
| Nhiều nhân viên/1 đầu khi hay phải chờ | Nhiều thread/core cho I/O-bound |
CPU-bound = "dùng não suốt" → số thread ≈ số core. I/O-bound = "chờ nhiều" → nhiều thread hơn core, để lấp chỗ trống lúc chờ. Câu hỏi phân loại: "thread này phần lớn thời gian đang tính hay đang chờ?"
2. Phân loại workload — nó đang tính hay đang chờ?
Định nghĩa cho chặt:
- CPU-bound (compute-bound): hiệu năng bị giới hạn bởi tốc độ CPU. Thread gần như luôn ở trạng thái running, hiếm khi block. Ví dụ: nén/giải nén, mã hoá, băm mật khẩu, xử lý ảnh, tính toán khoa học, parse dữ liệu lớn trong bộ nhớ.
- I/O-bound: hiệu năng bị giới hạn bởi tốc độ I/O, không phải CPU. Thread dành phần lớn thời gian ở trạng thái blocked chờ đĩa, mạng, hoặc database. Ví dụ: gọi REST API, truy vấn DB, đọc/ghi file, proxy request.
Cách nhận biết trên máy thật: chạy workload và nhìn mức sử dụng CPU. Nếu một tác vụ đơn luồng đẩy một core lên gần 100% → CPU-bound. Nếu nó chỉ dùng vài phần trăm CPU dù "đang chạy" → phần lớn thời gian nó đang blocked → I/O-bound. Một cách định lượng: gọi
utilization = thoi_gian_tinh / (thoi_gian_tinh + thoi_gian_cho)
CPU-bound có utilization gần 1 (gần như không chờ). I/O-bound có utilization nhỏ (chờ nhiều hơn tính). Endpoint gọi 3 API mất 300 ms nhưng chỉ tính vài ms có utilization ~0,01 — cực kỳ I/O-bound.
Ít workload thuần 100% một loại. Một endpoint có thể truy vấn DB (I/O) rồi xử lý kết quả (CPU). Phân loại theo phần chiếm thời gian nhiều nhất, và nếu hai phần đáng kể như nhau, cân nhắc tách chúng ra hai thread pool riêng (một cho phần I/O, một cho phần CPU) để chỉnh số thread độc lập. Đừng ép một con số cho cả hai.
3. Vì sao thêm thread giúp I/O-bound
Đây là chỗ ba bài trước hội tụ. Nhớ lại bài 02: thread blocked không tốn CPU — kernel lấy nó khỏi core và chạy thread khác.
Hình dung một core và các thread I/O-bound, mỗi thread: tính 10 ms rồi chờ I/O 90 ms, lặp lại. Với một thread:
Thread 1: [tinh 10ms][====== cho I/O 90ms ======][tinh 10ms][cho 90ms]...
Core: [ ban 10% ][======== RANH 90% ========][ ban 10% ]...
Core rảnh 90% thời gian — phí. Giờ cho 10 thread dùng chung core đó. Khi thread 1 block chờ I/O, scheduler cho thread 2 tính; thread 2 block, tới thread 3... Tới khi thread 1 chờ xong thì đã có 9 thread khác lấp đầy 90 ms đó:
Core: [T1][T2][T3][T4][T5][T6][T7][T8][T9][T10][T1]... luon ban
Một core "nuôi" được ~10 thread I/O-bound loại này (vì tỉ lệ chờ/tính = 90/10 = 9). Đây là lý do endpoint gọi API của bạn tăng tốc khi lên 200 thread: bạn đang lấp thời gian chờ mạng bằng công việc của các request khác. Thread blocked không tranh CPU, nên "thừa thread" ở đây không lãng phí — nó đúng là công cụ để giấu độ trễ I/O.
flowchart TB
subgraph io["I/O-bound: nhieu thread lap thoi gian cho -> core luon ban"]
direction LR
A["T1 tinh"] --> B["T2 tinh"] --> C["T3 tinh"] --> D["... T10 tinh"] --> A
W["T1..T10 blocked cho I/O<br/>(khong ton CPU)"]
end
subgraph cpu["CPU-bound: them thread qua so core -> chi them switch"]
direction LR
E["4 core chay full"] --> F["them thread thu 5..200"] --> G["tranh core<br/>-> context switch tang<br/>-> khong them suc tinh"]
end4. Vì sao thêm thread hại CPU-bound
Giờ workload CPU-bound: mỗi thread tính gần như liên tục, không chờ. Máy 4 core.
Với 4 thread: mỗi thread gần như độc chiếm một core. Cache nóng, gần như không context switch, cả 4 core chạy full — đây là điểm tối ưu.
Với 200 thread: vẫn chỉ 4 core làm việc thật. 200 thread không tạo thêm sức tính nào — chúng chỉ tranh 4 core. Scheduler phải luân phiên 200 thread qua 4 core, sinh rất nhiều context switch. Mỗi switch tốn trực tiếp (register) + gián tiếp (thread mới vào gặp cache lạnh, đẩy dữ liệu thread khác ra — bài 02). Thời gian đó bị trừ khỏi thời gian tính có ích:
CPU-bound, 4 core:
4 thread -> 4 core full, cache nong, ~0 switch -> NHANH NHAT
200 thread -> van 4 core, + hang loat context switch -> CHAM HON
(dot CPU vao "doi viec" thay vi "lam viec")
Thêm nữa, 200 thread CPU-bound còn ăn bộ nhớ (mỗi thread một stack) và tăng áp lực cache. Kết quả: throughput giảm, latency đuôi tăng — đúng như service băm mật khẩu của bạn.
p99 latency (percentile thứ 99) là ngưỡng độ trễ mà 99% request nằm dưới — chỉ 1% chậm nhất vượt qua nó. Nó đo phần "đuôi" chậm nhất của phân phối độ trễ, nơi người dùng cảm nhận rõ nhất sự giật lag; giá trị trung bình (mean) che giấu cái đuôi này còn p99 phơi nó ra. Cùng họ với tail latency ở bài 02: context switch thừa làm một số request chờ lâu bất thường, đẩy p99 lên.
Với CPU-bound, số thread hợp lý xấp xỉ số core (đôi khi cộng thêm 1–2 để lấp khe khi một thread lỡ block hiếm hoi).
Một tác vụ mỗi vòng: tính 20 ms, rồi chờ DB 80 ms. Máy 8 core. Bạn đoán nên để bao nhiêu thread để giữ 8 core bận mà không thừa? Nghĩ ra một con số trước, rồi đọc công thức bên dưới đối chiếu.
5. Công thức gợi ý — và vì sao nó chỉ là gợi ý
Có một công thức heuristic phổ biến (được Brian Goetz trình bày trong "Java Concurrency in Practice") để ước lượng số thread cho workload có cả tính lẫn chờ:
so_thread = so_core × U_cpu × (1 + W / C)
so_core : so CPU core kha dung
U_cpu : muc tan dung CPU MUC TIEU (0..1) ban MUON dat, thuong lay ~1
W : thoi gian CHO trung binh moi tac vu (wait)
C : thoi gian TINH trung binh moi tac vu (compute)
Lưu ý ký hiệu U_cpu ở đây không phải "tỉ lệ tính" (utilization của workload) ở mục 2 — nó là mức CPU bạn muốn hệ thống đạt (thường lấy ~1 để tận dụng tối đa), không phải tỉ lệ thời gian workload thực sự tính. Đừng cắm giá trị nhỏ như 0,01 của workload I/O-bound vào đây; phần "workload chờ nhiều hơn tính" đã nằm ở tỉ lệ W / C bên dưới. Tỉ lệ W / C mới là linh hồn công thức: chờ càng nhiều so với tính, hệ số (1 + W/C) càng lớn, số thread càng cao.
Áp vào ví dụ "thử đoán" (8 core, tính 20 ms, chờ 80 ms, lấy U_cpu = 1):
so_thread = 8 × 1 × (1 + 80/20) = 8 × (1 + 4) = 8 × 5 = 40 thread
Kiểm tra hai đầu mút để thấy công thức hợp lý:
| Workload | W / C | Hệ số (1 + W/C) | Số thread (8 core) |
|---|---|---|---|
| Thuần CPU-bound (không chờ) | 0 | 1 | 8 (≈ số core) |
| Chờ = tính | 1 | 2 | 16 |
| Chờ gấp 4 lần tính | 4 | 5 | 40 |
| Chờ gấp 9 lần tính (API chậm) | 9 | 10 | 80 |
Ở đầu CPU-bound (W/C = 0), công thức trả về đúng "số thread ≈ số core" — khớp mục 4. Ở đầu I/O-bound nặng, nó cho số thread lớn — khớp mục 3.
Nhưng đây là gợi ý, không phải định luật. Lý do phải đo thật rồi điều chỉnh:
WvàCkhông cố định — độ trễ API dao động, tính toán khác nhau theo dữ liệu. Công thức dùng trung bình, mà trung bình che giấu đuôi.- Nó bỏ qua chi phí context switch và giới hạn khác (bộ nhớ mỗi thread, số connection tối đa tới DB, số fd). Với I/O-bound, thường DB hoặc downstream service mới là nút cổ chai thật — 80 thread cùng đập vào một DB pool 20 connection thì thừa thread.
- Với virtual threads (Java 21) hay mô hình async, "một thread mỗi tác vụ chờ" gần như miễn phí, và công thức này (dành cho platform thread đắt) không còn là ràng buộc — bạn có thể có hàng nghìn tác vụ chờ mà không tốn thread hệ điều hành.
Dùng công thức để có điểm xuất phát, rồi benchmark quanh đó (bài 05) và nhìn nút cổ chai thật.
6. Áp vào Java thread pool
Trong Java, bạn hiếm khi tạo thread thủ công — dùng thread pool qua ExecutorService. Số thread pool chính là con số bài này giúp bạn chọn.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
int cores = Runtime.getRuntime().availableProcessors();
// CPU-bound: so thread ~ so core
ExecutorService cpuPool = Executors.newFixedThreadPool(cores);
// I/O-bound: nhieu hon, theo he so cho/tinh (vi du cho/tinh = 4 -> x5)
ExecutorService ioPool = Executors.newFixedThreadPool(cores * 5);
Quy tắc thực dụng: tách pool theo loại workload. Đừng nhét cả tác vụ CPU-bound lẫn I/O-bound vào một pool — số thread tối ưu của chúng khác nhau, gộp lại thì sai cho cả hai. Một pool nhỏ (≈ số core) cho phần tính nặng, một pool lớn cho phần gọi mạng/DB.
Nếu bạn dùng virtual threads (Java 21+), luật đổi: virtual thread cực rẻ, nên với tác vụ I/O-bound bạn có thể tạo một virtual thread cho mỗi tác vụ mà không cần bó vào pool cố định — nhưng phần CPU-bound vẫn bị giới hạn bởi số core vật lý. Chi tiết về thread pool và virtual thread nằm ở khoá Java Internals.
7. Pitfall của riêng concept này
❌ Nhầm 1 — "cứ tăng thread pool là nhanh hơn":
✅ Chỉ đúng cho I/O-bound, và chỉ tới khi nút cổ chai chuyển sang chỗ khác (DB, downstream). Với CPU-bound, tăng thread vượt số core làm chậm hơn vì context switch. Luôn hỏi "workload này đang chờ hay đang tính" trước khi tăng.
❌ Nhầm 2 — gộp CPU-bound và I/O-bound vào một pool:
// SAI: mot pool cho ca hai loai -> so thread sai cho ca hai
ExecutorService pool = Executors.newFixedThreadPool(50);
pool.submit(this::hashPassword); // CPU-bound: 50 qua nhieu
pool.submit(this::callExternalApi); // I/O-bound: 50 co the qua it
✅ Tách hai pool, chỉnh số thread độc lập theo loại. Một con số không thể tối ưu cho hai bản chất trái ngược.
❌ Nhầm 3 — áp công thức mà không đo:
✅ Công thức cho điểm xuất phát; số thật phụ thuộc độ trễ dao động, connection pool DB, bộ nhớ. 80 thread đập vào DB pool 20 connection thì 60 thread chỉ ngồi chờ lấy connection. Benchmark quanh giá trị công thức, đừng tin nó tuyệt đối.
8. 📚 Deep Dive
Nguồn tham khảo:
- "Java Concurrency in Practice" (Brian Goetz, 2006) — chương 8 "Applying Thread Pools" trình bày công thức
số_thread = số_core × U × (1 + W/C)và cách chọn kích thước pool theo loại workload. Đây là nguồn của heuristic trong bài. - sched(7) — nhắc lại cách Linux điều phối; thread blocked được lấy khỏi runqueue nên không tranh CPU (nền cho mục 3).
- Java 21 — Virtual Threads (JEP 444) — vì sao với I/O-bound, virtual thread phá vỡ ràng buộc "thread đắt" mà công thức trên giả định.
Ghi chú: công thức là ước lượng ban đầu, không phải luật vật lý. Nút cổ chai thật của I/O-bound thường nằm ở downstream (DB connection pool, rate limit của API) chứ không phải số thread — đo trước khi tin.
9. Liên hệ các bài khác
- Bài 02 — Trạng thái và context switch: "blocked không tốn CPU" là lý do nền cho việc thêm thread giúp I/O-bound; chi phí context switch là lý do nó hại CPU-bound.
- Bài 03 — Scheduler và time slice: CFS ưu tiên thread hay ngủ (I/O-bound) — bổ trợ cho việc nhiều thread I/O-bound vẫn phản hồi tốt.
- Bài 05 — Mini-challenge: bao nhiêu thread là đủ: bạn tự benchmark 1/4/16/64 thread cho cả hai loại workload và kiểm chứng các kết luận trên.
- Executor và thread pool (Java Internals): cách hiện thực thread pool trong Java và chọn kích thước — áp dụng trực tiếp con số bài này.
10. Tóm tắt
- CPU-bound: thread gần như luôn tính, nút cổ chai là số core → số thread ≈ số core; thêm nữa chỉ tăng context switch, làm chậm.
- I/O-bound: thread phần lớn thời gian blocked chờ (không tốn CPU) → dùng nhiều thread hơn core để lấp thời gian chờ, giấu độ trễ I/O.
- Phân loại bằng utilization = thời gian tính / (tính + chờ): gần 1 là CPU-bound, nhỏ là I/O-bound.
- Công thức gợi ý:
số_thread ≈ số_core × (1 + chờ/tính)— ở CPU-bound cho ≈ số core, ở I/O-bound cho số lớn. - Công thức chỉ là điểm xuất phát: đo thật, để ý nút cổ chai downstream (DB pool, rate limit) và độ trễ dao động.
- Trong Java: tách pool theo loại workload; virtual thread (Java 21) phá ràng buộc "thread đắt" cho phần I/O-bound.
11. Tự kiểm tra
Q1Tăng thread pool từ 10 lên 200 làm service A (gọi 3 API bên ngoài mỗi request) nhanh hơn, nhưng làm service B (băm mật khẩu mỗi request) chậm hơn. Giải thích cơ chế cho từng service.▸
Q2Vì sao một core có thể 'nuôi' khoảng 10 thread I/O-bound loại tính 10 ms rồi chờ 90 ms, mà không thread nào phải xếp hàng lâu?▸
Q3Máy 8 core. Tác vụ mỗi vòng: tính 20 ms, chờ DB 80 ms. Dùng công thức gợi ý để ước lượng số thread, và giải thích ý nghĩa của hệ số.▸
so_thread = so_core × (1 + W/C) với W = thời gian chờ, C = thời gian tính (lấy U = 1). Ở đây W = 80, C = 20, nên W/C = 4, hệ số (1 + W/C) = 5. Vậy so_thread = 8 × 5 = 40. Ý nghĩa hệ số 5: mỗi thread chỉ dùng core 20 ms trong mỗi chu kỳ 100 ms (dùng 1/5 thời gian), nên để giữ 8 core luôn bận cần khoảng 5 thread cho mỗi core lấp phần chờ — tổng 40. Lưu ý 40 chỉ là điểm xuất phát — nếu DB connection pool chỉ có 20 thì 40 thread sẽ có 20 thread ngồi chờ lấy connection; phải đo và nhìn nút cổ chai thật.Q4Vì sao gộp tác vụ CPU-bound và I/O-bound vào chung một thread pool là ý tưởng tồi? Nên làm gì thay thế?▸
Q5Đồng nghiệp áp công thức và đặt pool I/O-bound = 80 thread cho một service gọi DB. Throughput không tăng như kỳ vọng. Nêu ít nhất hai lý do công thức có thể lệch thực tế.▸
Q6Java 21 có virtual threads. Vì sao chúng thay đổi cách nghĩ về số thread cho tác vụ I/O-bound, nhưng KHÔNG gỡ bỏ giới hạn cho tác vụ CPU-bound?▸
Bài tiếp theo: Mini-challenge — bao nhiêu thread là đủ?
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên