ScopedValue trên virtual thread: truyền context không leak
Vì sao ThreadLocal vỡ ở quy mô triệu virtual thread, và ScopedValue (final Java 25, JEP 506) truyền context bất biến xuống nhánh fork của StructuredTaskScope.
TL;DR: Khi một request fan-out thành hàng nghìn subtask, dữ liệu ngữ cảnh — user đăng nhập, traceId, locale — cần tới tay mọi nhánh. ThreadLocal từng làm việc này trên thread pool, nhưng ở quy mô triệu virtual thread nó vỡ về chi phí: bộ nhớ nhân theo số thread, và mẹo "cache object theo thread" mất nghĩa vì mỗi virtual thread sống đúng một task. ScopedValue (final Java 25, JEP 506) truyền context bằng cách khác: ràng một giá trị bất biến vào phạm vi where(...).run(...), và khi fork một subtask bên trong phạm vi đó, subtask — chạy trên virtual thread riêng — vẫn kế thừa giá trị gần như miễn phí. Không sao chép, không API để quên dọn.
1. Vì sao ThreadLocal vỡ ở quy mô triệu virtual thread?
Bài 06b đã dựng nền: ThreadLocal cho mỗi thread một bản riêng trong ThreadLocalMap của chính Thread; trên thread pool, quên remove() là rò cả dữ liệu lẫn bộ nhớ; và ScopedValue như một công cụ confinement đóng cái bẫy đó bằng phạm vi tự đóng. Bài này đi tiếp một trục khác: khi request fan-out xuống hàng nghìn nhánh, context phải truyền tới mọi nhánh — và ở quy mô virtual thread, đây đúng là chỗ ThreadLocal gãy.
Virtual thread vẫn là Thread, nên ThreadLocal — công cụ confinement theo thread ở bài Confinement — giữ đúng ngữ nghĩa: mỗi thread một bản sao giá trị. Cái vỡ là chi phí và ý nghĩa ở quy mô mới.
Thứ nhất là phép nhân theo số thread. ThreadLocal tốn bộ nhớ tỉ lệ số thread đang giữ giá trị. Một buffer 4KB nhân vài trăm platform thread là một megabyte không ai để ý; cũng buffer đó nhân một triệu virtual thread là vài gigabyte heap — đủ biến một pattern vô hại thành nguồn áp lực GC nghiêm trọng. Mô hình vài trăm platform thread không phải lo con số này; mô hình triệu virtual thread thì có.
Thứ hai, pattern "pool một object đắt qua ThreadLocal" mất nghĩa:
// Classic trick on platform thread pools: one formatter per long-lived thread,
// reused across the thousands of tasks that thread serves. On virtual threads it
// caches nothing: each task gets a brand-new thread, so a brand-new formatter.
static final ThreadLocal<SimpleDateFormat> FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
Trên platform thread pool, mỗi worker sống rất lâu và phục vụ hàng nghìn task, nên một SimpleDateFormat khởi tạo một lần rồi tái dùng mãi. Virtual thread sống đúng một task rồi chết, không có "task sau" nào tái dùng cache, nên object đắt bị khởi tạo lại mỗi task — cache biến thành phí thuần.
Cộng hai vấn đề cố hữu mà bài 06b đã mổ — giá trị mutable không kiểm soát, và vòng đời không biên rõ dễ rò giữa các request nếu quên remove() — ThreadLocal trở thành công cụ sai hình dạng để mang context xuyên hàng triệu virtual thread. Java trả lời bằng ScopedValue.
2. ScopedValue: kế thừa context xuống nhánh fork
Hình dung một bảng thông báo dán ở sảnh chung: quản lý dán một tờ (không ai được sửa), mọi nhân viên đi qua đều đọc cùng một tờ đó chứ không chép riêng bản nào; hết giờ, tờ được gỡ và không ai đọc được nữa. ScopedValue hoạt động đúng vậy:
| Bảng thông báo | ScopedValue |
|---|---|
| Dán một tờ trong giờ làm | where(CONTEXT, ctx).run(...) — bind giá trị trong phạm vi |
| Mọi người đọc cùng một tờ, không chép | subtask fork đọc cùng một giá trị bất biến, không sao chép |
| Gỡ tờ khi hết giờ | hết khối run → giá trị tự unbound |
Bài 06b đã dùng ScopedValue như công cụ confinement: ràng một giá trị bất biến vào phạm vi where(...).run(...), tự unbound khi khối kết thúc, không có set để đè. Ở đây ta khai thác đúng tính chất khiến nó hợp quy mô virtual thread: kế thừa rẻ xuống subtask.
Khi fork một subtask bên trong một phạm vi ScopedValue, subtask đó — chạy trên virtual thread riêng — vẫn đọc được giá trị đang bound. Vì giá trị bất biến, việc "kế thừa" không sao chép gì cả: subtask chỉ tham chiếu tới cùng một giá trị chỉ-đọc. Đó là lý do hai cơ chế ra đời cùng thế hệ và thiết kế ăn khớp — vòng đời của ràng buộc (run) lồng khít vòng đời của nhóm task (try của scope).
flowchart TD
W["where(CONTEXT, ctx).run(...)<br/>ctx bat bien, bound trong pham vi"] --> S["StructuredTaskScope.open()"]
S -->|fork| A["Subtask A (virtual thread)<br/>CONTEXT.get() = ctx"]
S -->|fork| B["Subtask B (virtual thread)<br/>CONTEXT.get() = ctx"]
S -->|fork| C["Subtask C (virtual thread)<br/>CONTEXT.get() = ctx"]
A -. "khong sao chep, chi tham chieu" .-> V["Mot gia tri chi-doc"]
B -.-> V
C -.-> VĐây là điểm khác InheritableThreadLocal (bài 06b): InheritableThreadLocal sao chép bản đồ ngữ cảnh của cha sang con lúc tạo thread — tốn kém khi spawn hàng loạt. ScopedValue không sao chép: nó chia sẻ một giá trị bất biến, nên fan-out triệu nhánh vẫn gần như miễn phí.
Giờ đến lượt bạn ráp hai cơ chế. Đoạn dưới fork hai nhánh cần đọc CONTEXT, nhưng phần bind còn trống:
Điền vào chỗ /* ? */ sao cho cả hai subtask fork bên trong đều đọc được ctx qua CONTEXT.get(). Gợi ý: khối try của scope phải nằm bên trong phạm vi nào?
static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
void handleRequest(RequestContext ctx) throws InterruptedException {
/* ? -- dien phan bind CONTEXT o day */ {
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> checkInventory()); // can CONTEXT.get() == ctx
scope.fork(() -> chargePayment()); // va o day cung vay
scope.join();
}
}
}
Chỗ trống là ScopedValue.where(CONTEXT, ctx).run(() -> (đóng lại bằng ) sau khối). Khối try của scope phải nằm bên trong lambda của run, để lúc fork, ràng buộc CONTEXT còn hiệu lực và subtask kế thừa được:
void handleRequest(RequestContext ctx) {
ScopedValue.where(CONTEXT, ctx).run(() -> {
try (var scope = StructuredTaskScope.open()) { // default: fail-fast
scope.fork(() -> { /* CONTEXT.get() o day la ctx */ return checkInventory(); });
scope.fork(() -> { /* va o day cung vay */ return chargePayment(); });
scope.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
Nếu đặt ngược lại — mở scope trước rồi mới where(...).run(...) bên trong từng nhánh — mỗi nhánh phải tự bind lại, đúng cái phiền toái mà ScopedValue sinh ra để xóa.
3. Capstone: TicketFlow đặt vé có cấu trúc
Ráp tất cả vào TicketFlow. Ở các phiên bản trước, BookingService.book đã thread-safe nhờ ConcurrentHashMap.compute và tầng request đã chuyển sang virtual thread (bài 21). Giờ dựng phần còn thiếu: một request đặt vé cần song song hai việc độc lập — kiểm tồn kho và xác thực thanh toán — với fail-fast, một deadline chung, và mang context request qua cả hai nhánh.
record RequestContext(String userId, String traceId) {}
static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
BookingResult handleBooking(BookingRequest req) {
var ctx = new RequestContext(req.userId(), newTraceId());
return ScopedValue.where(CONTEXT, ctx).call(() -> doBooking(req));
}
BookingResult doBooking(BookingRequest req) {
try (var scope = StructuredTaskScope.open(
Joiner.<Object>awaitAllSuccessfulOrThrow(), // = default, viet ro vi can config
cf -> cf.withTimeout(Duration.ofSeconds(2)))) {
Subtask<Inventory> inv = scope.fork(() -> checkInventory(req.eventId()));
Subtask<PaymentAuth> pay = scope.fork(() -> authorizePayment(req.userId()));
scope.join(); // fail-fast: het ton kho HOAC the tu choi -> huy nhanh kia ngay
// Ca hai da thanh cong; giu cho la buoc cuoi, nhanh va nguyen tu.
Booking booking = service.book(req.eventId(), req.userId());
return BookingResult.ok(booking, inv.get(), pay.get());
} catch (Exception e) {
return BookingResult.fail(rootCause(e).getMessage());
}
}
Hai chi tiết đáng dừng. Một, doBooking không khai throws: join() có thể ném InterruptedException, FailedException hay TimeoutException, nhưng catch (Exception e) đón hết và đổi thành BookingResult.fail, nên handleBooking gọi qua ScopedValue.where(...).call(...) mà không phải khai checked exception. Joiner default ở đây trả null nên kết quả đọc qua hai ref inv và pay.
So với Future rời rạc ở bài 22, đoạn này đóng kín mọi lỗ rò: authorizePayment ném CardDeclinedException thì checkInventory bị interrupt ngay thay vì chạy phí; một service treo thì deadline 2 giây cắt cả nhóm; dù ra bằng đường nào, close() cũng đảm bảo không subtask nào sống quá khối try; và context userId/traceId chảy xuống cả hai nhánh không cần nhét vào chữ ký từng hàm. Việc giữ chỗ thực (service.book) chỉ chạy sau khi cả hai điều kiện đã thỏa, nên không bao giờ trừ tồn kho cho một thanh toán hỏng. Đây là code minh họa, chưa có module riêng; lõi đặt vé thread-safe vẫn là service.book từ v3.
4. Liên hệ các bài khác
- ThreadLocal và ScopedValue: per-thread state không leak — nền của bài này: cơ chế
ThreadLocalMap, bẫyremove(), vàScopedValuenhư công cụ confinement. Bài này thêm trục truyền context ở quy mô. - Virtual Threads: pinning và di cư — nơi nêu triệu chứng
ThreadLocalphình bộ nhớ ở quy mô triệu thread; bài này là lời giải đầy đủ. - Structured Concurrency —
fork/join/Joinermà capstone ở đây dựa vào;ScopedValuelà mảnh ghép context còn thiếu của nó. - Confinement — trục confinement gốc mà cả
ThreadLocallẫnScopedValueđều là các nấc trên đó. - Immutability — vì sao "bất biến" là chìa khóa khiến
ScopedValuekế thừa xuống triệu nhánh mà không cần sao chép.
5. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JEP 506: Scoped Values — final ở Java 25; phần "Motivation" so sánh
ScopedValuevớiThreadLocalchi tiết hơn nhiều so với mọi bài blog, và nêu thẳng vì saoThreadLocalkhông hợp với hàng triệu virtual thread. - JEP 505: Structured Concurrency (Fifth Preview) — preview ở JDK 25; đọc để thấy cách
forkkế thừaScopedValueđang bound.
Ghi chú: ScopedValue đi qua bốn vòng preview (JEP 446 ở Java 21, rồi 464/481/487) trước khi final ở Java 25 (JEP 506); cú pháp where(...).run(...) ổn định xuyên các vòng. Lưu ý biên dịch: capstone §3 vẫn cần --enable-preview kể cả trên Java 25, vì StructuredTaskScope còn ở preview (JEP 505, 5th preview) — chỉ riêng ScopedValue là đã final.
6. Tóm tắt — khép lại module
ThreadLocalgiữ đúng ngữ nghĩa trên virtual thread, nhưng chi phí nhân theo số thread (triệu bản sao) và mẹo cache-theo-thread mất nghĩa (mỗi virtual thread sống một task) — sai hình dạng để mang context ở quy mô này.ScopedValueràng giá trị bất biến vào phạm viwhere(...).run(...); subtaskforkbên trong kế thừa giá trị mà không sao chép — khác hẳnInheritableThreadLocalphải chụp bản đồ ngữ cảnh mỗi lần spawn.ScopedValuevàStructuredTaskScopekhớp nhau vì hai vòng đời lồng khít: context sống đúng bằng đời của nhóm task, không hơn không kém.- Capstone TicketFlow ráp cả ba — virtual thread, structured concurrency, scoped context — thành một request đặt vé fail-fast, có deadline chung, context chảy xuống mọi nhánh mà không nhét vào chữ ký hàm.
Chặng đường concurrency đã đi một vòng dài: từ Process và Thread — nơi một OS thread là tài nguyên đắt, ánh xạ one-to-one xuống kernel — tới thế giới virtual thread rẻ đến mức tạo cả triệu, structured concurrency cho bộ khung kỷ luật, và ScopedValue cho context chảy an toàn qua đó. Java đã đi từ mô phỏng thread của hệ điều hành đến tự dựng mô hình thực thi nhẹ của riêng JVM.
Nhưng một bài học không đổi suốt chặng: dù thread là platform hay virtual, dù gom bằng pool hay scope, công thức gây bug vẫn nguyên — shared memory cộng mutable data cộng unsynchronized access. Mọi công cụ trong module — confinement, immutability, lock, atomic, concurrent collection, executor, future, virtual thread, structured concurrency — đều chỉ là cách triệt tiêu một trong ba yếu tố đó, hoặc canh gác khi buộc phải giữ cả ba. Bài kế tiếp gom cả chặng vào một cheat sheet để bookmark và tự kiểm.
7. Tự kiểm tra
Q1ThreadLocal vẫn chạy đúng trên virtual thread. Vậy vấn đề thật sự nằm ở đâu khi số thread lên tới hàng triệu?▸
Ngữ nghĩa đúng — virtual thread vẫn là Thread, mỗi thread một bản sao giá trị. Vấn đề là chi phí và ý nghĩa ở quy mô mới. Chi phí: ThreadLocal tốn bộ nhớ tỉ lệ số thread, và số thread giờ là hàng triệu — một giá trị vài KB nhân một triệu là vài GB heap, biến pattern vô hại thành áp lực GC nghiêm trọng. Ý nghĩa: mẹo "cache một object đắt theo thread" mất tác dụng, vì virtual thread sống đúng một task rồi chết — không có task sau để tái dùng cache. Đây là quy mô mà mô hình vài trăm platform thread không phải lo tới.
Q2Mẹo static ThreadLocal<SimpleDateFormat> cache formatter theo thread hoạt động tốt trên platform thread pool. Vì sao nó thành phí thuần trên virtual thread?▸
Vì mẹo đó dựa trên giả định "thread sống lâu, phục vụ nhiều task". Trên platform thread pool, mỗi worker tồn tại qua hàng nghìn task, nên một SimpleDateFormat khởi tạo một lần rồi tái dùng mãi — khấu hao chi phí khởi tạo trên nhiều task. Virtual thread thì sống đúng một task rồi chết: mỗi task là một thread mới, nên withInitial chạy lại từ đầu mỗi task, tạo một formatter mới dùng đúng một lần. Cache không bao giờ được "hit" lần thứ hai, nên nó chỉ tốn thêm khởi tạo chứ không tiết kiệm gì.
Q3Khi fork một subtask bên trong phạm vi ScopedValue, subtask đọc được context. Cơ chế kế thừa này khác InheritableThreadLocal ở chỗ nào, và vì sao khác biệt đó quan trọng ở quy mô virtual thread?▸
InheritableThreadLocal sao chép giá trị (hoặc cả bản đồ ngữ cảnh) của cha sang con tại lúc tạo thread con — một lần chụp ảnh cho mỗi lần spawn. ScopedValue bất biến nên không sao chép gì: subtask chỉ tham chiếu tới cùng một giá trị chỉ-đọc đang bound trong phạm vi cha. Ở quy mô virtual thread — nơi một request có thể fork hàng nghìn nhánh — việc sao chép bản đồ ngữ cảnh mỗi lần spawn vừa tốn bộ nhớ vừa tốn thời gian, còn chia sẻ một tham chiếu bất biến thì gần như miễn phí. Đó là lý do ScopedValue scale còn InheritableThreadLocal thì không.
Q4Trong đoạn ráp ScopedValue với StructuredTaskScope, vì sao khối try của scope phải nằm BÊN TRONG lambda của where(...).run(...), không phải ngược lại?▸
Vì subtask chỉ kế thừa được ràng buộc ScopedValue đang hiệu lực tại thời điểm fork. Nếu khối try (nơi gọi fork) nằm trong lambda của run, thì lúc fork chạy, CONTEXT còn bound, nên mọi nhánh đọc được ctx. Đặt ngược lại — mở scope trước, rồi mới where(...).run(...) bên trong từng nhánh — thì tại điểm fork chưa có ràng buộc nào, và mỗi nhánh phải tự bind lại từng cái, đúng cái phiền toái mà ScopedValue sinh ra để xóa. Vòng đời context phải bao ngoài vòng đời nhóm task.
Q5Trong capstone, service.book chỉ chạy sau scope.join(). Nếu authorizePayment ném CardDeclinedException, điều gì xảy ra với checkInventory và với service.book?▸
Joiner default là fail-fast (awaitAllSuccessfulOrThrow), nên khi authorizePayment ném exception, scope bị cancel ngay: checkInventory (nếu còn chạy) bị interrupt thay vì chạy phí tới cùng, và join() ném FailedException. Luồng điều khiển nhảy tới catch, trả BookingResult.fail — service.book không bao giờ được gọi. Đó chính là điều ta muốn: chỉ trừ tồn kho khi thanh toán đã chắc chắn thành công. Nếu dùng Future rời rạc, checkInventory có thể đã chạy xong và ai đó vô ý gọi book cho một thanh toán hỏng.
Q6Vì sao ScopedValue và StructuredTaskScope được nói là 'khớp nhau', ra đời cùng thế hệ?▸
Vì hai vòng đời lồng khít nhau theo cùng triết lý "ràng vào phạm vi". StructuredTaskScope ràng vòng đời nhóm subtask vào một khối try; ScopedValue ràng vòng đời một giá trị vào một khối run. Khi lồng khối try bên trong khối run, context sống đúng bằng đời của nhóm task — không rò ra ngoài, không cần dọn thủ công. Thêm nữa, virtual thread khiến fan-out hàng nghìn nhánh thành chuyện thường, và đó vừa là kịch bản ThreadLocal đắt nhất (bản sao theo thread) vừa là chỗ ScopedValue tỏa sáng (kế thừa bất biến không sao chép). Ba cơ chế giải cùng một bài toán từ ba góc, nên thiết kế ăn khớp.
Bài tiếp theo: Tổng kết module — cheat sheet & self-assessment
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