Ta đã đo nhiều mặt của khóa: chi phí mutex, spinlock so mutex, tranh chấp làm scale âm. Giờ đến một hiện tượng có tên riêng và một hình ảnh rất đắt: lock convoy — "đoàn tàu khóa". Nó xảy ra khi nhiều luồng tranh một khóa cho một vùng tới hạn ngắn, và kết quả là chương trình song song của bạn không chỉ chậm hơn kỳ vọng, mà chậm hơn cả bản một luồng. Tôi đo, và con số phơi bày một sự thật phản trực giác: với vùng tới hạn tí xíu, thêm luồng làm tệ đi, và cái đắt không phải việc trong khóa — mà là bàn giao khóa.
Đoàn tàu hình thành thế nào
Hình dung nhiều luồng cùng cần một mutex, mỗi lần chỉ để làm một việc rất ngắn trong đó (tăng một biến đếm, đẩy một phần tử). Luồng A giữ khóa, làm việc tí xíu, nhả khóa. Ngay lúc nhả, nó đánh thức một luồng B đang ngủ chờ khóa. Nhưng đánh thức không tức thì — B phải được hệ điều hành lập lịch lại, một chuyển ngữ cảnh ~8,5 µs như ta đã đo. Trong suốt 8,5 µs đó, khóa nằm trống — không ai làm gì cả. Rồi B thức dậy, làm việc tí xíu của nó (vài ns), nhả khóa, đánh thức C... và cứ thế.
Kết quả là một "đoàn tàu": các luồng nối đuôi nhau qua khóa, nhưng tốc độ của cả đoàn bị quyết định bởi chi phí đánh thức giữa mỗi toa, không phải bởi việc tí xíu trong khóa. Vùng tới hạn chỉ tốn vài ns, nhưng mỗi lần bàn giao khóa từ luồng này sang luồng khác tốn hàng µs. Cái đắt gấp cả nghìn lần cái rẻ. Tôi đo trong container gcc:13 (10 lõi), một mutex bảo vệ một phép cộng duy nhất — vùng tới hạn ngắn nhất có thể.
Đo (a): nhiều luồng chậm hơn một luồng
Cho NT luồng, mỗi luồng lặp: khóa, shared++, mở khóa. Đo throughput (triệu phép cộng/giây), kiểm tổng khớp (shared == NT × số vòng):
NT | throughput | so với 1 luồng
1 | 236 M/s | 1,00×
2 | 22 M/s | 0,09× (!!)
4 | 64 M/s | 0,27×
8 | 47 M/s | 0,20×
Một luồng đạt 236 triệu/giây — khóa không tranh chấp rất rẻ (chỉ khóa-mở trong cache của chính nó). Nhưng thêm luồng thứ hai, throughput sụp xuống 22 triệu — chậm hơn một luồng 11 lần! Không phải chậm hơn kỳ vọng, mà chậm hơn bản tuần tự. Với 4 và 8 luồng, throughput hồi lên chút (0,27× và 0,20×) nhưng vẫn thảm — nhiều luồng luôn tệ hơn một luồng 4 tới 11 lần. (Trường hợp 2 luồng tệ nhất vì đó là ping-pong thuần: hai luồng đổi tay nhau liên tục, mỗi lần một lần đánh thức đầy đủ.)
Đây là bản chất convoy: bạn thêm sức tính (nhiều luồng) nhưng chúng không thể chạy song song — chúng bị tuần tự hóa qua khóa, và tệ hơn, mỗi lần chuyển tay trả một chi phí đánh thức. Số lõi không giúp gì; cái nút cổ chai là bàn giao khóa.
Đo (b): gom lô, hồi phục 118 lần
Vậy thuốc là gì? Không phải thêm luồng (đã thấy phản tác dụng), không phải đổi loại khóa (spinlock chỉ đổi cách chờ, không giảm số lần bàn giao). Thuốc là giảm số lần bàn giao khóa — và cách trực tiếp nhất là gom lô: thay vì khóa cho mỗi phép cộng, khóa một lần cho một lô phép cộng. Đo ở 8 luồng, thay đổi kích thước lô:
batch = 1 : 47 triệu/giây (convoy)
batch = 10 : 471 triệu/giây -> hồi phục 10×
batch = 100 : 5491 triệu/giây -> hồi phục 118×
Gom 100 phép cộng dưới một lần khóa đưa throughput từ 47 lên 5491 triệu/giây — hồi phục 118 lần, và vượt xa cả bản một luồng (236). Vì sao? Vì số lần bàn giao khóa giảm 100 lần: thay vì một triệu lần khóa-mở-đánh thức mỗi luồng, chỉ còn mười nghìn. Cái đắt (bàn giao) được chia cho cả lô. Đây đúng là bài học batching ở producer-consumer: giảm số lần đồng bộ gần như luôn thắng giảm chi phí mỗi lần. Đoàn tàu tan rã khi mỗi toa chở nhiều việc hơn.
Một lần tôi đo hớ: cái đắt là bàn giao, không phải khóa
Tôi vào đo với hai niềm tin. Thứ nhất, ngây thơ: "thêm luồng thì xong nhanh hơn". Đo phá tan: với vùng tới hạn ngắn, thêm luồng làm chậm hơn cả một luồng (0,09–0,27×). Thứ hai, tinh vi hơn và là đo hớ thật của tôi: "khóa chỉ tốn đúng thời gian giữ khóa" — tức tôi nghĩ chi phí khóa nằm ở việc bên trong vùng tới hạn. Sai. Vùng tới hạn ở đây là một phép cộng (vài ns), nhưng throughput sụp về ~5 triệu/giây, tức ~200 ns mỗi thao tác — gấp hàng chục lần việc thật. Chi phí không nằm trong khóa; nó nằm ở bàn giao khóa giữa các luồng: mỗi lần chuyển tay là một lần đánh thức (~8,5 µs chia đều ra), và khóa nằm trống trong lúc chờ.
Bài học đo lường: trong một convoy, thứ chặn throughput không phải việc bạn làm trong khóa, mà số lần khóa được bàn giao giữa các luồng — và mỗi bàn giao tốn một lần đánh thức, không phải thời gian giữ khóa. Điều này đổi hẳn cách sửa. Nếu tin "cái đắt là giữ khóa", tôi sẽ tối ưu việc bên trong vùng tới hạn (vốn đã tí xíu, không giúp gì). Sự thật là phải giảm số lần bàn giao: gom lô, làm việc ngoài khóa rồi chỉ khóa để cập nhật kết quả cuối, chia khóa thành nhiều khóa nhỏ (mỗi luồng một mảnh), hay bỏ khóa dùng thao tác nguyên tử. Đúng tinh thần đo lường: chỉ đo mới cho biết nút thắt là tần suất bàn giao, không phải nội dung vùng tới hạn.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: một khóa nóng bảo vệ việc tí xíu là dấu hiệu convoy — gom lô hoặc bỏ khóa. Nếu bạn thấy nhiều luồng cùng khóa liên tục cho một cập nhật nhỏ (một biến đếm, một con trỏ), và thêm luồng làm chậm đi, đó là đoàn tàu khóa. Cách chữa không phải thêm luồng hay đổi mutex thành spinlock, mà giảm tần suất khóa: gom nhiều cập nhật dưới một lần khóa, hoặc cho mỗi luồng một bản cục bộ rồi gộp cuối (như reduction).
Hệ quả thứ hai: giữ vùng tới hạn hoặc rất hiếm, hoặc đủ lớn để bù chi phí bàn giao. Convoy tệ nhất khi vùng tới hạn ngắn mà thường xuyên — tỉ lệ chi phí bàn giao trên việc thật cao nhất. Hoặc làm việc thật ngoài khóa (chuẩn bị xong rồi mới khóa để ghi), hoặc gom để mỗi lần khóa làm nhiều — cả hai đều đẩy tỉ lệ về phía việc thật.
Hệ quả thứ ba là tinh thần đo lường: đo cái đắt thật, đừng đoán nó nằm trong khóa. Con số mang theo: khóa một vùng tới hạn ngắn (1 phép cộng) với nhiều luồng gây lock convoy — throughput SỤP xuống 0,09-0,27× so một luồng (2-8 luồng CHẬM hơn 1 luồng 4-11 lần), vì mỗi lần nhả khóa phải đánh thức một waiter (~8,5µs) và khóa nằm trống chờ; cái đắt là BÀN GIAO khóa, không phải vùng tới hạn. Thuốc không phải thêm luồng hay đổi loại khóa, mà GIẢM SỐ LẦN bàn giao — gom lô 100 thao tác/khóa hồi phục ~118× (47->5491 triệu/s).
Thử ba mươi giây
Nhìn một đoạn code đa luồng có khóa và hỏi: khóa này được lấy bao nhiêu lần mỗi giây, và mỗi lần làm gì? Nếu nó được lấy hàng triệu lần để làm một việc tí xíu (tăng đếm, thêm vào list), bạn có nguy cơ convoy — và dấu hiệu chẩn đoán là: thử chạy nó với 1 luồng rồi với 8 luồng và bấm giờ. Nếu 8 luồng chậm hơn 1 luồng, đó chính là đoàn tàu khóa. Rồi thử gom lô: thay vì khóa cho mỗi thao tác, tích lũy cục bộ vài chục thao tác rồi khóa một lần để cập nhật — đo lại. Bạn rất có thể thấy throughput nhảy vọt nhiều lần. Ba mươi giây đó dạy bạn nhận ra một trong những cái bẫy hiệu năng phổ biến nhất của code song song: không phải khóa chậm, mà bàn giao khóa quá thường xuyên — và thuốc luôn là làm cho mỗi lần giữ khóa đáng giá hơn.