Confinement: Thread safety bằng cách không chia sẻ
Thread safety bằng cách triệt tính shared: ad-hoc confinement, stack confinement, ThreadLocal và ScopedValue (Java 25). Khi nào confinement là đủ.
TL;DR: Confinement triệt tiêu tính "shared": dữ liệu chỉ một thread chạm tới thì tự động thread-safe, kể cả khi class của nó không hề thread-safe. Bài này đi dọc một trục từ mong manh tới cứng cáp: ad-hoc confinement (chỉ dựa quy ước và kỷ luật), stack confinement (biến cục bộ — ngôn ngữ bảo đảm, miễn tham chiếu không rò khỏi stack frame), ThreadLocal (mỗi thread một bản sao riêng, lưu trong ThreadLocalMap của chính Thread object — nhưng trên thread pool, quên remove() là rò rỉ cả dữ liệu lẫn bộ nhớ vì value bị strong reference giữ sống theo thread), và ScopedValue (Java 25) — bound theo phạm vi, immutable, tự dọn. Né chia sẻ bao giờ cũng rẻ hơn đồng bộ hóa.
1. Giới thiệu
Bài trước khép lại bằng bốn chiến lược để đối phó với shared mutable state, và một câu nhắc đi nhắc lại: gốc của mọi rắc rối nằm ở hai tính từ "shared" và "mutable". Lần này ta lấy con dao mổ và cắt đúng tính từ thứ nhất. Nếu một dữ liệu chỉ thuộc về một thread duy nhất, không bao giờ có thread thứ hai chạm tới, thì nó không còn "shared" nữa; và một dữ liệu không shared thì atomicity lẫn visibility đều không còn là vấn đề, bất kể nó mutable đến đâu.
Đây là chiến lược đầu tiên trong bốn chiến lược, và cũng là chiến lược rẻ nhất về mặt tư duy. Mọi kỹ thuật khóa, mọi happens-before, mọi atomic class đều là cách trả lời câu hỏi "làm sao để nhiều thread cùng chạm vào một dữ liệu mà vẫn đúng". Confinement né hẳn câu hỏi đó bằng một câu trả lời khác: đừng để nhiều thread cùng chạm. Một đối tượng bị giam trong một thread thì tự động thread-safe, kể cả khi bản thân class của nó không hề thread-safe — SimpleDateFormat, một HashMap, một ArrayList đều an toàn tuyệt đối chừng nào chỉ đúng một thread sờ tới chúng.
Điểm tinh tế của confinement không nằm ở ý tưởng, mà ở chỗ bảo đảm nó cứng đến mức nào. Java không có một từ khóa nào để khai báo "biến này thuộc về một thread"; phần lớn việc giam dữ liệu dựa vào quy ước, vào kỷ luật, hoặc vào cấu trúc của ngôn ngữ. Vì vậy bài này đi theo một trục: từ dạng confinement mong manh nhất, nơi chỉ có lời hứa của lập trình viên giữ cho mọi thứ đúng, tới dạng cứng cáp nhất, nơi chính ngôn ngữ và runtime đứng ra bảo đảm. Mỗi nấc trên trục đó đổi sự mong manh lấy một ràng buộc rõ ràng hơn.
2. Ad-hoc confinement
Dạng yếu nhất gọi là ad-hoc confinement: trách nhiệm giam dữ liệu hoàn toàn nằm ở kỷ luật của lập trình viên, không có cơ chế ngôn ngữ nào trợ giúp. Bạn quyết định rằng một đối tượng nào đó "chỉ được thread X dùng", viết một comment nói thế, rồi tin rằng mọi người về sau sẽ tôn trọng quyết định ấy.
Sự mong manh là hiển nhiên. Không có gì ngăn một đồng nghiệp — hay chính bạn sáu tháng sau — vô tình truyền tham chiếu của đối tượng đó sang một thread khác. Compiler im lặng, test đơn luồng vẫn xanh, và lỗi chỉ lộ ra dưới tải. Một field private được cho là "chỉ thread khởi tạo mới sửa" có thể bị một getter công khai vô tình phát tán; một biến tưởng như cục bộ có thể bị một lambda bắt giữ rồi mang sang một executor khác.
public class RequestProcessor {
// Quy ước (chỉ là comment): 'buffer' CHỈ được dùng bởi thread khởi tạo processor.
private final StringBuilder buffer = new StringBuilder(); // StringBuilder KHÔNG thread-safe
public void append(String chunk) {
buffer.append(chunk); // an toàn — nếu, và chỉ nếu, quy ước trên được giữ
}
}
StringBuilder không thread-safe, nhưng nếu thật sự chỉ một thread gọi append, đoạn này chạy đúng mãi mãi. Vấn đề là cụm từ "nếu thật sự". Quy ước không được ngôn ngữ thực thi, nên nó chỉ mạnh bằng người yếu nhất chạm vào code này.
Vì lý do đó, ad-hoc confinement nên dùng càng ít càng tốt, và chỉ ở những nơi mà việc nâng lên một dạng cứng hơn là bất khả thi hoặc không đáng. Tuy vậy, có một biến thể của ad-hoc confinement đáng giá đến mức trở thành quyết định kiến trúc: giam cả một phân hệ vào một thread duy nhất. Swing là ví dụ kinh điển. Mọi thao tác đọc hay sửa lên các UI component của Swing đều được giam vào đúng một thread, event dispatch thread; phần lớn lỗi concurrency trong ứng dụng Swing đến từ việc một thread khác lỡ tay chạm vào các đối tượng bị giam đó. Quyết định "toàn bộ UI là single-threaded" nghe có vẻ thô, nhưng nó biến một bài toán đồng bộ hóa khổng lồ thành một quy tắc duy nhất, dễ nhớ và dễ kiểm tra: muốn động vào UI thì phải đang ở trên EDT.
Cùng tinh thần đó áp được vào TicketFlow. Thay vì để nhiều thread cùng gọi book rồi tranh nhau sold, ta có thể dồn mọi yêu cầu đặt vé vào một hàng đợi, và để đúng một thread tiêu thụ hàng đợi đó thực thi tuần tự. Khi ấy events và sold bị giam trong thread tiêu thụ; book không cần một dòng synchronized nào, vì không bao giờ có thread thứ hai đụng vào hai map đó.
// Phac thao: phan he dat ve single-threaded. 'events' va 'sold' bi giam trong worker.
var requests = new LinkedBlockingQueue<BookingCommand>();
Thread worker = new Thread(() -> {
var events = new HashMap<String, Event>(); // KHONG thread-safe -- va khong can thread-safe
var sold = new HashMap<String, Integer>();
while (!Thread.currentThread().isInterrupted()) {
try {
BookingCommand cmd = requests.take(); // chi worker nay doc/ghi hai map tren
cmd.applyTo(events, sold);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // khoi phuc co interrupt -> vong lap thoat
}
}
});
worker.start();
LinkedBlockingQueue là một BlockingQueue — hàng đợi chặn: gọi take() trên queue rỗng sẽ chờ cho tới khi có phần tử, thay vì trả về null ngay (bài Blocking queues & producer-consumer sẽ đào sâu họ cấu trúc này). Chính vì take() có thể chờ vô hạn nên nó ném InterruptedException — và worker xử lý đúng theo pattern cooperative cancellation đã học ở bài Thread API và vòng đời: bắt exception, khôi phục cờ interrupt bằng Thread.currentThread().interrupt(), để điều kiện vòng lặp nhìn thấy cờ và thoát êm thay vì nuốt mất tín hiệu dừng.
Hai HashMap ở đây không thread-safe, và đó hoàn toàn không phải vấn đề: chúng được tạo bên trong thân run, không bao giờ rò ra ngoài, và chỉ worker chạm tới. Đây vẫn là ad-hoc confinement — không có gì ngăn ai đó về sau thêm một getter trả sold ra ngoài — nhưng việc gói trọn state vào trong một thread đã làm bề mặt sai sót nhỏ lại đáng kể. Đáng chú ý là cùng một dữ liệu đó, ở BlockingQueue, lại là thứ duy nhất được phép chia sẻ; chính nó là cây cầu an toàn để chuyển công việc từ nhiều thread sản xuất sang một thread tiêu thụ. Việc đặt một đối tượng vào hàng đợi thread-safe sẽ safe-publish nó cho worker — "công bố an toàn": worker nhìn thấy đối tượng ở trạng thái hoàn chỉnh, đúng như lúc producer đặt vào, chứ không phải một bản dở dang vì reordering. Nền móng đầy đủ của safe publication sẽ được dựng ở bài Immutability.
3. Stack confinement
Bước lên một nấc, ta có stack confinement: đối tượng chỉ với tới được qua biến cục bộ. Ở đây lần đầu tiên ngôn ngữ đứng về phía ta. Biến cục bộ sống trên stack của thread đang chạy, mà stack thì — như bài Process và Thread đã nhấn mạnh — là phần trạng thái riêng của từng thread, không thread nào khác truy cập được. Một biến cục bộ về bản chất đã bị giam, miễn là ta không tự tay để tham chiếu của nó thoát ra.
Phân biệt hai trường hợp giúp thấy rõ ranh giới. Với biến cục bộ kiểu nguyên thủy, stack confinement là tuyệt đối và không thể phá vỡ: không có cách nào lấy được tham chiếu tới một biến int cục bộ, nên đơn giản là không tồn tại cơ chế để chia sẻ nó. Đây chính là lý do SeatPriceCalculator ở bài trước an toàn dù không có một dòng đồng bộ hóa — mọi dữ liệu nó cần đều là tham số và biến cục bộ trên stack của thread gọi.
Với biến cục bộ kiểu tham chiếu, câu chuyện đòi hỏi một chút cẩn thận. Bản thân tham chiếu nằm trên stack, nhưng đối tượng nó trỏ tới nằm trên heap, vùng dùng chung. Stack confinement chỉ còn đúng chừng nào không có tham chiếu thứ hai tới đối tượng đó lọt ra ngoài thread. Hãy nhìn một thao tác đếm vé bán theo từng hạng ghế, thực hiện gọn trong một method:
public Map<String, Long> countByTier(List<Booking> bookings) {
Map<String, Long> counts = new HashMap<>(); // HashMap cục bộ — bị giam trên stack
for (Booking b : bookings) {
counts.merge(b.tier(), 1L, Long::sum);
}
return Map.copyOf(counts); // trả ra một bản immutable, KHÔNG rò 'counts'
}
counts là một HashMap không thread-safe, nhưng nó được tạo, dùng, và vứt bỏ hoàn toàn bên trong một lần gọi countByTier. Mỗi thread gọi method này có một counts riêng trên stack của nó; hai thread chạy đồng thời không hề biết tới counts của nhau. Đối tượng mutable không thread-safe, nhưng cách dùng nó lại thread-safe — và đó đúng là sức hấp dẫn của stack confinement: nó cho phép dùng tự do các collection rẻ, không đồng bộ, ngay giữa một chương trình đa luồng.
Cái bẫy duy nhất, nhưng quan trọng, là để tham chiếu thoát ra. Nếu method trên kết thúc bằng return counts thay vì Map.copyOf(counts), ta đã trao một tham chiếu sống tới đối tượng mutable cho caller, và stack confinement tan biến: từ giờ caller có thể giữ nó, chia sẻ nó, sửa nó từ thread khác. Tệ hơn cả return là âm thầm để đối tượng cục bộ lọt vào một cấu trúc sống lâu hơn stack frame — đưa nó cho một listener, nhét nó vào một field, hay submit một lambda bắt giữ nó vào một executor:
public void process(List<Booking> bookings) {
Map<String, Long> counts = new HashMap<>(); // tưởng là bị giam...
executor.submit(() -> counts.merge("VIP", 1L, Long::sum)); // ...nhưng lambda mang nó sang thread khác
counts.merge("STANDARD", 1L, Long::sum); // race: hai thread cùng sửa 'counts'
}
Khoảnh khắc counts bị một lambda chạy trên thread khác bắt giữ, nó không còn bị giam nữa — dù tên biến vẫn là biến cục bộ. Stack confinement, vì thế, là một bảo đảm có điều kiện: ngôn ngữ giam giúp ta phần tham chiếu trên stack, nhưng giữ cho đối tượng heap không rò ra ngoài vẫn là trách nhiệm của người viết. Phần thưởng khi giữ đúng kỷ luật ấy thì lớn, vì nó miễn phí về hiệu năng và không cần một cơ chế đồng bộ hóa nào.
Biến cục bộ kiểu nguyên thủy: giam tuyệt đối — không tồn tại cách lấy tham chiếu. Biến cục bộ kiểu tham chiếu: giam có điều kiện — tham chiếu nằm trên stack nhưng object nằm trên heap; chừng nào không return tham chiếu sống, không gán vào field, không để lambda bắt giữ, nó vẫn bị giam.
4. ThreadLocal
Cả ad-hoc lẫn stack confinement đều giam dữ liệu trong một phạm vi hẹp: một thread cụ thể, hoặc một lần gọi method. Nhưng đôi khi ta cần một thứ vừa "toàn cục" trong tầm với của code, vừa riêng cho mỗi thread. ThreadLocal là cơ chế chính thức của Java cho nhu cầu đó: nó cho mỗi thread một bản sao riêng của một biến, độc lập hoàn toàn với bản của các thread khác.
Có thể hình dung ThreadLocal<T> như một cái tủ có nhiều ngăn, mỗi thread được phát đúng một ngăn mang tên mình. Khi thread A gọi get, nó luôn nhận lại đúng giá trị mà chính A đã set trước đó; giá trị của A và của B không bao giờ lẫn vào nhau, dù chúng dùng chung một biến ThreadLocal.
| Tủ nhiều ngăn | ThreadLocal |
|---|---|
| Cái tủ đặt giữa văn phòng | object ThreadLocal — điểm truy cập chung cho mọi thread |
| Ngăn mang tên từng người | entry trong ThreadLocalMap của từng Thread |
| Chìa khóa nhận diện chủ ngăn | Thread.currentThread() — thread nào hỏi, mở ngăn của thread đó |
| Đồ để quên trong ngăn khi nghỉ việc | value chưa remove() khi thread quay về pool |
Use case kinh điển là per-thread context: một thứ không thread-safe nhưng lại tiện khi dùng như biến toàn cục trong suốt vòng đời xử lý của một thread. Một ví dụ sách giáo khoa là SimpleDateFormat, vốn nổi tiếng là không thread-safe; thay vì khóa nó hay tạo mới mỗi lần, ta phát cho mỗi thread một bản riêng:
public final class TicketDateFormat {
private static final ThreadLocal<DateFormat> FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public static String format(Date date) {
return FORMAT.get().format(date); // mỗi thread dùng SimpleDateFormat của riêng nó
}
}
withInitial cho phép khai báo cách tạo giá trị ban đầu; lần đầu một thread gọi get, lambda chạy để dựng riêng cho nó một SimpleDateFormat, và những lần sau thread đó dùng lại đúng đối tượng ấy. Hai thread không bao giờ chạm vào cùng một formatter, nên cái không thread-safe trở nên an toàn mà không tốn một khóa nào. Một use case nữa rất phổ biến trong ứng dụng server là giữ ngữ cảnh request — một correlation ID để gắn vào log, thông tin user đã xác thực, hay một JDBC Connection (kết nối tới database; mỗi thread giữ một connection riêng thay vì nhiều thread tranh nhau một kết nối dùng chung vốn không thread-safe) — ở một ThreadLocal, để các tầng code sâu bên dưới đọc được mà không phải kéo nó qua từng tham số method.
4.1 Cơ chế bên dưới: ThreadLocalMap và WeakReference
Để hiểu vì sao ThreadLocal cô lập được dữ liệu — và vì sao nó có thể rò rỉ — phải nhìn vào nơi dữ liệu thực sự nằm. Trực giác dễ tưởng ThreadLocal<T> là một cái map từ thread sang giá trị, nằm bên trong object ThreadLocal. Thực tế ngược lại: dữ liệu nằm trong chính object Thread. Mỗi Thread mang một field tên threadLocals kiểu ThreadLocal.ThreadLocalMap — một hash map tí hon của riêng thread đó, trong đó key là các object ThreadLocal mà thread từng dùng, value là giá trị tương ứng của riêng nó. Khi gọi FORMAT.get(), code thực sự chạy là: lấy Thread.currentThread(), mở threadLocals của thread ấy, tra bằng key là chính object FORMAT. Vì map đó chỉ thread chủ chạm tới, mọi get/set đều là thao tác đơn luồng — không chia sẻ, không cần một chút đồng bộ hóa nào. Đây là confinement đúng nghĩa đen: dữ liệu đi theo thread.
Một chi tiết cài đặt quyết định toàn bộ câu chuyện rò rỉ: mỗi entry trong ThreadLocalMap giữ key qua WeakReference — tham chiếu yếu, loại tham chiếu không ngăn GC thu hồi object nó trỏ tới — còn value thì giữ qua strong reference bình thường. Thiết kế này để khi một object ThreadLocal không còn ai dùng (ví dụ class khai báo nó bị unload), entry tương ứng trong map của mọi thread không níu nó sống mãi: key bị GC thu, entry trở thành "stale entry" và sẽ được dọn nhân tiện khi thread đó gọi set/get/remove lần sau. Nhưng để ý chữ "nhân tiện": không có gì bảo đảm việc dọn xảy ra, và quan trọng hơn, value vẫn bị strong reference giữ chặt chừng nào entry còn nằm trong map.
Ghép chuỗi tham chiếu lại sẽ thấy hình dạng của leak:
flowchart LR
P["Thread pool"] --> T["Worker thread (song rat lau)"]
T --> M["ThreadLocalMap"]
M --> E["Entry"]
E -.->|"weak ref (key)"| K["ThreadLocal"]
E ==>|"strong ref (value)"| V["Value cua request cu"]Chừng nào thread còn sống, ThreadLocalMap của nó còn sống; chừng nào entry chưa bị gỡ, value còn bị strong reference trỏ tới — GC bó tay. Với một thread bình thường, điều này vô hại: thread chết, map chết theo, value được thu hồi. Nhưng thread trong pool không bao giờ chết, và đó là chỗ cơ chế này trở thành bẫy.
4.2 Pitfall: ThreadLocal gặp thread pool
Sức tiện của ThreadLocal đi kèm một cạm bẫy mà ai làm server cũng phải biết: rò rỉ dữ liệu và bộ nhớ khi dùng chung với thread pool. Vấn đề bắt nguồn từ chính bản chất của pool. Thread trong pool sống rất lâu và được tái sử dụng cho hết request này tới request khác. Khi một request đặt một giá trị vào ThreadLocal rồi kết thúc, thread không chết đi — nó quay về pool, mang theo nguyên cái giá trị cũ trong ngăn của nó. Request sau chạy trên đúng thread đó sẽ thấy giá trị thừa từ request trước, một lỗi rò rỉ dữ liệu giữa các request đủ tệ về mặt đúng đắn. Nhưng nguy hiểm hơn là về bộ nhớ: chừng nào thread còn sống, giá trị đó còn bị giữ và không bao giờ được thu hồi, dù request đã xong từ lâu. Với một pool vài trăm thread, mỗi thread ôm một object nặng, đó là một rò rỉ tích lũy âm thầm.
Đặt thành code, đây là phiên bản sai mà rất nhiều codebase server thật đã từng chứa:
// SAI: set ma khong remove -- thread quay ve pool van om gia tri cu
public class AuthFilter {
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
public void doFilter(Request req, Chain chain) {
CURRENT_USER.set(authenticate(req)); // gan user cua request nay
chain.proceed(req); // tang sau doc CURRENT_USER.get()
// thieu remove(): request sau tren cung thread co the doc nham User cu
}
}
Hai hậu quả cùng lúc. Về tính đúng đắn: nếu request sau đi qua một nhánh không gọi set (chẳng hạn request không cần xác thực), CURRENT_USER.get() ở tầng sâu trả về user của request trước — một lỗi lẫn ngữ cảnh giữa các request, ở đây mang cả màu sắc bảo mật. Về bộ nhớ: mỗi worker thread trong pool găm một User (cùng mọi thứ object đó trỏ tới) sống vô hạn.
Lời giải là kỷ luật remove: thứ gì đặt vào ThreadLocal trong phạm vi một tác vụ thì phải gỡ ra khi tác vụ kết thúc, và cách an toàn duy nhất là gỡ trong khối finally.
// DUNG: remove trong finally -- don ngan truoc khi tra thread ve pool
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
public void handle(Request req) {
REQUEST_ID.set(req.id());
try {
process(req); // cac tang sau doc REQUEST_ID.get()
} finally {
REQUEST_ID.remove(); // BAT BUOC tren thread pool -- neu khong, ro sang request sau
}
}
Khối finally bảo đảm việc gỡ xảy ra kể cả khi process ném exception, đúng tinh thần một tài nguyên phải được trả lại dù đường thoát là bình thường hay ngoại lệ. Bỏ qua remove không gây lỗi ngay; nó chỉ âm thầm tích tụ, và đó chính là điều khiến nó nguy hiểm. Nói cách khác, ThreadLocal là confinement mạnh về mặt cô lập dữ liệu, nhưng nó đẩy sang ta một trách nhiệm mới về vòng đời: ai đặt thì người đó phải dọn.
5. InheritableThreadLocal
ThreadLocal cô lập triệt để — đến mức đôi khi quá triệt để. Khi một thread tạo ra một thread con, theo mặc định thread con khởi đầu với các ngăn ThreadLocal trống trơn; mọi ngữ cảnh mà thread cha đã dày công thiết lập đều không truyền sang. Đôi lúc ta lại muốn điều ngược lại: muốn thread con thừa hưởng ngữ cảnh của cha, chẳng hạn để một tác vụ con vẫn gắn đúng correlation ID của request đã sinh ra nó.
InheritableThreadLocal ra đời cho đúng nhu cầu đó. Tại thời điểm một thread con được tạo, nó sao chép giá trị từ các InheritableThreadLocal của thread cha sang ngăn của mình.
private static final InheritableThreadLocal<String> TENANT = new InheritableThreadLocal<>();
TENANT.set("acme-corp");
Thread child = new Thread(() -> {
System.out.println(TENANT.get()); // in "acme-corp" — kế thừa từ thread cha
});
child.start();
Bốn chữ "tại thời điểm tạo" mới là phần đáng giá: việc kế thừa chỉ là một lần chụp ảnh, giá trị được sao chép đúng lúc thread con được dựng, và sau đó hai thread hoàn toàn độc lập. Nếu thread cha đổi giá trị sau khi đã tạo con, thread con không hề thấy thay đổi ấy; ngược lại, con sửa bản của nó cũng không động tới cha. Và vì đây chỉ là sao chép tham chiếu chứ không phải sao chép sâu, nếu giá trị là một đối tượng mutable thì cha và con cùng trỏ vào một đối tượng — chia sẻ trở lại, kèm theo mọi rủi ro mà ta đang cố tránh. Vì thế InheritableThreadLocal chỉ thật sự an toàn khi giá trị là immutable.
Giới hạn lớn nhất lộ ra rõ trong thế giới thread pool và virtual thread. Sự kế thừa chỉ xảy ra khi một thread trực tiếp tạo một thread khác; nhưng với một executor, các thread đã được dựng từ trước, độc lập với thread đang submit tác vụ, nên chẳng có "lúc tạo con" nào để chụp ảnh ngữ cảnh — InheritableThreadLocal đơn giản không truyền được qua ranh giới submit. Mô hình kế thừa theo cha-con cũng không khớp với cách virtual thread được tạo ra với số lượng khổng lồ: việc mỗi lần spawn lại sao chép cả một bản đồ ngữ cảnh là một chi phí không nhỏ và một mô hình vòng đời mơ hồ. Chính những điểm gãy này đặt nền cho một cách tiếp cận khác hẳn, gắn ngữ cảnh vào một phạm vi rõ ràng thay vì vào vòng đời mờ mịt của thread.
6. ScopedValue (Java 25)
Cách tiếp cận đó là ScopedValue, final từ Java 25. Thay vì gắn một giá trị vào một thread rồi tin rằng có ai đó sẽ dọn, ScopedValue ràng giá trị vào một phạm vi có biên giới rõ ràng: giá trị chỉ tồn tại trong lúc một khối code nhất định đang chạy, và biến mất ngay khi khối đó kết thúc. Nó immutable trong suốt phạm vi ấy, và không hề có phương thức set để sửa giữa chừng — điểm khác biệt nền tảng so với ThreadLocal.
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public void handle(Request req) {
ScopedValue.where(REQUEST_ID, req.id())
.run(() -> process(req)); // REQUEST_ID.get() hợp lệ ở mọi nơi BÊN TRONG khối này
// ra khỏi đây, REQUEST_ID không còn bound — không cần, và không thể, remove()
}
Cấu trúc where(...).run(...) nói lên toàn bộ tinh thần: giá trị được bound đúng cho khoảng thời gian khối lambda chạy, kể cả những lời gọi sâu bên trong nó, rồi tự động được unbound khi run trả về. Không có bước dọn dẹp thủ công nào, nên cũng không có khả năng quên dọn — cái cửa sổ rò rỉ mà ThreadLocal mở ra trên thread pool ở đây đã được đóng kín bằng chính cấu trúc của API. Tính bất biến trong phạm vi cũng loại bỏ cả một lớp lỗi nơi một tầng code sâu lén set lại ngữ cảnh và làm các tầng khác bối rối.
"Không sửa được" không có nghĩa là cứng nhắc. Khi một tầng code sâu thật sự cần một ngữ cảnh khác — chạy một đoạn với quyền hệ thống, đổi tenant cho một tác vụ con — nó không set đè giá trị, mà bind chồng một phạm vi con:
ScopedValue.where(REQUEST_ID, req.id()).run(() -> {
audit(); // doc req.id()
ScopedValue.where(REQUEST_ID, "system").run(() -> {
cleanupExpiredHolds(); // doc "system" -- chi trong khoi nay
});
audit(); // doc lai req.id() -- tu khoi phuc
});
Phạm vi con che giá trị của phạm vi cha đúng trong khối của nó, và khi khối kết thúc, giá trị cũ tự hiện lại — không cần lưu-rồi-khôi-phục thủ công như trò String old = TL.get(); TL.set(x); ... TL.set(old) đầy rủi ro với ThreadLocal. Mọi "thay đổi" đều có biên giới nhìn thấy được ngay trong cấu trúc code.
ScopedValue đặc biệt hợp với virtual thread, và đây không phải sự trùng hợp. Virtual thread được tạo ra với số lượng rất lớn — bài Process và Thread đã nói về mô hình một virtual thread cho mỗi request — nên mọi cơ chế ngữ cảnh gắn vào vòng đời thread đều phải rẻ và sạch. Mô hình phạm vi có biên giới của ScopedValue khớp tự nhiên với mô hình "một tác vụ, một phạm vi" của Structured Concurrency (vẫn ở dạng preview tại Java 25), nơi các tác vụ con kế thừa scoped value của cha một cách rõ ràng và an toàn mà không phải sao chép tốn kém. Ở đây ta chỉ giới thiệu ScopedValue như nấc cao nhất của trục confinement — một cách giam ngữ cảnh vừa cứng cáp về bảo đảm, vừa nhẹ về chi phí. Cách nó phối hợp với virtual thread và Structured Concurrency là một câu chuyện riêng, để dành cho bài Virtual threads và bài Structured Concurrency & ScopedValue ở cuối module.
7. Liên hệ các bài khác
- Thread Safety — bài này thi công chiến lược thứ nhất trong bốn chiến lược mà bài đó gọi tên; mọi thuật ngữ (shared, mutable, race) đều định nghĩa ở đó.
- Thread API và vòng đời — pattern bắt
InterruptedExceptionrồi khôi phục cờ interrupt mà worker single-threaded ở mục 2 dùng được dạy kỹ ở đó. - Immutability — dựng nền safe publication mà cây cầu
BlockingQueuecủa mục 2 mới nhắc lướt; cũng là chiến lược thứ hai, cắt vào tính từ còn lại. - Blocking queues & producer-consumer — đào sâu chính cái hàng đợi chặn làm cây cầu chia sẻ duy nhất giữa producer và worker bị giam.
- Virtual threads và Structured Concurrency & ScopedValue — nơi
ScopedValuephát huy trọn vẹn: hàng triệu thread, ngữ cảnh kế thừa theo phạm vi.
8. 📚 Deep Dive Oracle
Spec / reference chính thức:
- ThreadLocal Javadoc (Java 21) — contract chính thức, kể cả ghi chú về vòng đời giá trị gắn theo thread.
- JEP 506 — Scoped Values (final, Java 25) — motivation section giải thích thẳng vì sao
ThreadLocalkhông hợp với hàng triệu virtual thread. - Java Concurrency in Practice (Goetz et al.), §3.3 Thread Confinement — nguồn của trục ad-hoc → stack → ThreadLocal.
Ghi chú: JEP 506 đáng đọc nhất trong ba link — phần "Motivation" là một bản phân tích súc tích những điểm gãy của ThreadLocal (mutability không kiểm soát, vòng đời không biên giới, chi phí kế thừa) mà bài này đã đi qua.
9. Tổng kết
Confinement là chiến lược trả lời câu hỏi thread safety bằng cách từ chối đặt ra nó: nếu dữ liệu không bị chia sẻ, không có gì để đồng bộ. Toàn bộ bài này đi dọc một trục từ mong manh tới cứng cáp, và chính cái trục ấy là điều đáng mang theo.
| Nấc | Ai đứng ra bảo đảm | Mong manh ở đâu |
|---|---|---|
| Ad-hoc | Quy ước + kỷ luật người viết | Một getter hay lambda vô tình phát tán tham chiếu là vỡ, compiler im lặng |
| Stack | Ngôn ngữ — stack riêng từng thread | Đối tượng heap rò khỏi stack frame (return tham chiếu sống, lambda bắt giữ) |
ThreadLocal | Runtime — ThreadLocalMap trong từng Thread | Vòng đời: quên remove() trên thread pool là rò dữ liệu lẫn bộ nhớ |
ScopedValue | API — bound theo phạm vi, immutable, tự dọn | Phải cấu trúc code theo scope; final từ Java 25 |
Confinement là đủ khi ta thật sự có thể không chia sẻ một dữ liệu — và nó là chiến lược nên thử trước tiên, vì né được vấn đề bao giờ cũng rẻ hơn giải nó. Nhưng có những dữ liệu, theo bản chất bài toán, buộc phải đến tay nhiều thread: một Event mà hàng nghìn request cùng đọc, một bảng giá dùng chung cho cả hệ thống. Với chúng, ta không thể bỏ tính "shared". Nhưng vẫn còn một con dao nữa, cắt vào tính từ còn lại. Nếu một dữ liệu được chia sẻ nhưng không bao giờ thay đổi sau khi khởi tạo, thì đọc nó lúc nào cũng cho cùng một kết quả, và nó an toàn bất kể bao nhiêu thread cùng nhìn vào. Đó là Immutability, chiến lược thứ hai, và là chủ đề của bài tiếp theo.
10. Tự kiểm tra
Q1Trong countByTier, một HashMap không thread-safe được dùng giữa chương trình đa luồng mà vẫn an toàn tuyệt đối. Vì sao? Điều gì sẽ phá vỡ bảo đảm đó?▸
Q2Trong worker single-threaded của TicketFlow, vì sao requests.take() phải bọc try/catch InterruptedException, và vì sao trong catch lại gọi Thread.currentThread().interrupt()?▸
Q3ThreadLocal.get() tra cứu dữ liệu ở đâu? Vì sao thao tác đó không cần một chút đồng bộ hóa nào?▸
Dữ liệu không nằm trong object ThreadLocal mà nằm trong chính object Thread: mỗi thread mang một field threadLocals kiểu ThreadLocal.ThreadLocalMap, trong đó key là các object ThreadLocal thread từng dùng, value là giá trị của riêng thread đó. get() thực chất là: lấy Thread.currentThread(), mở map của thread ấy, tra bằng key là chính object ThreadLocal.
Không cần đồng bộ hóa vì map đó chỉ đúng một thread — thread chủ — đọc và ghi. Không có truy cập từ thread thứ hai thì không có data race; đây là confinement theo đúng nghĩa đen, dữ liệu đi theo thread.
Q4Giải thích chuỗi tham chiếu gây memory leak khi dùng ThreadLocal với thread pool. Key được giữ bằng WeakReference — vì sao điều đó vẫn không cứu được value?▸
Q5Vì sao InheritableThreadLocal không truyền được ngữ cảnh qua thread pool, và vì sao mô hình của nó không hợp với virtual thread?▸
Q6ScopedValue đóng những lỗ hổng nào của ThreadLocal, và đóng bằng cơ chế gì?▸
Bài tiếp theo: Immutability — thread safety bằng cách không thay đổi
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