+2

Tôi nói gì khi nói về Java virtual thread

Điều gì mang tôi đến với virtual thread

Là một backend developer, tôi bắt đầu sự nghiệp coding của mình bằng C++ rồi sau đó là Java, Js. Nhờ Spring framework mà việc develop các ứng dụng backend của tôi trở nên nhẹ nhàng hơn, và dần dần tôi cũng thay thế từ backend trong danh xưng thành Java khi giới thiệu: Tôi là 1 Java developer.

Cách đây 2 năm, tôi có một số service yêu cầu xử lý lượng message concurrent khá lớn. Những ứng dụng code bằng Java trở nên cồng kềnh, nặng nề, không còn phù hợp nữa và tôi tìm tới Go (Go lang). Điều làm tôi ấn tượng nhất ở Go là các Go routine(tương tự như thread ở Java) được thiết kế một cách cực kỳ lightweight. Trong một ứng dụng Go, tôi có thể mở hàng chục nghìn, thậm chí hàng trăm nghìn go routine để xử lý các task concurrent hay những request bất đồng bộ một cách nhẹ nhàng. Tôi đã tự hỏi sao Java không thiết kế như vậy nhỉ, hay liệu có cách nào mà tôi cũng có thể làm được những điều như vậy bằng Java không? Bởi vì dù sao, lúc đó tôi cũng đang là 1 Java developer mà. Và rồi tôi tìm thấy Java virtual thread.

Oracle đã làm gì với virtual thread

Được thử nghiệm từ Java 19 và release chính thức trong phiên bản Java 21 vào tháng 9 năm 2023, Virtual thread được Oracle giới thiệu là 1 kiểu thread lightweight, được thiết kế để giảm chi phí tài nguyên và tăng khả năng mở rộng cho các ứng dụng xử lý concurrent. “Virtual threads are lightweight threads that reduce the effort of writing, maintaining, and debugging high-throughput concurrent applications.

Vậy Oracle đã làm điều này như thế nào?

Platform thread

Trước tiên ta hãy cùng nhau ôn lại về cách mà Java thiết kế các thread cổ điển hay còn gọi là các platform thread. image.png

Khi ta tạo một đối tượng Thread trong Java (ví dụ: new Thread(runnable).start()), JVM sẽ nhận được yêu cầu và bắt đầu quá trình tạo thread. Nó gọi tới os để yêu cầu khởi tạo 1 native thread tương ứng thông qua JNI (Java Native Interface). Các thông tin như kích thước stack hay thread priority cũng được JVM gửi kèm để os có thể tạo đúng native thread mong muốn. Sau khi os tạo thành công native thread, JVM gắn Java thread với native thread, duy trì một cấu trúc dữ liệu nội bộ để quản lý trạng thái của thread và đồng bộ hóa với native thread.

Một khi việc mapping 1:1 này hoàn tất, thread Java trở thành một "vỏ bọc" cho native thread. Mọi thao tác trên thread Java (như start(), interrupt(), join()) sẽ được JVM chuyển thành các lệnh tương ứng xuống hệ điều hành. Việc lên lịch để đưa thread vào core CPU cũng do os xử lý. Hệ điều hành có bộ lập lịch riêng, chịu trách nhiệm phân bổ thời gian CPU cho tất cả các native threads (bao gồm cả những thread từ JVM và các tiến trình khác trên hệ thống).

Nói chung, ở platform thread, làm JVM khá nhàn…

Tuy nhiên đây cũng chính là vấn đề. Chi phí để khởi tạo và duy trì một thread native trên os là không hề nhỏ. Điều này làm giới hạn khả năng mở nhiều thread cho ứng dụng và là hạn chế của Java khi xử lý các bài toàn về concurrent. Mức tối thiểu phổ biến để khởi tạo 1 native thread trên các os là 1MB. Nếu hệ thống mới chỉ mở 1000 thread thì riêng chi phí để tạo ra đống này đã là 1GB 😞. Vậy thì còn đâu tài nguyên cho ứng dụng nữa.

Virtual thread

image.png Điều đầu tiên khi tôi đọc thiết kế của virtual thread là nó khá … quen quen. Chính go routine là đây chứ đâu 😃)

Java thực hiện ảo hoá thread lên một mức nữa và đặt tên cho nó là virtual thread. Virtual thread lúc này không được mapping 1:1 với os thread mà được quản lý thông qua một bộ lập lịch(scheduler). Các virtual thread lúc này được lập lịch để chạy trên các carrier thread. Còn các carrier thread thì chính là các platform thread được ánh xạ với các native thread như ở trên.

Khi có một yêu cầu tạo virtual thread, JVM sẽ thực hiện tạo 1 instance của lớp Virtual thread. Đối tượng này không ánh xạ trực tiếp tới native thread mà được quản lý như một thực thể "ảo" trong JVM. Nếu đây là lần đầu tiên tạo virtual thread, JVM khởi tạo một ForkJoinPool đặc biệt chứa các carrier thread. Số lượng carrier threads trong pool mặc định bằng số core CPU của server. Virtual thread mới tạo sẽ được thêm vào queue của scheduler. Tuy nhiên, scheduler không chạy virtual thread ngay lập tức mà chờ đến khi có carrier thread rảnh.

Khi tất cả đã sẵn sàng, Virtual thread được JVM mount lên carrier thread. Điều này có nghĩa là code của virtual thread (trong Runnable) bắt đầu chạy trên stack của carrier thread. JVM sử dụng 1 cơ chế gọi là continuation để quản lý điểm bắt đầu thực thi của virtual thread. Carrier thread thực thi virtual thread như một native thread bình thường. Tuy nhiên, đây không phải là một thread riêng cho virtual thread mà là một tài nguyên được chia sẻ.

Nếu virtual thread gặp một thao tác blocking như I/O, Thread.sleep(), hoặc lock, Virtual thread sẽ được JVM unmount khỏi carrier thread. Các trạng thái thực thi của virtual thread (program counter, stack frame,…) được ghi vào đối tượng Continuation trong heap để thread có thể thực thi tiếp khi nó được đánh thức.

Testing

Wow, chỉ nhìn là đã thấy xịn sò rồi. Thay vì mở nhiều native thread, tôi chỉ mở số thread bằng số core cpu thôi, còn các task vụ concurrent tôi sẽ handle nó bằng 1 object ảo gọi là virtual thread. Đây thực sự là một thiết kế rất đáng mong đợi, và giờ ta sẽ cùng kiểm chứng nó có thực sự lightweight như lời đồn và liệu virtual thread có đủ để thay thế hoàn toàn thread cổ điển (platform thread) hay không.

Kịch bản 1

Để kiểm tra tính lightweight của virtual thread, tôi khá thích ý tưởng của anh bạn này https://medium.com/@AliBehzadian/java-thread-performance-vs-virtual-threads-part-2-8a4fd517a7ef Kịch bản thì khá đơn giản: mở 10000 thread (hoặc hơn nữa), cho số thread này xử lý một task nào đó và đo thời gian từ lúc khởi tạo thread tới lúc tất cả các thread hoàn thành và release. Tuy nhiên, tôi có chỉnh sửa lại code test trên để đảm bảo rằng các thread chỉ bắt đầu thực hiện task khi tất cả đã được khởi tạo xong. Và đây là kết quả:

  • Với 1000 threads
    • Thời gian để xử lý với platform thread: 194ms
    • Thời gian để xử lý với virtual thread: 29ms
  • Với 5000 threads
    • Thời gian để xử lý với platform thread: 2397ms
    • Thời gian để xử lý với virtual thread: 67ms

Khi tôi tăng số thread lên thành 10000 thì platform thread bắt đầu “cá đuối” và xuất hiện lỗi.

Lỗi này thông báo số thread đã tới giới hạn và os không còn resource (hoặc không cho phép do đạt limit cấu hình) để tạo thêm native thread. Trong khi đó với virtual thread tôi có thể thoải mái tạo tới 1 triệu, 10 triệu và thậm chí là nhiều hơn mà không gặp bất cứ một vấn đề nào liên quan tới memory.

1-0 cho virtual thread khi so về tính lightweight

Kịch bản 2 - Test hiệu năng

Lần này tôi tạo ra 2 thread pool cùng có kích thước là 100 thread, sau đó tôi submit 10000 task khá nặng về tính toán và thử xem pool nào có tốc độ xử lý tốt hơn. Và dưới đây là kết quả:

  • Pool platform thread xử lý mất: 294721ms
  • Pool virtual thread xử lý mất: 305390ms

Tại sao lại vậy? Tại sao 2 pool lại cho tốc độ xử lý gần bằng nhau, thậm chí pool virtual thread còn chậm hơn 1 xíu.

Vấn đề không phải ở thread mà ở cpu. Tất cả các task đều được quy về cho core của cpu xử lý. Việc tạo ra nhiều thread giúp các task được xử lý 1 cách đồng thời (concurrent) chứ không phải là song song(parallel). Bạn có thể tạo ra 10 triệu thread, tuy nhiên nếu cpu bạn chỉ có 2 core xử lý, thì tại 1 thời điểm, chỉ có 2 task được cpu thực thi. Vì thế nên hiệu năng xử lý của 2 pool là gần như ngang bằng, virtual thread có chậm hơn do việc phải chờ lên lịch ở scheduler, rồi các thao tác mount, unmount vào các carrier thread,…

Tổng kết

Java virtual thread thực sự là một điều hay ho khác biệt hoàn toàn so với platform threads, ngoài giúp các Java developer khệnh khạng hơn khi nói chuyện với các đồng nghiệp khác, còn giúp ứng dụng Java trở nên linh hoạt hơn với các concurrent task. Hỗ trợ xử lý hàng nghìn, thậm chí hàng triệu tác vụ đồng thời mà không tiêu tốn quá nhiều tài nguyên hệ thống, đặc biệt phù hợp trong các ứng dụng như máy chủ web, xử lý I/O, hoặc hệ thống yêu cầu phản hồi cao.

Tuy nhiên Virtual threads được thiết kế cho các tác vụ I/O-bound (như đọc/ghi file, mạng). Với các tác vụ nặng về CPU (như tính toán phức tạp), dùng platform threads hoặc thread pool truyền thống vẫn cứ là okela hơn.

Việc sử dụng virtual thread không cẩn thận cũng dễ dẫn tới các bug về locking. Và thực sự rất khó debug khi sử dụng virtual thread. Tôi sẽ dẫn chứng cho các bạn việc các kỹ sư Netflix bị dead lock do virtual thread và đã vất vả thế nào để debug được nó: https://netflixtechblog.com/java-21-virtual-threads-dude-wheres-my-lock-3052540e231d

Dù là như vậy, virtual thread vẫn đáng thử nghiệm và có 1 slot trong Java application của các bạn. Chúng ta có thể follow theo lời khuyên của Paul Bakker(Java Champion and developer in the Java Platform team at Netflix) bắt đầu sử dụng virtual thread với các tác vụ bất đồng bộ, sau đó là tới những task concurrent về I/O https://www.infoq.com/presentations/netflix-java/#:~:text=Virtual Threads-,Back,-to virtual threads

Tới đây là hết rồi. Một lần nữa happy reading, and may your code always compile on the first try!

Tài liệu tham khảo

Document về virtual thread của oracle:

https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-2BCFC2DD-7D84-4B0C-9222-97F9C7C6C521 Code test kịch bản 1:

import java.util.Random;
import java.util.concurrent.CountDownLatch;
import java.util.ArrayList;
import java.util.List;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        Main main = new Main();
        main.startTest();
    }

    CountDownLatch latch;
    CountDownLatch startLatch; // Latch để đồng bộ bắt đầu chạy thread
    long[] numbers;

    private void startTest() throws InterruptedException {
        int numThreads = 1000;
        latch = new CountDownLatch(numThreads);
        startLatch = new CountDownLatch(1); // Chỉ cần 1 tín hiệu để bắt đầu
        numbers = createRandomLongArray();

        // Test với regular threads
        System.out.println("Starting test with regular threads...");
        long start = System.currentTimeMillis();
        List<Thread> regularThreads = new ArrayList<>(numThreads);
        for (int i = 0; i < numThreads; i++) {
            Thread thread = new Thread(task);
            regularThreads.add(thread);
        };
        for (Thread thread : regularThreads) {
            thread.start();
        }
        startLatch.countDown(); // Ra tín hiệu để tất cả thread bắt đầu chạy
        latch.await(); // Chờ tất cả thread hoàn thành
        long end = System.currentTimeMillis();
        System.out.println("Time taken with regular threads: " + (end - start) + "ms");

        // Reset cho virtual threads
        latch = new CountDownLatch(numThreads);
        startLatch = new CountDownLatch(1);
        // Test với virtual threads
        System.out.println("\nStarting test with virtual threads...");
        List<Thread> virtualThreads = new ArrayList<>(numThreads);
        for (int i = 0; i < numThreads; i++) {
            Thread thread = Thread.ofVirtual().unstarted(task); // Tạo thread nhưng chưa chạy
            virtualThreads.add(thread);
        }
        start = System.currentTimeMillis();
        for (Thread thread : virtualThreads) {
            thread.start();
        }
        startLatch.countDown(); // Ra tín hiệu để tất cả thread bắt đầu chạy
        latch.await(); // Chờ tất cả thread hoàn thành
        end = System.currentTimeMillis();
        System.out.println("Time taken with virtual threads: " + (end - start) + "ms");
    }

    Runnable task = new Runnable() {
        @Override
        public void run() {
            try {
                startLatch.await(); // Chờ tín hiệu bắt đầu
                int palindromeCount = 0;
                for (long num : numbers) {
                    if (isPalindrome(num)) {
                        palindromeCount++;
                    }
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                latch.countDown(); // Giảm đếm latch khi hoàn thành
            }
        }
    };

    private long[] createRandomLongArray() {
        long[] numbers = new long[1_000];
        Random random = new Random();

        for (int i = 0; i < numbers.length; i++) {
            numbers[i] = 1 + random.nextLong(Long.MAX_VALUE - 1);
        }

        return numbers;
    }

    public boolean isPalindrome(long num) {
        long reversed = 0, remainder, original = num;
        while (num != 0) {
            remainder = num % 10;
            reversed = reversed * 10 + remainder;
            num /= 10;
        }
        return original == reversed;
    }
}

Code test kịch bản 2

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        Main main = new Main();
        main.startTest();
    }

    private void startTest() throws InterruptedException {
        int numTasks = 10000; // Số lượng task
        int threadCount = 1000; // Số thread cố định cho cả hai pool

        // Thread pool cho platform threads 
        ExecutorService platformPool = Executors.newFixedThreadPool(threadCount);

        // Thread pool cho virtual threads 
        ExecutorService virtualPool = Executors.newFixedThreadPool(threadCount,
                Thread.ofVirtual().factory()); // Virtual thread factory

        // Test với platform threads
        System.out.println("Starting test with platform thread pool (100 threads)...");
        long platformTime = runTasks(platformPool, numTasks);
        System.out.println("Time taken with platform threads: " + platformTime + "ms");

        // Test với virtual threads
        System.out.println("\nStarting test with virtual thread pool (100 carrier threads)...");
        long virtualTime = runTasks(virtualPool, numTasks);
        System.out.println("Time taken with virtual threads: " + virtualTime + "ms");

        // Đóng các pool
        platformPool.shutdown();
        virtualPool.shutdown();
        platformPool.awaitTermination(10, TimeUnit.SECONDS);
        virtualPool.awaitTermination(10, TimeUnit.SECONDS);
    }

    private long runTasks(ExecutorService pool, int numTasks) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(numTasks);
        long start = System.currentTimeMillis();

        // Gửi 10.000 task vào pool
        for (int i = 0; i < numTasks; i++) {
            pool.submit(() -> {
                try {
                    performHeavyCpuTask();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    latch.countDown(); // Giảm latch khi task hoàn thành
                }
            });
        }

        latch.await(); // Chờ tất cả task hoàn thành
        long end = System.currentTimeMillis();
        return end - start;
    }

    // Tác vụ nặng về CPU: Tính tổng các số nguyên tố từ 1 đến 1 triệu, lặp 10 lần
    private void performHeavyCpuTask() throws InterruptedException {
        int limit = 1_000_000;
        int iterations = 10; // Lặp lại để tăng tải CPU
        long sum = 0;
        Thread.sleep(10);
        for (int iter = 0; iter < iterations; iter++) {
            for (int num = 2; num <= limit; num++) {
                if (isPrime(num)) {
                    sum += num;
                }
            }
        }
    }

    // Kiểm tra số nguyên tố
    private boolean isPrime(int num) {
        if (num <= 1) return false;
        if (num <= 3) return true;
        if (num % 2 == 0 || num % 3 == 0) return false;
        for (int i = 5; i * i <= num; i += 6) {
            if (num % i == 0 || num % (i + 2) == 0) return false;
        }
        return true;
    }
}

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí