ReadWriteLock, StampedLock & AQS — khi đọc áp đảo ghi
ReadWriteLock cho nhiều reader song song khi đọc áp đảo ghi, StampedLock thêm optimistic read, và AQS — bộ khung đứng sau mọi khóa của java.util.concurrent.
TL;DR: ReentrantLock mạnh hơn synchronized nhưng vẫn là khóa độc quyền: một thread giữ thì tất cả phải chờ, kể cả 99 thread chỉ muốn đọc. ReadWriteLock tách read khỏi write — nhiều reader chạy song song, writer độc quyền; cho phép downgrade write xuống read nhưng cấm upgrade ngược lại, vì một thread giữ read lock xin write lock sẽ tự treo. StampedLock đẩy thêm một bước với optimistic read: đọc không giành khóa, rồi validate kiểm tra có writer chen vào không — đổi lại nó không reentrant và không có Condition. Bên dưới tất cả là AQS: một biến state kiểu int cập nhật bằng CAS cộng một hàng đợi FIFO các thread được park/unpark.
1. Vì sao khóa độc quyền bóp nghẹt workload đọc nhiều?
ReentrantLock ở bài trước cho ta tryLock, timeout, lockInterruptibly, Condition — nhưng vẫn là khóa độc quyền tuyệt đối: một thread giữ thì mọi thread khác phải chờ, bất kể đọc hay ghi. Đặt cạnh workload TicketFlow: tồn kho vé bị đọc liên tục, chỉ bị ghi khi đặt hoặc hủy — tỷ lệ đọc trên ghi hàng trăm trên một, mà hai reader không bao giờ đụng độ vì đọc không sửa state. Bắt hàng trăm lần đọc vô hại xếp hàng là bóp nghẹt throughput vô cớ.
ReadWriteLock nới đúng chỗ đó: tách thành hai khóa liên kết. Nhiều reader giữ read lock đồng thời chừng nào không có writer; write lock loại trừ với tất cả. Analogy quen thuộc là trang wiki nội bộ:
| Đời thường (trang wiki) | Cơ chế read-write lock |
|---|---|
| Nhiều người cùng mở xem một trang | Read lock — share, không giới hạn số reader |
| Một người bấm Edit, mọi người khác phải chờ | Write lock — độc quyền với cả reader lẫn writer |
| Lưu xong, chuyển ngay sang chế độ xem không rời trang | Downgrade: giành read trước khi nhả write |
| Đang xem mà muốn sửa thì phải đóng chế độ xem, xin Edit lại | Không có upgrade — nhả read rồi giành write như thao tác mới |
Mấu chốt cho mục 1.1: writer không share với bất kỳ ai, kể cả "chính mình trong vai reader" — đó là gốc rễ của lệnh cấm upgrade. Đây cũng là biến thể v1 capstone gợi mở: Monitor Pattern ở bài volatile & synchronized serialize cả những lần đọc thuần túy, ReentrantReadWriteLock giải phóng chúng.
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
private final Lock readLock = rw.readLock(), writeLock = rw.writeLock();
private final Map<String, Integer> remaining = new HashMap<>(); // @GuardedBy("rw")
public int seatsLeft(String eventId) { // doc nhieu: reader chay song song
readLock.lock();
try { return remaining.getOrDefault(eventId, 0); }
finally { readLock.unlock(); }
}
public Booking book(String eventId, String userId) { // ghi it: loai tru
writeLock.lock();
try { // compound action: doc-kiem-ghi
int left = remaining.getOrDefault(eventId, 0);
if (left <= 0) throw new SoldOutException(eventId);
remaining.put(eventId, left - 1);
return new Booking(eventId, userId, left);
} finally { writeLock.unlock(); }
}
Cờ fairness qua constructor đánh đổi throughput như ReentrantLock: chế độ unfair có thể bỏ đói writer khi reader đến liên tục cứ nhập hội với reader đang giữ khóa; fair buộc reader mới xếp sau writer đang chờ.
1.1 Downgrade được, upgrade thì không
ReentrantReadWriteLock hỗ trợ lock downgrading: thread giữ write lock giành thêm read lock rồi nhả write lock, hạ xuống quyền đọc mà không buông khóa giữa chừng — hữu ích khi vừa ghi xong muốn đọc tiếp giá trị vừa ghi.
void updateThenRead(String eventId, int newLeft) {
writeLock.lock();
try {
remaining.put(eventId, newLeft);
readLock.lock(); // gianh read TRUOC khi nha write - khong co khe ho
} finally { writeLock.unlock(); } // ha cap: gio chi con giu read lock
try { /* doc gia tri vua ghi; reader khac da co the vao cung */ }
finally { readLock.unlock(); }
}
Chiều ngược lại — upgrade từ read lên write — API không hỗ trợ: gọi writeLock().lock() khi đang giữ read lock thì chính một thread duy nhất cũng tự treo. Write lock chỉ được trao khi mọi read lock đã nhả, kể cả của chính thread đang xin — nó chờ chính mình buông read lock, mà đang đứng chờ write lock nên không bao giờ buông.
Đây là rationale thiết kế cho việc JDK không cung cấp upgrade kiểu "chờ rồi nâng": nếu API cho giữ read lock rồi chờ các reader khác nhả mới nâng, hai reader cùng xin nâng sẽ chờ nhau mãi mãi. Cách đúng là nhả read, giành write, rồi kiểm tra lại điều kiện — giữa hai bước, state có thể đã bị thread khác đổi.
2. StampedLock: optimistic read
StampedLock (Java 8) mở ra chế độ thứ ba mà cả synchronized lẫn ReentrantReadWriteLock đều không có: optimistic read. Tư tưởng vay từ CAS ở bài Atomic & CAS — thay vì giành khóa rồi mới đọc, ta đọc lạc quan như thể không ai ghi, rồi kiểm tra xem giả định đó còn đúng không.
Mỗi thao tác khóa trả về một long gọi là stamp — bằng chứng để nhả khóa hoặc xác thực. tryOptimisticRead() không giành khóa gì cả, chỉ trả stamp ghi lại "phiên bản" hiện tại của khóa. Ta đọc dữ liệu vào biến cục bộ rồi gọi validate(stamp): không writer nào chen vào từ lúc lấy stamp thì nó trả true và dữ liệu nhất quán; ngược lại trả false, ta phải đọc lại — thường là rơi xuống một read lock thật.
private final StampedLock sl = new StampedLock();
private int remaining;
public int seatsLeft() {
long stamp = sl.tryOptimisticRead(); // khong gianh khoa, chi chup phien ban
int value = remaining; // doc lac quan vao bien cuc bo
if (!sl.validate(stamp)) { // co writer chen vao giua chung?
stamp = sl.readLock(); // co -> roi xuong read lock that
try { value = remaining; }
finally { sl.unlockRead(stamp); }
}
return value;
}
Ở đường nhanh — không writer — seatsLeft không ghi vào shared state nào của khóa, kể cả bộ đếm reader mà ReentrantReadWriteLock phải tăng giảm. Không ghi thì không tạo cache contention giữa các core, nên với đọc cực nhiều, StampedLock bỏ xa read-write lock thường.
tryConvertToWriteLock(stamp) là câu trả lời của StampedLock cho bài toán upgrade mà ReentrantReadWriteLock cấm tiệt ở mục 1.1. Mấu chốt ở chữ try: nó thử nâng cấp mà không chờ — nâng được ngay thì trả stamp mới chế độ write, không thì trả 0 lập tức để ta tự xử lý. Không chờ thì không có vòng chờ, nên upgrade kiểu này không thể deadlock:
long stamp = sl.readLock();
try {
while (remaining > 0) {
long ws = sl.tryConvertToWriteLock(stamp); // thu nang cap, KHONG cho
if (ws != 0L) { stamp = ws; remaining--; return; } // thanh cong: stamp che do write
sl.unlockRead(stamp); // that bai: nha read...
stamp = sl.writeLock(); // ...gianh write nhu thao tac moi
// vong while kiem tra LAI dieu kien - state co the da doi trong khe ho
}
} finally {
sl.unlock(stamp); // unlock theo dung stamp dang giu
}
Đây là pattern lấy từ Javadoc của StampedLock, vạch rõ ranh giới với lệnh cấm upgrade của ReentrantReadWriteLock: upgrade chờ được thì không thể tồn tại, còn upgrade thử-rồi-rút-lui hợp lệ — cùng triết lý với tryLock ở bài trước.
Cạm bẫy thì sắc, cả ba đều đắt, và mục 5 mổ kỹ hai cái đầu: StampedLock không reentrant — gọi lại method cũng giành write lock trên cùng object sẽ tự khóa chính mình (Nhầm 2); đòi kỷ luật validate — đọc hết vào biến cục bộ rồi mới validate, không hành động trên dữ liệu chưa xác thực (Nhầm 3); và không hỗ trợ Condition, dùng với interrupt cũng có bẫy riêng. Nó là công cụ chuyên dụng cho cấu trúc dữ liệu đọc-rất-nhiều, không phải khóa đa dụng.
3. Nền tảng bên dưới: AQS và LockSupport
Tất cả những khóa này được xây trên cái gì? Câu trả lời gần như đồng nhất. Phần lớn đồng bộ hóa trong java.util.concurrent — ReentrantLock, ReentrantReadWriteLock, Semaphore, CountDownLatch — đều dựng trên một khung chung tên AbstractQueuedSynchronizer, viết tắt AQS. AQS quản lý một thứ tưởng đơn giản mà đủ tổng quát: một số nguyên trạng thái cập nhật bằng CAS, cộng một hàng đợi FIFO các thread đang chờ. Mỗi lớp khóa chỉ định nghĩa "trạng thái nghĩa là gì" và "khi nào giành/nhả được"; AQS lo phần khó: xếp hàng, park, đánh thức đúng thread khi tới lượt, fair lẫn unfair, interruptible lẫn timeout. Cùng biến state kiểu int, mỗi lớp diễn giải một kiểu:
| Synchronizer | Ý nghĩa của state |
|---|---|
ReentrantLock | Acquisition count — 0 là tự do, mỗi lần tái nhập cộng 1 (ta đã gặp count này ở bài volatile & synchronized) |
ReentrantReadWriteLock | Một int chẻ đôi: 16 bit cao đếm số reader, 16 bit thấp đếm write reentrancy |
Semaphore | Số giấy phép (permit) còn lại |
CountDownLatch | Số đếm còn lại trước khi cổng mở |
flowchart TD
T["Thread goi lock()"] --> C{"CAS state 0 -> 1<br/>thanh cong?"}
C -->|"co"| H["Gianh duoc khoa ngay<br/>(khong block, khong xep hang)"]
C -->|"khong"| Q["Tao node, noi vao cuoi<br/>hang doi FIFO cua AQS"]
Q --> P["park() - thread ngu,<br/>cho duoc unpark"]
H --> R["unlock(): state ve 0,<br/>unpark(thread o node dau hang doi)"]
R --> W["Node dau hang doi tinh day"]
W --> C
style H fill:#6EE7B7
style Q fill:#FCD34D
style P fill:#FCD34DSơ đồ trên là vòng đời một lần giành khóa: đường nhanh là một cú CAS duy nhất trên state — không hàng đợi, không syscall; CAS thất bại thì thread mới trả giá xếp hàng và ngủ. Điều này giải thích hành vi bài trước: unfair lock cho thread mới CAS thẳng vào state không nhìn hàng đợi (barging), fair lock bắt kiểm tra hàng đợi trước — đắt hơn nhưng đúng thứ tự.
Vì sao xếp hàng cho thread ngủ thay vì quay vòng CAS đến khi thành công? Vì spinning đốt CPU: thread chiếm trọn một core chỉ để hỏi "đến lượt tôi chưa", và khi critical section đủ dài, chi phí đó vượt xa một lần ngủ-rồi-tỉnh. AQS dùng cả hai: thử CAS vài nhịp ngắn trước khi chịu park — đường giữa hai thái cực đã so ở bài Atomic & CAS.
Tầng ngủ/đánh thức nằm thấp hơn nữa, ở LockSupport với cặp park()/unpark(thread): park tạm dừng thread hiện tại, unpark đánh thức một thread cụ thể. Khác biệt then chốt so với wait/notify: unpark gọi trước park vẫn có hiệu lực — nó để lại một "giấy phép" (permit) khiến lần park kế tiếp trả về ngay, tránh race "đánh thức trước khi kịp ngủ" vốn ám ảnh wait/notify. AQS dùng chính park/unpark để hiện thực hàng đợi — và đó cũng là nền để tự viết synchronizer khi thư viện thiếu thứ bạn cần.
4. Khi nào chọn cơ chế đồng bộ nào?
Sau bốn bài của khối Synchronization, ta có một dải công cụ từ nhẹ tới nặng; nguyên tắc là chọn công cụ đơn giản nhất đủ cho bài toán. Cây quyết định dưới đi đúng thứ tự nên cân nhắc:
flowchart TD
A{"Chi can visibility<br/>tren mot bien don?"} -->|"co"| V["volatile"]
A -->|"khong"| B{"Read-modify-write<br/>tren dung mot bien?"}
B -->|"co"| AT["Atomic* / CAS"]
B -->|"khong"| C{"synchronized du?<br/>(khong can tryLock, timeout,<br/>fairness, nhieu Condition)"}
C -->|"du"| SY["synchronized"]
C -->|"thieu kha nang"| D{"Doc ap dao ghi,<br/>moi lan doc du dai?"}
D -->|"khong"| RL["ReentrantLock"]
D -->|"co"| E{"Doc cuc nhieu, chap nhan<br/>non-reentrant + khong Condition?"}
E -->|"khong"| RW["ReentrantReadWriteLock"]
E -->|"co"| ST["StampedLock"]
style V fill:#6EE7B7
style SY fill:#6EE7B7
style RW fill:#FCD34D
style ST fill:#C4B5FDCùng nội dung đó dưới dạng bảng tra cứu, kèm cột nhắc mặt trái của mỗi nấc:
| Cơ chế | Dùng khi | Không hợp khi |
|---|---|---|
volatile | Một cờ trạng thái hoặc tham chiếu, một writer hoặc ghi độc lập với giá trị cũ, không có invariant nhiều biến | Cần read-modify-write nguyên tử, hoặc nhiều biến ràng buộc nhau |
Atomic* / CAS | Read-modify-write nguyên tử trên đúng một biến; counter, accumulator, tham chiếu cập nhật lạc quan | Cần phối hợp nhiều biến trong một thao tác, hoặc cần chờ điều kiện |
synchronized | Mặc định cho mọi compound action cần loại trừ; gọn, an toàn, JVM tự nhả | Cần tryLock, timeout, interruptible, fairness, hoặc nhiều wait set |
ReentrantLock | Khi cần đúng một trong các khả năng synchronized thiếu | Khi synchronized đã đủ — đừng đổi chỉ vì "nghe mạnh hơn" |
ReentrantReadWriteLock | Đọc áp đảo ghi, mỗi lần đọc đủ dài để song song có ý nghĩa | Ghi nhiều, hoặc thao tác đọc cực ngắn |
StampedLock | Đọc cực nhiều trên cấu trúc dữ liệu nhỏ, chấp nhận đổi sự tiện lấy throughput | Cần reentrancy hoặc Condition; code chưa kỷ luật về validate |
Leo thang từ nhẹ tới nặng và dừng ở nấc đầu tiên đủ dùng. Mỗi nấc đi lên mua thêm một khả năng cụ thể và trả bằng một bề mặt lỗi cụ thể: explicit lock trả bằng kỷ luật unlock, read-write trả bằng bookkeeping, stamped trả bằng reentrancy và kỷ luật validate.
5. Pitfall tổng hợp
❌ Nhầm 1: upgrade read lock lên write lock. Như mục 1.1 đã mổ, giữ read lock rồi gọi writeLock().lock() khiến chính một thread duy nhất tự treo — write lock chờ mọi read lock nhả, kể cả của chính nó.
✅ Nhả read lock trước, giành write lock như một thao tác mới, rồi kiểm tra lại điều kiện — state có thể đã đổi trong khe hở giữa hai khóa:
readLock.lock();
boolean available;
try { available = remaining.getOrDefault(eventId, 0) > 0; }
finally { readLock.unlock(); } // nha read TRUOC
if (available) {
writeLock.lock(); // gianh write nhu thao tac moi
try {
if (remaining.getOrDefault(eventId, 0) > 0) // kiem tra LAI dieu kien
remaining.merge(eventId, -1, Integer::sum);
} finally { writeLock.unlock(); }
}
❌ Nhầm 2: gọi lại method giành khóa trên cùng StampedLock. Nó không reentrant — thread đang giữ write lock gọi tiếp một method cũng writeLock() trên cùng object là deadlock tức thì với chính mình.
public void book() {
long stamp = sl.writeLock();
try { audit(); } // audit() ben trong cung goi sl.writeLock() -> tu treo
finally { sl.unlockWrite(stamp); }
}
✅ Mỗi đường đi qua StampedLock chỉ giành khóa đúng một lần — tách phần việc cần khóa ra method private không tự giành khóa; nếu gọi lồng nhau là không tránh được, quay về ReentrantLock/ReentrantReadWriteLock.
❌ Nhầm 3: hành động trên dữ liệu optimistic read trước khi validate. Dữ liệu đọc lạc quan có thể đang dở dang do writer chen ngang — dereference một tham chiếu như vậy có thể thấy state hỏng.
✅ Đọc hết vào biến cục bộ, gọi validate(stamp), chỉ dùng dữ liệu khi validate trả về true; nếu false, rơi xuống readLock() và đọc lại.
❌ Nhầm 4: mặc định read-write lock nhanh hơn mutex thường. Bookkeeping của ReentrantReadWriteLock nặng hơn ReentrantLock; với ghi nhiều hoặc đọc cực ngắn, nó chậm hơn.
✅ Chỉ chuyển sang read-write lock khi đo đạc cho thấy đọc áp đảo ghi và mỗi lần đọc đủ dài để song song có ý nghĩa.
6. 📚 Deep Dive Oracle
Spec / reference chính thức:
- ReentrantReadWriteLock (Java 21 API) — quy tắc downgrade/không-upgrade kèm code mẫu cache trong section "Sample usages".
- StampedLock (Java 21 API) — đoạn đầu Javadoc cảnh báo non-reentrant và liệt kê kỷ luật dùng optimistic read.
- The java.util.concurrent Synchronizer Framework — Doug Lea — paper gốc về AQS: vì sao chọn một biến
intstate + hàng đợi CLH, các đánh đổi fair/unfair. - LockSupport (Java 21 API) — ngữ nghĩa permit của
park/unpark.
Ghi chú: paper AQS đáng đọc nhất danh sách — 12 trang, giải thích trọn kiến trúc đứng sau gần như mọi synchronizer bạn dùng hằng ngày.
7. Liên hệ các bài khác
- Bài 08 — volatile & synchronized: acquisition count của intrinsic lock —
statecủaReentrantLocktrong AQS là phiên bản tường minh của count đó. - Bài 10 — Atomic & CAS: CAS là viên gạch của AQS — đường nhanh của mọi lần giành khóa là một cú CAS trên
state; optimistic read cũng vay tư tưởng lạc quan này. - Bài 11 — ReentrantLock & Condition: nửa đầu câu chuyện explicit lock, mà bài này mở rộng theo trục read/write.
- Bài 16 — Synchronizers:
Semaphore,CountDownLatch,CyclicBarrierlà các cách diễn giải khác nhau của cùng biếnstateAQS. - Bài 17 — Executor & thread pool: lớp
WorkertrongThreadPoolExecutorkế thừa thẳng AQS — bằng chứng AQS là khung dựng synchronizer, không chỉ chi tiết hiện thực của lock công khai. - Bài 20 — Virtual threads:
LockSupport.parklà điểm virtual thread "tháo" khỏi carrier thread — hiểu park/unpark ở đây là nền để hiểu vì sao virtual thread block rẻ.
8. Tóm tắt
ReadWriteLocktách read khỏi write: nhiều reader song song khi không có writer, writer độc quyền — thắng lớn khi đọc áp đảo ghi và mỗi lần đọc đủ dài.- Downgrade write xuống read hợp lệ; upgrade read lên write bị cấm — một thread duy nhất cố upgrade cũng tự treo vì write chờ mọi read nhả, kể cả của chính nó.
StampedLockthêm optimistic read:tryOptimisticReadkhông giành khóa, đọc vào biến cục bộ,validatexác nhận không có writer chen vào; đường nhanh không ghi vào state nên không cache contention. Đổi lại không reentrant, khôngCondition.- AQS là khung chung của
ReentrantLock,ReentrantReadWriteLock,Semaphore,CountDownLatch: một biếnstatekiểuintcập nhật bằng CAS cộng hàng đợi FIFO thread chờ.LockSupport.park/unparklà tầng ngủ/đánh thức dưới nó; permit cho phépunparkđến trướcparkmà không mất tín hiệu — điềuwait/notifykhông làm được. - Chọn cơ chế từ nhẹ tới nặng:
volatile→Atomic*→synchronized→ReentrantLock→ read-write → stamped; mỗi nấc thêm sức mạnh là thêm bề mặt để sai.
Khối Synchronization khép lại với một nhận xét: tự dựng giao thức khóa có thể làm đúng, nhưng dễ sai. Cách đạt thread safety bền nhất thường là không tự viết phần đồng bộ, mà giao cho component đã được kiểm thử và tối ưu sẵn — hướng delegation với các concurrent collection. Đó là nơi BookingService tiến tới v2, và là chủ đề bài sau.
9. Tự kiểm tra
Q1Một thread đang giữ read lock của ReentrantReadWriteLock gọi writeLock().lock() — chuyện gì xảy ra, và vì sao?▸
lock() nên không bao giờ chạy đến chỗ unlock() — một vòng chờ khép kín trong đúng một thread, không cần thread thứ hai nào tham gia.Q2Vì sao JDK không cung cấp thao tác upgrade an toàn từ read lock lên write lock?▸
Q3Vì sao optimistic read của StampedLock bắt buộc phải đọc hết vào biến cục bộ rồi mới validate, thay vì dùng dữ liệu ngay?▸
tryOptimisticRead() không giành khóa gì cả, nên trong lúc ta đọc, một writer hoàn toàn có thể đang ghi dở — dữ liệu ta thấy có thể là một state nửa vời, không nhất quán. validate(stamp) là bước xác nhận "không có writer nào chen vào từ lúc lấy stamp": chỉ khi nó trả về true, dữ liệu đã đọc mới được coi là nhất quán. Hành động trước khi validate — nhất là dereference một tham chiếu có thể đã bị ghi đè — là dùng dữ liệu chưa được xác thực, có thể dẫn đến crash hoặc kết quả sai khó tái hiện.Q4Vì sao tính non-reentrant của StampedLock đặc biệt nguy hiểm với người đã quen synchronized và ReentrantLock?▸
StampedLock, cùng cấu trúc gọi đó là deadlock tức thì: thread giữ write lock xin lại write lock trên cùng object sẽ chờ chính nó nhả — vĩnh viễn. Bug này thường ẩn trong refactor tưởng vô hại (tách một method lớn thành hai method đều giành khóa), và chỉ phát nổ ở runtime.Q5park/unpark của LockSupport khác wait/notify ở điểm then chốt nào, và permit đóng vai trò gì?▸
unpark(thread) có thể được gọi trước khi thread đó park() mà tín hiệu không bị mất: nó để lại một permit, khiến lần park kế tiếp trả về ngay. notify thì ngược lại — nếu được gọi khi chưa có ai wait, tín hiệu biến mất không dấu vết, sinh ra loại race "đánh thức trước khi kịp ngủ". Ngoài ra unpark nhắm đích một thread cụ thể, còn notify chọn ngẫu nhiên trong wait set; và park không đòi hỏi đang giữ khóa nào. Chính các tính chất này khiến AQS chọn park/unpark làm tầng ngủ/đánh thức.Q6AQS quản lý những gì mà cả ReentrantLock, Semaphore lẫn CountDownLatch đều dựng được trên nó?▸
state kiểu int cập nhật bằng CAS, và một hàng đợi FIFO các thread đang chờ (park). Mỗi synchronizer chỉ cần định nghĩa ý nghĩa của state và điều kiện giành/nhả: với ReentrantLock đó là acquisition count, với Semaphore là số permit còn lại, với CountDownLatch là số đếm còn lại. Toàn bộ phần khó — xếp hàng, park/unpark đúng thread, fair/unfair, timeout, interruptible — AQS làm một lần cho tất cả.Q7Khi nào ReentrantReadWriteLock lại chậm hơn một ReentrantLock thường, dù nghe có vẻ luôn ưu việt hơn?▸
Bài tiếp theo: Delegation & concurrent collections
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