[Java 21] Virtual Thread aka Fibers

Nam Vu
15 min readOct 10, 2023

Java 21 đã chính thức (official) release LTS (long time support) version, nó cung cấp khá nhiều các tính năng (feature) hay ho, nhưng theo người viết thì feature hay ho nhất đó chính là Virtual Thread. Nhờ nó mà việc xử lý đa luồng (multi-threading) trong Java không còn phức tạp và nặng nề nữa. Nó sẽ là một cuộc cách mạng mà từ đó multi-thread/concurrency programming trong Java không còn thua kém các ngôn ngữ như Go-lang hay Kotlin nữa. Make Java great again and time to say goodbye to Reactive 😏

Platform Thread

Đầu tiên để hiểu về Virtual Thread ta sẽ tìm hiểu về cách mà Java quản lý Thread truyền thống. Java quản lý thread thông qua Java Virtual Machine (JVM), JVM sẽ thông qua một “thin wrappers” (một class/interface wrappers) để tương tác với OS threads mà ta gọi nó là Platform Threads. Về cơ bản platform thread là heavyweight (nặng) và tốn tài nguyên mà tôi đã từng đề cập nó ở bài viết này .

Tôi sẽ nói rõ thêm về cách mà OS quản lý bộ nhớ cho Thread, đó là OS lưu trữ thông tin về các luồng (threads) trong một cấu trúc dữ liệu gọi là “thread stack” dưới dạng các khối bộ nhớ nguyên khối (monolithic blocks of memory) tại thời điểm một thread được tạo ra, và nó sẽ không thể thay đổi memory size (kích thước bộ nhớ) đó sau này. Thread stack chứa các thông tin về trạng thái của thread (thread state) và thứ tự của các method được gọi trong nó. Cụ thể, thread stack bao gồm:

Stack Frames (Frames): Mỗi hàm gọi (method call hoặc function) của thread đó được lưu trữ trong một “stack frame”. Stack frame chứa thông tin như return addresses, các local variables (biến cục bộ) , function parameters (tham số hàm), và các dữ liệu khác để phục vụ cho method call hoặc function trong thread đó.
Pointer (Con trỏ): OS sử dụng một thanh ghi đặc biệt (register) được gọi là “stack pointer” để theo dõi vị trí hiện tại của Thread Stack. Con trỏ này di chuyển lên và xuống khi các hàm được gọi (call) và trả về (return) để luôn chỉ ra được đỉnh (top) của thread stack
Cơ chế Push và Pop: Dữ liệu được đẩy vào thread stack thông qua một cơ chế “push,” và khi một method call kết thúc, stack frame của nó sẽ bị loại bỏ (remove) thông qua một cơ chế “pop.” Điều này giúp duy trì trạng thái của thread và cho phép nó quay lại các method call trước đó.

Chính vì cơ chế đó mà nếu trong trường hợp trong code chúng ta gọi một hàm đệ quy (recursion method) không kết thúc hoặc các hàm lồng nhau (nested function calls) gọi quá nhiều lần có thể dẫn đến việc tạo ra quá nhiều stack frame, và khi không còn đủ bộ nhớ trong thread stack để lưu trữ thêm stack frame, sẽ xảy ra lỗi StackOverflowException.

Hoặc trong chương trình chúng ta lạm dụng tạo ra quá nhiều thread cũng khiến cho việc bộ nhớ phần cứng không đủ để cấp phát bộ nhớ cho Thread Stack và StackOverflowException vẫn có thể xảy ra, và việc giới hạn số lượng Thread được tạo ra là cần thiết. Tuy nhiên nó sẽ có thể tạo ra một vấn đề khá khó chịu rằng nếu chúng ta cần xây dựng một server applications cần chịu tải tốt với việc phục vụ một khối lượng lớn yêu cầu đồng thời (service large volumes of concurrent requests) với việc gán mỗi yêu cầu (request )đến cho một thread duy nhất trong suốt thời gian tồn tại của task đó. Với cách tiếp cận (approach) đó thì chúng ta có thể dễ dàng phục vụ 1000 yêu cầu đồng thời (concurrent requests) — nhưng chúng ta sẽ không thể phục vụ 1 triệu concurrent requests bằng kỹ thuật tương tự.

Để giải quyết việc một server applications cần phục vụ nhiều concurrent requests như trên thì thường lối tiếp cận của chúng ta là sử dụng kỹ thuật Reactive Programming Concept như tôi đã đề cập ở bài viết này . Tuy nhiên theo kinh nghiệm của bản thân người viết thì nó chỉ phù hợp với những ứng dụng có business đơn giản không quá phức tạp. Vì phải yêu cầu developer suy nghĩ và lập trình theo một hướng khác biệt, điều đó khiến cho code base trở nên khó hiểu (non human readable), khó bảo trì (maintenance), khó test và cũng khó debug hơn nhiều lần.

Virtual threads aka Fibers

Virtual threads được tạo ra để khắc phục nhược điểm về việc tạo và quản lý thread truyền thống có thể gây ra overhead lớn và tốn tài nguyên, và điều này có thể giới hạn khả năng mở rộng của ứng dụng. Giống như Platform thread thì Virtual thread được implement từ java.lang.thread nhưng điểm khác biệt là thay vì lưu “stack frame” trong bộ nhớ nguyên khối (monolithic blocks of memory) được quản lý bởi OS thì Virtual thread được lưu ở bộ nhớ Heap của Java garbage-collected và được quản lý bởi “Virtual Thread Scheduler” aka “Fiber Scheduler” trong JVM. Chi phí để tạo mỗi virtual thread chỉ khoảng vài trăm bytes, do đó ứng dụng có thể tạo và hoạt động hiệu quả với hàng triệu Virtual thread.

Virtual thread thực tế vẫn chạy trên các OS thread, tuy nhiên khi một virtual thread bị block bởi I/O operation thì Virtual Thread Scheduler (thành phần quản lý các virtual thread trong JVM) sẽ chặn (suspends) và un-mounting (gỡ) virtual thread đó cho tới khi nó được tiếp tục (resume). OS thread được liên kết với Virtual thread đó sẽ không bị block và vẫn tự do mount (gắn) và thực hiện các virtual thread khác. Cơ chế này gần tương tự như Go-routine trong Go-lang mà tôi đã đề cập ở bài viết này.

Virtual thread được đặt tên như vậy vì cách triển khai (implement) của nó khá giống với Virtual memory (bộ nhớ ảo). Virtual memory là một kỹ thuật cho phép ứng dụng sử dụng nhiều hơn bộ nhớ RAM thực sự có sẵn trên máy tính để thực thi chương trình hoặc lưu trữ dữ liệu. Phần cứng (hardware) thực hiện điều đó bằng cách tạm thời ánh xạ (mapping) virtual memory vào bộ nhớ vật lý (physical memory) khi cần. Khi một ứng dụng yêu cầu truy cập dữ liệu của physical memory đó (nằm ngoài RAM), hệ điều hành sẽ sao chép dữ liệu đó từ virtual memory vào RAM (quá trình gọi là “paging in”) để cho phép ứng dụng truy cập, và đẩy dữ liệu (swap out) các dữ liệu không cần thiết từ RAM về physical memory để giải phóng bộ nhớ RAM cho các ứng dụng khác.

Virtual thread cũng hoạt động tương tự để mô phỏng nhiều thread thì Virtual Thread Scheduler cũng lưu trữ và ánh xạ (mapping) một số lượng lớn virtual thread tới một số lượng nhỏ các OS thread. Quá trình này được gọi là “multiplexing” hoặc “thread multiplexing”.

Trái tim của Virtual Thread là Virtual Thread Scheduler (VTS) đó là một thành phần quản lý việc lập lịch và quản lý các virtual threads. VTS cũng quản lý mapping giữa virtual threads và OS threads. Dưới đây là cách nó hoạt động:

  • Virtual Thread Scheduler (VTS): Nhiệm vụ của nó là quản lý việc lập lịch và phân chia thời gian cho các virtual thread. Thay vì tạo một OS thread cho mỗi virtual thread, VTS quản lý các virtual thread trong một tập hợp nhỏ các OS thread thực sự như trình bầy bên trên.
  • Mapping Virtual Threads: Khi một ứng dụng tạo ra một virtual thread mới, VTS sẽ xác định OS thread trống (hoặc ít hoạt động nhất) trong một pool các OS threads sẵn sàng để sử dụng.
  • Thread Execution: Virtual threads được lập lịch bởi VTS và thực thi trên OS thread mà chúng đã được mapped tới. Một virtual thread có thể chạy trên một OS thread trong một khoảng thời gian và sau đó bị tạm dừng khi ứng dụng gọi hàm blocking (chẳng hạn như I/O hoặc sleep).
  • Multiplexing: Nếu một virtual thread bị tạm dừng hoặc blocked (ví dụ I/O blocking), OS thread tương ứng với nó có thể được sử dụng để thực thi một virtual thread khác. Quá trình này có thể xảy ra nhiều lần và cho phép sử dụng hiệu quả các OS threads khi một số virtual threads đang idle hoặc blocked. Quá trình này còn hay được gọi là cooperative multitasking.
  • Context Switching: Khi một virtual thread cần tiếp tục thực thi (ví dụ: khi hàm blocking hoàn thành), VTS sẽ thực hiện context switching để chuyển lại quyền kiểm soát từ OS thread hiện tại về virtual thread tương ứng. Tuy nhiên, việc này không tạo ra overhead lớn như khi chuyển đổi giữa các OS thread hệ thống truyền thống.

Có thể bạn sẽ thắc mắc vì sao context switching trong virtual thread lại không tạo overhead lớn như ở OS thread truyền thống 🤔 Hãy cùng đọc tiếp đoạn dưới đây để tìm hiểu lý do nhé 😃

  • Lightweight Nature: Virtual thread không có trạng thái hệ thống riêng (system-level state) như OS thread. Khi một virtual thread tạm ngừng thực thi, chỉ cần lưu trạng thái ứng dụng (application-level state) của nó, và các thông tin khác. Điều này giúp giảm đi sự phức tạp và overhead khi chuyển đổi. Ngoài ra Virtual thread là các đối tượng nhẹ (lightweight objects) chỉ tốn chi phí vài trăm bytes. Trong một ứng dụng, bạn có thể tạo hàng triệu virtual thread mà không phải bận tâm về việc tiêu thụ nhiều bộ nhớ hoặc CPU.
  • No share resource: Không có việc chia sẻ tài nguyên, virtual thread thường không cần chia sẻ tài nguyên hệ thống với các virtual thread khác. Trong khi đó, các OS thread truyền thống thường cần chia sẻ tài nguyên như bộ nhớ (share resources), và các cấu trúc dữ liệu khác, dẫn đến overhead khi chuyển đổi giữa chúng.
  • Application management: Virtual Thread Scheduler được quản lý từ phía ứng dụng, điều này có nghĩa là ứng dụng Java có sự kiểm soát chặt chẽ hơn việc quản lý việc tạm ngừng và tiếp tục thực thi của virtual thread. Ứng dụng có thể kiểm soát chính xác khi nào virtual thread nên ngừng và tiếp tục, giúp giảm overhead trong quá trình chuyển đổi.
  • Scheduler Optimization: Virtual Thread Scheduler được thiết kế để tối ưu hóa việc chuyển đổi giữa các virtual thread. Nó có thể quản lý việc chuyển đổi một cách thông minh và hiệu quả, hạn chế thời gian và tài nguyên cần thiết.
  • Small OS Thread: Virtual Thread Scheduler quản lý một số Platform thread ít hơn so với số virtual thread. Điều này giúp giảm số lần chuyển đổi giữa các OS thread thực sự và tối ưu hóa việc sử dụng tài nguyên hệ thống.

Lý thuyết vậy là đủ, giờ chúng ta hãy thử code (implement) một virtual thread bằng JDK 21 nhé. Việc đầu tiên ta hãy tiến hành cài đặt Java 21 ở link sau https://www.oracle.com/java/technologies/downloads/#java21

Virtual thread cũng là một implement của java.lang.thread do đó ta có thể sử dụng Thread and Thread.builder APIs để tạo virtual thread, ngoài ra chúng ta cũng có thể sử dụng java.util.concurrent.Executor để tạo một ExecutorService dùng để start một virtual thread cho mỗi task.

Cách 1: sử dụng Thread interface

Để tạo một virtual thread ta có thể sử dụng method Thread.ofVirtual() ví dụ:

public class Main {
public static void main(String[] args) throws InterruptedException {
var vThread = Thread.ofVirtual().unstarted(() -> System.out.println(Thread.currentThread()));
vThread.start();
vThread.join();
System.out.println("Class = " + vThread.getClass());
}
}

Method unstarted() dùng để khởi tạo một virtual thread nhưng chưa start nó cho tới khi method start() được gọi, còn method join() để đảm bảo rằng main thread không kết thúc trước vThread của chúng ta. Sau khi chạy màn hình sẽ in ra kết quả:

VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
Class = class java.lang.VirtualThread

Ngoài ra ta cũng thể sử dụng Thread.Builder để tạo một virtual Thread như sau:

public class Main {
public static void main(String[] args) throws InterruptedException {
Thread.Builder vThreadBuilder = Thread.ofVirtual().name("My virtual thread");
Runnable task = () -> System.out.println(Thread.currentThread());
Thread vThread = vThreadBuilder.start(task);
vThread.join();
System.out.println("Class = " + vThread.getClass());
}
}

Màn hình cũng in ra kết quả tương tự như bên trên

VirtualThread[#21,My virtual thread]/runnable@ForkJoinPool-1-worker-1
Class = class java.lang.VirtualThread

Với việc sử dụng Thread.Builder chúng ta cũng có thể tạo nhiều virtual thread như sau:

public class Main {
public static void main(String[] args) throws InterruptedException {
Thread.Builder vThreadBuilder = Thread.ofVirtual().name("My virtual thread");
Runnable task = () -> System.out.println(Thread.currentThread());
Thread vThread1 = vThreadBuilder.start(task);
vThread1.join();
System.out.println("Class = " + vThread1.getName());

Thread vThread2 = vThreadBuilder.start(task);
vThread2.join();
System.out.println("Class = " + vThread2.getName());
}
}

Màn hình kết quả sẽ hiển thị như sau:

VirtualThread[#21,My virtual thread]/runnable@ForkJoinPool-1-worker-1
Thread name = My virtual thread
VirtualThread[#24,My virtual thread]/runnable@ForkJoinPool-1-worker-1
Thread name = My virtual threa

Cách 2: Khởi tạo và chạy Virtual Thread với Executors.newVirtualThreadPerTaskExecutor() method

Ví dụ sau tạo một ExecutorService bằng cách sử dụng phương thức Executors.newVirtualThreadPerTaskExecutor()

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class MainExecutor {
public static void main(String [] args) {
try (ExecutorService myExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<?> future1 = myExecutor.submit(() -> System.out.println("Running thread 1"));
Future<?> future2 = myExecutor.submit(() -> System.out.println("Running thread 2"));
future1.get();
future2.get()
System.out.println("Task completed");
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}

Ở đoạn code trên ta sử dụng try-with-resources để khởi tạo một ExecutorService bằng phương thức Executors.newVirtualThreadPerTaskExecutor() . Mỗi khi gọi ExecutorService.submit(Runnable), một virtual thread mới được tạo ra và bắt đầu để thực hiện công việc của nó (in ra dòng “Running thread”). Phươngnày trả về một phiên bản của Future. Lưu ý rằng phương thức Future.get() sẽ chờ đợi để công việc của luồng hoàn thành để tránh việc main thread kết thúc trước khi virtual thread hoàn thành.

Chú ý

Về cơ bản Virtual thread vẫn là thread, chúng ta vẫn có thể đối sử với nó như một thread bình thường như start, stop, join, suspend … Chỉ duy nhất một điều chúng ta không thể làm với virtual thead là tạo ra nó như một “non-daemon thread” và nó sẽ luôn là một “deamon thread”.

Non-Daemon Thread: Là một luồng mà khi ứng dụng Java hoặc chương trình chính kết thúc, nó chạy ở chế độ non-daemon và JVM sẽ không dừng (stop/kill) thread đó khi chương trình chính (main) kết thúc. Non-daemon thread có thể tiếp tục chạy ngay cả khi chương trình chính đã kết thúc.

Daemon Thread: Ngược lại, một daemon thread là một luồng chạy ở chế độ daemon. Khi chương trình chính kết thúc, JVM tự động kết thúc và giết (kill) tất cả các daemon thread còn hoạt động. Daemon thread thường được sử dụng để thực hiện các nhiệm vụ phụ và không cần hoạt động sau khi chương trình chính đã kết thúc.

Do đó với virtual thread mọi những vấn đề (problems) về thread khác mà ta phải đối mặt như reace condition, deadlocks, visibility, tearability vẫn còn đó. So be aware of that 😈.

Ví dụ thực tế (real example).

Hãy xem xét một đoạn code sau

User user = userService.findUserByName(name);
if (!repo.contains(user)) {
repo.save(user);
}
var cart = cartService.loadCartFor(user);
var total = cart.items().stream().mapToInt(Item: :price).sum();
var transactionId = paymentService.pay(user, total);
emailService.send(user, cart, transactionId);

Đoạn code trên khá là rõ ràng (straight forward code) dễ hiểu (step by step code), dễ đọc (human readable), dễ bảo trì (maintain). Nó thực hiện các tác vụ như tìm kiếm user, nếu không tìm thấy thì lưu một user mới, thực hiện load giỏ hàng của user, tính tổng các item trong giỏ hàng, thực hiện thanh toán và gửi email cho khách hàng.

Tuy nhiên có một vấn đề rằng nó không thể scale, các đoạn code luôn sử lý tuần tự và có những thao tác blocking I/O như request lên Database để lưu trữ hoặc lấy dữ liệu. Vấn đề là ta phải adapt đoạn code đó với kỹ thuật asynchronous framework mà ứng dụng đang sử dụng. Ví dụ như nếu ta sử dụng CompletableFuture API thì đoạn code trên sẽ trông như

var future = supplyAsync(() -> userService.findUserByName(name))
.thenCompose(
user -> allof(supplyAsync(() -> !repo.contains(user))
.thenAccept(
doesNotContain -> {
if (doesNotContain) {
repo.save(user);
}
}
), supplyAsync(() -> cartService.loadCartFor(user))
.thenApply(cart ->
supplyAsync(() -> cart.items().stream().mapToInt(Item:: price).sum())
.thenApply(total -> paymentService.pay(user, total))
.thenAccept(transactionId -> emailService.send(user, cart, transactionId))
)
)
);
}

Và tin tôi đi nếu bạn viết code theo Reactive Programming concept nhìn nó cũng sẽ tương tự như vậy, quá nhiều call-back (call-back hell). Rối rắm, khó bảo trì, khó đọc, khó test và việc debug là gần như là impossible.

Nhưng nếu viết lại đoạn trên với Virtual Thread code sẽ trở nên trong sáng đẹp đẽ và mọi người đều hạnh phúc với nó 😙

Runnable task = () -> {
User user = userService.findUserByName(name);
if (!repo.contains(user)) {
repo.save(user);
}
var cart = cartService.loadCartFor(user);
var total = cart.items().stream().mapToInt(Item:: price).sum();
var transactionId = paymentService.pay(user, total);
emailService.send(user, cart, transactionId);
};
Thread virtualThread =Thread.ofVirtual().unstarted(task);
virtualThread.start ();
// do something else
virtualThread.join();

Khi Virtual thread gặp các block I/O như request tới DB thì virtual thread đó sẽ được un-mount khỏi OS thread và các virtual thread khác sẽ được tiếp tục chạy trên OS thread đó, do đó việc block ở đây về cơ bản là rất rẻ (cheap) vì nó không hề blockOS thread (platform thread) mà chỉ block ở virtual thread.

Những chú ý và lời khuyên khi sử dụng Virtual Thread

Ogier

Avoid Pinning

Như đề cập bên trên, việc Virtual Thread Scheduler (VTS) mount (gắn) một virtual thread vào một OS thread, thì OS thread đó được gọi là một carrier thread . Và cũng có những trường hợp VTS un-mount (ngắt kết nối) một virtual thread ra khỏi một carrier thread thường thì khi nó gặp một blocking (I/O) nào đó. Khi đó carrier thread đó sẽ trống và VTS sẽ thực hiện mount một virtual thread khác lên nó.

Tuy nhiên cũng có trường hợp một virtual thread không thể un-mount ra khỏi một carrier thread khi nó đang thực hiện một blocking operation nào đó, và trường hợp đó được gọi là pinned. Và nó sẽ xảy ra với những trường hợp sau

  • Virtual thread được chạy trong một khối (block) hoặc phương thức (method) được đồng bộ hóa (synchronized)
  • Virtual thread được chạy một a native method or a foreign function (tham khảo thêm về Foreign Function and Memory API)

Việc virtual thread bị pinned không làm cho chương trình bị sai nhưng sẽ ảnh hưởng tới khả năng mở rộng (scalability) của ứng dụng, vì lúc này OS thread sẽ bị chiếm dụng bởi một virtual thread trong một thời gian dài. Do đó ta luôn nên xem xét lại các synchronized block/method trong chương trình, và nên thay thế nó bằng java.util.concurrent.locks.ReentrantLock bởi vì loại lock này không share resource.

Don’t Pool Virtual Threads

Thread Pool là một kỹ thuật dùng để tối ưu hoá việc sử dụng thread trong lập trình đa luồng (multi-threading). Mục tiêu của thread pool là giảm overhead liên quan đến việc tạo và quản lý thread mỗi khi cần thực hiện một multi-threading task. Thay vì tạo ra một thread mới mỗi khi cần, thread pool duy trì một tập hợp các thread sẵn sàng để sử dụng lại cho nhiều task. Và số lượng các thread sẵn sàng đó có thể được cố định (fix) từ trước.

Tuy nhiên chúng ta không nên pool virtual thread, bởi vì virtual thread là rất rẻ (cheap) có vòng đời ngắn (short-lived), tốt nhất hãy tạo mỗi virtual thread cho mỗi tác vụ (task) trong ứng dụng. Và không cần quan tâm tới việc pool nó 😄.

Xem lại cách sử dụng các Thread-Local Variables

Hãy cân nhắc cẩn thận việc sử dụng các Thread-local Variables vì có thể tạo ra rất nhiều virtual thread trong ứng dụng của chúng ta.

Sử dụng Semaphores nếu muốn hạn chế resource được sử dụng

Semaphore có thể hạn chế số lượng thread truy cập tài nguyên vật lý hoặc logic. Sử dụng semaphores (thay vì thread-local) nếu chúng ta cần hạn chế concurrency trong ứng dụng. Ví dụ: nếu chúng ta cần đảm bảo chỉ một số lượng thread được chỉ định mới có thể truy cập vào một resource hạn chế (limited resource), chẳng hạn như các request tới database.

Don’t run parallel streams in a Virtual thread
Không chạy một parallel streams bên trong một virtual thread, nếu làm vậy chúng ta đang làm giảm performances của ứng dụng. Bởi vì chạy một task chỉ thực hiện các phép tính trong bộ nhớ (in-memory computations) trong một virtual thread là vô ích. Đừng làm điều đó, virtual thread không được thiết kế để làm vậy.

Link tham khảo:

--

--