Mini-challenge — bao nhiêu thread là đủ?
Thí nghiệm: benchmark 1, 4, 16, 64 thread cho workload CPU-bound và I/O-bound, rồi giải thích vì sao thêm thread không phải lúc nào cũng nhanh hơn.
TL;DR: Bài 04 nói lý thuyết: CPU-bound thì số thread ≈ số core, I/O-bound thì nhiều thread hơn core. Giờ bạn chứng minh nó trên máy mình. Bạn sẽ chạy 64 tác vụ qua các thread pool cỡ 1, 4, 16, 64 — một lần với tác vụ CPU-bound (băm/tính toán liên tục), một lần với tác vụ I/O-bound (giả lập chờ bằng sleep) — rồi đo thời gian. Kỳ vọng: với CPU-bound, thời gian ngừng cải thiện khi pool vượt số core (thậm chí tệ đi vì context switch); với I/O-bound, thời gian tiếp tục giảm mạnh khi tăng thread, gần tỉ lệ nghịch với số thread cho tới khi mỗi tác vụ có một thread. Đây là bài tổng hợp cả module: blocked không tốn CPU (bài 02), scheduler chia core (bài 03), phân loại workload (bài 04).
🎯 Đề bài
Bạn cần trả lời bằng số đo thật, không phải bằng cảm giác: với một loại công việc cụ thể, tăng số thread có làm nhanh hơn không, và tới đâu thì hết tác dụng?
Có 64 tác vụ cần hoàn thành. Bạn sẽ chạy chúng qua một ExecutorService với kích thước pool lần lượt là 1, 4, 16, 64 thread, và đo tổng thời gian hoàn thành cả 64 tác vụ. Làm hai lần với hai loại tác vụ:
- Tác vụ CPU-bound: một vòng lặp tính toán nặng (ví dụ cộng dồn
Math.sqrt), chạy liên tục ~50 ms, không chờ gì. - Tác vụ I/O-bound:
Thread.sleep(50)— giả lập một lời gọi mạng/DB mất 50 ms mà trong đó thread blocked, không dùng CPU.
Nhiệm vụ:
- Predict trước (xem box bên dưới) — đoán bảng kết quả trước khi chạy.
- Viết harness chạy 64 tác vụ qua pool cỡ 1/4/16/64, đo thời gian cho cả hai loại workload.
- Chạy trên máy bạn, ghi lại số đo.
- Giải thích vì sao hai loại workload cho hai hình dạng kết quả khác nhau — và vì sao con số của bạn có thể khác con số "kỳ vọng" trong bài.
Đừng chạy code vội. Với máy bạn (giả sử C core), hãy đoán ra giấy: khi tăng pool 1 → 4 → 16 → 64, thời gian sẽ đổi thế nào cho CPU-bound? Cho I/O-bound? Cụ thể: pool nào là điểm dừng cải thiện của mỗi loại? Loại nào thấy thời gian giảm gần như tỉ lệ nghịch với số thread? Viết dự đoán ra trước, rồi mới đối chiếu với kết quả thật — chỗ dự đoán sai chính là chỗ bạn học được nhiều nhất.
🔍 Phân tích I-P-O
| Mô tả | |
|---|---|
| Input | 64 tác vụ; kích thước pool ∈ {1, 4, 16, 64}; loại workload (CPU-bound hoặc I/O-bound) |
| Process | Submit 64 tác vụ vào pool, chờ tất cả xong, đo thời gian tường (wall-clock) |
| Output | Bảng: kích thước pool → thời gian hoàn thành, cho mỗi loại workload |
Điều kiện đo cho sạch:
- Warmup JIT trước khi đo (chạy vài vòng bỏ đi) — nhất là với CPU-bound.
- Mỗi cấu hình đo vài lần, lấy trung bình hoặc số nhỏ nhất.
- Cùng số tác vụ, cùng độ nặng mỗi tác vụ cho mọi pool size.
- In ra số core thật (
Runtime.getRuntime().availableProcessors()) để đối chiếu.
📦 Concept mapping
Bài này tổng hợp trực tiếp cả module Thread & Lập lịch:
| Kiến thức cần | Bài gốc | Vai trò trong thí nghiệm |
|---|---|---|
| Blocked không tốn CPU | Bài 02 | Giải thích vì sao I/O-bound (sleep) scale được vượt số core |
| Context switch tốn phí | Bài 02 | Giải thích vì sao CPU-bound tệ đi khi pool quá lớn |
| Scheduler chia core, timer preempt | Bài 03 | Vì sao 64 thread CPU-bound vẫn chỉ chạy trên C core |
| CPU-bound vs I/O-bound, công thức số thread | Bài 04 | Giả thuyết bạn đang kiểm chứng |
Cơ chế kỳ vọng, tính trước bằng đầu:
Với I/O-bound (mỗi tác vụ sleep 50 ms, thread blocked nên không tranh CPU), thời gian gần như chỉ phụ thuộc có bao nhiêu tác vụ chạy song song:
64 tac vu, moi tac vu sleep 50ms:
pool 1 -> 64 dot noi tiep = 64 × 50ms ~ 3200 ms
pool 4 -> 16 dot = 16 × 50ms ~ 800 ms
pool 16 -> 4 dot = 4 × 50ms ~ 200 ms
pool 64 -> 1 dot = 1 × 50ms ~ 50 ms
Vì thread chỉ ngủ (không cần CPU), 64 thread cùng ngủ một lúc là hoàn toàn khả thi dù chỉ có 4 core — thời gian giảm gần tỉ lệ nghịch với số thread.
Với CPU-bound (mỗi tác vụ tính ~50 ms, chiếm CPU thật), số việc song song bị chặn bởi số core C:
64 tac vu, moi tac vu tinh 50ms CPU, may C = 4 core:
pool 1 -> 1 core lam viec = 64 × 50ms ~ 3200 ms
pool 4 -> 4 core full = (64/4) × 50ms ~ 800 ms
pool 16 -> van 4 core ~ 800ms + overhead switch (KHONG nhanh hon)
pool 64 -> van 4 core ~ 800ms + overhead nhieu hon (co the CHAM hon)
Điểm mấu chốt: I/O-bound cải thiện tới pool 64; CPU-bound chững lại ở pool = C và không nhanh hơn nữa (đôi khi chậm hơn). Nhìn thấy hai đường cong khác hình này bằng số đo của chính bạn là mục tiêu bài học.
▶️ Starter code
Khung sẵn — bạn điền phần runWorkload cho hai loại tác vụ và vòng lặp qua các pool size:
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
public class ThreadPoolBench {
static final int TASKS = 64;
static final int[] POOL_SIZES = {1, 4, 16, 64};
// CPU-bound: tinh lien tuc ~50ms, khong cho gi.
// Ket qua duoc tra ve de JIT khong loai bo vong lap (dead code elimination).
static double cpuTask() {
double acc = 0;
for (int i = 1; i < 30_000_000; i++) {
acc += Math.sqrt(i);
}
return acc;
}
// I/O-bound: gia lap cho I/O 50ms bang sleep -> thread BLOCKED, khong ton CPU.
static void ioTask() throws InterruptedException {
Thread.sleep(50);
}
// TODO: submit TASKS tac vu vao pool co poolSize thread, cho tat ca xong, tra ve thoi gian ms.
static long runWorkload(int poolSize, boolean cpuBound) throws Exception {
// Goi y: Executors.newFixedThreadPool(poolSize);
// submit TASKS Callable/Runnable;
// thu thap Future va .get() de doi tat ca;
// do bang System.nanoTime() quanh vong submit + cho.
return 0;
}
public static void main(String[] args) throws Exception {
System.out.println("Cores: " + Runtime.getRuntime().availableProcessors());
// TODO: warmup, roi in bang ket qua cho CPU-bound va I/O-bound.
}
}
💡 Gợi ý
Gợi ý 1 — đo đúng một lượt:
Dùng System.nanoTime() bao quanh đoạn submit + chờ tất cả. Để "chờ tất cả xong", thu mọi Future rồi gọi .get() trên từng cái — .get() chặn cho tới khi tác vụ đó hoàn thành.
long start = System.nanoTime();
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < TASKS; i++) {
futures.add(pool.submit(callable));
}
for (Future<?> f : futures) {
f.get(); // chan cho toi khi tac vu xong
}
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
Gợi ý 2 — nhớ tắt pool:
Sau mỗi lần đo, gọi pool.shutdown() rồi pool.awaitTermination(...). Không tắt thì thread nền còn sống, tích luỹ qua các vòng đo và làm nhiễu số liệu.
Gợi ý 3 — warmup cho CPU-bound:
JIT cần vài vòng để biên dịch cpuTask() sang mã máy tối ưu. Chạy workload CPU-bound một hai lần trước khi đo và bỏ kết quả đi, nếu không lần đo đầu sẽ chậm giả tạo.
Gợi ý 4 — dự đoán rồi so:
Trước khi nhìn số, viết ra bạn nghĩ pool nào thắng cho mỗi loại. Máy bạn mấy core? Với CPU-bound, kỳ vọng thời gian chững lại ở pool ≈ số core.
✅ Lời giải
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
public class ThreadPoolBench {
static final int TASKS = 64;
static final int[] POOL_SIZES = {1, 4, 16, 64};
// CPU-bound: tinh lien tuc, khong cho.
static double cpuTask() {
double acc = 0;
for (int i = 1; i < 30_000_000; i++) {
acc += Math.sqrt(i);
}
return acc;
}
// I/O-bound: sleep 50ms -> thread BLOCKED, nha CPU.
static void ioTask() throws InterruptedException {
Thread.sleep(50);
}
static long runWorkload(int poolSize, boolean cpuBound) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(poolSize);
try {
long start = System.nanoTime();
List<Future<?>> futures = new ArrayList<>(TASKS);
for (int i = 0; i < TASKS; i++) {
Callable<Object> task = () -> {
if (cpuBound) {
return cpuTask();
} else {
ioTask();
return null;
}
};
futures.add(pool.submit(task));
}
for (Future<?> f : futures) {
f.get(); // doi tung tac vu xong
}
return (System.nanoTime() - start) / 1_000_000;
} finally {
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
}
}
static void report(boolean cpuBound) throws Exception {
String label = cpuBound ? "CPU-bound" : "I/O-bound";
System.out.println("== " + label + " ==");
for (int size : POOL_SIZES) {
long best = Long.MAX_VALUE;
for (int r = 0; r < 3; r++) { // do 3 lan, lay nho nhat
best = Math.min(best, runWorkload(size, cpuBound));
}
System.out.printf(" pool %2d -> %5d ms%n", size, best);
}
}
public static void main(String[] args) throws Exception {
System.out.println("Cores: " + Runtime.getRuntime().availableProcessors());
// Warmup JIT cho CPU-bound
for (int w = 0; w < 2; w++) {
runWorkload(4, true);
}
report(true); // CPU-bound
report(false); // I/O-bound
}
}
Kết quả kỳ vọng
Trên một máy 4 core (JDK 21), con số sẽ gần bảng dưới — nhưng con số tuyệt đối tuỳ máy (CPU, JDK, tải nền); hình dạng hai đường cong mới là điều cần quan sát, không phải giá trị mili-giây chính xác. Máy bạn nhiều/ít core hơn thì cột CPU-bound dịch theo:
| Pool size | CPU-bound (4 core) | I/O-bound |
|---|---|---|
| 1 | ~3200 ms | ~3200 ms |
| 4 | ~800 ms | ~800 ms |
| 16 | ~800 ms (không cải thiện) | ~200 ms |
| 64 | ~800 ms hoặc chậm hơn | ~50 ms |
Hai hình dạng rõ rệt:
- CPU-bound: giảm mạnh từ pool 1 → 4 (lấp đủ 4 core), rồi chững lại — pool 16 và 64 không nhanh hơn vì vẫn chỉ 4 core làm việc thật. Pool 64 có thể chậm hơn pool 4 chút ít vì thêm context switch và áp lực cache (bài 02).
- I/O-bound: giảm gần tỉ lệ nghịch với số thread suốt từ 1 tới 64 — vì thread sleep không tranh CPU, 64 thread cùng "chờ" một lúc hoàn toàn khả thi trên 4 core, và mỗi lần tăng gấp 4 số thread thì thời gian giảm ~4 lần.
Đây chính là hai kết luận của bài 04, giờ hiện ra bằng số đo của bạn: với CPU-bound, điểm ngọt là ≈ số core; với I/O-bound, thêm thread vẫn giúp cho tới khi mỗi tác vụ có một thread.
Nếu số của bạn khác — đó cũng là bài học
- Số core khác 4: cột CPU-bound chững ở pool = số core của bạn, không phải 4. Máy 8 core sẽ thấy pool 4 chưa tối ưu, pool 8 mới bão hoà.
- CPU-bound pool 64 không chậm hơn rõ: JIT và scheduler hiện đại giấu overhead khá tốt với tác vụ tính toán đều; chênh lệch switch có thể nhỏ. Muốn thấy rõ, tăng số tác vụ hoặc rút ngắn mỗi tác vụ (switch nhiều hơn).
- I/O-bound không xuống tới ~50 ms ở pool 64: chi phí tạo/quản lý 64 thread và độ chính xác của
Thread.sleep(thường sai số vài ms) tạo sàn. Đó là overhead thật của thread hệ điều hành — chính thứ mà virtual thread (phần Mở rộng) giải quyết.
🎓 Mở rộng
Đổi tỉ lệ chờ/tính và soi lại công thức
Đổi ioTask thành "tính 10 ms rồi sleep 40 ms" (workload hỗn hợp, tỉ lệ chờ/tính = 4). Theo công thức bài 04, số thread tối ưu ≈ số_core × (1 + 4) = số_core × 5. Chạy thử pool = số_core × 5 và so với pool 64 — bạn sẽ thấy điểm ngọt nằm quanh giá trị công thức dự đoán, không phải "càng nhiều càng tốt".
Đo bằng virtual threads (Java 21)
Thay Executors.newFixedThreadPool(size) bằng Executors.newVirtualThreadPerTaskExecutor() cho workload I/O-bound:
// Moi tac vu mot virtual thread -- khong can chon pool size
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
// submit TASKS tac vu I/O-bound...
}
Với I/O-bound, virtual thread đạt thời gian tốt như pool 64 (~50 ms) mà không cần bạn tính pool size — vì mỗi tác vụ có thread riêng và virtual thread cực rẻ khi block. Nhưng thử nó với CPU-bound và bạn sẽ thấy: virtual thread không nhanh hơn newFixedThreadPool(số_core), vì phần tính vẫn bị chặn bởi số core vật lý (kết luận cuối bài 04).
Đo context switch thật bằng perf (Linux)
Nếu bạn dùng Linux, đếm context switch của mỗi cấu hình để thấy bằng chứng phần cứng:
# Build thanh JAR roi do
perf stat -e context-switches,cpu-migrations java -jar bench.jar
Kỳ vọng: cấu hình CPU-bound pool 64 có context-switches cao hơn hẳn pool 4 — đây là chi phí bạn đang trả khi thừa thread cho workload CPU-bound.
✨ Điều bạn vừa làm được
Bạn vừa biến ba bài lý thuyết thành một thí nghiệm đo được và tự kiểm chứng:
- Đo, không đoán: dựng harness có warmup, đo nhiều lần, lấy số nhỏ nhất — kỷ luật benchmark thật.
- Thấy blocked không tốn CPU: 64 thread sleep cùng lúc trên 4 core vẫn nhanh — bằng chứng sống cho bài 02.
- Thấy context switch có giá: CPU-bound chững ở số core, không nhanh hơn khi thừa thread — bằng chứng cho bài 02/03.
- Phân loại workload và chọn số thread: đối chiếu số đo với công thức bài 04, và biết vì sao I/O-bound scale còn CPU-bound thì không.
- Predict trước, đối chiếu sau: chỗ dự đoán sai là chỗ mô hình trong đầu bạn được sửa — đó là cách hiểu sâu, không phải học thuộc.
Kỹ năng này — nhìn một workload, phân loại, chọn số thread có căn cứ, rồi đo để xác nhận — là thứ phân biệt người cấu hình thread pool bằng hiểu biết với người copy một con số từ Stack Overflow.
Bài tiếp theo: Tổng kết module — Thread & Lập lịch CPU
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