Luồng ảo đã xuất hiện rải rác suốt mười ba bài của chặng đồng thời. Hôm nay là bài dành trọn cho nó: cơ chế bên trong, chi phí đo được, và cách chẩn đoán khi nó không nhanh như mong đợi.
Có một chỗ phép đo của tôi đi ngược lời khuyên bạn hay đọc, và tôi sẽ nói rõ ở cuối bài.
Gắn và tháo
Luồng ảo không phải luồng của hệ điều hành. Nó là một đối tượng Java chạy trên một luồng nền tảng gọi là luồng mang. Khi nó chặn, JVM tháo nó ra, cất ngăn xếp vào heap, và luồng mang đi làm việc khác.
Nghe trừu tượng, nên tôi in tên luồng mang ra ở mỗi bước:
64/64 luồng ảo ĐỔI luồng mang giữa chừng
[worker-2, worker-7, worker-14, worker-8, worker-3]
[worker-3, worker-2, worker-4, worker-5, worker-11]
[worker-3, worker-7, worker-11, worker-5]
Cùng một luồng ảo, năm bước, năm luồng mang khác nhau. Sau mỗi lần sleep, nó được gắn lại vào bất kỳ luồng mang nào đang rảnh.
Hệ quả trực tiếp: Thread.currentThread() bên trong luồng ảo không ổn định theo nghĩa luồng nền tảng. Mọi thứ gắn với danh tính luồng nền tảng — ThreadLocal của thư viện bên thứ ba, khoá dựa trên tên luồng, bộ đếm theo luồng — đều có thể hiểu sai. Đây là lý do bài hôm qua kết ở ScopedValue.
Lịch trình luồng mang là một ForkJoinPool riêng, mặc định có số luồng bằng số nhân. Chỉnh được bằng -Djdk.virtualThreadScheduler.parallelism=N, nhưng hiếm khi cần.
Chi phí thật
100.000 luồng ảo -> 102 MB (1.025 byte mỗi luồng)
500.000 luồng ảo -> 390 MB ( 781 byte mỗi luồng)
1.000.000 luồng ảo -> 818 MB ( 818 byte mỗi luồng)
Khoảng 800 byte cho một luồng ảo có ngăn xếp nông, so với 1MB đặt chỗ cho ngăn xếp của một luồng nền tảng. Một triệu luồng ảo vừa trong 818MB heap.
Nhưng con số đó phụ thuộc độ sâu ngăn xếp, và đây là phần ít được nhắc:
100.000 luồng, ngăn xếp sâu 1 khung -> 96 MB ( 969 byte/luồng)
100.000 luồng, ngăn xếp sâu 50 khung -> 219 MB (2.199 byte/luồng)
100.000 luồng, ngăn xếp sâu 200 khung -> 618 MB (6.188 byte/luồng)
Sáu lần khi ngăn xếp sâu 200 khung — mà 200 khung là chuyện bình thường trong một ứng dụng Spring có vài tầng proxy.
Điểm hay là ngăn xếp lớn dần theo nhu cầu chứ không đặt chỗ trước như luồng nền tảng. Điểm cần nhớ: đừng lấy con số 800 byte làm cơ sở tính dung lượng cho ứng dụng thật; hãy đo trên chính mã của bạn.
Ghim: chỗ lợi ích bốc hơi
200 luồng ảo, mỗi luồng một khoá riêng nên không hề tranh chấp, mỗi luồng ngủ 100 ms bên trong khoá:
synchronized : 1342 ms
ReentrantLock : 107 ms
Mười hai lần rưỡi. Không tranh chấp gì cả, chỉ khác cách khoá.
Khi luồng ảo chặn bên trong synchronized trên Java 21, nó không tháo ra được — luồng mang bị giam theo. Chỉ 16 luồng ảo chạy được cùng lúc thay vì 200, nên 200 chia 16 là 13 lượt, mỗi lượt 100 ms.
ReentrantLock biết cách tháo, nên cả 200 chạy đồng thời và tổng thời gian bằng đúng một lần ngủ.
Ngoài synchronized, còn hai nguồn ghim nữa: phương thức native / JNI, và một số thao tác I/O trên hệ thống tệp mà JDK chưa chuyển sang cơ chế không chặn.
Chẩn đoán bằng JDK Flight Recorder
Đây là phần đáng giá nhất trong thực tế, vì trên hệ thống thật bạn không biết chỗ nào đang ghim.
java -XX:StartFlightRecording=filename=vt.jfr,settings=profile,\
+jdk.VirtualThreadPinned#enabled=true MyApp
Event Type Count Size (bytes)
jdk.VirtualThreadPinned 200 3117
jdk.VirtualThreadStart 0 0
jdk.VirtualThreadEnd 0 0
jdk.VirtualThreadSubmitFailed 0 0
Đúng 200 sự kiện, khớp chính xác với 200 luồng ảo trong phép thử. Và mỗi sự kiện kèm dấu vết ngăn xếp:
jfr print --events jdk.VirtualThreadPinned vt.jfr
jdk.VirtualThreadPinned {
duration = 101 ms
eventThread = "" (javaThreadId = 43, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
java.lang.VirtualThread.parkNanos(long) line: 648
java.lang.Thread.sleep(long) line: 507
VT.ngu(long) line: 71
...
]
}
parkOnCarrierThread là chữ ký của việc ghim — nó nói rằng luồng ảo phải đỗ cùng với luồng mang thay vì tháo ra. Và dòng cuối chỉ thẳng vào mã của bạn.
Ba sự kiện còn lại cũng đáng bật:
jdk.VirtualThreadStart / End mặc định tắt vì rất nhiều, nhưng bật lên thì đếm được ứng dụng tạo bao nhiêu luồng ảo.
jdk.VirtualThreadSubmitFailed là dấu hiệu xấu: lịch trình không nhận nổi luồng ảo. Thấy sự kiện này là có chuyện nghiêm trọng.
Cách nhẹ hơn cho môi trường phát triển là -Djdk.tracePinnedThreads=short, in dấu vết ra console — nhưng nó ồn và tốn, nên đừng bật trên máy chủ.
Chỗ tôi phải đính chính một lời khuyên phổ biến
Lời khuyên bạn hay đọc là: "đừng dùng luồng ảo cho tải tốn CPU, chúng chậm hơn".
Tôi đo, chạy ba lần lấy giá trị nhỏ nhất, 64 tác vụ tính toán thuần trên máy 16 nhân:
pool nền tảng = 16 luồng : 90 ms
64 luồng ảo : 89 ms
10.000 luồng ảo (cùng tổng việc) : 82 ms
pool nền tảng 16 (cùng tổng việc) : 94 ms
Không chậm hơn. Chúng ngang nhau, và ở trường hợp chia nhỏ thành 10.000 tác vụ thì luồng ảo còn nhanh hơn một chút — nhiều khả năng vì lịch trình ForkJoinPool cân bằng tải tốt hơn một pool cố định dùng chung một hàng đợi.
Nghĩ kỹ thì hợp lý: luồng ảo chạy trên đúng số luồng mang bằng số nhân, nên mức song song thật y hệt pool nền tảng cùng kích thước. Không có gì để chậm đi.
newFixedThreadPool(số nhân) vẫn là câu trả lời đơn giản nhất.
Tôi nêu chuyện này vì nó là kiểu lời khuyên dễ lan truyền: nghe hợp lý, được lặp lại nhiều, và không ai kiểm. Kết luận thực tế thì gần như không đổi — vẫn dùng pool nền tảng cho tải CPU — nhưng lý do phải đúng, vì lý do sai sẽ dẫn tới quyết định sai ở tình huống kế tiếp.
Vài chi tiết thực dụng
Luồng ảo không có tên mặc định:
mặc định getName() = '' (rỗng)
đặt tên được = 'xu-ly-don-hang-0'
isVirtual() = true, isDaemon() = true
Tên rỗng làm log và ảnh chụp ngăn xếp khó đọc. Đặt tên khi tạo nếu bạn cần lần theo.
Luôn là daemon. Không giữ JVM sống, và không đổi được. Nên phải tự chờ chúng — thường bằng try-với-tài-nguyên quanh newVirtualThreadPerTaskExecutor().
Đừng gộp chúng vào pool. Luồng ảo rẻ tới mức pool trở nên vô nghĩa; gộp lại là mang về đúng những vấn đề mà luồng ảo sinh ra để bỏ đi. Một luồng ảo cho mỗi tác vụ, xong thì vứt.
Giới hạn tài nguyên, không giới hạn luồng. Trước kia pool 20 luồng vô tình giới hạn luôn số truy vấn đồng thời tới cơ sở dữ liệu. Với luồng ảo, mười nghìn luồng sẽ xin mười nghìn kết nối. Cần Semaphore hoặc chính pool kết nối để chặn — đây là thay đổi tư duy quan trọng nhất khi chuyển sang luồng ảo.
Khi nào đổi, khi nào không
Nên đổi: máy chủ xử lý nhiều request đồng thời mà phần lớn thời gian là chờ — gọi dịch vụ khác, truy vấn CSDL, đọc ghi tệp. Đây là đa số ứng dụng web.
Không cần đổi: tải thuần tính toán (không được gì), ứng dụng chỉ có vài chục tác vụ đồng thời (pool nền tảng đủ), hoặc mã dựa nhiều vào ThreadLocal và synchronized quanh các đoạn chặn — lúc đó phải sửa những chỗ đó trước, nếu không sẽ đo được kết quả tệ hơn rồi kết luận sai về luồng ảo.
Thử ba mươi giây
Chạy ứng dụng của bạn với:
-XX:StartFlightRecording=filename=vt.jfr,+jdk.VirtualThreadPinned#enabled=true
rồi jfr summary vt.jfr. Con số bên cạnh jdk.VirtualThreadPinned cho biết ngay bạn có đang mất phần lớn lợi ích hay không — và jfr print chỉ thẳng ra dòng mã gây ra nó.
Ngày mai: Structured Concurrency — gom vòng đời các tác vụ con vào một phạm vi, để việc huỷ và lan truyền lỗi trở nên dễ đoán thay vì phải tự canh từng Future.