Mẫu producer-consumer (sản xuất - tiêu thụ) là xương sống của gần như mọi hệ thống đồng thời: một hay nhiều luồng sản xuất việc, đẩy vào một hàng đợi có chặn (bounded blocking queue), và các luồng tiêu thụ lấy ra xử lý. Hàng đợi này gộp mọi thứ ta đã đo — mutex, biến điều kiện, chi phí đánh thức. Câu hỏi thực tế: throughput của nó bị chặn bởi cái gì, và làm sao tăng? Tôi đo, và câu trả lời không phải "xử lý nhanh hơn" hay "hàng đợi to hơn" như trực giác mách.
Hàng đợi có chặn, và cái giá per-item
Một hàng đợi có chặn dùng một mutex và hai biến điều kiện: not_full (producer chờ khi hàng đầy) và not_empty (consumer chờ khi hàng rỗng). Mỗi lần đẩy: khóa mutex, chờ nếu đầy, ghi phần tử, báo not_empty, mở khóa. Mỗi lần lấy: tương tự với not_empty/not_full. Nghĩa là mỗi phần tử qua hàng đợi tốn một lần khóa và có thể một lần đánh thức — mà đánh thức là ~8,5 µs như ta đã đo. Chi phí đồng bộ đó, chứ không phải việc xử lý, thường mới là thứ chặn throughput.
Tôi đo trong container gcc:13 với một producer, một consumer, việc xử lý gần như bằng 0 (chỉ để đo phí đồng bộ thuần), và kiểm tổng khớp để chắc không mất/trùng phần tử.
Đo: batching nhanh 7,4 lần, hàng đợi bão hòa
Đầu tiên, batching — đẩy/lấy nhiều phần tử dưới một lần khóa/signal thay vì từng phần tử, với hàng đợi cỡ 1024:
batch = 1 : 6,8 triệu op/giây
batch = 32 : 50,6 triệu op/giây -> nhanh 7,4 lần
batch = 1024 : 43,0 triệu op/giây -> không hơn batch=32 (bão hòa)
Đẩy/lấy từng phần tử chỉ đạt 6,8 triệu op/giây — dù việc xử lý gần như bằng 0! Vì mỗi phần tử trả một lần khóa + đánh thức. Gom 32 phần tử mỗi lô amortize chi phí đó trên cả lô, và throughput vọt lên 7,4 lần. Nhưng gom to hơn nữa (1024) không nhanh hơn — thậm chí hơi chậm lại; qua một điểm, chi phí đồng bộ đã bị pha loãng đủ, batch to thêm chỉ giảm độ chồng lấn giữa hai luồng.
Tiếp theo, kích thước hàng đợi (đẩy/lấy từng phần tử):
CAP = 1 : 0,1 triệu op/giây (thảm họa)
CAP = 64 : 3,4 triệu op/giây (nhanh 34 lần so CAP=1)
CAP = 4096 : 6,4 triệu op/giây (chỉ ~2 lần so CAP=64)
Hàng đợi cỡ 1 là thảm họa: producer đẩy một phần tử rồi phải chờ consumer lấy (hàng đầy), consumer lấy rồi phải chờ producer đẩy (hàng rỗng) — hai luồng lockstep, mỗi phần tử một lần đánh thức đầy đủ, chỉ 0,1 triệu op/giây (~10 µs/phần tử). Tăng lên 64 giúp 34 lần (hai bên có đệm để không chặn nhau liên tục). Nhưng từ 64 lên 4096 chỉ thêm ~2 lần — bão hòa.
Một lần tôi đo hớ: đồng bộ mới là nút thắt
Tôi vào đo với hai niềm tin. Thứ nhất: "throughput hàng đợi phụ thuộc tốc độ xử lý của consumer". Sai — với việc gần như bằng 0, throughput vẫn chỉ 6,8 triệu op/giây ở chế độ per-item; nút thắt là chi phí đồng bộ (khóa + đánh thức), không phải xử lý. Thứ hai: "hàng đợi càng lớn càng nhanh". Cũng sai — kích thước giúp tránh chặn tới một điểm (CAP~64) rồi bão hòa; 4096 hầu như không hơn 64.
Sự thật, và là bài học đo lường: kẻ giết throughput của một hàng đợi đồng thời thường là đồng bộ per-item, và thuốc là batching. Mỗi lần bàn giao một phần tử qua ranh giới luồng phải trả phí khóa + có thể đánh thức; nếu bạn bàn giao từng phần tử, phí đó nhân với số phần tử và bóp nghẹt tất cả. Gom nhiều phần tử mỗi lần bàn giao chia phí đó cho cả lô. Nếu tôi tin "xử lý nhanh hơn thì hàng đợi nhanh hơn" mà không đo, tôi đã tối ưu sai chỗ hoàn toàn — mài giũa phần xử lý trong khi nút thắt nằm ở khâu bàn giao.
So với hàng đợi lock-free
Đáng đặt cạnh hàng đợi lock-free SPSC ở phần trước, vốn đạt 32 triệu op/giây không khóa. Thú vị là hàng đợi có khóa của bài này, khi batching, đạt tới 50 triệu op/giây — vượt cả SPSC lock-free per-item. Bài học: batching là một đòn mạnh đến mức nó có thể khiến một hàng đợi mutex đơn giản nhanh hơn một hàng đợi lock-free phức tạp. Điều này củng cố thông điệp phần 9: đừng vội chọn lock-free vì nhãn "nhanh" — một hàng đợi có khóa với batching tốt vừa nhanh, vừa dễ viết đúng, vừa xử lý được nhiều producer/consumer. Cái đắt của bàn giao qua luồng — dù lock-free hay có khóa — chủ yếu là chi phí đồng bộ mỗi lần bàn giao; giảm số lần bàn giao (batching) hầu như luôn thắng giảm chi phí mỗi lần (lock-free). Và như mọi phần, chỉ đo mới cho biết nút thắt của bạn nằm ở đâu — đừng đoán rằng lock-free hay hàng đợi lớn là câu trả lời.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: gom lô ở ranh giới hàng đợi. Nếu một producer-consumer chậm, đừng vội tối ưu phần xử lý — đo xem chi phí đồng bộ per-item có phải nút thắt không, và nếu có, cho mỗi lần khóa xử lý một lô phần tử. Batching là một trong những đòn tăng throughput mạnh nhất và rẻ nhất trong lập trình đồng thời, đúng như mẫu "gom việc rồi đánh thức một lần" mà bài biến điều kiện đã gợi ý.
Hệ quả thứ hai: chọn kích thước hàng đợi vừa đủ, đừng quá lớn. Hàng đợi phải đủ lớn để producer và consumer không liên tục chặn nhau (tránh thảm họa CAP nhỏ), nhưng lớn hơn điểm bão hòa chỉ tốn bộ nhớ và tăng độ trễ (phần tử nằm chờ lâu hơn) mà không thêm throughput. Đo đường cong của bạn để tìm điểm bão hòa thay vì đoán "càng lớn càng tốt".
Hệ quả thứ ba là tinh thần đo lường: tìm nút thắt thật trước khi tối ưu. Con số mang theo: throughput hàng đợi có chặn bị đồng bộ per-item bóp nghẹt — đẩy/lấy từng phần tử chỉ 6,8 triệu op/s dù xử lý ~0 (mỗi phần tử một lần khóa + đánh thức ~8,5µs); batching (gom 32 phần tử/lô) amortize chi phí đó -> 50,6 triệu op/s, nhanh 7,4 lần, nhưng batch quá lớn (1024) bão hòa; kích thước hàng đợi quá nhỏ (CAP=1) là thảm họa (0,1 tr op/s, lockstep) còn tăng lên chỉ giúp tới ~CAP 64 rồi bão hòa. Đồng bộ per-item là kẻ giết throughput; batching là thuốc; hàng đợi lớn giúp có hạn.
Thử ba mươi giây
Nếu bạn có một producer-consumer, thử một thí nghiệm: đo throughput khi mỗi lần khóa xử lý một phần tử, rồi đổi để mỗi lần khóa xử lý một lô (ví dụ 32) phần tử — chỉ cần dời vòng lặp vào trong vùng khóa. Bạn rất có thể thấy throughput tăng nhiều lần, dù logic xử lý không đổi một chữ. Rồi thử đổi kích thước hàng đợi từ 1 lên 64 lên 4096 và đo lại: bạn sẽ thấy 1 là thảm họa, 64 tốt hơn hẳn, và 4096 gần như không hơn 64. Ba mươi giây đó dạy bạn nhìn đúng nút thắt của một hệ đồng thời: không phải "xử lý nhanh cỡ nào", mà "bàn giao giữa các luồng tốn bao nhiêu" — và batching là cách rẻ nhất để trả ít đi.