Hình dung một phòng đọc tư liệu trong thư viện. Kiểu "mutex": mỗi lần chỉ một người được vào phòng, kể cả khi họ chỉ liếc một trang — ai cũng xếp hàng ngoài cửa. Kiểu "read-write lock": cho nhiều người đọc vào cùng lúc, nhưng có một nhân viên ở cửa phải gạch sổ mỗi lần ai đó ra/vào (để biết khi nào phòng trống hẳn cho người cần sắp xếp lại tài liệu). Nghe thì kiểu thứ hai hiển nhiên tốt hơn cho phòng đông người đọc. Nhưng nếu chuyến "đọc" của bạn chỉ là liếc một giây, thì cái nghi thức gạch sổ ở cửa — một cuốn sổ chung mà ai cũng chen tới ký — còn tốn thời gian hơn cả cái liếc, và hóa ra chậm hơn cả cảnh xếp hàng một-người-một-lượt. Rwlock có đúng cái bẫy đó.

Một mutex tuần tự hóa mọi người vào vùng tới hạn — kể cả những người chỉ đọc, không thay đổi gì. Nếu dữ liệu của bạn được đọc rất nhiều và ghi rất ít, việc bắt các luồng đọc xếp hàng chờ nhau nghe thật phí. Read-write lock (rwlock) hứa hẹn giải quyết điều đó: cho nhiều luồng đọc cùng lúc, chỉ độc quyền khi ghi. Nghe như luôn thắng cho tải đọc-nhiều. Tôi đo, và phát hiện "luôn thắng" là một cái bẫy.

rwlock: đọc song song, ghi độc quyền

pthread_rwlock có hai kiểu khóa. rdlock (khóa đọc): nhiều luồng có thể giữ đồng thời — nếu tất cả chỉ đọc, chúng chạy song song, không chặn nhau. wrlock (khóa ghi): độc quyền — khi một luồng ghi, không ai khác được đọc hay ghi. Ý tưởng rất hợp lý: với dữ liệu đọc-nhiều, cho các luồng đọc chạy song song thay vì xếp hàng như dưới mutex.

Nhưng rwlock không miễn phí. Để biết "hiện có bao nhiêu luồng đang đọc" (nhằm biết khi nào an toàn cho một luồng ghi vào), rwlock phải duy trì một bộ đếm số luồng đọc — chính là cuốn sổ ở cửa. Mỗi lần rdlock/unlock, nó phải cập nhật bộ đếm đó — mà cập nhật một biến chung chính là một phép ghi vào trạng thái chia sẻ, gây cache-line bouncing giữa các lõi, y như một khóa ghi nhỏ. Nói cách khác, mỗi thao tác đọc dưới rwlock mang theo một chi phí ghi ẩn. Câu hỏi là: chi phí đó có nhỏ hơn cái lợi của việc đọc song song không?

Đo: chậm 5,9 lần, hay nhanh 6,3 lần

Tôi đo với 8 luồng, 100% đọc (kịch bản lý tưởng nhất cho rwlock), ở hai độ dài vùng tới hạn đọc, trong container gcc:13.

CS đọc CỰC NGẮN (đọc ~0 việc):
  rwlock : 166,8 ns   mutex : 28,3 ns   -> rwlock CHẬM 5,9 lần

CS đọc DÀI (~vài trăm ns việc đọc):
  rwlock : 148 ns     mutex : 954 ns    -> rwlock NHANH 6,3 lần (0,16×)

Cùng một workload 100% đọc, 8 luồng, mà kết quả đảo ngược hoàn toàn tùy độ dài vùng tới hạn đọc. Khi vùng tới hạn đọc cực ngắn (gần như không làm gì trong khóa), rwlock chậm hơn mutex 5,9 lần — dù đây đáng lẽ là sân nhà của rwlock. Khi vùng tới hạn đọc dài (vài trăm nano giây thực sự đọc), rwlock nhanh hơn mutex 6,3 lần. Điểm lật nằm ở độ dài đọc, không phải ở "đọc nhiều hay ít".

(Môi trường đo: container gcc:13 trên host ARM AArch64, 8 luồng 100% đọc, pthread_rwlock so pthread_mutex, hai độ dài vùng tới hạn. Con số tuyệt đối đổi theo máy; điều bền vững là hình dạng: đọc cực ngắn thì rwlock thua vì chi phí bộ đếm, đọc đủ dài thì rwlock thắng nhờ song song.)

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với niềm tin sách vở: "tải đọc-nhiều thì rwlock luôn nhanh hơn mutex, vì nó cho đọc song song". Đo ra niềm tin đó thiếu một điều kiện quan trọng. Ở trường hợp đọc cực ngắn, rwlock thua đậm — và lý do rất rõ khi nghĩ về cơ chế.

Khi vùng tới hạn đọc ngắn, có hai vấn đề. Một, không có gì để song song hóa: nếu việc đọc chỉ tốn 0 nano giây, thì việc cho 8 luồng làm "0 nano giây" song song chẳng tiết kiệm được gì. Hai, chi phí quản lý lấn át: mỗi rdlock phải cập nhật bộ đếm đọc chung — một phép ghi bị 8 lõi tranh nhau, dòng cache nảy qua lại (cả 8 người chen ký cùng cuốn sổ ở cửa) — và chi phí đó (hàng trăm ns) lớn hơn nhiều so với cái đọc tí xíu được bảo vệ. Trong khi mutex chỉ cần một phép atomic đơn giản (28 ns). Kết quả: rwlock trả một cái giá quản lý đắt để "song song hóa" một thứ không đáng song song.

Ngược lại, khi vùng tới hạn đọc dài, mọi thứ đảo chiều: mutex bắt 8 luồng xếp hàng, mỗi luồng chờ 7 luồng kia đọc xong (954 ns ≈ 8 lần công đọc), còn rwlock cho cả 8 đọc cùng lúc (148 ns ≈ một lần công đọc). Giờ cái lợi song song lớn hơn nhiều chi phí bộ đếm, và rwlock thắng. Niềm tin đối nghịch — "rwlock rắc rối, cứ mutex cho lành" — cũng sai ở đây: bỏ rwlock là bỏ mất cú 6,3× khi đọc dài.

Hai niềm tin gặp nhau ở một bài học: một cấu trúc "chuyên cho trường hợp X" chỉ đáng dùng khi X đủ lớn để bù chi phí của nó. rwlock chuyên cho đọc-song-song, nhưng cái đọc phải đủ dài để việc song song đáng giá. Nếu tôi tin "đọc nhiều → dùng rwlock" mà không đo độ dài đọc, tôi đã có thể làm code chậm đi gần 6 lần trong khi tưởng mình đang tối ưu.

Cùng một mẫu, ba lần

rwlock chậm-với-đọc-ngắn là cùng một hình dạng với hai bài trước. Spinlock chỉ thắng khi vùng tới hạn cực ngắn và ít luồng; rwlock chỉ thắng khi vùng tới hạn dài và đọc áp đảo — ngược điều kiện, nhưng cùng một dạng bài học: một nguyên hàm đồng bộ chuyên biệt có một "vùng thắng" hẹp, và ngoài vùng đó nó thua nguyên hàm đơn giản. Cả hai đều dạy rằng không có "khóa tốt nhất" tuyệt đối — chỉ có khóa phù hợp với hình dạng cụ thể của tải: độ dài vùng tới hạn, tỉ lệ đọc/ghi, số luồng so với số lõi. Và như contention và scaling đã chỉ, hình dạng đó quyết định mọi thứ. Cách duy nhất để biết mình ở trong hay ngoài vùng thắng của một nguyên hàm là đo đúng cách chính tải của mình — đo ở nhiều độ dài vùng tới hạn, nhiều số luồng, để thấy điểm lật, chứ không đọc một con số đơn lẻ rồi khái quát.

Về đến code hằng ngày

Điều đầu tiên: chỉ dùng rwlock khi vùng tới hạn đọc thật sự dài, và đọc áp đảo. Nếu code đọc dưới khóa chỉ là vài phép truy cập nhanh, một mutex đơn giản gần như luôn tốt hơn — rẻ hơn và không có chi phí bộ đếm. rwlock dành cho trường hợp mỗi thao tác đọc làm việc đáng kể (duyệt một cấu trúc, tính toán) và ghi thì hiếm.

Điều thứ hai: coi chừng writer starvation (đói ghi) khi ghi không hiếm. rwlock ưu tiên đọc có thể khiến luồng ghi chờ mãi nếu luôn có luồng đọc mới đến. Nếu tải của bạn không thật sự đọc-áp-đảo, cái lợi song song teo lại (khóa ghi độc quyền chặn hết đọc mỗi lần ghi) trong khi rủi ro đói ghi tăng.

Chỗ này cái bài học "bộ đếm đọc là một ghi chung" đã đẩy các nền tảng ra những lời giải tinh hơn, và biết chúng giúp chọn đúng công cụ. Java có ReentrantReadWriteLock — bản rwlock kinh điển, và tài liệu khuyên đúng điều tôi đo: chỉ đáng khi phần đọc đủ dài và đọc áp đảo, kèm cờ fair để chống đói ghi. Nhưng Java 8 thêm StampedLock với chế độ optimistic read né hẳn cái bẫy này: reader không cập nhật bộ đếm chung (không ghi gì cả), chỉ đọc một "tem" rồi kiểm lại sau — đúng ý tưởng seqlock tôi từng đo, và nó xóa chính cái chi phí ghi-chung khiến rwlock thua ở đọc ngắn. Go thì sync.RWMutex cố ý ưu tiên writer (một Lock() đang chờ chặn reader mới) để tránh đói ghi. Và ở tầng database, câu trả lời triệt để nhất là MVCC: reader đọc một ảnh chụp nhất quán mà không khóa gì cả, nên đọc không bao giờ chặn ghi và ngược lại — bỏ hẳn khái niệm "bộ đếm reader". Mẫu số chung: "cho nhiều người đọc song song" là ý hay, nhưng cách theo dõi ai đang đọc mới quyết định nó nhanh hay chậm — và các thiết kế tốt nhất tìm cách để đường đọc không ghi gì chung.

Muốn tự thấy thì cho 8 luồng cùng đọc một dữ liệu dưới pthread_rwlock_rdlock, với phần đọc rất ngắn (đọc một biến), đo thời gian mỗi thao tác; rồi thay bằng pthread_mutex và đo lại. Bạn sẽ ngạc nhiên thấy mutex nhanh hơn, dù 100% đọc. Giờ tăng phần đọc lên (thêm một vòng lặp nhỏ ~vài trăm ns bên trong khóa) và đo lại cả hai — lần này rwlock vượt hẳn mutex. Vài phút đó cho bạn thấy tận mắt cái điểm lật: rwlock không phải "luôn tốt cho đọc nhiều", mà "tốt khi mỗi lần đọc đủ dài để song song hóa đáng giá" — và chỉ đồng hồ ở đúng độ dài đọc của bạn mới nói được bạn đang ở bên nào của điểm lật đó.