Virtual Threads: pinning, khi nào dùng và di cư thread pool
Pinning ghim carrier khi virtual thread không unmount được (synchronized trên Java 21, native frame); khi nào nên dùng virtual thread và di cư từ thread pool.
TL;DR: Virtual thread rẻ không có nghĩa dùng sao cũng thắng. Scheduler virtual thread là một ForkJoinPool FIFO của JVM, và điểm nền tảng là nó không preempt theo lát thời gian: một virtual thread giữ carrier tới khi tự block hoặc xong, nên task CPU-bound chiếm carrier hệt platform thread. Khi virtual thread block mà không unmount được — pinning — carrier bị ghim chết: 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ỡ). Lợi ích chỉ đến với workload I/O-bound. Di cư từ thread pool chỉ đổi một dòng tạo executor, nhưng phải thay cái trần pool cũ bằng Semaphore tường minh để không đánh sập downstream.
1. Lập lịch và pinning: khi phép màu tắt
Bài trước dựng cơ chế lõi: 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 cuộn vào heap (continuation) nên thread khởi đầu chỉ tốn vài trăm byte. Toàn bộ cái rẻ đến từ việc unmount được khi block. Bài này mổ phần vận hành: khi nào unmount không xảy ra (pinning), workload nào hợp, và cách di cư an toàn.
Virtual thread rẻ không có nghĩa là dùng sao cũng thắng. Có những lúc phép màu unmount tắt lịm: một service chạy mượt trên hàng nghìn virtual thread bỗng throughput sụp và độ trễ tăng vọt dưới tải, trong khi CPU vẫn gần như nhàn rỗi — nghịch lý kinh điển của carrier bị ghim. Ba phần dưới mổ đúng phần vận hành: ai lập lịch virtual thread, khi nào nó ghim chết carrier, workload nào thật sự hợp, và cách di cư an toàn từ thread pool.
Ai lập lịch virtual thread? Không phải OS — OS chỉ thấy các carrier. Scheduler là chính JVM: một ForkJoinPool chuyên dụng chạy chế độ FIFO (khác common pool LIFO + work-stealing của bài Fork/Join), số worker mặc định bằng số core, chỉnh qua system property jdk.virtualThreadScheduler.parallelism.
Nó khác kernel ở một điểm nền tảng: 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, bất kể thread đó muốn hay không; JVM thì không — một virtual thread đã mount giữ carrier cho tới khi tự nó nhả ra, bằng cách gặp 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ãy nhìn một virtual thread "tham ăn":
// Never blocks, so it never unmounts: holds one carrier hostage until the loop ends
Thread.startVirtualThread(() -> {
long sum = 0;
for (long i = 0; i < 5_000_000_000L; i++) sum += i; // pure CPU work
System.out.println(sum);
});
Task CPU-bound thuần 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, kể cả 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.
Một mảnh nhỏ đáng biết: compensation. Khi virtual thread block mà không unmount được — bị pin, hoặc block qua ManagedBlocker như ở bài Fork/Join — scheduler có thể tạm mở rộng nhóm carrier để giữ độ song song không tụt. Đó là van an toàn, 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.
Toàn bộ phép màu phụ thuộc vào việc unmount được khi block. Có lúc không thể, và khi đó virtual thread ghim — pin — chặt carrier: nó block, carrier kẹt theo, không phục vụ được ai. 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 thì throughput sụp, triệu chứng là độ trễ tăng vọt dưới tải dù CPU vẫn nhàn.
sequenceDiagram
participant A as Virtual thread A
participant C1 as Carrier 1
participant IO as Blocking I/O
A->>C1: mount - carrier chay code cua A
A->>IO: block (synchronized tren Java 21 / native frame)
Note over A,C1: KHONG unmount duoc - stack khong cuon vao heap
Note over C1: Carrier 1 bi GHIM (pinned) - ket cung, khong mount ai khac
IO-->>A: I/O hoan tat - carrier moi duoc nha
Note over A,C1: suot thoi gian cho, mot OS thread bi mat vao cho choTới Java 25, nguồn gây pinning đáng kể còn lại là native frame: khi ngăn xếp lời gọi đang đi qua một native method (JNI, một số đường foreign function), JVM không cuộn được stack ấy vào heap nên không unmount được; block ngay tại đó thì carrier bị ghim suốt thời gian chờ.
Đáng nói là synchronized. Trong các bản đầu (Java 21), block bên trong khối hay method synchronized cũng ghim carrier — một 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ừ đó, block trong khối synchronized vẫn unmount bình thường, và baseline Java 25 thừa hưởng cách hành xử đã sửa. Dù vậy lời khuyên cũ vẫn nguyên giá trị vì hai lý do: rất nhiều hệ thống production còn chạy Java 21 LTS, nơi cái bẫy còn đó; và ReentrantLock vốn dĩ 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ũ.
// On Java 21: the blocking call below pins the carrier
public Quote refresh(String symbol) {
synchronized (lock) { // Object lock
return remoteApi.fetch(symbol); // long I/O while holding the lock
}
}
// Safe on every runtime, and clearer about intent
public Quote refresh(String symbol) {
lock.lock(); // ReentrantLock
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 Atomicity: 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 lời gọi mạng.
Chẩn đoán pinning không phải đoán mò: JDK Flight Recorder phát event jdk.VirtualThreadPinned mỗi khi một virtual thread bị ghim quá ngưỡng thời gian, và soi event này là cách chắc chắn nhất để tìm thủ phạm trong service thật. Trên Java 21 còn có cờ nhanh -Djdk.tracePinnedThreads=full in 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.
2. Khi nào nên dùng virtual thread, khi nào không
Virtual thread vẫn là Thread, nên ThreadLocal — công cụ confinement theo thread ở bài Confinement — giữ đúng ngữ nghĩa: mỗi thread một bản sao giá trị. Cái vỡ là chi phí và ý nghĩa. Thứ nhất là phép nhân theo số thread: ThreadLocal tốn bộ nhớ tỉ lệ số thread đang giữ giá trị, nên một buffer 4KB nhân vài trăm platform thread là một megabyte không ai để ý, còn nhân 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. Thứ hai, pattern "pool object qua ThreadLocal" mất nghĩa:
// Classic trick on platform thread pools: one formatter per long-lived thread,
// reused across the thousands of tasks that thread serves. On virtual threads it
// caches nothing: each task gets a brand-new thread, so a brand-new formatter.
static final ThreadLocal<SimpleDateFormat> FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
Virtual thread sống đúng một task rồi chết, không có "task sau" nào tái dùng cache, nên object đắt bị khởi tạo lại mỗi task. Cộng 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 là công cụ sai hình dạng để mang context (user đang đăng nhập, traceId) xuyên hàng triệu virtual thread. Java trả lời bằng 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 gần như miễn phí — một nửa nội dung bài kế tiếp.
Ranh giới sử dụng, sau khi đã thấy cơ chế, trở nên rõ ràng thay vì cảm tính. Hợp nhất là task dành phần lớn thời gian chờ thứ gì đó bên ngoài: web server 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 chỗ nào duy trì rất nhiều kết nối mà mỗi kết nối phần lớn nằm im — số task đồng thời giờ bị chặ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ì: nó gần như không bao giờ unmount, chiếm carrier hệt platform thread, và vì không có preemption theo lát thời gian còn chặn đường mọi virtual thread khác.
| Workload | Công cụ đúng | Vì sao |
|---|---|---|
| Nhiều request đồng thời, mỗi cái chờ DB/API là chính | Virtual thread, mỗi task một thread | Lợ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/Join | Work-stealing cân tải CPU; virtual thread không thêm core nào |
| Tính toán nặng, task độc lập | Pool platform thread cỡ số core | Giớ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 manh | Semaphore tường minh trước lời gọi | Van giới hạn tách khỏi cơ chế chạy task - không mượn kích thước pool |
Ba nhầm lẫn đọng lại. Nhầm 1: pool virtual thread, theo trực giác cũ "thread thì phải pool":
// WRONG: pooling virtual threads rebuilds the ceiling they were meant to remove
ExecutorService pool = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
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 vào pool cố định là tự dựng lại đúng cái trần chúng sinh ra để phá. 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, hãy nói thẳng bằng Semaphore(200) đặt ngay trước lời gọi đó — tách bạch thread để chạy task với semaphore để giới hạn tài nguyên. Nhầm 2: synchronized ôm I/O trên JDK 21, ghim carrier như mục 1 đã mổ; đổi sang ReentrantLock hoặc thu hẹp khoá. Nhầm 3: né Thread.sleep và blocking call vì sợ "tốn thread" — phản xạ 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ẻ, vì Thread.sleep, đọc socket, chờ queue đều đã được trang bị lại để unmount. Viết code tuần tự, chặn thoải mái, chính là phong cách virtual thread sinh ra để phục vụ; vặn sang async ở đây là tự bỏ cái lợi lớn nhất.
3. Di cư từ thread pool: bỏ trần rồi thay bằng gì?
Trên giấy, việc di cư nhỏ đến mức đáng ngờ. Một service chạy task lên pool platform thread kiểu bài Executor và thread pool chỉ cần đổi nơi tạo executor:
ExecutorService pool = Executors.newFixedThreadPool(200); // before: pool size is the ceiling
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor(); // after: no such ceiling
Code task bên trong không đổi: vẫn 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 — cái đẹp của việc virtual thread vẫn là Thread. Nhưng một dòng đó kéo theo đổi tư duy sizing, và đây mới là phần dễ sai. Với pool cũ, một con số duy nhất vừa lấp đầy CPU vừa vô tình 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 — nhóm carrier do JVM tự lo theo số core, số virtual thread cứ bằng số task — nên câu hỏi tách làm hai. Một, bao nhiêu task được phép chạy đồng thờ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. Pool 200 thread cũ vẫn lặng lẽ bảo vệ database bằng cách không bao giờ cho quá 200 query bay đi cùng lúc. Bỏ 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 xuống tài nguyên downstream chứ không xoá nó; 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: 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 ở bước kiểm tồn kho hay thanh toán; đồng thờ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 khối khoá ôm qua I/O vẫn nên đổi sang ReentrantLock và thu hẹp đúng phần shared mutable state.
Còn một câu hỏi vận hành: làm sao nhìn thấy hàng trăm nghìn virtual thread khi debug? jstack không trả lời được — nó chỉ liệt kê platform thread, tức vài chục carrier, còn virtual thread 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, 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)
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
File JSON 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 (mục 1) 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 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.
4. Liên hệ các bài khác
- Virtual Threads: vì sao rẻ hơn platform thread nhiều bậc — cơ chế mount/unmount và carrier thread mà toàn bộ bài này dựa vào; pinning chỉ có nghĩa khi đã hiểu unmount.
- Confinement — ngữ nghĩa
ThreadLocalmà mục 2 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. - Atomicity — nguyên tắc giữ khoá đủ ngắn để bao compound action rồi nhả trước I/O, gốc của lời khuyên tránh ôm khoá qua lời gọi mạng ở mục 1.
- Executor và thread pool — tư duy pool + sizing mà mục 3 thay thế; cancellation qua
Future.cancelvẫn dùng y nguyên trên executor virtual thread. - Fork/Join — nơi đúng cho CPU-bound;
ManagedBlockerlà tiền thân của cơ chế compensation ở mục 1. - 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, cùng
ScopedValuethayThreadLocal.
5. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JEP 491: Synchronize Virtual Threads without Pinning — Java 24; mô tả cách JVM làm lại monitor để
synchronizedthôi ghim carrier. - Java Core Libraries Guide — Virtual Threads — hướng dẫn chính thức của Oracle: best practice, ví dụ migration, và mục troubleshooting pinning.
Ghi chú: mục troubleshooting của guide Oracle liệt kê đúng các nguồn pinning và cách bắt bằng JFR — đọc kèm JEP 491 để thấy vì sao synchronized từng ghim và Java 24 sửa ra sao.
6. Tóm tắt
- Scheduler virtual thread là một
ForkJoinPoolFIFO của JVM và không preempt theo lát thời gian: virtual thread giữ carrier tới khi tự block hoặc xong, nên task CPU-bound thuộc về pool cỡ số core hoặc Fork/Join. - Pinning là block mà không unmount được, ghim chết carrier: nguồn còn lại trên Java 25 là native frame;
synchronizedchỉ còn là vấn đề trên Java 21 (JEP 491 ở Java 24 đã gỡ). - Săn pinning bằng JFR event
jdk.VirtualThreadPinned; xem virtual thread bằngjcmd <pid> Thread.dump_to_file -format=json,jstackkhông thấy chúng. - Chỉ thắng với workload I/O-bound; đừng pool virtual thread — mỗi task một thread mới, giới hạn tài nguyên downstream bằng
Semaphoretường minh. ThreadLocalvẫn đúng ngữ nghĩa nhưng chi phí nhân theo số thread và cache-theo-thread mất nghĩa; context chuyển sangScopedValue.
Nhưng newVirtualThreadPerTaskExecutor mới chỉ cho ta virtual thread rẻ và một ranh giới close(). Nó chưa nói gì về kỷ luật vòng đời của một nhóm task: subtask hỏng thì có huỷ ngay các subtask anh em không, hay để chúng rò ra ngoài? Request cha bị cancel thì sự huỷ có lan xuống không? Đó là structured concurrency, cùng ScopedValue để mang context bất biến qua phạm vi đó — chủ đề bài kế tiếp.
7. Tự kiểm tra
Q1Pinning 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.
Q2Vì 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".
Q3Task 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.
Q4Đổ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ó.
Q5Gọ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?▸
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.
Q6ThreadLocal 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
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