Hình dung một tòa nhà cao tầng mà thang máy chạy một chuyến cho mỗi người: bạn bấm nút, nó xuống đón mình bạn, đưa lên, rồi lại xuống đón người kế. Phí cố định mỗi chuyến (xuống, mở cửa, lên, mở cửa) trả cho đúng một hành khách — kinh khủng. Cách ai cũng biết là đúng: thang gom vài người rồi mới đi một chuyến. Số người di chuyển mỗi giờ tăng vọt (throughput lên) vì phí mỗi chuyến chia cho cả nhóm. Nhưng có cái giá âm thầm: người vào thang đầu tiên phải đứng chờ cửa đóng, chờ thêm người — chuyến đi của riêng họ lâu hơn (độ trễ lên). Batching trong lập trình đồng thời là đúng cái thang máy đó, và hiểu nó nghĩa là hiểu cả hai mặt.
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-consumer và lock 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.
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 (một chuyến thang chở nhiều người). 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ý — người vào thang đầu tiên chờ thang đầy. 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ôi trường đo: container gcc:13 trên host ARM AArch64, 10 lõi, 8 luồng cập nhật bộ đếm chung, số lấy sau warmup. Con số tuyệt đối đổi theo máy và theo độ nặng mỗi item; điều bền vững là hình dạng: gộp lô giảm số lần đồng bộ nên throughput tăng mạnh, còn độ trễ mỗi item tăng theo kích thước lô.)
Hai niềm tin đối nghịch, cùng 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 cho thấy ngược lại — 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.
Hai niềm tin gặp nhau ở một sự thật: 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 và 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ề đến code hằng ngày
Điều đầ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.
Điều 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ỏ; nếu thiên về thông lượng (xử lý theo mẻ, ghi log, đồng bộ nền), lô lớn hơn được. Chìa khóa là 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 (cái nào tới trước) — đúng cái thang máy đóng cửa khi đầy hoặc sau vài giây, để throughput cao khi tải nặng mà độ trễ không vượt trần khi tải nhẹ.
Chỗ này cái công thức "gom lại, đồng bộ một lần, và đặt một thời hạn" xuất hiện ở gần như mọi tầng hạ tầng, dưới nhiều tên. Kafka producer là ví dụ sách giáo khoa: batch.size (kích thước lô tối đa) đi kèm linger.ms (thời hạn chờ gom) — chính là lô thích ứng "đầy hoặc hết giờ" tôi vừa nói. JDBC có addBatch()/executeBatch() (và rewriteBatchedStatements=true của MySQL) biến 100 câu INSERT rời thành một lượt gửi — cùng cú 2000× ở tầng database. Group commit của Postgres/InnoDB gom fsync của nhiều transaction vào một lần ghi đĩa (amortize đúng cái phí fsync đắt ta từng đo), đánh đổi bằng độ trễ commit nhỉnh lên — commit_delay chỉnh đúng thang này. Ở tầng mạng, thuật toán Nagle của TCP tự gom các gói nhỏ để bớt overhead, và người ta tắt nó bằng TCP_NODELAY khi cần độ trễ thấp — đúng quyết định "throughput hay latency" này. Và mọi BufferedWriter/bufio.Writer bạn từng dùng chính là batching cho system call. Mẫu số chung: đâu có một phí cố định trả theo lần, gom nhiều việc vào một lần là đòn mạnh nhất — và ở đâu cũng đi kèm cùng một câu hỏi "lô to bao nhiêu thì độ trễ còn chịu được".
Muốn tự soi thì 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. Vài phút 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.