Ta đã đo nhiều cách cho đọc rẻ: rwlock (nhưng bộ đếm reader chung làm đọc sụp khi đông), RCU (đọc gần miễn phí, thu hồi trễ). Seqlock (sequence lock) là một cách nữa, đơn giản đến bất ngờ, cho dữ liệu đọc-nhiều-ghi-ít mà lại nhỏ và copy được — và nó dựa trên một ý tưởng khác hẳn khóa: đọc lạc quan, kiểm tra sau, thử lại nếu hỏng. Nghe giống "rwlock nhanh hơn", nhưng tôi đo, và nó là một sự đổi chác khác về bản chất — không phải nhanh hơn miễn phí.

Seqlock: đọc lạc quan, thử lại nếu rách

Một bộ đếm, chẵn lẻ báo trạng thái

Seqlock chỉ cần một bộ đếm chuỗi (sequence counter). Quy ước: bộ đếm chẵn nghĩa "không ai đang ghi", lẻ nghĩa "có writer đang ghi dở". Writer: trước khi sửa dữ liệu, tăng bộ đếm lên lẻ; sửa xong, tăng tiếp lên chẵn. Reader đọc lạc quan: đọc bộ đếm (gọi s1); nếu nó lẻ (writer đang ghi) thì chờ; đọc dữ liệu; đọc bộ đếm lần nữa (s2); nếu s1 == s2 thì "trong lúc tôi đọc không ai ghi" — dữ liệu nhất quán, xong; nếu s1 != s2 thì có writer xen vào giữa — đọc lại từ đầu.

Điểm mấu chốt quyết định hiệu năng: reader không ghi gì vào trạng thái chung — nó chỉ đọc bộ đếm. Khác hẳn rwlock, nơi mỗi reader phải cập nhật một bộ đếm reader chung (và cái đó nảy giữa các lõi). Vì reader seqlock chỉ đọc, nó rẻ và scale. Đổi lại: reader có thể phải thử lại, và writer không bao giờ bị reader chặn (writer ưu tiên). Tôi đo cả hai mặt trong container gcc:13 (10 lõi), với dữ liệu có bất biến a + b == 0 để phát hiện đọc rách (torn read).

Đo (a): đọc scale, nhanh hơn rwlock 862 lần

Cho N luồng đọc liên tục (không có writer), so throughput đọc seqlock với rwlock:

N | seqlock  | rwlock | seqlock/rwlock
1 |  1.093 M/s |  227 M/s |   5×
4 |  3.943    |   11    | 366×
8 |  6.619    |  7,7    | 862×

Seqlock đọc scale gần tuyến tính: 1.093 triệu đọc/giây ở 1 luồng lên 6.619 triệu ở 8 luồng (~6×). Vì reader chỉ đọc bộ đếm, không luồng nào ghi chung một dòng cache. rwlock thì sụp (227 → 7,7 triệu) — đúng cái ghi-chung đắt làm bộ đếm reader thành điểm nóng. Ở 8 luồng, đọc seqlock nhanh hơn đọc rwlock 862 lần. Đây là lý do nhân Linux dùng seqlock cho những thứ đọc cực nhiều như jiffies (đồng hồ hệ thống): đọc gần miễn phí và scale.

Đo (b): luôn nhất quán — nhưng phải thử lại

Điều seqlock bảo đảm là tính nhất quán: reader không bao giờ chấp nhận một snapshot rách. Để kiểm, tôi cho 4 reader seqlock đọc cùng lúc với 1 writer ghi ở các tần suất khác nhau, và đếm số lần đọc rách (nhìn thấy a + b != 0) cùng số lần thử lại:

tần suất ghi | retry/đọc | đọc rách (a+b != 0)
ghi hiếm     | 0,002     | 0   (luôn nhất quán)
ghi vừa      | 0,15      | 0
ghi dồn dập  | 12,7      | 0

mọi tần suất ghi, số đọc rách là 0 — reader không bao giờ trả về một snapshot không nhất quán. Nhưng cái giá là thử lại: khi writer hiếm (vài lần/giây), reader gần như không phải đọc lại (0,002 retry/đọc). Khi writer dồn dập (~19 triệu ghi/giây), mỗi lần đọc phải thử lại trung bình 12,7 lần trước khi bắt được một snapshot yên, và throughput đọc sụp từ 3.815 xuống 266 triệu/giây. Đây là bản chất seqlock: nó không chặn reader (như rwlock), mà bắt reader làm lại việc khi bị writer xen — tốt khi ghi hiếm, tệ khi ghi nhiều.

Để thấy seqlock bảo vệ điều gì, tôi đo cùng workload nhưng đọc không qua seqlock (đọc thẳng a, b):

không seqlock: ~49% số lần đọc thấy RÁCH (a + b != 0) -> snapshot hỏng

Không có seqlock, gần một nửa số lần đọc bắt được dữ liệu nửa-cũ-nửa-mới (writer đã ghi a mà chưa ghi b) — snapshot vô nghĩa. Seqlock biến 49% rách đó thành 0% rách, đổi bằng việc thỉnh thoảng đọc lại.

Một lần tôi đo hớ: seqlock không phải "rwlock nhanh hơn"

Tôi vào đo với hai niềm tin. Thứ nhất: "seqlock chỉ là một rwlock được tối ưu, nhanh hơn thôi". Sai — nó khác về cơ chế: rwlock chặn (reader chờ writer, writer chờ reader); seqlock không chặn mà cho reader đọc lạc quan rồi thử lại. Hệ quả khác hẳn: reader seqlock scale (không ghi chung) nhưng có thể phải làm lại việc; writer seqlock ưu tiên — nó không bao giờ bị reader chặn, ngược hẳn cái rwlock để dòng reader bỏ đói writer ta đã đo. Thứ hai: "reader seqlock luôn đọc được ngay". Sai — dưới ghi nặng, reader thử lại tới 12,7 lần mỗi đọc; nó luôn nhất quán nhưng không luôn nhanh.

Bài học đo lường: mỗi nguyên hàm đồng bộ là một sự đổi chác riêng, không phải bậc thang "cái sau nhanh hơn cái trước". rwlock, RCU, seqlock đều làm "đọc rẻ hơn mutex", nhưng theo ba cách khác nhau với ba cái giá khác nhau: rwlock trả bằng bộ đếm reader chung (đọc sụp khi đông); RCU trả bằng thu hồi trễ (writer chờ grace period); seqlock trả bằng thử lại (reader làm lại khi ghi nhiều) và giới hạn dữ liệu (chỉ copy được, không side-effect). Chọn cái nào tùy hình dạng workload — tần suất ghi, kích thước dữ liệu, reader có tác dụng phụ không. Đúng tinh thần đo lường: đừng xếp hạng công cụ theo "nhanh/chậm" chung chung, mà đo cái giá cụ thể của từng cái trên workload cụ thể của mình.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: seqlock hợp cho dữ liệu nhỏ, copy được, đọc-rất-nhiều-ghi-hiếm. Một cấu trúc cấu hình vài trường, một cặp tọa độ, một mốc thời gian — thứ đọc hàng triệu lần và cập nhật hiếm — là ứng viên lý tưởng. Reader gần như miễn phí và scale, writer ưu tiên. Nhưng nhớ giới hạn: reader đọc lạc quan có thể thấy dữ liệu rách giữa chừng, nên nó chỉ được copy giá trị ra rồi kiểm, tuyệt đối không làm gì có tác dụng phụ hay đi theo con trỏ (con trỏ rách trỏ tới bộ nhớ đã free = sập). Đó là lý do seqlock chỉ dùng cho dữ liệu trơn (POD), không cho cấu trúc có con trỏ.

Hệ quả thứ hai: coi chừng tần suất ghi — ghi nhiều giết seqlock. Nếu writer chạy thường xuyên, reader retry liên tục và throughput sụp (đo được: 3.815 → 266 khi ghi dồn). Seqlock chỉ thắng khi ghi hiếm hơn đọc rất nhiều. Nếu ghi và đọc cân bằng, một mutex thường hay RCU có thể tốt hơn. Đo đường cong retry của bạn theo tần suất ghi thực tế.

Hệ quả thứ ba là tinh thần đo lường: chọn nguyên hàm theo hình dạng workload, không theo "nhanh hơn" chung chung. Con số mang theo: seqlock cho reader đọc LẠC QUAN (chỉ đọc bộ đếm seq, không ghi chung) -> đọc scale gần tuyến tính, nhanh 862× rwlock ở 8 luồng (rwlock SỤP vì bộ đếm reader chung); và LUÔN nhất quán (torn=0 nhờ retry, so đọc thẳng RÁCH ~49%). NHƯNG reader phải THỬ LẠI khi writer xen — ghi hiếm ~0 retry, ghi dồn 12,7 retry/đọc và throughput sụp 3815->266 M/s. Seqlock KHÔNG phải rwlock nhanh hơn — nó đổi chác khác: reader làm lại thay vì bị chặn, writer ưu tiên, chỉ hợp đọc-nhiều-ghi-ít với dữ liệu copy được nhỏ.

Thử ba mươi giây

Nghĩ về một mẩu dữ liệu nhỏ dùng chung mà đọc rất nhiều, ghi rất hiếm — một cấu hình, một cặp giá trị phải nhất quán với nhau, một đồng hồ. Hỏi: reader hiện có phải lấy khóa mỗi lần đọc không? Nếu có, và có nhiều reader, cái khóa (kể cả rwlock) thành điểm nóng. Với seqlock, reader chỉ cần đọc một bộ đếm trước và sau, so hai lần — không ghi gì chung, nên scale. Nhưng hỏi ngay hai câu để không lạm dụng: dữ liệu có nhỏ và copy được không (nếu có con trỏ hay cần side-effect thì không dùng được), và writer có hiếm không (nếu ghi nhiều, reader retry tơi bời). Ba mươi giây cân "đọc siêu nhiều, ghi hiếm, dữ liệu trơn" đó cho bạn biết seqlock có hợp không — và nhắc rằng mỗi công cụ đồng bộ là một đổi chác riêng: seqlock cho bạn đọc gần miễn phí và luôn nhất quán, đổi bằng thử lại khi ghi và một giới hạn chặt về loại dữ liệu.