Virtual threads trong Boot — một dòng config đổi những gì
spring.threads.virtual.enabled bật lên thì Tomcat, @Async và scheduler đổi ra sao, và hồ sơ tải nào thật sự hưởng lợi từ virtual threads.
TL;DR: spring.threads.virtual.enabled=true (yêu cầu Java 21+, Spring Boot 3.2+) đổi ba chỗ cùng lúc: Tomcat xử lý mỗi request trên một virtual thread thay vì lấy từ pool 200, @Async chuyển sang chạy trên SimpleAsyncTaskExecutor dùng virtual thread, và taskScheduler cũng vậy — mọi cấu hình pool cũ (max-threads, core-size...) hết tác dụng. Nó đáng bật khi phần lớn thời gian xử lý request là chờ I/O (gọi API ngoài, query DB) và code có blocking call thật; không đáng bật khi việc nặng là tính toán CPU, vì số core vẫn là trần cứng. Hiểu nhầm phổ biến nhất: virtual thread không làm một request đơn lẻ nhanh hơn — nó chỉ giúp hệ thống nhận nhiều request đồng thời hơn.
Một service gọi 3 API bên ngoài cho mỗi request chạy ổn ở tải thấp rồi nghẽn cứng khi traffic tăng, trong khi CPU server gần như rảnh. Bạn thêm spring.threads.virtual.enabled=true theo hướng dẫn tìm được, request qua nhanh hơn thật, nhưng không rõ cái gì vừa đổi và liệu nó có đáng bật cho service khác không.
Bài này giải thích dòng config đó chạm vào đâu trong runtime Boot, vì sao thread pool truyền thống là tài nguyên khan hiếm còn virtual thread thì không, và cách bạn tự quyết bật hay không cho một service cụ thể.
1. Kịch bản — TaskFlow cạn pool trong khi CPU gần như rảnh
Giả sử bạn vận hành TaskFlowOrchestrator — mỗi request xử lý một đơn hàng bằng cách gọi tuần tự ba service ngoài: thanh toán, kiểm tra tồn kho, đặt lịch giao hàng.
@Service
public class TaskFlowOrchestrator {
private final PaymentClient paymentClient;
private final InventoryClient inventoryClient;
private final ShippingClient shippingClient;
public TaskFlowOrchestrator(PaymentClient paymentClient,
InventoryClient inventoryClient,
ShippingClient shippingClient) {
this.paymentClient = paymentClient;
this.inventoryClient = inventoryClient;
this.shippingClient = shippingClient;
}
public OrderResult process(OrderRequest request) {
var payment = paymentClient.charge(request.orderId());
var inventory = inventoryClient.reserve(request.orderId());
var shipping = shippingClient.schedule(request.orderId());
return new OrderResult(payment, inventory, shipping);
}
}
Ba lời gọi đều là blocking call đồng bộ, mỗi call chờ mạng trả về trước khi gọi call tiếp theo — tổng cộng khoảng 300 đến 500 mili giây chờ ròng cho một request, gần như không tính toán CPU đáng kể.
server.tomcat.threads.max mặc định là 200 — Tomcat nhúng trong Boot chỉ có tối đa 200 platform thread xử lý request đồng thời. Ở tải thấp việc này ổn: thread trả về pool ngay sau vài trăm mili giây. Nhưng khi traffic tăng lên vài trăm request mỗi giây, mỗi request giữ một thread suốt lúc chờ, pool 200 cạn sạch trước khi request đầu tiên kịp xong. Request mới xếp hàng, timeout tăng, trong khi top trên server báo CPU idle khoảng 5%. Không phải máy yếu — chỉ hết "chỗ ngồi" để chờ, dù chẳng chỗ nào đang bận tính toán thật sự.
Đây chính xác là hình mẫu tải virtual thread sinh ra để giải: nhiều tác vụ đồng thời, phần lớn thời gian là chờ.
2. Một dòng config chạm ba chỗ
spring.threads.virtual.enabled=true không phải cờ tối ưu chung chung — nó bật ba mảnh cấu hình riêng biệt trong Boot cùng lúc, với điều kiện cứng: Java 21 trở lên và Spring Boot 3.2 trở lên. Thiếu một trong hai, property này không có tác dụng gì.
Chỗ chạm 1 — Tomcat protocol handler. Boot đăng ký TomcatVirtualThreadsWebServerFactoryCustomizer, nhiệm vụ chính thức là "kích hoạt VirtualThreadExecutor trên protocol handler của Tomcat". Tomcat không còn lấy thread từ pool 200 cố định — mỗi request nhận một virtual thread mới, dùng xong thì bỏ, không tái sử dụng như phần tử trong pool.
Chỗ chạm 2 — @Async. Bean applicationTaskExecutor đứng sau @Async đổi từ ThreadPoolTaskExecutor sang SimpleAsyncTaskExecutor chạy trên virtual thread. Method @Async không đổi dòng code nào, nhưng từ giờ mỗi lời gọi sinh một virtual thread riêng thay vì mượn thread từ pool.
Chỗ chạm 3 — taskScheduler. Tương tự, bean chạy @Scheduled đổi từ ThreadPoolTaskScheduler sang SimpleAsyncTaskScheduler dùng virtual thread.
| Chỗ chạm | Trước (mặc định) | Sau khi bật |
|---|---|---|
| Request HTTP vào Tomcat | Platform thread từ pool server.tomcat.threads.max (200) | Virtual thread mới cho mỗi request |
@Async | ThreadPoolTaskExecutor (applicationTaskExecutor) | SimpleAsyncTaskExecutor dùng virtual thread |
@Scheduled | ThreadPoolTaskScheduler (taskScheduler) | SimpleAsyncTaskScheduler dùng virtual thread |
Một khi bật virtual threads, mọi property cấu hình pool (server.tomcat.threads.max, spring.task.execution.pool.*, spring.task.scheduling.pool.*) bị bỏ qua — lý do đơn giản: không còn pool cố định nào để cấu hình kích thước. Đặt lại các property này sau khi bật virtual threads là công vô ích, không phải bug.
3. Vì sao thread pool là tài nguyên khan hiếm?
Pool 200 không phải con số tuỳ tiện — nó phản ánh chi phí thật của platform thread. Mỗi platform thread ánh xạ 1-1 với một OS thread, kèm vùng stack riêng mặc định khoảng 1MB, giữ nguyên suốt vòng đời thread kể cả lúc block chờ mạng — không làm gì nhưng vẫn chiếm chỗ. Context switch giữa các platform thread cũng đi qua kernel, tốn vài nghìn cycle CPU mỗi lần. Cộng dồn: 200 thread × 1MB là 200MB bộ nhớ dành cho việc chờ, và 200 là giới hạn thực tế trước khi RAM và chi phí context-switch thành vấn đề.
Virtual thread phá đúng giả định đó. Cơ chế chi tiết (mount/unmount, continuation, carrier thread) đã mổ kỹ ở bài Virtual Threads của java-internals — ở đây chỉ cần nhớ lại một câu: khi virtual thread gặp blocking I/O, nó tự tháo khỏi carrier thread (unmount), giải phóng carrier ngay cho việc khác, rồi mount lại khi I/O xong. Carrier thread không bao giờ ngồi im chờ.
flowchart TB
A["Request den"] --> B["Lay 1 platform thread tu pool 200"]
B --> C["Goi API ngoai, cho I/O ~ 400ms"]
C --> D["Thread giu nguyen cho toi khi xong, khong lam gi khac duoc"]
D --> E{"Con thread ranh trong pool?"}
E -- "khong, het 200" --> F["Request moi xep hang cho"]
E -- "co" --> G["Nhan request moi"]flowchart TB
A2["Request den"] --> B2["JVM tao 1 virtual thread rieng"]
B2 --> C2["Goi API ngoai, cho I/O ~ 400ms"]
C2 --> D2["Virtual thread unmount khoi carrier"]
D2 --> E2["Carrier thread ranh ngay, nhan request khac"]
E2 --> F2["I/O xong, virtual thread mount lai de tra ket qua"]Từ đây suy ra đúng lằn ranh thắng-thua. Tải I/O-bound (phần lớn thời gian là chờ — gọi API, query DB, đọc file) thắng đậm, vì phần chờ vốn không cần CPU, chỉ cần một chỗ giữ trạng thái rẻ. Tải CPU-bound (phần lớn thời gian là tính toán thật) không hưởng lợi gì, vì carrier thread vẫn bận suốt lúc tính và số core vật lý vẫn là trần cứng. Virtual thread không tạo thêm CPU, nó chỉ ngừng lãng phí vào việc chờ.
4. Bật hay không — bảng quyết định
| Hồ sơ tải | Bật virtual threads? | Vì sao |
|---|---|---|
| Tỉ lệ thời gian chờ I/O cao trên tổng thời gian xử lý (gọi API ngoài, JDBC/RestClient đồng bộ) | Bật | Carrier thread rảnh ngay khi virtual thread unmount, nhận request khác thay vì ngồi chờ |
| Việc nặng là tính toán CPU (mã hoá, xử lý ảnh, thuật toán numeric) chiếm phần lớn thời gian | Đừng bật | Carrier thread vẫn bận suốt lúc tính — không có gì để unmount; lợi ích bằng 0, chỉ thêm chi phí tạo virtual thread |
Đã dùng WebFlux với driver reactive thật sự (Mono/Flux, R2DBC, WebClient non-blocking) | Không cần | Request vốn đã không giữ thread nào khi chờ I/O — virtual thread không cộng thêm gì cho mô hình đã non-blocking |
Có blocking call bên trong khối synchronized hoặc gọi native code (JNI) | Cân nhắc kỹ | Dễ dính pinning — virtual thread không unmount được, mất hết lợi ích. Cơ chế và cách phát hiện thuộc bài sau |
Trục quyết định thật sự chỉ có một câu hỏi: phần lớn thời gian request của bạn dành cho chờ, hay dành cho tính? Nếu chưa chắc, đo — đừng đoán.
5. Đo thế nào cho đúng?
Sai lầm phổ biến nhất: gửi một request, đo thời gian phản hồi, thấy y hệt lúc chưa bật, rồi kết luận "không có tác dụng". Tài liệu Oracle nói thẳng: virtual thread tồn tại để tăng thông lượng (throughput) — số request hệ thống xử lý đồng thời mỗi đơn vị thời gian.
Đo cho đúng là đo throughput ở mức concurrency cao:
- Load test tăng dần số kết nối đồng thời — ví dụ 50, 200, 500, 2000 — cùng kịch bản gọi
TaskFlowOrchestrator.process. - Ở mỗi mức, ghi số request thành công mỗi giây và tỉ lệ lỗi/timeout, không chỉ latency trung bình.
- Pool 200 platform thread sẽ thấy throughput đi ngang rồi tụt ngay khi kết nối đồng thời vượt qua vùng lân cận 200; virtual thread tiếp tục leo vì không còn trần đó.
- Khác biệt chỉ lộ rõ ở vùng tải vượt xa kích thước pool cũ — tải thấp thì hai cấu hình gần như không khác nhau.
Pitfall thường gặp
Bật spring.threads.virtual.enabled=true không phải phép màu an toàn — có ba hỏng hóc thật sự hay gặp: pinning (virtual thread bị "kẹt" vào carrier khi gặp synchronized hoặc native call), giả định sai về ThreadLocal (mỗi request giờ là một thread mới toanh, không tái sử dụng như platform thread trong pool), và mất backpressure tự nhiên mà pool cũ vô tình cung cấp (pool cạn từng là một dạng giới hạn tải, virtual thread thì gần như không giới hạn). Cả ba thuộc phạm vi bài sau — xem Virtual threads: ba cạm bẫy khi bật trước khi đưa cấu hình này lên production.
Đào sâu
Tài liệu chính thức:
- Virtual Threads — Java SE 21 Documentation (Oracle) — giải thích vì sao platform thread đắt (ánh xạ 1-1 OS thread, stack lớn) và khẳng định rõ virtual thread tăng throughput chứ không giảm latency.
- Spring Boot 3.2.x Reference — Task Execution and Scheduling — định nghĩa chính thức của property
spring.threads.virtual.enabledvà ghi chú property cấu hình pool hết tác dụng khi bật. - TomcatVirtualThreadsWebServerFactoryCustomizer — Spring Boot API — class chịu trách nhiệm kích hoạt
VirtualThreadExecutortrên protocol handler của Tomcat.
Ghi chú: ba nguồn trên là chỗ tra chính xác nhất khi bạn cần biết version nào bắt đầu hỗ trợ gì, hoặc muốn đọc đúng tên bean/class Boot dùng thay vì nhớ theo trí nhớ.
Liên hệ các bài khác
- Bài 01 — Record làm DTO — module này bắt đầu từ record cho tầng HTTP; bài đó và bài này cùng chuẩn bị nền cho mini-challenge TaskFlow.
- Bài 03 — Virtual threads: ba cạm bẫy khi bật — pinning,
ThreadLocal, mất backpressure — những thứ bài này chỉ nhắc tên, bài sau mới mổ kỹ. - Mini-challenge: TaskFlow v4 — nơi bạn thật sự bật virtual threads cho
TaskFlowOrchestratorvà đo lại throughput bằng chính bảng quyết định ở bài này. - java-internals — Virtual Threads — cơ chế mount/unmount, continuation, carrier thread ở mức JVM; đọc nếu phần "Cơ chế bên dưới" ở trên chưa đủ chi tiết.
Tóm tắt
- Bật virtual threads đổi runtime ở ba nơi cùng lúc — Tomcat,
@Async, scheduler — không phải một cấu hình rời rạc. - Thiếu Java 21 hoặc Boot 3.2 thì property này lặng lẽ vô nghĩa, không báo lỗi gì.
- Câu hỏi quyết định duy nhất: phần lớn thời gian xử lý một request là chờ hay là tính?
- Hệ thống đã dùng WebFlux thật sự (non-blocking driver) thì virtual thread không cộng thêm gì.
- Muốn thấy khác biệt phải đo throughput ở tải vượt xa pool cũ, không phải latency một request.
Tự kiểm tra
Q1Vì sao đặt lại server.tomcat.threads.max sau khi đã bật spring.threads.virtual.enabled=true không có tác dụng gì?▸
server.tomcat.threads.max chỉ có nghĩa khi còn pool để giới hạn kích thước; không còn pool thì property đó bị bỏ qua, không phải bug mà là hệ quả trực tiếp của việc đổi executor.Q2Một service chủ yếu làm việc mã hoá ảnh (CPU nặng, gần như không gọi mạng) có nên bật virtual threads không? Giải thích bằng đúng cơ chế mount/unmount.▸
Q3Bạn benchmark bằng cách gửi một request duy nhất, đo response time trước và sau khi bật virtual threads, thấy hai con số gần như bằng nhau. Kết luận 'virtual threads không có tác dụng' có đúng không?▸
Q4Service của bạn đã chạy WebFlux với R2DBC và WebClient non-blocking hoàn toàn. Bật spring.threads.virtual.enabled=true có giúp gì thêm không? Vì sao?▸
Q5Vì sao pool 200 platform thread mặc định của Tomcat lại trở thành nút thắt đúng ở vùng tải mà CPU vẫn còn rảnh, thay vì lúc CPU đã bận?▸
Bài tiếp theo: Virtual threads: ba cạm bẫy khi bật
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
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