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.

Read-write lock

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. 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ột lần tôi đo hớ: đọc nhiều chưa đủ

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 — 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.

Bài học đo lường: 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả 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. Lúc đó mutex công bằng lại là lựa chọn an toàn hơn.

Hệ quả thứ ba là tinh thần đo lường: đừng chọn cấu trúc đồng bộ theo nhãn "đọc-nhiều thì dùng rwlock". Con số mang theo: rwlock cho đọc song song nhưng phải trả chi phí cập nhật bộ đếm đọc (một ghi chung, cache nảy); nên với 8 luồng 100% đọc mà vùng tới hạn đọc CỰC NGẮN, rwlock chậm hơn mutex 5,9 lần (167 so 28 ns) — chỉ khi vùng tới hạn đọc đủ DÀI (~vài trăm ns) rwlock mới thắng 6,3 lần (148 so 954 ns) nhờ đọc thật sự song song; đọc ngắn hoặc ghi thường xuyên thì mutex đơn giản thường tốt hơn. Đo độ dài đọc và tỉ lệ đọc/ghi thật của bạn trước khi chọn.

Thử ba mươi giây

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. Ba mươi giây đó 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 đó.