Structured Concurrency & ScopedValue: Lifecycle có kỷ luật cho nhóm task
Lifecycle có kỷ luật cho nhóm task: StructuredTaskScope (JEP 505, preview thứ năm ở Java 25), các Joiner fail-fast, và ScopedValue (final Java 25) thay ThreadLocal trong thế giới Loom.
TL;DR: ExecutorService + Future để hở hai lỗ: subtask có thể sống lâu hơn method đã sinh ra nó (task leak), và lỗi của một nhánh không tự lan sang các nhánh anh em. StructuredTaskScope (JEP 505, preview thứ năm ở Java 25) ràng vòng đời cả nhóm subtask vào một khối try: fork tỏa nhánh, join hợp lưu, close bảo đảm không nhánh nào sống sót ra ngoài block. Mặc định open() dùng Joiner.awaitAllSuccessfulOrThrow() - chính sách fail-fast: một nhánh ném exception là scope cancel ngay, các nhánh còn lại bị interrupt, join() ném FailedException. Đi cùng là ScopedValue (final Java 25, JEP 506) - context bất biến, tự thu hồi theo phạm vi, kế thừa gần như miễn phí xuống subtask - thay ThreadLocal trong thế giới virtual thread.
1. Giới thiệu: cái giá của concurrency không cấu trúc
Hãy nhìn một đoạn code mà mọi senior Java đều từng viết. Một handler cần dữ liệu user và tồn kho để dựng một response, hai nguồn độc lập nên ta chạy song song bằng ExecutorService:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<User> userF = executor.submit(() -> findUser(userId));
Future<Inventory> invF = executor.submit(() -> checkInventory(eventId));
User user = userF.get(); // (1)
Inventory inv = invF.get(); // (2)
return new Response(user, inv);
Đoạn này trông gọn, nhưng nó rò rỉ ở những chỗ không nhìn thấy ngay. Giả sử findUser ném exception ở dòng (1). Ta nhận lỗi và thoát method. Nhưng checkInventory thì sao? Nó vẫn đang chạy. Không ai gọi invF.cancel(...), nên nó tiếp tục chiếm một virtual thread, có thể giữ một database connection, cho tới khi tự kết thúc — rồi kết quả của nó bị vứt vào hư không. Ta vừa tạo một task leak.
Đảo ngược lại cũng tệ. Giả sử findUser xong rất nhanh nhưng checkInventory treo vĩnh viễn vì service phía dưới chết. Dòng (2) sẽ block mãi mãi, và cùng với nó là cả thread đang xử lý request. Caller không có cách nào áp một deadline lên cả nhóm hai task; nó chỉ thấy hai Future rời rạc, không có khái niệm "hai cái này thuộc về cùng một công việc".
Gốc rễ nằm ở chữ "rời rạc". ExecutorService cho ta một cái pool, và mỗi submit ném một task vào đó như thả một lá thư vào hòm thư công cộng. Mối quan hệ cha–con giữa "code đang gọi" và "task được sinh ra" hoàn toàn không được mã hóa ở đâu cả. Task con có thể sống lâu hơn method đã sinh ra nó, lỗi của nó không tự động lan về cha, và việc hủy phải làm thủ công, từng cái một, đúng và đủ — một kỷ luật mà con người làm sai thường xuyên.
Hãy so với cách ta viết code tuần tự. Một lời gọi hàm có cấu trúc cây rõ ràng: hàm con luôn trả về trước hàm cha, exception lan lên theo call stack, và khi một block kết thúc thì mọi thứ nó mở ra đã đóng lại. Cấu trúc khối lồng nhau ấy là thứ làm code tuần tự dễ suy luận. Concurrency cổ điển đánh mất đúng cấu trúc đó. Structured concurrency là nỗ lực mang nó trở lại: ràng buộc vòng đời của subtask vào một block cú pháp, sao cho mở scope ở đầu block thì chắc chắn mọi subtask đã kết thúc trước khi ra khỏi block.
2. StructuredTaskScope: fan-out, rồi fan-in
Đến Java 25, StructuredTaskScope (trong java.util.concurrent) đã đi qua nhiều vòng preview và ở bản này là preview lần thứ năm (JEP 505) — API đã ổn định về hình dạng nhưng vẫn cần bật --enable-preview để biên dịch. Hình dạng dưới đây bám đúng JDK 25; nếu bạn từng đọc các bản preview trước, lưu ý API đã được làm lại đáng kể.
Ý tưởng cốt lõi gói trong ba động tác: mở một scope, fork các subtask vào nó, rồi join để chờ cả nhóm. Viết lại ví dụ ở phần 1:
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
Response handle(String userId, String eventId) throws InterruptedException {
try (var scope = StructuredTaskScope.open()) { // mo pham vi
Subtask<User> user = scope.fork(() -> findUser(userId));
Subtask<Inventory> inv = scope.fork(() -> checkInventory(eventId));
scope.join(); // diem hop luu duy nhat
return new Response(user.get(), inv.get());
}
}
Khác biệt so với ExecutorService không nằm ở số dòng code — gần như bằng nhau — mà nằm ở những đảm bảo mà cấu trúc khối áp đặt. scope.fork(...) khởi chạy mỗi subtask trên một virtual thread riêng và trả về một Subtask, một handle nhẹ mô tả kết quả tương lai của nó. scope.join() chặn cho tới khi mọi subtask đã fork kết thúc — hoặc, tùy chính sách, cho tới khi đã đủ điều kiện dừng sớm. Và mấu chốt nằm ở chữ try-with-resources: khi luồng điều khiển rời khối try, dù bằng return bình thường hay vì một exception bắn ra, scope.close() được gọi tự động, và nó bảo đảm không còn subtask nào của scope này còn sống. Không có đường nào để một subtask vượt ra ngoài cái block đã sinh ra nó.
Subtask.get() chỉ được phép gọi sau join(). Đây là điểm khác biệt cố ý so với Future.get(): Future.get() tự nó là một lệnh block, mời gọi ta chờ từng task một và sinh ra đủ loại thứ tự xen kẽ khó lường. Subtask.get() thì không block — tại thời điểm bạn gọi nó, join() đã đảm bảo subtask kết thúc rồi, nên nó chỉ đơn thuần đọc ra kết quả đã có. Việc chờ được dồn về đúng một chỗ là join(), đúng tinh thần "fan-out rồi fan-in": tỏa ra nhiều nhánh, rồi gom lại tại một điểm hợp lưu duy nhất.
Hãy hình dung scope như một người quản đốc giao việc cho một tổ thợ. Quản đốc phát phiếu việc (fork), rồi đứng ở cửa xưởng đợi (join). Quy tắc bất di bất dịch của xưởng này: quản đốc không được rời xưởng chừng nào còn một người thợ chưa về. Nếu quản đốc buộc phải đi — vì hết giờ, vì cháy xưởng — ông ta phải gọi tất cả thợ về trước đã. Cái xưởng có tường có cửa ấy chính là khối try; không ai trèo tường ra ngoài được.
3. Chính sách kết thúc: khi nào thì dừng cả nhóm
Vậy scope.open() không tham số áp dụng chính sách gì? Đây là chỗ rất dễ đoán nhầm, nên nói thẳng theo JEP 505: mặc định là Joiner.awaitAllSuccessfulOrThrow() — một chính sách fail-fast, không phải "chờ tất cả rồi tính sau". Chừng nào mọi nhánh đều thành công, nó chờ đủ cả nhóm rồi cho join() trả về (kiểu trả về là Void, tức join() trả null — kết quả đọc qua từng Subtask.get()). Nhưng chỉ cần một subtask ném exception, scope bị cancel ngay lập tức: mọi nhánh chưa xong bị interrupt, join() ném StructuredTaskScope.FailedException bọc nguyên nhân gốc, và luồng điều khiển bay ra khỏi khối try, kích hoạt close() dọn sạch. Đoạn code ở phần 2, không truyền tham số gì, đã mang sẵn hành vi fail-fast này.
Vẽ chuỗi sự kiện đó thành sơ đồ — một subtask fail kéo cả cây xuống có trật tự:
flowchart TD
P["Scope cha: open() roi join()"] --> A["Subtask A: findUser"]
P --> B["Subtask B: checkInventory"]
P --> C["Subtask C: fetchPromo"]
A -- "nem exception" --> X["Scope bi cancel"]
X -- "interrupt" --> B
X -- "interrupt" --> C
X --> J["join() nem FailedException<br/>close() bao dam moi nhanh da ket thuc"]Chính sách kết thúc được mô hình hóa bằng một đối tượng Joiner truyền vào StructuredTaskScope.open(...); không truyền thì nhận default ở trên. (Trong các bản preview cũ, vai trò này do các lớp ShutdownOnFailure/ShutdownOnSuccess đảm nhiệm; chúng đã được thay bằng các Joiner tương ứng.)
3.1 Ba Joiner cho kết quả "tất cả phải thành công"
Họ Joiner có ba thành viên hay gặp nhất cho bài toán chờ nhiều nhánh, khác nhau ở hai câu hỏi: có fail-fast không và join() trả về gì.
| Joiner | Khi một subtask fail | join() trả về | Hợp với |
|---|---|---|---|
awaitAllSuccessfulOrThrow() (default của open()) | Cancel cả scope ngay, join() ném FailedException | null (kiểu Void) — đọc kết quả qua từng Subtask.get() | Ít nhánh, mỗi nhánh một kiểu khác nhau, đã giữ sẵn Subtask ref |
allSuccessfulOrThrow() | Cancel cả scope ngay, join() ném FailedException | Một Stream các Subtask — duyệt kết quả như stream | Nhiều nhánh đồng kiểu, không muốn giữ từng ref |
awaitAll() | Không cancel — chờ mọi nhánh kết thúc bất kể thành bại | null — tự kiểm tra Subtask.state() từng cái | Cần đủ kết quả lẫn lỗi của tất cả các nhánh (batch, báo cáo) |
Ví dụ user + inventory ở phần 2 rơi đúng ô thứ nhất: hai nhánh hai kiểu (User, Inventory), ta giữ sẵn hai Subtask ref và đọc qua get() sau join() — nên cứ dùng open() mặc định, không cần truyền joiner. Còn khi các nhánh đồng kiểu và ta muốn gom kết quả như một dòng chảy, allSuccessfulOrThrow() gọn hơn vì join() trả thẳng stream:
import static java.util.concurrent.StructuredTaskScope.Joiner;
List<Quote> fetchQuotes(List<String> symbols) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<Quote>allSuccessfulOrThrow())) {
symbols.forEach(s -> scope.fork(() -> fetchQuote(s)));
return scope.join() // Stream cac Subtask da thanh cong
.map(Subtask::get)
.toList();
}
}
Còn awaitAll() là lựa chọn khi fail-fast không phải điều ta muốn — chẳng hạn chạy mười phép kiểm tra cấu hình và cần báo cáo đầy đủ cái nào đậu cái nào rớt: scope chờ hết mọi nhánh, rồi ta tự duyệt từng Subtask, hỏi state() xem nó SUCCESS hay FAILED mà xử lý.
Quay lại fail-fast — cơ chế hủy đáng được nói rõ. Khi findUser ném exception, joiner lập tức cancel scope: nó interrupt virtual thread đang chạy checkInventory, không chờ nó nữa. Việc hủy ở đây dựa trên cơ chế interrupt của Java, nên nó chỉ thực sự cắt được những subtask biết tôn trọng interrupt — phần lớn blocking I/O trên virtual thread đều tôn trọng. Một vòng lặp tính toán thuần CPU không kiểm tra Thread.interrupted() sẽ không bị cắt giữa chừng; điều này không phải hạn chế riêng của structured concurrency mà là bản chất của cancellation hợp tác trong Java — ta đã học cơ chế này ở bài Thread API và vòng đời và thấy lại ở Executor (mục cancellation qua Future.cancel).
3.2 Lấy kết quả thành công đầu tiên
Mặt đối xứng của fail-fast là success-fast. Hãy tưởng tượng ta query cùng một thông tin từ ba replica, và chỉ cần một câu trả lời nhanh nhất; hai cái còn lại, dù đúng, đều dư thừa. Joiner.anySuccessfulResultOrThrow() phục vụ đúng kiểu này:
import static java.util.concurrent.StructuredTaskScope.Joiner;
String fetchFastest(List<String> replicas) throws InterruptedException {
try (var scope = StructuredTaskScope.open(
Joiner.<String>anySuccessfulResultOrThrow())) {
for (String r : replicas) {
scope.fork(() -> queryReplica(r));
}
return scope.join(); // tra ket qua thanh cong DAU TIEN
}
}
Subtask nào trả kết quả thành công đầu tiên sẽ kích hoạt việc cancel scope; mọi subtask còn lại bị interrupt và hủy. Ở đây scope.join() trả thẳng về giá trị thành công đó, nên ta không cần giữ tham chiếu Subtask nào. Nếu mọi nhánh đều thất bại, join() ném exception gom các nguyên nhân lại. Đây là pattern hedged request kinh điển — đánh đổi một ít tài nguyên dư để cắt đuôi độ trễ — và với structured concurrency nó gói gọn trong vài dòng có lifecycle đảm bảo, thay vì một mớ CompletableFuture.anyOf (bài Future & CompletableFuture) cộng logic hủy thủ công.
3.3 Deadline cho cả nhóm
Vì cả nhóm subtask sống trong một scope, ta có thể áp một deadline lên toàn bộ nhóm tại điểm join, thứ mà ba Future rời rạc không cho làm gọn. Timeout là cấu hình của scope, truyền qua tham số thứ hai của open(...):
import java.time.Duration;
try (var scope = StructuredTaskScope.open(
Joiner.<Object>awaitAllSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofMillis(500)))) {
Subtask<User> user = scope.fork(() -> findUser(userId));
Subtask<Inventory> inv = scope.fork(() -> checkInventory(eventId));
scope.join(); // qua 500ms: nem TimeoutException
return new Response(user.get(), inv.get());
}
(Ở đây phải truyền joiner tường minh vì cần tham số config phía sau; Joiner.<Object>awaitAllSuccessfulOrThrow() chính là default của open() viết rõ ra, với chỉ dấu kiểu Object vì hai nhánh khác kiểu nhau.)
Quá hạn 500ms, scope bị cancel đúng như khi một subtask fail: mọi nhánh chưa xong bị interrupt, join() ném StructuredTaskScope.TimeoutException (một unchecked exception lồng trong API mới, không phải java.util.concurrent.TimeoutException cũ), khối đóng sạch. Deadline trở thành thuộc tính của cả phép tính concurrent, không phải gánh nặng dán thủ công lên từng nhánh.
4. So với ExecutorService: vì sao có kỷ luật hơn
Đặt hai mô hình cạnh nhau, khác biệt không phải là tính năng mà là hình dạng vòng đời. ExecutorService tách rời ba việc vốn nên đi cùng nhau: lúc submit task, lúc chờ kết quả, và lúc dọn dẹp. Một pool có thể sống suốt đời ứng dụng, nhận task từ bất kỳ đâu, và chẳng có gì trong kiểu dữ liệu nói cho ta biết task nào thuộc về công việc nào. Quan hệ cha–con tồn tại trong đầu lập trình viên chứ không trong code, nên trình biên dịch không giúp được gì khi ta quên hủy hay quên chờ.
StructuredTaskScope buộc ba việc đó về cùng một khối cú pháp. Phạm vi của scope trùng khít với một block try; mọi subtask fork trong block phải kết thúc trước khi ra khỏi block; lỗi lan từ con lên cha theo đúng đường mà luồng điều khiển sẽ đi. Cây gọi concurrent giờ phản chiếu cây gọi tuần tự: con luôn kết thúc trước cha, không có nhánh nào mồ côi. Chính vì cấu trúc này mà ba thuộc tính khó-đảm-bảo trở thành mặc định miễn phí — không task leak vì close() luôn chờ hết; lỗi không bị nuốt vì nó lan lên cha; cancellation không bị quên vì việc cancel là tự động theo chính sách.
Điều này không có nghĩa ExecutorService bị khai tử. Một pool dài hạn nhận việc từ nhiều nguồn không liên quan — ví dụ một background worker xử lý hàng đợi sự kiện — vẫn là việc của ExecutorService, vì ở đó vốn dĩ không có quan hệ cha–con để mà cấu trúc hóa. StructuredTaskScope dành cho đúng tình huống ngược lại: một đơn vị công việc tỏa ra nhiều nhánh phụ rồi gom lại, sống và chết trong phạm vi một lời gọi. Phần lớn fan-out trong xử lý một request thuộc loại sau, và đó là nơi structured concurrency tỏa sáng.
5. ScopedValue: mang context bất biến qua phạm vi
Còn một mảnh ghép. Khi một request fan-out thành nhiều subtask, thường có những dữ liệu ngữ cảnh — user đang đăng nhập, một traceId để gắn log, locale — cần đến tay mọi nhánh. Cách cũ là ThreadLocal (cơ chế confinement theo thread từ bài Confinement). Cách cũ ấy, dưới thời virtual thread, lộ rõ những vết nứt của nó.
ThreadLocal có ba vấn đề cố hữu. Thứ nhất, nó mutable không giới hạn: bất kỳ code nào cầm được ThreadLocal đều có thể gọi set đè giá trị, nên rất khó suy luận giá trị thực sự là gì tại một điểm. Thứ hai, vòng đời của nó không có biên rõ ràng: nếu quên gọi remove(), giá trị bám lại trên thread và rò sang request sau khi thread được tái dùng từ pool — một lớp bug khó chịu kinh điển. Thứ ba, và đây là chỗ virtual thread làm vấn đề nặng thêm như bài trước đã phân tích: mỗi ThreadLocal có giá trị bị giam riêng theo từng thread, và khi ta có cả triệu virtual thread, mỗi cái mang một bản sao, chi phí bộ nhớ phình lên theo cách mà mô hình vài trăm platform thread chưa từng phải lo.
ScopedValue (final ở Java 25, JEP 506) là câu trả lời. Thay vì "gắn một giá trị lên thread rồi nhớ gỡ ra", nó "ràng một giá trị vào một phạm vi động và giá trị tự biến mất khi ra khỏi phạm vi". ScopedValue nằm ngay trong package java.lang nên không cần import:
static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
void handleRequest(RequestContext ctx) {
ScopedValue.where(CONTEXT, ctx).run(() -> {
// Trong pham vi nay - va moi loi goi no dan toi - CONTEXT.get() tra ve ctx
processOrder();
});
// Ra khoi run(): CONTEXT khong con bound nua
}
void processOrder() {
RequestContext ctx = CONTEXT.get(); // doc o sau trong call tree, khong can truyen tay
log.info("processing user={}, trace={}", ctx.userId(), ctx.traceId());
}
Khác biệt nằm ở ba điểm đối ngược với ThreadLocal. Giá trị là bất biến trong suốt phạm vi: không có set, chỉ có where(...).run(...) thiết lập một ràng buộc cho đúng khối lệnh con. Vòng đời tự đóng: hết khối run, ràng buộc tan biến, không có gì để quên gỡ, nên không có chuyện rò sang request sau. Và vì giá trị bất biến, chia sẻ nó cho hàng triệu virtual thread không tốn một bản sao mỗi thread; cấu trúc dữ liệu bên dưới được thiết kế để các thread con kế thừa ràng buộc một cách rẻ tiền.
Mảnh ghép cuối cùng là chỗ ScopedValue bắt tay StructuredTaskScope. Khi bạn fork một subtask bên trong một phạm vi ScopedValue, subtask đó — chạy trên virtual thread riêng — vẫn kế thừa các ràng buộc đang có hiệu lực. Đó là lý do hai cơ chế này ra đời cùng một thế hệ và được thiết kế ăn khớp:
ScopedValue.where(CONTEXT, ctx).run(() -> {
try (var scope = StructuredTaskScope.open()) { // default: fail-fast
scope.fork(() -> { /* CONTEXT.get() o day van la ctx */ return checkInventory(); });
scope.fork(() -> { /* va o day cung vay */ return chargePayment(); });
scope.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Context chảy xuống cây subtask một cách an toàn, bất biến, và tự thu hồi — đúng tinh thần "có cấu trúc" mà cả bài này theo đuổi.
6. Capstone: TicketFlow đặt vé có cấu trúc
Hãy ráp tất cả vào TicketFlow. Trong các phiên bản trước, lõi BookingService.book đã thread-safe nhờ ConcurrentHashMap.compute (per-event atomicity), và tầng request đã chuyển sang virtual thread. Giờ ta dựng phần còn thiếu: xử lý 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 chính sách fail-fast, một deadline chung, và mang theo 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
// Toi day 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());
}
}
Inventory checkInventory(String eventId) {
log.info("trace={} checking inventory", CONTEXT.get().traceId()); // context tu chay toi day
// ... goi inventory service ...
}
Hai chi tiết đáng dừng lại. Một, doBooking không khai throws gì cả: join() có thể ném InterruptedException, FailedException hay TimeoutException, nhưng khối catch (Exception e) đã đón hết và đổi thành BookingResult.fail — nhờ vậy handleBooking gọi nó qua ScopedValue.where(...).call(...) mà không phải khai checked exception nào. Hai, joiner truyền vào chính là default awaitAllSuccessfulOrThrow() viết tường minh — bắt buộc viết ra vì ta cần tham số config thứ hai cho timeout; join() của joiner này trả null nên kết quả đọc qua hai Subtask ref inv và pay.
So với cách viết bằng Future rời rạc ở phần 1, đoạn này đóng kín mọi lỗ rò: nếu authorizePayment ném CardDeclinedException, nhánh checkInventory bị interrupt ngay thay vì chạy phí; nếu một service treo, deadline 2 giây cắt cả nhóm; và dù đi ra bằng đường nào, close() cũng đảm bảo không subtask nào sống sót quá khối try. Context userId và traceId thì chảy xuống cả hai nhánh mà không cần nhét vào chữ ký từng hàm. Việc giữ chỗ thực sự (service.book) chỉ chạy sau khi cả hai điều kiện tiên quyết đã thỏa, nên ta không bao giờ trừ tồn kho cho một thanh toán hỏng. Lưu ý đây là code minh họa cho bài viết, chưa có module riêng trong capstone-ticketflow/; lõi đặt vé thread-safe vẫn là service.book từ v3.
7. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JEP 505: Structured Concurrency (Fifth Preview) — bản preview ở JDK 25 mà bài này bám theo; phần mô tả
Joinernói rõ default củaopen()làawaitAllSuccessfulOrThrow()và ngữ nghĩa cancel. - 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. - Javadoc
StructuredTaskScope(JDK 25 preview) — đọc để thấy đủ contract củafork/join/closevà các trạng thái củaSubtask.
Ghi chú: structured concurrency vẫn là preview — hình dạng API có thể còn chỉnh ở các JDK sau, nên khi đọc tài liệu trên mạng hãy kiểm tra nó viết cho bản preview nào; các lớp ShutdownOnFailure/ShutdownOnSuccess bạn gặp trong bài viết cũ thuộc API đời trước, đã bị thay bằng Joiner.
8. Liên hệ các bài khác
- Thread API và vòng đời — interrupt và cooperative cancellation là cơ chế bên dưới việc scope hủy subtask; không hiểu interrupt thì không giải thích được vì sao vòng lặp CPU thuần "không chịu chết".
- Confinement —
ThreadLocalmàScopedValuethay thế chính là công cụ confinement theo thread; so hai cách ràng dữ liệu vào "phạm vi" rất đáng để ngẫm. - Executor và thread pool — đối trọng trực tiếp: hủy thủ công qua
Future.cancelso với cancel tự động theo chính sáchJoiner. - Future & CompletableFuture —
anySuccessfulResultOrThrow()thay cho patternCompletableFuture.anyOf+ hủy tay; nhìn lại để thấy structured concurrency rút gọn được gì. - Virtual Threads — tiền đề vật chất của bài này:
forktạo một virtual thread cho mỗi subtask, chỉ khả thi vì thread đã rẻ; vàScopedValuegiải đúng bài toánThreadLocalphình heap nêu ở đó.
9. Tổng kết — khép lại module
Mười bảy bài concept, ta đã đi một vòng dài. Bắt đầu từ bài Process và Thread, nơi process và thread còn là khái niệm của hệ điều hành: một OS thread là một tài nguyên đắt đỏ, ánh xạ one-to-one xuống kernel, và chính cái đắt đó định hình mọi quyết định thiết kế suốt nhiều năm — phải gom thread vào pool, phải tiết kiệm, phải coi mỗi Thread là một thứ quý. Bài concept cuối này khép lại ở một thế giới khác hẳn: virtual thread rẻ tới mức ta tạo cả triệu, và structured concurrency cho ta một bộ khung kỷ luật để cái sự rẻ ấy không biến thành hỗn loạn. Java đã đi từ chỗ mô phỏng thread của hệ điều hành đến chỗ tự dựng mô hình thực thi nhẹ của riêng JVM.
Nhưng có một bài học không hề thay đổi suốt chặng đường đó, và nó đáng được nhắc lại lần cuối. Dù thread là platform hay virtual, dù ta gom chúng bằng pool hay bằng scope, cái công thức gây bug vẫn nguyên vẹn: shared memory + mutable data + unsynchronized access = concurrency bug. Virtual thread không xóa được nó; structured concurrency không xóa được nó. BookingService.book vẫn phải thread-safe bất kể nó chạy trong scope nào, vì hai virtual thread tranh nhau một sold cũng tạo ra race y hệt hai platform thread. Mọi công cụ trong module này — confinement, immutability, lock, atomic, concurrent collection, executor, future, fork/join, virtual thread, structured concurrency — đều chỉ là những cách khác nhau để hoặc triệt tiêu một trong ba yếu tố đó, hoặc canh gác thật cẩn thận khi buộc phải giữ cả ba.
Đó là sợi chỉ đỏ đáng mang theo. Các API sẽ còn tiến hóa — đã có ScopedValue thay ThreadLocal, có Joiner thay ShutdownOnFailure, và sẽ còn nữa. Nhưng câu hỏi senior cần hỏi trước mỗi dòng code concurrent thì không đổi: dữ liệu này có được chia sẻ không, nó có thay đổi không, và nếu cả hai thì ai đang canh gác nó? Trả lời được ba câu đó, bạn đã nắm phần lõi của Java concurrency — phần mà không phiên bản JDK nào làm cho lỗi thời. Bài tổng kết kế tiếp gom toàn bộ chặng đường vào một trang cheat sheet để bạn bookmark và tự kiểm tra.
10. Tự kiểm tra
Q1Structured concurrency giải quyết loại leak nào mà ExecutorService + Future để hở?▸
Task leak — subtask sống lâu hơn đơn vị công việc đã sinh ra nó. Với ExecutorService, khi một Future.get() ném exception và method thoát ra, các task anh em vẫn chạy tiếp vì không ai cancel chúng: chúng chiếm thread, giữ connection, rồi kết quả bị vứt bỏ. Quan hệ cha–con chỉ tồn tại trong đầu lập trình viên, không trong code. StructuredTaskScope mã hóa quan hệ đó vào một khối try: close() chạy khi rời block (kể cả vì exception) và bảo đảm mọi subtask đã kết thúc — không có đường nào để một nhánh sống sót ra ngoài phạm vi đã sinh ra nó.
Q2Default joiner của StructuredTaskScope.open() làm gì khi một nhánh fail?▸
Default là Joiner.awaitAllSuccessfulOrThrow() — fail-fast, không phải "chờ tất cả". Khi một subtask ném exception, scope bị cancel ngay: mọi nhánh chưa xong bị interrupt, và join() ném StructuredTaskScope.FailedException bọc nguyên nhân gốc, đẩy luồng điều khiển ra khỏi khối try để close() dọn sạch. Khi mọi nhánh đều thành công, join() trả null (kiểu Void) và ta đọc kết quả qua từng Subtask.get(). Muốn hành vi "chờ tất cả bất kể thành bại" phải xin tường minh bằng Joiner.awaitAll().
Q3awaitAllSuccessfulOrThrow() và allSuccessfulOrThrow() khác nhau ở đâu, chọn cái nào khi nào?▸
Cả hai đều fail-fast — một nhánh fail là cancel cả scope và join() ném FailedException. Khác nhau ở thứ join() trả về. awaitAllSuccessfulOrThrow() (chính là default của open()) trả null: hợp khi các nhánh khác kiểu nhau và ta đã giữ sẵn từng Subtask ref để get() sau join(). allSuccessfulOrThrow() trả một Stream các Subtask: hợp khi nhiều nhánh đồng kiểu và ta muốn duyệt kết quả như stream, không cần giữ từng ref. Chọn sai không gây bug, chỉ gây code gượng — ví dụ lấy joiner trả stream rồi vứt stream đi là dấu hiệu nên quay về default.
Q4Vì sao Subtask.get() không block còn Future.get() thì block?▸
Vì hai API đặt điểm chờ ở chỗ khác nhau. Future.get() tự nó là lệnh chờ: gọi lúc nào cũng được, và block tới khi task xong — mời gọi ta chờ từng task một theo những thứ tự xen kẽ khó lường. Subtask.get() bị cấm gọi trước join() (ném exception nếu vi phạm); tại thời điểm hợp lệ để gọi nó, join() đã bảo đảm subtask kết thúc rồi, nên get() chỉ đọc ra kết quả có sẵn. Thiết kế này dồn toàn bộ việc chờ về đúng một điểm hợp lưu là join() — fan-out rồi fan-in — làm thứ tự thực thi dễ suy luận hơn hẳn.
Q5ScopedValue khác ThreadLocal ở những điểm nào?▸
- Bất biến trong phạm vi: không có
set; giá trị được ràng một lần quawhere(...).run(...)và không code nào bên trong đổi được — suy luận "giá trị là gì tại điểm này" trở nên tầm thường. - Vòng đời tự đóng: hết khối
run/call, ràng buộc tan biến; không córemove()để quên, không rò giá trị sang request sau nhưThreadLocaltrên thread pool. - Chi phí kế thừa:
ThreadLocalnhân bản giá trị theo từng thread (triệu virtual thread = triệu bản sao);ScopedValuebất biến nên thread con kế thừa ràng buộc gần như miễn phí, không copy.
Q6Vì sao ScopedValue hợp với virtual thread và structured concurrency đến vậy?▸
Vì cả ba cùng giải một bài toán theo cùng một triết lý "ràng vào phạm vi". Virtual thread làm fan-out hàng nghìn subtask cho một request thành chuyện thường — và đó chính là kịch bản ThreadLocal đắt nhất (bản sao theo thread) lẫn nguy hiểm nhất (rò giá trị). ScopedValue bất biến nên kế thừa xuống thread con không tốn bản sao, và khi fork bên trong một phạm vi where(...), subtask trên virtual thread riêng vẫn đọc được đúng context đó. Vòng đời của ràng buộc (khối run) và vòng đời của nhóm subtask (khối try của scope) lồng khít nhau — context sống đúng bằng đời của công việc, không hơn không kém.
Q7Việc hủy subtask trong scope dựa trên cơ chế gì, và khi nào nó không cắt được?▸
Dựa trên interrupt — cơ chế cancellation hợp tác có từ bài Thread API. Khi joiner quyết định cancel (một nhánh fail, đã có kết quả đầu tiên, hoặc quá timeout), scope interrupt các virtual thread đang chạy nhánh còn lại. Interrupt chỉ "đề nghị dừng": blocking I/O chuẩn của JDK tôn trọng nó (ném InterruptedException và thoát), nhưng một vòng lặp CPU thuần không bao giờ kiểm tra Thread.interrupted() sẽ chạy tới cùng — scope đành chờ nó ở close(). Đây không phải hạn chế riêng của structured concurrency mà là bản chất cancellation trong Java: muốn hủy được thì code phải viết để hủy được.
Bài tiếp theo: Tổng kết module — cheat sheet & self-assessment
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