Suốt sê-ri, một mẫu cứ trở đi trở lại: chi phí đồng bộ tính theo lần, không theo việc. Mỗi lần khóa, mỗi lần đánh thức, mỗi lần dòng cache nảy tốn một khoản cố định — và nếu bạn đồng bộ mỗi thao tác, khoản đó nhân với số thao tác, bóp nghẹt tất cả. Ta đã thấy nó ở producer-consumerlock convoy. Giờ tôi tách riêng liều thuốc chung của chúng — batching (gộp lô) — làm chủ đề, đo nó tới tận cùng, và phát hiện nó mạnh đến kinh ngạc nhưng có một cái giá mà "gộp càng nhiều càng tốt" bỏ qua.

Batching: gộp lô để giảm số lần đồng bộ

Giảm số lần, không tăng tốc mỗi lần

Có hai cách nghĩ về việc giảm chi phí đồng bộ. Cách sai (mà nhiều người thử đầu tiên): làm mỗi lần đồng bộ nhanh hơn — đổi mutex sang spinlock, tối ưu đường khóa. Cách đúng, mạnh hơn nhiều: giảm số lần đồng bộ — gộp nhiều thao tác dưới một lần khóa. Nếu mỗi lần đồng bộ tốn X, và bạn có N thao tác: làm từng cái tốn N×X; gộp thành lô K thì chỉ tốn (N/K)×X — chi phí đồng bộ chia cho K.

Tôi đo hiệu ứng này trong container gcc:13 (10 lõi): 8 luồng cùng cập nhật một bộ đếm chung dưới một mutex, gộp B thao tác dưới mỗi lần khóa, quét B từ 1 tới 1024, kiểm tổng khớp.

Đo (a): throughput tăng tới hai nghìn lần

batch =    1 :     32 triệu ops/giây   (1×)
batch =    4 :    140 triệu            (4,3×)
batch =   16 :    568 triệu            (17,6×)
batch =   64 :  2.307 triệu            (71×)
batch =  256 : 10.126 triệu            (313×)
batch = 1024 : 64.614 triệu            (2000×)

Con số gây choáng: gộp lô đẩy throughput từ 32 triệu lên 64,6 tỉ ops/giây — nhanh hai nghìn lần. Mỗi lần tăng gấp bốn kích thước lô, throughput tăng gần bằng ấy, vì số lần khóa giảm bấy nhiêu. Ở batch=1, tám luồng tranh nhau khóa cho mỗi phép cộng — đúng lock convoy ta đã đo, chỉ 32 triệu/giây. Ở batch=1024, mỗi luồng khóa một lần rồi làm 1024 phép cộng — số lần bàn giao khóa giảm nghìn lần, và throughput vọt theo.

Chú ý nó chưa bão hòa ngay cả ở batch=1024 — và tôi phải nói thật về điều này. Với workload của tôi, việc mỗi item (một phép cộng) quá nhỏ so với chi phí khóa, nên chừng nào còn khóa thì còn amortize được thêm; nếu việc mỗi item nặng hơn, hoặc nếu chính việc trong lô trở thành nút thắt, throughput sẽ chững lại ở một điểm. Điều tôi đo được rõ ràng: batching là một trong những đòn tăng throughput mạnh nhất trong lập trình đồng thời, và giới hạn thật của nó ở đây không phải throughput — mà là thứ tiếp theo.

Đo (b): cái giá là độ trễ

Batching không miễn phí. Cái giá: một item phải chờ đầy lô mới được xử lý. Nếu bạn gom 256 item rồi mới đồng bộ một lần, item đầu tiên của lô phải chờ 255 item nữa tới trước khi lô được xả. Tôi đo độ trễ trung bình mỗi item (thời gian từ khi item sinh ra tới khi lô của nó được xả), với một producer sinh item đều đặn:

batch =   1 : độ trễ trung bình  0,09 µs/item
batch =   4 :                    0,20 µs
batch =  16 :                    0,60 µs
batch =  64 :                    2,14 µs
batch = 256 :                    8,47 µs

Độ trễ tăng gần tuyến tính theo kích thước lô: từ 0,09 µs (không gộp) lên 8,47 µs ở batch=256 — cao hơn ~94 lần. Điều này logic: item trung bình phải chờ nửa lô lấp đầy, nên độ trễ ~ tỉ lệ với kích thước lô. Đây là đánh đổi cốt lõi: batching đổi độ trễ lấy thông lượng. Gộp lô lớn cho throughput cao ngất, nhưng mỗi item mất nhiều thời gian hơn để được xử lý — tệ nếu bạn cần phản hồi nhanh (một request người dùng, một giao dịch cần độ trễ thấp).

Một lần tôi đo hớ: "gộp càng nhiều càng tốt" là sai

Tôi vào đo với hai niềm tin. Thứ nhất: "tối ưu chi phí đồng bộ là làm mỗi lần đồng bộ nhanh hơn". Đo phá tan — cách mạnh hơn hàng nghìn lần là giảm số lần đồng bộ (batching), không phải mài giũa từng lần. Thứ hai, và là đo hớ chính: "vậy thì gộp lô càng lớn càng tốt". Sai — nhìn throughput thì có vẻ vậy (2000× ở batch=1024!), nhưng độ trễ cũng tăng theo lô (94× ở batch=256). "Càng lớn càng tốt" chỉ đúng nếu bạn chỉ quan tâm throughput và không quan tâm độ trễ — điều hiếm khi đúng.

Bài học đo lường: batching là một sự đánh đổi throughput–độ trễ, không phải một nút "nhanh hơn" một chiều — và tối ưu là cân bằng, không phải cực đại hóa. Batch tối ưu là cái đủ lớn để amortize gần hết chi phí đồng bộ (lấy phần lớn lợi throughput), nhưng đủ nhỏ để độ trễ vẫn trong mức chấp nhận được cho ứng dụng của bạn. Nhìn hai đường cong cùng lúc: throughput tăng nhanh rồi lợi ích giảm dần (từ batch 256 lên 1024 gấp đôi lô mà lợi throughput đã ít giá trị thực tế), trong khi độ trễ vẫn tăng đều. Điểm ngọt nằm ở chỗ throughput đã gần tối đa mà độ trễ chưa quá cao — với workload này, đâu đó quanh batch 16–64 là hợp lý cho phần lớn nhu cầu. Đúng tinh thần cả sê-ri: chỉ đo cả hai chiều (throughput latency) mới cho biết batch tối ưu, không phải nhìn một con số rồi cực đại hóa nó.

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

Hệ quả đầu tiên: khi đồng bộ là nút thắt, giảm số lần đồng bộ trước tiên. Nếu một hệ đồng thời chậm vì khóa/đánh thức/dòng cache nảy quá nhiều, đừng vội đổi loại khóa — hãy hỏi "tôi có thể gộp nhiều thao tác dưới một lần đồng bộ không?". Gom nhiều bản ghi ghi một lần, gom nhiều cập nhật khóa một lần, gom nhiều gói tin gửi một lần. Đây là đòn mạnh nhất (đo được: tới 2000×), và đúng nguyên lý reduction — gộp cục bộ rồi đồng bộ một lần.

Hệ quả thứ hai: chọn kích thước lô theo yêu cầu độ trễ, đừng cực đại. Nếu ứng dụng cần độ trễ thấp (giao dịch tương tác), giữ lô nhỏ — chấp nhận throughput thấp hơn để mỗi item xử lý nhanh. Nếu ứng dụng thiên về thông lượng (xử lý theo mẻ, ghi log, đồng bộ dữ liệu nền), lô lớn hơn được. Nhiều hệ dùng lô thích ứng: gộp tới kích thước tối đa hoặc tới một thời hạn (ví dụ "gom 100 item hoặc 5 ms, cái nào tới trước") — để throughput cao khi tải nặng mà độ trễ không vượt trần khi tải nhẹ.

Hệ quả thứ ba là tinh thần đo lường: đo cả throughput lẫn latency — batch là đánh đổi, không phải tối đa. Con số mang theo: batching (gộp K thao tác dưới 1 lần đồng bộ) là cách ĐÚNG để giảm chi phí đồng bộ — giảm SỐ LẦN đồng bộ, không phải tăng tốc mỗi lần: throughput tăng tới 2000× (32 -> 64.614 triệu ops/s từ batch 1 tới 1024) vì phí khóa chia cho cả lô. NHƯNG batch lớn đánh đổi latency: độ trễ mỗi item tăng theo kích thước lô (0,09 -> 8,47 µs từ batch 1 tới 256, ~94 lần). Nên 'càng lớn càng tốt' SAI — batch tối ưu cân bằng throughput và latency, không cực đại; và lô thích ứng (theo kích thước HOẶC thời hạn) cho cả hai.

Thử ba mươi giây

Nhìn một chỗ trong hệ của bạn nơi nhiều thao tác nhỏ đi qua một điểm đồng bộ — ghi từng bản ghi vào database, gửi từng gói tin, khóa cho từng cập nhật. Hỏi: tôi đang đồng bộ mỗi thao tác, hay gộp lô? Nếu mỗi thao tác, thử gom (ví dụ ghi 100 bản ghi trong một transaction thay vì 100 transaction) và đo throughput — bạn rất có thể thấy nó nhảy vọt nhiều lần. Nhưng rồi hỏi câu thứ hai, quan trọng không kém: độ trễ của một thao tác đơn lẻ có bị ảnh hưởng không? Nếu người dùng phải chờ lô lấp đầy trước khi thao tác của họ chạy, bạn đã đổi độ trễ lấy thông lượng — chấp nhận được cho việc nền, tệ cho việc tương tác. Ba mươi giây cân hai câu đó cho bạn kích thước lô đúng: đủ lớn để nhanh, đủ nhỏ để không ai phải chờ quá lâu — vì batching không phải "càng nhiều càng tốt", mà là tìm điểm cân bằng.