Thread pool: tái sử dụng thread cho đúng cách
Tiếp đi: học thread pool.
Bài trước Order Service đã dùng Executors.newFixedThreadPool(10) để gọi User, Inventory, Shipping song song. Giờ mình mổ xẻ cái pool đó: nó chạy thế nào, cấu hình ra sao cho production.
1. Bài toán
- Sau khi tạo đơn, Order Service phải gửi email xác nhận (~500ms mỗi email).
- Giờ sale, 1.000 đơn/phút. Không muốn request phải chờ gửi email xong.
- Cách đầu tiên ai cũng nghĩ:
new Thread(() -> sendEmail(order)).start().
2. Vấn đề
Chạy được, nhưng khi tải tăng sẽ có hai vấn đề:
- Tốn chi phí tạo/hủy: mỗi thread là một OS thread, tạo ra rồi bỏ đi cho mỗi task là phí.
- Không giới hạn: 10.000 đơn đến cùng lúc là 10.000 thread, dễ hết bộ nhớ (
OutOfMemoryError: unable to create native thread) hoặc nghẽn vì context switch.
new Thread mỗi task:
order 1 → tạo T1 → chạy → hủy
order 2 → tạo T2 → chạy → hủy
...
order 10000 → T10000 ← 10.000 stack, CPU chỉ 8 core
3. Giải pháp: Thread pool
Tạo sẵn một số thread cố định, task đến thì bỏ vào hàng đợi, thread rảnh lấy ra chạy, chạy xong quay lại lấy task tiếp. Task (việc) và thread (người làm) tách rời: 1000 task, chỉ 10 thread.
Mọi factory trong Executors đều tạo ra ThreadPoolExecutor với các tham số:
corePoolSize: số thread giữ thường trực.maximumPoolSize: số thread tối đa khi quá tải.keepAliveTime: thread vượt core rảnh quá lâu thì bị hủy.workQueue: hàng đợi task.threadFactory: đặt tên thread.RejectedExecutionHandler: làm gì khi hết chỗ.
4. Code từng bước
Bước 1: bản đơn giản.
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
int id = i;
pool.submit(() -> sendEmail(id)); // 1000 task, chỉ 10 thread
}
Bước 2: tự cấu hình đầy đủ.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60, TimeUnit.SECONDS, // keepAliveTime cho thread vượt core
new ArrayBlockingQueue<>(100), // workQueue có giới hạn
Executors.defaultThreadFactory(), // threadFactory
new ThreadPoolExecutor.CallerRunsPolicy()); // rejection handler
Bước 3: trong Spring Boot, khai báo bean có tên và giới hạn rõ ràng.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "emailExecutor")
public ThreadPoolTaskExecutor emailExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(8);
ex.setQueueCapacity(200);
ex.setThreadNamePrefix("email-");
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
ex.setWaitForTasksToCompleteOnShutdown(true);
ex.setAwaitTerminationSeconds(30);
return ex;
}
}
@Service
public class NotificationService {
@Async("emailExecutor")
public void sendOrderConfirm(String email) { ... }
}
Bước 4: tắt pool đúng cách (khi tự tạo, không phải bean Spring). Pool không tự tắt, thread của pool là non-daemon nên quên shutdown thì JVM có thể không thoát.
pool.shutdown(); // không nhận task mới, chạy nốt task trong queue
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // quá hạn: interrupt thread đang chạy, trả task chưa chạy
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
shutdownNow() chỉ gửi interrupt, không giết thread ngay; task phải tự kiểm tra interrupt (hoặc đang ở sleep/wait) thì mới dừng. Java 19+ ExecutorService là AutoCloseable, dùng được try-with-resources. Bean ThreadPoolTaskExecutor thì Spring tự shutdown khi context đóng.
Bước 5: test. Gọi API tạo đơn, xem log thread có tên email-1, email-2…; chụp thread dump bằng jcmd <pid> Thread.print để thấy pool.
5. Cách hoạt động
Thứ tự xử lý khi có task mới, đây là chỗ hay bị hỏi:
New Task
↓
worker < core? ── YES → tạo thread
│ NO
queue còn chỗ? ── YES → vào queue
│ NO
worker < max? ── YES → tạo thêm thread
│ NO
REJECT → RejectedExecutionHandler
Nhiều người tưởng đủ core thì tạo thêm thread ngay. Không phải: queue đầy rồi mới tăng lên max. Ví dụ core=5, max=10, queue=100, 200 task cùng vào (chưa task nào xong):
Task 1–5 → tạo 5 core thread, chạy ngay
Task 6–105 → vào queue (100/100)
Task 106–110 → queue đầy, workers 5 < max 10 → tạo thêm thread 6..10
Task 111–200 → queue đầy + đã max → REJECT (90 task)
Hệ quả: nếu queue quá lớn (ví dụ 10.000) thì max gần như không bao giờ được dùng, pool cứ chạy 5 thread và task chờ rất lâu. Khi traffic giảm, các thread vượt core rảnh quá keepAliveTime bị hủy, pool quay về 5.
6. So sánh
Rejection policy:
| Policy | Hành vi | Khi nào dùng |
|---|---|---|
AbortPolicy (mặc định) | Ném RejectedExecutionException | Muốn biết ngay là quá tải |
CallerRunsPolicy | Thread gọi submit tự chạy task | Muốn tự giảm tốc (backpressure), không mất task |
DiscardPolicy | Bỏ task, im lặng | Task không quan trọng (log, metric) |
DiscardOldestPolicy | Bỏ task cũ nhất trong queue, thử lại | Chỉ cần dữ liệu mới nhất |
Vì sao nhiều team cấm Executors.newXxx():
| Factory | Bên trong | Rủi ro |
|---|---|---|
newFixedThreadPool(n) | LinkedBlockingQueue không giới hạn | Task dồn vô hạn vào queue → OOM, latency tăng mà không ai biết |
newSingleThreadExecutor() | Như trên, 1 thread | Như trên |
newCachedThreadPool() | max = Integer.MAX_VALUE, SynchronousQueue | Tạo thread không giới hạn |
Pool size bao nhiêu?
- CPU-bound (tính toán, nén, mã hóa): khoảng
số corehoặcsố core + 1. Nhiều hơn chỉ tốn context switch. - I/O-bound (gọi DB, HTTP): thread phần lớn thời gian ngồi chờ, nên cần nhiều hơn:
số core × (1 + thời gian chờ / thời gian tính). Ví dụ chờ 90ms, tính 10ms, 8 core → 8 × 10 = 80 thread.
Công thức chỉ là điểm bắt đầu, phải đo bằng load test. Nhớ giới hạn phía sau: pool 80 thread mà DB connection pool chỉ có 10 thì 70 thread vẫn ngồi chờ connection. Với I/O-bound trên Java 21, virtual thread thường là lựa chọn đơn giản hơn.
7. Khi nào nên dùng / không nên dùng
- Nên: mọi chỗ cần chạy nhiều task nền hoặc song song. Pool là
static/bean dùng chung, không tạo mới mỗi lần gọi function. - Tách pool theo loại việc: email riêng, báo cáo riêng, để một loại chậm không kéo chết loại khác.
- Pool không làm code thread-safe: 3 worker cùng
counter++vẫn race condition (bài 3.4). - Cẩn thận ThreadLocal: thread trong pool không chết, giá trị cũ còn lại cho task sau. Luôn
remove()trongfinally. Chiều ngược lại, task submit sang pool không thấy ThreadLocal của thread request (SecurityContextHolder, MDC).
Request A → Thread-1 → currentUser = User A
request A xong, Thread-1 KHÔNG chết
Request B → Thread-1 được reuse → currentUser vẫn là User A ❌
CURRENT_USER.set(user);
try { handle(); } finally { CURRENT_USER.remove(); }
Giải thích giúp mình đoạn code này: ở bước nào task được đưa vào queue, và khi nào thì được thực thi?
ExecutorService executor = Executors.newFixedThreadPool(2);
Callable<Integer> task = () -> {
return 10 + 20;
};
Future<Integer> future = executor.submit(task);
Integer result = future.get();
executor.shutdown();
Đúng chủ đề rồi. Đi từng dòng:
newFixedThreadPool(2): tạo pool tối đa 2 worker + 1 queue. Worker được tạo dần khi có task.Callable task = ...: chưa vào queue, chưa chạy. Chỉ là object mô tả việc.executor.submit(task): đây mới là lúc bắt đầu. Bọc thành FutureTask, rồi theo đúng flow ở trên: còn dưới core thì tạo worker chạy ngay (không qua queue), đủ core và đều bận thì vào queue. TrảFuturevề gần như ngay lập tức.future.get(): thread gọi chờ tới khi worker chạy xongcall(). Nếu đã xong thì lấy luôn.shutdown(): không nhận task mới, worker làm nốt rồi kết thúc.
main ── submit ──► [worker < core?] ─YES─► worker-1 chạy call() ngay
│ NO (2 worker đều bận)
▼
Task Queue [task3, task4...] ──► worker rảnh lấy ra chạy
main ── get() ── WAITING ...................... ◄── worker xong, đánh thức main
Vậy "add vào queue" không phải lúc nào cũng xảy ra. Task chỉ ngồi queue khi các worker đang bận.
Bạn có nói có một cách viết để chờ tất cả Future xong cùng lúc, là cách nào?
Đúng, đó là ExecutorService.invokeAll(). Rất hợp với bài doanh thu 10 cửa hàng ở bài trước: 10 Callable, pool 5 thread, phải chờ cả 10 xong.
record StoreRevenue(int storeId, double revenue) {}
public static double calculateTotalRevenue() throws Exception {
List<Callable<StoreRevenue>> tasks = new ArrayList<>();
for (int storeId = 1; storeId <= 10; storeId++) {
int id = storeId;
tasks.add(() -> new StoreRevenue(id, getRevenue(id)));
}
// submit tất cả và BLOCK tới khi cả 10 task xong
List<Future<StoreRevenue>> futures = EXECUTOR.invokeAll(tasks);
double total = 0;
for (Future<StoreRevenue> f : futures) { // cùng thứ tự với tasks
total += f.get().revenue(); // không còn phải chờ
}
return total;
}
submit() | invokeAll() | |
|---|---|---|
| Trả về | 1 Future, ngay lập tức | List Future, sau khi TẤT CẢ xong |
| Block? | Không (block ở get()) | Có, block thread gọi |
| Timeout | Ở từng get(timeout) | invokeAll(tasks, 5, SECONDS): quá hạn thì cancel task chưa xong |
Còn có invokeAny(): trả kết quả của task xong đầu tiên, hủy phần còn lại.
Mình hiểu là Tomcat threads khác với application worker threads, đúng không? Hay chúng là một? Vẽ sơ đồ giúp mình với.
Đúng, là hai pool khác nhau trong cùng một JVM. Chỉ chỉnh một chút: Tomcat không hẳn "bọc application". Trong Spring Boot, Tomcat là HTTP server embedded chạy cùng JVM; Tomcat worker gọi vào Spring MVC → Controller → Service. ExecutorService là pool riêng, optional, do mình tạo.
Clients
│ HTTP
▼
┌──────────────────────── Order Service JVM ────────────────────────┐
│ Tomcat pool (http-nio-8080-exec-1..200) │
│ │ │
│ ▼ │
│ DispatcherServlet → OrderController → OrderService │
│ │ submit / @Async │
│ ▼ │
│ Application pool (email-1..8, pool-1-...) │
└───────────────────────────────────────────────────────────────────┘
Lưu ý quan trọng: nếu Tomcat thread submit sang pool của mình rồi gọi future.get() ngay, Tomcat thread vẫn bị giữ, giờ thành 2 thread cùng dính vào một request. Pool riêng có ích khi chạy song song nhiều việc (như checkout ở bài 3.2) hoặc chạy nền không chờ (email). Còn để Tomcat thread được nhả ra thật sự thì cần async servlet / CompletableFuture trả từ controller, sẽ học ở bài 3.9.
Phỏng vấn hỏi "giải thích thread pool và cách cấu hình" thì trả lời sao?
Câu 1: Thread pool là gì, vì sao cần?
🇻🇳 Tiếng Việt:
Thread pool giữ sẵn một số thread để tái sử dụng cho nhiều task, tránh chi phí tạo/hủy thread và giới hạn số thread chạy cùng lúc. Task được đưa vào queue, worker rảnh lấy ra chạy. Pool không làm code thread-safe, và vì thread được reuse nên ThreadLocal phải được remove sau mỗi task.
🇬🇧 English:
A thread pool keeps a set of reusable threads for many tasks, avoiding thread creation cost and bounding concurrency. Tasks go into a queue and idle workers pick them up. A pool does not make code thread-safe, and since threads are reused, ThreadLocal values must be removed after each task.
Câu 2: ThreadPoolExecutor xử lý task mới thế nào?
🇻🇳 Tiếng Việt:
Có core, max, keepAlive, queue và rejection policy. Task mới: chưa đủ core thì tạo thread, đủ core thì vào queue, queue đầy mới tăng tới max, vẫn đầy thì reject. Nên queue quá lớn thì max gần như vô dụng.
🇬🇧 English:
It has core size, max size, keep-alive, a work queue and a rejection policy. A new task creates a thread if below core, otherwise goes to the queue; only when the queue is full does the pool grow to max, and beyond that the task is rejected. So with a huge queue, max is practically never reached.
Câu 3: Cấu hình pool thế nào trong production?
🇻🇳 Tiếng Việt:
Em tránh
Executors.newFixedThreadPoolvànewCachedThreadPoolvì queue hoặc số thread không giới hạn. Em tự cấu hình queue có giới hạn, đặt tên thread, chọn policy nhưCallerRunsPolicyđể có backpressure, tách pool theo loại việc. Size: CPU-bound khoảng số core, I/O-bound nhiều hơn theo tỉ lệ chờ/tính, không vượt quá xa DB connection pool, rồi đo bằng load test. Khi tắt thìshutdown+awaitTermination.
🇬🇧 English:
I avoid
Executors.newFixedThreadPoolandnewCachedThreadPoolbecause of their unbounded queue or thread count. I configure a bounded queue, named threads, a policy likeCallerRunsPolicyfor backpressure, and separate pools per workload. Sizing: about the core count for CPU-bound work, more for I/O-bound based on wait/compute ratio, not far above the DB connection pool, then verified with load tests. On shutdown I useshutdownplusawaitTermination.
Cách nhớ ngắn gọn
- Pool = đội thợ cố định + hàng đợi việc.
- core → queue → max → reject (queue đầy mới tăng max).
- Queue phải có giới hạn; CallerRuns = tự giảm tốc.
- CPU-bound ≈ số core; I/O-bound nhiều hơn, nhưng nhìn DB pool.
- Tomcat pool ≠ pool của mình; thread reuse → nhớ remove ThreadLocal.
Tự kiểm tra
Core = 4, max = 8, queue = 100. Task thứ 5 đến khi 4 thread đều bận thì sao?
Vào queue. Chỉ khi queue đủ 100 task thì mới tạo thêm thread thứ 5.
Vì sao newFixedThreadPool có thể gây OOM?
Nó dùng LinkedBlockingQueue không giới hạn; task đến nhanh hơn xử lý sẽ dồn mãi trong bộ nhớ.
CallerRunsPolicy giúp gì?
Không mất task, và làm chậm phía submit, tạo backpressure tự nhiên khi pool quá tải.
shutdown() khác shutdownNow()?
shutdown chạy nốt task đã nhận; shutdownNow interrupt thread đang chạy và trả về task chưa chạy.
invokeAll() khác vòng for submit() thế nào?
invokeAll block tới khi tất cả task xong rồi trả List Future cùng thứ tự; submit trả Future ngay, block nằm ở từng get().