Java Internals & Concurrency/Virtual Threads: Thread-per-request trở lại
17/39
Bài 17 / 39~15 phútConcurrency cơ bảnMiễn phí lượt xem

Virtual Threads: Thread-per-request trở lại

Thread-per-request trở lại với virtual thread (final Java 21): cơ chế mount/unmount, vì sao rẻ, pinning cần tránh, khi nào nên dùng và di cư từ thread pool.

TL;DR: Virtual thread (final từ Java 21, JEP 444) phá giả định mỗi java.lang.Thread chiếm trọn một OS thread: JVM multiplex hàng triệu virtual thread lên một nhóm nhỏ carrier thread, mount khi chạy và unmount khi gặp blocking I/O — stack được cuộn vào heap nên mỗi thread khởi đầu chỉ tốn vài trăm byte. Hệ quả: mô hình thread-per-request quay lại ở quy mô triệu kết nối, và pool virtual thread trở thành anti-pattern. Nhưng virtual thread chỉ thắng với workload I/O-bound — scheduler không preempt theo lát thời gian, task CPU-bound chiếm carrier tới khi xong. Cần tránh pinning (synchronized quanh I/O trên JDK 21 — Java 24 đã gỡ với JEP 491), và khi bỏ cái trần pool thì phải thay nó bằng Semaphore tường minh để bảo vệ tài nguyên downstream.

1. Giới thiệu

Bài trước khép lại Phần B với Fork/Join: một framework sinh ra để vắt kiệt CPU cho lớp bài toán divide-and-conquer, nơi work-stealing cân tải giữa các core và mỗi worker thread gần như không bao giờ ngồi không. Nhưng ngay cuối bài đó có một vết nứt: Fork/Join sợ nhất là task blocking. Một worker dừng lại chờ I/O là một core bị bỏ phí, và ta phải lôi ManagedBlocker ra vá. Cái khó chịu đó không phải lỗi của Fork/Join. Nó là triệu chứng của một giả định sâu hơn, đã đi cùng Java từ bài Process và Thread: mỗi java.lang.Thread chiếm trọn một OS thread suốt vòng đời của nó.

Giả định ấy đặt ra một cái trần. Một OS thread tốn vài megabyte stack và một slot trong scheduler của kernel; tạo vài nghìn cái là hệ thống bắt đầu rên. Nên từ những bài đầu ta đã không rải new Thread() khắp nơi mà gom chúng vào pool, và đến bài Executor và thread pool thì gửi task vào ExecutorService cho một số cố định platform thread gánh. Pool giải được bài toán chi phí tạo/hủy thread, nhưng nó không xoá được cái trần - nó chỉ dời cái trần thành kích thước pool. Khi cả 200 thread trong pool đều đang nằm chờ database, request thứ 201 phải xếp hàng, dù CPU gần như rảnh hoàn toàn. Ta đang giữ những tài nguyên đắt đỏ chỉ để chờ.

Virtual thread phá đúng giả định đó. Một virtual thread rẻ tới mức tạo một triệu cái cho một triệu task blocking I/O là chuyện bình thường. Khi thread không còn là tài nguyên khan hiếm, ta không cần pool nó, không cần đếm nó, không cần né tránh việc nó block. Và khi đó mô hình lập trình đơn giản nhất - mỗi request một thread, code tuần tự từ trên xuống, gặp I/O thì cứ chặn - quay trở lại làm được, ở quy mô mà mười năm trước buộc ta phải viết callback hoặc CompletableFuture chỉ để né cái trần thread. Virtual thread là tính năng final từ Java 21; đến bản LTS Java 25 mà series này lấy làm baseline, nó đã là nền tảng ổn định để dựa vào.

2. Tạo một virtual thread

Virtual thread vẫn là một java.lang.Thread. Đây không phải là lời nói cho vui: cùng API, cùng Thread.currentThread(), cùng ThreadLocal, cùng cách ném và bắt exception. Code nghiệp vụ viết cho platform thread chạy y nguyên trên virtual thread mà không phải sửa một dòng. Khác biệt nằm ở cách JVM lập lịch nó, không ở bề mặt API.

Cách thấp nhất là Thread.ofVirtual(), builder cho ta một Thread.Builder để đặt tên rồi start:

Thread vt = Thread.ofVirtual()
        .name("booking-worker")
        .start(() -> System.out.println("Chay trong virtual thread"));
vt.join();

Đối xứng với nó là Thread.ofPlatform() nếu ta cần một platform thread tường minh. Khi không cần builder, có lối tắt một dòng:

Thread.startVirtualThread(() -> System.out.println("Chay trong virtual thread"));

Nhưng trong ứng dụng thật, ta hiếm khi tự start từng thread. Cách dùng đúng quy mô là một executor sinh ra đúng một virtual thread mới cho mỗi task được submit:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        int id = i;
        executor.submit(() -> handleRequest(id));
    }
}   // try-with-resources: close() blocks until every task finishes

Cái tên newVirtualThreadPerTaskExecutor nói thẳng triết lý của nó, và đây là chỗ trực giác cũ phải gỡ bỏ. Đây không phải một thread pool. Nó không giữ một tập thread chờ tái sử dụng; mỗi task nhận một virtual thread mới tinh, chạy xong thì thread đó chết hẳn. Với platform thread, làm vậy là tự sát vì chi phí tạo thread; với virtual thread, tạo thread rẻ đến mức không đáng để tái sử dụng. ExecutorService giờ đóng vai trò một cổng quản lý vòng đời và lan truyền lỗi cho một đám task, chứ không còn là cơ chế giới hạn số thread. Điểm khác biệt này là gốc rễ của khá nhiều lời khuyên ở các phần sau.

Một điểm dễ vấp: newVirtualThreadPerTaskExecutor là một ExecutorService đúng nghĩa, nên nó cài AutoCloseable, và close() sẽ chặn cho tới khi mọi task đã submit chạy xong. Dùng nó trong try-with-resources cho ta một ranh giới gọn gàng - vào block thì fan-out, ra khỏi block thì chắc chắn mọi task đã hoàn tất. Cấu trúc này chính là cây cầu nối sang structured concurrency ở bài sau.

3. Mười nghìn với một triệu: thử mới thấy khác bậc

Lời khẳng định "virtual thread rẻ" nghe trừu tượng cho tới khi ta đặt hai thử nghiệm cạnh nhau. Cùng một kịch bản: mỗi task ngủ một giây để giả lập chờ I/O - đúng kiểu workload mà một web server gặp hàng ngày, thread không tính toán gì, chỉ chờ.

Thử nghiệm thứ nhất, 10.000 platform thread:

// Experiment 1: 10,000 platform threads, each just waits 1 second
try (var executor = Executors.newThreadPerTaskExecutor(
        Thread.ofPlatform().factory())) {
    IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));   // simulate blocking I/O
        return i;
    }));
}

Mỗi platform thread khi sinh ra đòi OS cấp một vùng stack riêng - mặc định reserve cỡ 1MB trên các hệ 64-bit (chỉnh được qua -Xss) - cộng một entry trong scheduler của kernel. Mười nghìn thread nghĩa là cỡ chục gigabyte address space được reserve và mười nghìn slot kernel phải lập lịch. Trên nhiều máy, chương trình chết trước khi tạo đủ số đó với OutOfMemoryError: unable to create native thread, hoặc đụng giới hạn số thread per-process của OS. Và toàn bộ chi phí ấy được tiêu chỉ để... ngủ.

Thử nghiệm thứ hai, một triệu virtual thread:

// Experiment 2: 1,000,000 virtual threads, same waiting workload
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 1_000_000).forEach(i -> executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));   // simulate blocking I/O
        return i;
    }));
}

Chạy được, trên một máy phát triển bình thường. Mỗi virtual thread khởi đầu chỉ là một object trên heap cỡ vài trăm byte; stack của nó không phải vùng nhớ OS cấp sẵn mà lớn dần theo độ sâu lời gọi thực tế, và sống trong heap. Một triệu virtual thread đang ngủ là vài trăm megabyte heap - thứ GC quản lý như mọi object khác - và không chiếm thêm OS thread nào ngoài nhóm carrier nhỏ cỡ số core.

Đừng đọc hai con số trên như một benchmark chính xác - bộ nhớ thực tế phụ thuộc độ sâu stack, phiên bản JVM, hệ điều hành. Điều cần mang theo là bậc độ lớn: platform thread đếm bằng nghìn thì hệ thống đã rên, virtual thread đếm bằng triệu vẫn là chuyện thường. Khác nhau không phải vài lần, mà vài bậc. Vì sao được như vậy - câu trả lời nằm ở cơ chế mount/unmount ngay dưới đây.

💡 Tự kiểm chứng trên máy bạn

Chạy thử nghiệm thứ hai rồi mở terminal khác đếm OS thread của process: ps -o nlwp <pid> trên Linux (hoặc xem cột Threads trong Activity Monitor/Task Manager). Con số sẽ chỉ vài chục - nhóm carrier cộng vài thread nội bộ của JVM - trong khi chương trình đang "chạy" một triệu thread. Không có minh chứng nào trực quan hơn cho việc virtual thread không phải OS thread.

4. Cơ chế mount và unmount: vì sao virtual thread rẻ

Để hiểu vì sao một virtual thread rẻ hơn một platform thread vài bậc, phải nhìn xuống cách JVM chạy nó. Nhắc lại mô hình ánh xạ từ bài Process và Thread: platform thread theo kiểu one-to-one, mỗi Java thread ăn một OS thread riêng suốt đời. Virtual thread thì gần với mô hình many-to-many. JVM giữ một nhóm nhỏ platform thread gọi là carrier thread - mặc định là một ForkJoinPool riêng có số worker cỡ bằng số CPU core - và multiplex rất nhiều virtual thread lên nhóm carrier nhỏ đó.

Cơ chế khoá ở hai động tác: mount và unmount. Khi một virtual thread sẵn sàng chạy, JVM mount nó lên một carrier thread; lúc này carrier chạy code của virtual thread như chạy code bình thường, và OS lập lịch carrier như mọi platform thread khác. Mấu chốt nằm ở thời điểm virtual thread gặp một thao tác blocking đã được Loom trang bị lại - chờ socket, chờ kết quả query, Thread.sleep, chờ một BlockingQueue. Thay vì chặn nguyên cái carrier như platform thread vẫn làm, JVM unmount virtual thread khỏi carrier: nó cuộn stack của virtual thread cất vào heap (cấu trúc này gọi là continuation), rồi trả carrier về cho nhóm để một virtual thread khác đang sẵn sàng mount lên. Khi thao tác blocking kia hoàn tất, virtual thread được đánh dấu sẵn sàng trở lại, chờ tới lượt mount lên một carrier nào đó - không nhất thiết là carrier cũ - và chạy tiếp từ đúng chỗ nó dừng.

Cả vũ điệu đó, vẽ thành sequence diagram:

sequenceDiagram
    participant A as Virtual thread A
    participant C1 as Carrier 1
    participant IO as Tang I/O (socket, DB)
    participant C2 as Carrier 2
    A->>C1: mount - carrier chay code cua A
    A->>IO: goi blocking I/O (doc socket)
    Note over A,C1: JDK unmount A - cuon stack vao heap (continuation)
    Note over C1: Carrier 1 ranh - mount virtual thread B khac
    IO-->>A: I/O hoan tat - A san sang chay tiep
    A->>C2: remount (co the la carrier khac)
    Note over A,C2: chay tiep tu dung lenh da dung

Một phép so sánh đời thường giúp hình dung. Carrier thread là một nhân viên tổng đài, còn virtual thread là từng cuộc gọi của khách. Mô hình platform thread cũ giống như cấp cho mỗi cuộc gọi một nhân viên riêng: khách gọi xong đặt máy đi pha cà phê, nhân viên vẫn phải áp tai vào ống nghe ngồi chờ, không phục vụ ai khác. Số nhân viên là cái trần cho số cuộc gọi đồng thời. Mô hình virtual thread thì khác: hễ khách bảo "chờ tôi chút", nhân viên ghi lại đúng chỗ đang dở của cuộc gọi đó vào một tờ giấy, đặt sang bên, và bắt ngay cuộc gọi khác. Khi khách quay lại, bất kỳ nhân viên rảnh nào cũng cầm tờ giấy lên và tiếp tục. Một nhúm nhân viên phục vụ được hàng nghìn cuộc gọi, miễn là phần lớn thời gian các cuộc gọi đang ngồi chờ.

Tổng đàiVirtual thread
Nhân viên tổng đàiCarrier thread (nhóm nhỏ, cỡ số core)
Cuộc gọi của kháchVirtual thread (rất nhiều, rẻ)
Khách bảo "chờ tôi chút"Thao tác blocking I/O
Tờ giấy ghi chỗ đang dởContinuation - stack cuộn vào heap
Nhân viên bắt cuộc gọi khácCarrier mount virtual thread khác
Bất kỳ nhân viên rảnh nào tiếp tụcRemount lên carrier bất kỳ

Cái rẻ đến từ chính chỗ đó. Stack của một virtual thread không phải là một vùng stack OS cố định cỡ megabyte cấp sẵn; nó sống trên heap và co giãn theo độ sâu thực tế của lời gọi, khởi đầu chỉ vài trăm byte. Khi virtual thread đang chờ I/O, nó không giữ một OS thread nào cả - chỉ là một ít byte trên heap, thứ mà garbage collector quản lý như mọi object khác. Tạo, huỷ, chuyển ngữ cảnh virtual thread đều diễn ra trong JVM ở user space, không phải lội xuống kernel. Đó là lý do một triệu virtual thread đang chờ là khả thi, trong khi một triệu platform thread sẽ giết chết máy từ lâu trước con số đó.

Hệ quả thực dụng cần rút ra ngay: lợi ích của virtual thread đến hoàn toàn từ việc unmount khi block. Một virtual thread mà chẳng bao giờ unmount thì chẳng khác gì chạy trên platform thread, chỉ tốn thêm một lớp gián tiếp. Nên virtual thread chỉ phát huy khi workload thật sự dành phần lớn thời gian để chờ. Và quan trọng hơn, có những tình huống virtual thread muốn unmount mà không được - đó là pinning, ta sẽ tới ngay sau khi nhìn rõ hơn cách scheduler phân việc.

5. Lập lịch virtual thread: không có lát thời gian

Ai lập lịch virtual thread? Không phải OS - OS chỉ thấy các carrier. Scheduler của virtual thread là chính JVM, cụ thể là một ForkJoinPool chuyên dụng chạy chế độ FIFO (khác với common pool LIFO + work-stealing của bài Fork/Join), số worker mặc định bằng số core, chỉnh được qua system property jdk.virtualThreadScheduler.parallelism.

Và scheduler này có một khác biệt nền tảng so với kernel: nó không preempt theo lát thời gian. OS cắt ngang một platform thread sau mỗi time slice vài millisecond để nhường CPU cho thread khác, bất kể thread đó muốn hay không. Scheduler của JVM thì không làm vậy với virtual thread: một virtual thread đã mount sẽ giữ carrier cho tới khi tự nó nhả ra - bằng cách gặp một thao tác blocking (unmount), kết thúc, hoặc gọi Thread.yield() tường minh. Không có chuông báo hết giờ.

Hệ quả nhìn thấy được ngay. Hãy nhìn một virtual thread "tham ăn":

// This virtual thread never blocks, so it never unmounts:
// it holds one carrier hostage until the loop finishes
Thread.startVirtualThread(() -> {
    long sum = 0;
    for (long i = 0; i < 5_000_000_000L; i++) {
        sum += i;                  // pure CPU work, no blocking call
    }
    System.out.println(sum);
});

Một task CPU-bound thuần như vậy - vòng lặp tính toán không bao giờ block - sẽ chiếm trọn một carrier từ đầu đến cuối. Nếu số task như vậy bằng hoặc vượt số carrier, mọi virtual thread khác phải xếp hàng chờ, kể cả những request I/O-bound chỉ cần vài millisecond CPU. Triệu chứng ở production là latency tăng vọt khó hiểu khi ai đó "tiện tay" ném một job tính toán nặng vào executor virtual thread của tầng request. Đây chính là lý do cơ chế khiến virtual thread dành cho I/O-bound: lợi ích đến từ unmount, mà CPU-bound thì không bao giờ unmount. Công việc tính toán nặng vẫn thuộc về một pool platform thread cỡ số core, hoặc Fork/Join nếu bài toán có dạng chia để trị.

Còn một mảnh nhỏ đáng biết: compensation. Khi một virtual thread block mà không unmount được - bị pin (mục 6), hoặc block qua một ManagedBlocker như ta gặp ở bài Fork/Join - scheduler có thể tạm mở rộng nhóm carrier thêm vài thread để bù cho carrier đang kẹt, giữ độ song song không tụt. Đó là van an toàn để hệ thống không nghẹt thở, không phải giấy phép để pinning thoải mái: mỗi carrier bù vẫn là một OS thread thật, đắt đúng như bài Process và Thread đã chỉ ra.

6. Pinning: khi virtual thread không unmount được

Toàn bộ phép màu phụ thuộc vào việc virtual thread unmount được khi block. Có những lúc nó không thể, và khi đó nó ghim - pin - chặt carrier thread lại: virtual thread block, nhưng carrier thì cũng kẹt theo, không phục vụ được virtual thread nào khác. Một carrier bị ghim đúng bằng việc đánh mất một OS thread vào chỗ chờ - chính cái giá ta dùng virtual thread để né. Ghim đủ nhiều carrier cùng lúc thì throughput sụp, và triệu chứng thường là độ trễ tăng vọt dưới tải dù CPU vẫn nhàn.

Tới Java 25, nguồn gây pinning đáng kể nhất còn lại là native frame. Khi ngăn xếp lời gọi đang đi qua một native method - một lời gọi JNI, hoặc một số đường foreign function - JVM không thể cuộn cái stack ấy cất vào heap, nên không unmount được; nếu code block ngay tại đó, carrier bị ghim suốt thời gian chờ.

Đáng nói là synchronized. Trong các bản đầu của virtual thread (Java 21), block bên trong một khối hay method synchronized cũng ghim carrier - một cái bẫy lớn, vì synchronized có ở khắp nơi trong code Java và thư viện cũ. Java 24 đã xử lý đúng trường hợp này (JEP 491): từ đó, một virtual thread block trong khối synchronized vẫn unmount được bình thường. Vì baseline của series là Java 25, ta thừa hưởng cách hành xử đã sửa: synchronized quanh một đoạn block thông thường không còn ghim carrier nữa. Dù vậy, lời khuyên thực dụng từ thời pinning vẫn nguyên giá trị vì hai lý do. Thứ nhất, rất nhiều hệ thống production vẫn chạy trên Java 21 LTS, nơi cái bẫy còn đó. Thứ hai, ReentrantLock vốn dĩ là lựa chọn tốt hơn cho khoá giữ qua một đoạn I/O dài, độc lập với chuyện pinning - nó cho tryLock có timeout, cho khoá nhả ở scope khác nơi giành, và không bao giờ có nguy cơ ghim kể cả trên runtime cũ.

Cụ thể, một service gọi remote bên trong khối khoá, nếu viết theo lối cũ:

private final Object lock = new Object();

public Quote refresh(String symbol) {
    synchronized (lock) {            // on Java 21: blocking below pins the carrier
        return remoteApi.fetch(symbol);   // long I/O while holding the lock
    }
}

nên đổi sang ReentrantLock, vừa an toàn trên mọi runtime, vừa rõ ý hơn:

private final ReentrantLock lock = new ReentrantLock();

public Quote refresh(String symbol) {
    lock.lock();
    try {
        return remoteApi.fetch(symbol);
    } finally {
        lock.unlock();
    }
}

Tốt hơn nữa là đừng ôm khoá qua một lời gọi I/O dài chút nào - đúng nguyên tắc từ bài Thread safety: giữ khoá đủ lâu để bao trọn compound action trên shared state, rồi nhả trước khi làm việc dài hơi như network. Khoá để bảo vệ shared mutable state, không phải để serialize các lời gọi mạng.

Chẩn đoán pinning không phải đoán mò. JDK Flight Recorder phát ra một event tên jdk.VirtualThreadPinned mỗi khi một virtual thread bị ghim quá một ngưỡng thời gian; bật JFR rồi soi event này là cách chắc chắn nhất để tìm thủ phạm trong một service thật. Trên Java 21 còn có cờ chẩn đoán nhanh -Djdk.tracePinnedThreads=full in ra stack trace tại điểm ghim, nhưng cờ này đã bị loại bỏ ở Java 24 sau khi synchronized thôi gây pinning, nên trên baseline 25 ta dựa vào JFR. Mục 10 sẽ nói thêm về bộ công cụ quan sát.

7. ThreadLocal trong thế giới virtual thread

Virtual thread vẫn là Thread, nên ThreadLocal - công cụ confinement theo thread ta đã mổ ở bài Confinement - hoạt động đúng nguyên ngữ nghĩa: mỗi thread một bản sao giá trị, không thread nào thấy bản của thread khác. Không có gì vỡ về mặt đúng/sai. Cái vỡ là về mặt chi phí và ý nghĩa, ở hai điểm.

Thứ nhất, phép nhân theo số thread. ThreadLocal trả chi phí bộ nhớ tỉ lệ với số thread đang giữ giá trị. Thời platform thread, "số thread" là vài trăm - một ThreadLocal cache một buffer 4KB tốn cỡ một megabyte cho cả pool, không ai để ý. Thời virtual thread, "số thread" là hàng trăm nghìn đến hàng triệu. Cùng cái buffer 4KB ấy nhân với 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. Giá trị càng to, càng nhiều ThreadLocal rải trong các thư viện, hoá đơn càng dày.

Thứ hai, pattern "pool object qua ThreadLocal" mất nghĩa. Trên platform thread pool, một thread sống rất lâu và phục vụ hàng nghìn task nối tiếp; cache một object đắt-khởi-tạo (formatter, parser, buffer) vào ThreadLocal để các task sau tái dùng là một chiêu tối ưu kinh điển:

// Classic trick on platform thread pools: one formatter per thread,
// reused across thousands of tasks served by that long-lived thread
static final ThreadLocal<SimpleDateFormat> FORMAT =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// On virtual threads this caches nothing: each task gets a brand-new
// thread, so a brand-new SimpleDateFormat - created once, used once, discarded

Virtual thread sống đúng một task rồi chết - cache vừa khởi tạo xong thì thread mang nó cũng kết thúc, không có "task sau" nào để tái dùng. Object đắt được khởi tạo lại mỗi task, tức là pattern không những hết lợi mà còn tốn thêm chỗ chứa.

Cộng với hai vấn đề cố hữu sẵn có - giá trị mutable không kiểm soát, vòng đời không biên rõ ràng dễ rò giữa các request - ThreadLocal trở thành công cụ sai hình dạng cho việc mang context (user đang đăng nhập, traceId) xuyên qua hàng triệu virtual thread. Java trả lời bằng một cơ chế mới sinh ra cùng thế hệ Loom: ScopedValue - giá trị bất biến, ràng vào một phạm vi tự đóng, kế thừa xuống thread con với chi phí gần như bằng không. Đó là một nửa nội dung của bài kế tiếp.

8. Khi nào nên dùng, khi nào không

Sau khi đã thấy cơ chế, ranh giới sử dụng trở nên rõ ràng thay vì cảm tính. Virtual thread là công cụ cho throughput của workload I/O-bound, không phải một liều tăng tốc đa năng.

Hợp nhất là những task dành phần lớn thời gian chờ thứ gì đó bên ngoài: web server xử lý nhiều request đồng thời, service liên tục gọi database, service fan-out tới nhiều remote API rồi gộp kết quả, hay bất cứ chỗ nào ta muốn duy trì rất nhiều kết nối đồng thời mà mỗi kết nối phần lớn thời gian nằm im. Đây là những workload mà cái trần kích thước pool từng cắn ta, và virtual thread gỡ đúng cái trần đó: số task đồng thời giờ bị giới hạn bởi bộ nhớ và tài nguyên downstream, không còn bởi số OS thread.

Ngược lại, với task CPU-bound thuần - nén ảnh, tính ma trận, parse một khối dữ liệu lớn trong bộ nhớ - virtual thread không cho thêm gì. Như mục 5 đã chỉ ra, một task CPU-bound gần như không bao giờ unmount, vì nó luôn có việc để tính chứ không ngồi chờ; nó chiếm carrier hệt như chiếm một platform thread, và vì không có preemption theo lát thời gian, nó còn chặn đường mọi virtual thread khác đang xếp hàng. Giới hạn thật ở đây là số CPU core, và công cụ đúng vẫn là một pool kích thước cỡ số core, hay Fork/Join của bài trước. Tạo một triệu virtual thread cho công việc CPU-bound chỉ thêm overhead lập lịch chứ không làm core nào tính nhanh hơn.

Gom lại thành một bảng quyết định:

WorkloadCông cụ đúngVì sao
Nhiều request đồng thời, mỗi cái chờ DB/API là chínhVirtual thread, mỗi task một threadLợi ích đến từ unmount khi chờ; số task không còn bị trần OS thread chặn
Fan-out gọi N service rồi gộp kết quảVirtual thread (+ structured concurrency, bài sau)Hàng nghìn nhánh chờ song song, gần như không tốn tài nguyên
Tính toán nặng dạng chia để trịFork/JoinWork-stealing cân tải CPU; virtual thread không thêm core nào
Tính toán nặng, task độc lậpPool platform thread cỡ số coreGiới hạn thật là CPU; nhiều thread hơn core chỉ thêm context switch
Giới hạn lời gọi tới downstream mong manhSemaphore tường minh trước lời gọiVan giới hạn tách khỏi cơ chế chạy task - không mượn kích thước pool

Và một quy tắc gần như tuyệt đối, hệ quả trực tiếp của phần 2: đừng pool virtual thread. Pool tồn tại để tái sử dụng một tài nguyên đắt và khan hiếm; virtual thread không đắt cũng không khan, nên gom chúng vào một pool cố định là vừa vô nghĩa vừa phản tác dụng - ta lại tự dựng lại đúng cái trần mà virtual thread sinh ra để phá. Cũng vì lẽ đó, các cấu hình kiểu newFixedThreadPool xưa nay vô tình đóng vai trò bộ giới hạn truy cập tài nguyên downstream nay không còn làm việc đó nữa. Nếu cần giới hạn số lời gọi đồng thời tới một database hay một API mong manh, hãy dùng một công cụ giới hạn tường minh như Semaphore đặt ngay trước lời gọi đó, chứ đừng mượn kích thước pool làm cái van. Tách bạch hai mối quan tâm - thread để chạy task, semaphore để giới hạn tài nguyên - là tư duy đúng trong thế giới virtual thread.

9. Di cư từ thread pool sang virtual thread

Trên giấy, việc di cư nhỏ đến mức đáng ngờ. Một service đang chạy task lên một pool platform thread kiểu bài Executor và thread pool, đổi nơi tạo executor là gần như xong:

// Before: fixed pool, its size is the concurrency ceiling
ExecutorService pool = Executors.newFixedThreadPool(200);

// After: one virtual thread per task, no such ceiling
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();

Code task bên trong không cần đổi: vẫn là submit một Runnable hay Callable, vẫn nhận Future, vẫn viết tuần tự gặp I/O thì chặn. Đó là cái đẹp của việc virtual thread vẫn là Thread. Nhưng đổi một dòng đó kéo theo việc phải đổi tư duy sizing, và đây mới là phần dễ sai.

Với pool cũ, sizing là một bài toán quen: chọn số thread sao cho lấp đầy CPU mà không quá tải scheduler, và con số đó vô tình cũng chặn luôn số lời gọi downstream đồng thời. Với virtual thread, khái niệm "chọn số thread" biến mất - ta không size nhóm carrier (JVM tự lo theo số core), và số virtual thread thì cứ bằng số task. Việc từng giải bằng một con số duy nhất giờ tách thành hai câu hỏi riêng. Một, bao nhiêu task được phép chạy đồng thời? Câu trả lời thường là "bao nhiêu cũng được, để chúng cùng tiến". Hai, bao nhiêu lời gọi đồng thời thì database hay remote API kia chịu nổi? Câu này phải trả lời tường minh bằng Semaphore hoặc connection pool ngay tại tài nguyên đó, chứ không còn được cái trần pool trả lời hộ.

Đây là cái bẫy di cư phổ biến nhất, và nó âm thầm. Cái pool 200 thread cũ vẫn lặng lẽ bảo vệ database khỏi quá tải bằng cách không bao giờ cho quá 200 query bay đi cùng lúc. Đổi sang virtual thread và bỏ luôn lớp bảo vệ ngầm đó, một đợt tải đột biến mười nghìn request có thể phóng mười nghìn query đồng thời và đánh sập đúng cái database ta định phục vụ. Virtual thread dời nút thắt cổ chai từ tầng thread của ứng dụng xuống tài nguyên downstream; di cư an toàn nghĩa là nhìn thấy nút thắt mới đó và đặt một cái van tường minh ngay tại nó.

Trong capstone TicketFlow, đây chính là bước nhảy lên v4. Tới v3, executor xử lý các tác vụ của TicketFlow vẫn là pool platform thread cố định kiểu task execution. Sang v4, executor xử lý request đổi sang newVirtualThreadPerTaskExecutor, để mỗi request đặt vé chạy trên virtual thread riêng và thoải mái chặn ở các bước gọi I/O như kiểm tồn kho hay thanh toán. Đồng thời phải rà lại những chỗ synchronized quanh lõi book từ bài thread safety: trên baseline 25 chúng không còn ghim carrier, nhưng những khối khoá ôm qua I/O vẫn nên đổi sang ReentrantLock và thu hẹp lại đúng phần shared mutable state, để code vừa đúng vừa không serialize các lời gọi mạng một cách vô ích.

10. Quan sát virtual thread: thread dump và JFR

Một hệ thống chạy hàng trăm nghìn virtual thread đặt ra câu hỏi vận hành rất thực tế: làm sao nhìn thấy chúng khi cần debug? Công cụ quen tay nhất - jstack - không trả lời được: nó chỉ liệt kê platform thread, tức là ta thấy nhóm carrier vài chục cái, còn hàng trăm nghìn virtual thread đang sống thì vô hình. Một service "kẹt" mà jstack trông sạch sẽ là dấu hiệu kinh điển của việc nhìn nhầm tầng.

JDK cung cấp một dạng thread dump mới qua jcmd, có liệt kê virtual thread và nhóm chúng theo "container" (executor hoặc scope đã sinh ra chúng):

# jstack only shows platform threads (the carriers)
# the new dump format includes virtual threads, grouped by container
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json

File JSON này cho thấy từng virtual thread với stack trace của nó - đủ để trả lời "mười nghìn request đang kẹt ở đâu" (thường là cùng một frame chờ một downstream chậm).

Công cụ thứ hai là JDK Flight Recorder. Ngoài jdk.VirtualThreadPinned đã nhắc ở mục 6 - event quan trọng nhất khi nghi pinning - còn có jdk.VirtualThreadSubmitFailed (scheduler không nhận thêm task, hiếm nhưng nghiêm trọng). Hai event start/end của virtual thread tồn tại nhưng mặc định tắt, vì ở quy mô triệu thread chúng tạo lượng dữ liệu khổng lồ. Quy trình thực dụng: bật JFR mặc định trong production (-XX:StartFlightRecording), khi latency bất thường thì soi jdk.VirtualThreadPinned trước, rồi mới tới thread dump JSON để nhìn toàn cảnh.

11. Pitfall tổng hợp

Nhầm 1: pool virtual thread. Trực giác cũ "thread thì phải pool" dẫn tới code kiểu này:

// WRONG: pooling virtual threads rebuilds the ceiling they were meant to remove
ExecutorService pool = Executors.newFixedThreadPool(
        200, Thread.ofVirtual().factory());

✅ Virtual thread rẻ - tạo mới cho mỗi task bằng newVirtualThreadPerTaskExecutor(). Nếu mục đích thật của con số 200 là giới hạn truy cập một tài nguyên, dùng Semaphore(200) đặt ngay trước lời gọi tài nguyên đó.

Nhầm 2: synchronized ôm I/O, chạy trên JDK 21. Block trong khối synchronized ghim carrier trên Java 21 - throughput sụp dưới tải dù CPU nhàn.

✅ Đổi sang ReentrantLock, hoặc tốt hơn: thu hẹp khoá để không ôm I/O. Ghi chú phiên bản: Java 24 (JEP 491) đã gỡ pinning cho synchronized, nên trên baseline 24/25 đây không còn là bẫy - nhưng codebase chạy 21 LTS vẫn phải né.

Nhầm 3: né Thread.sleep và blocking call trong virtual thread vì sợ "tốn thread". Phản xạ này sinh ra từ thời platform thread, nơi một thread ngủ là một OS thread bị giam.

✅ Ngược lại hoàn toàn: blocking trong virtual thread là rẻ - Thread.sleep, đọc socket, chờ queue đều đã được trang bị lại để unmount, trả carrier cho việc khác. Viết code tuần tự, chặn thoải mái, chính là phong cách mà virtual thread sinh ra để phục vụ; vặn vẹo sang async ở đây là tự bỏ cái lợi lớn nhất.

12. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: JEP 444 là tài liệu nên đọc trọn một lần - nó ngắn hơn tưởng tượng và trả lời gần hết các câu "vì sao thiết kế như vậy", từ chuyện vì sao không làm async/await tới vì sao giữ nguyên API Thread.

13. Liên hệ các bài khác

  • Process và Thread — cái giá của một OS thread (stack, slot kernel, context switch) là toàn bộ lý do virtual thread tồn tại; đọc lại để thấy chính xác virtual thread né được khoản nào.
  • Thread API và vòng đời — virtual thread vẫn là Thread, nên interrupt và cooperative cancellation ở đó áp dụng nguyên vẹn, và sẽ thành xương sống của cancellation trong structured concurrency.
  • Confinement — ngữ nghĩa ThreadLocal mà mục 7 dựa vào; hiểu confinement theo thread rồi mới thấy vì sao nó đổ vỡ về chi phí ở quy mô triệu thread.
  • Executor và thread pool — tư duy pool + sizing mà virtual thread thay thế; cancellation qua Future.cancel ở đó vẫn dùng y nguyên trên executor virtual thread.
  • Fork/Join — nơi đúng cho CPU-bound; đồng thời chính ForkJoinPool là engine carrier của virtual thread, và ManagedBlocker là tiền thân của cơ chế compensation.
  • Structured Concurrency & ScopedValue — bài kế tiếp: khi thread đã rẻ và fan-out hàng nghìn task thành chuyện thường, cần một khung kỷ luật vòng đời cho cả nhóm.

14. Tóm tắt

  • Virtual thread vẫn là java.lang.Thread - cùng API, cùng ngữ nghĩa; khác biệt nằm ở scheduling: JVM multiplex nhiều virtual thread lên một nhóm nhỏ carrier (ForkJoinPool cỡ số core).
  • Cơ chế lõi là mount/unmount: gặp blocking I/O, JDK cuộn stack vào heap (continuation), trả carrier cho virtual thread khác; I/O xong thì remount - có thể lên carrier khác.
  • Rẻ vì stack sống trên heap, khởi đầu vài trăm byte và co giãn theo lời gọi; một triệu virtual thread đang chờ là khả thi, một triệu platform thread thì không - khác nhau theo bậc độ lớn.
  • Không có preemption theo lát thời gian: virtual thread giữ carrier tới khi tự block hoặc xong; task CPU-bound vì thế không hợp - chúng thuộc về pool cỡ số core hoặc Fork/Join.
  • Pinning là khi virtual thread block mà không unmount được, ghim chết carrier: nguồn còn lại trên Java 25 là native frame; synchronized chỉ còn là vấn đề trên Java 21 (JEP 491 ở Java 24 đã gỡ).
  • Đừng pool virtual thread - mỗi task một thread mới; giới hạn tài nguyên downstream bằng Semaphore tường minh thay vì mượn kích thước pool.
  • ThreadLocal vẫn chạy đúng nhưng chi phí nhân theo số thread, và pattern cache-theo-thread mất nghĩa khi thread sống đúng một task; context nên chuyển sang ScopedValue.
  • Quan sát: jcmd <pid> Thread.dump_to_file -format=json để thấy virtual thread (jstack không thấy); JFR event jdk.VirtualThreadPinned để săn pinning.

Virtual thread không phát minh ra một mô hình lập trình mới. Nó làm sống lại mô hình cũ và đơn giản nhất - mỗi request một thread, code tuần tự, gặp I/O thì cứ chặn - ở quy mô mà cái trần OS thread từng cấm. Đổi lại, ta phải hiểu cơ chế mount/unmount để biết lợi ích đến từ đâu, biết tránh pinning để lợi ích không bốc hơi, và quan trọng nhất, biết rằng việc bỏ cái trần thread sẽ dời nút thắt cổ chai xuống tài nguyên downstream chứ không xoá nó đi.

Nhưng newVirtualThreadPerTaskExecutor mới chỉ cho ta một đám virtual thread rẻ và một ranh giới close() để chờ chúng. Nó chưa nói gì về kỷ luật vòng đời của một nhóm task: nếu một subtask hỏng, ta có huỷ ngay các subtask anh em không, hay để chúng chạy nốt rồi rò ra ngoài? Nếu request cha bị cancel, sự huỷ có lan xuống các subtask con không? ExecutorService thường để những câu hỏi đó cho ta tự xoay xở, và đó là mảnh đất màu mỡ cho task leak. Khi thread đã rẻ và việc fan-out hàng nghìn task con cho một request thành chuyện thường ngày, nhu cầu về một cái khung buộc cả nhóm task cùng mở, cùng kết thúc, cùng lan lỗi và cùng huỷ trở nên cấp thiết. Đó là structured concurrency, cùng với ScopedValue để mang context bất biến qua phạm vi đó - và là chủ đề của bài kế tiếp.

15. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao một triệu virtual thread không cần một triệu OS thread?

Vì virtual thread không gắn cố định với OS thread. JVM giữ một nhóm nhỏ carrier thread (cỡ số core) và mount virtual thread lên đó chỉ khi nó thực sự chạy. Khi virtual thread gặp blocking I/O, JDK unmount nó: stack được cuộn vào heap dưới dạng continuation, carrier được trả lại để chạy virtual thread khác. Một virtual thread đang chờ vì thế không chiếm OS thread nào - nó chỉ là vài trăm byte tới vài KB trên heap. Một triệu virtual thread đang chờ I/O = một triệu object nhỏ trên heap + vài chục carrier, chứ không phải một triệu stack megabyte và một triệu slot scheduler kernel.

Q2
Pinning là gì, và nó còn là vấn đề ở JDK nào?

Pinning là tình huống virtual thread block nhưng JVM không unmount được, nên carrier bị ghim kẹt theo - mất đúng một OS thread vào chỗ chờ, ngược hẳn mục đích của virtual thread. Trên Java 21, nguồn pinning lớn nhất là block bên trong khối/method synchronized; Java 24 (JEP 491) đã làm lại monitor để trường hợp này unmount bình thường. Từ Java 24/25, nguồn còn lại chủ yếu là native frame (JNI/FFM) trên stack. Chẩn đoán bằng JFR event jdk.VirtualThreadPinned; cờ -Djdk.tracePinnedThreads chỉ tồn tại tới Java 21-23, đã bị gỡ ở JDK 24.

Q3
Vì sao không nên pool virtual thread?

Pool tồn tại để khấu hao chi phí của một tài nguyên đắt và khan hiếm - đúng với platform thread (mỗi cái một OS thread + stack megabyte). Virtual thread không đắt (tạo trong user space, vài trăm byte) cũng không khan hiếm, nên tái sử dụng không tiết kiệm được gì đáng kể. Tệ hơn, một pool cố định N virtual thread dựng lại đúng cái trần concurrency mà virtual thread sinh ra để phá. Nếu con số N thực chất để bảo vệ một downstream (database, API), hãy nói thẳng điều đó bằng Semaphore đặt trước lời gọi - tách bạch "thread để chạy task" khỏi "van giới hạn tài nguyên".

Q4
Task CPU-bound chạy trên virtual thread có nhanh hơn không? Vì sao?

Không - thậm chí có thể tệ hơn cho phần còn lại của hệ thống. Lợi ích của virtual thread đến hoàn toàn từ việc unmount khi chờ; task CPU-bound không bao giờ chờ nên không bao giờ unmount, nó chiếm carrier y như chiếm một platform thread, cộng thêm một lớp lập lịch gián tiếp. Nguy hiểm hơn: scheduler virtual thread không preempt theo lát thời gian, nên một task tính toán dài giữ rịt carrier và làm mọi virtual thread khác (kể cả request I/O nhẹ) phải xếp hàng - latency tăng vọt. Giới hạn thật của CPU-bound là số core; công cụ đúng là pool cỡ số core hoặc Fork/Join.

Q5
Đổi newFixedThreadPool(200) sang newVirtualThreadPerTaskExecutor() - một dòng code. Rủi ro ngầm nào xuất hiện?

Mất van bảo vệ downstream. Cái pool 200 thread, ngoài việc chạy task, còn âm thầm bảo đảm không bao giờ có quá 200 lời gọi database/API bay đi cùng lúc. Executor virtual thread không có trần: một đợt tải đột biến mười nghìn request sẽ phóng mười nghìn query đồng thời và có thể đánh sập downstream. Di cư an toàn phải kèm một giới hạn tường minh - Semaphore hoặc connection pool có kích thước - đặt ngay tại tài nguyên cần bảo vệ. Virtual thread dời nút thắt cổ chai xuống downstream chứ không xoá nó.

Q6
Gọi Thread.sleep trong virtual thread có ghim carrier không? Điều này nói gì về phong cách code nên viết?

Không ghim. Thread.sleep là một trong các thao tác blocking đã được Loom trang bị lại: khi virtual thread sleep, JDK unmount nó, carrier được trả về chạy thread khác, hết giờ ngủ thì remount. Điều này đúng với hầu hết blocking call chuẩn của JDK - đọc socket, chờ BlockingQueue, JDBC qua socket. Hệ quả về phong cách: trên virtual thread, code tuần tự + blocking là phong cách được khuyến khích, không phải thứ phải né; vặn code sang async/callback để "tiết kiệm thread" là mang phản xạ của thời platform thread vào nơi nó không còn đúng.

Q7
ThreadLocal trên virtual thread có hoạt động không? Vấn đề thật sự nằm ở đâu?

Hoạt động đúng ngữ nghĩa - 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. Một là phép nhân: chi phí ThreadLocal tỉ lệ số thread, và số thread giờ là hàng triệu - một giá trị cache vài KB nhân triệu lần là hàng GB heap. Hai là pattern cache-object-theo-thread mất nghĩa: virtual thread sống đúng một task rồi chết, cache không bao giờ được tái dùng. Cộng với tính mutable và vòng đời không biên sẵn có, câu trả lời cho context xuyên task là ScopedValue - bất biến, tự thu hồi theo phạm vi, kế thừa rẻ - học ở bài kế tiếp.

Bài tiếp theo: Structured Concurrency & ScopedValue — lifecycle có kỷ luật cho nhóm task

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

Đặt 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