producer-consumer ta nối hai luồng qua một hàng đợi. Nhưng có một câu hỏi ta né tránh: nếu producer đẩy nhanh hơn consumer xử lý thì sao? Trực giác nói "dùng hàng đợi thật lớn để không mất việc". Nhưng nếu producer bền bỉ nhanh hơn, không hàng đợi nào đủ lớn — nó chỉ dời thảm họa. Lời giải đúng là backpressure: một cơ chế ghìm producer lại khi consumer không theo kịp. Tôi đo cả hai kịch bản, và con số cho thấy vì sao hàng đợi lớn là một cái bẫy còn backpressure là sự khác biệt giữa ổn định và sụp đổ.

Backpressure: ghìm producer, đừng để hàng đợi nổ

Producer nhanh hơn: hàng đợi phình

Đặt bài toán: một producer đẩy item vào hàng đợi rất nhanh, một consumer lấy ra và xử lý chậm hơn (mỗi item tốn một khoảng thời gian). Nếu tốc độ đẩy vào lớn hơn tốc độ lấy ra, hiệu số đó tích lũy — hàng đợi dài ra mãi. Hai hệ quả: bộ nhớ phình (mỗi item chờ chiếm chỗ), và latency của mỗi item tăng (item mới phải chờ tất cả item trước nó được xử lý — hàng càng dài, chờ càng lâu).

Câu hỏi thực tế: hàng đợi không giới hạn có "cứu" được việc không? Tôi đo trong container gcc:13: producer đẩy tự do, consumer xử lý ~300 ns mỗi item, chạy 500 ms, đo độ sâu hàng đợi cuối cùng, latency lớn nhất của item, và bộ nhớ hàng đợi chiếm.

Đo (a): hàng đợi vô hạn — thảm họa

Hàng đợi KHÔNG giới hạn (500 ms):
  sản xuất : 10.122.285 item
  tiêu thụ :  4.664.712 item
  -> độ sâu cuối: 5.457.573 item (và đang tăng)
  latency lớn nhất: 270 ms
  bộ nhớ hàng đợi: 43 MB (và đang tăng)

Trong nửa giây, producer đẩy 10,1 triệu item nhưng consumer chỉ kịp xử lý 4,66 triệu — 5,46 triệu item kẹt lại trong hàng đợi, và con số đó đang tăng. Item cuối cùng phải chờ 270 mili giây mới được xử lý (nó ngồi sau 5 triệu item khác). Bộ nhớ hàng đợi đã 43 MB và tăng tuyến tính theo thời gian. Đây là thảm họa: nếu để chạy tiếp, độ sâu, latency, bộ nhớ đều tăng không có trần — cuối cùng hết RAM và sập, hoặc latency lớn tới mức vô dụng. Hàng đợi lớn không giải quyết gì: nó chỉ cho phép sự mất cân bằng tích lũy lâu hơn trước khi nổ. Với producer bền bỉ nhanh hơn, không kích thước hàng đợi nào đủ.

Đo (b): backpressure — ổn định

Bây giờ dùng hàng đợi có chặn (bounded, sức chứa K): khi hàng đầy, producer bị chặn — nó phải chờ tới khi consumer lấy bớt ra mới đẩy tiếp. Đo với K = 1000 và K = 100:

Có chặn K=1000 (backpressure):
  sản xuất: 3.859.106  =  tiêu thụ: 3.858.156   (khớp!)
  độ sâu cuối: ~950     latency max: 381 µs     bộ nhớ ~0

Có chặn K=100:
  độ sâu cuối: ~99      latency max: 199 µs      bộ nhớ ~0

Khác hẳn. Producer giờ tự ghìm về tốc độ consumer: sản xuất ≈ tiêu thụ (3,86 triệu = 3,86 triệu). Độ sâu hàng đợi ổn định quanh K (950 với K=1000, 99 với K=100). Latency có trần — 381 µs với K=1000, chỉ 199 µs với K=100 (K nhỏ hơn → item chờ ít hơn → latency thấp hơn). Bộ nhớ gần 0 (hàng đợi nhỏ, cố định). Hệ ổn định dù producer muốn đẩy nhanh hơn — cái chặn buộc nó chờ. Đây là backpressure: dùng chính việc chặn producer làm tín hiệu "chậm lại".

Chú ý con số K: K nhỏ hơn cho latency thấp hơn mà throughput như nhau (cả hai ~3,8 triệu, đúng bằng tốc độ consumer). Vì throughput bị chặn ở mắt xích chậm nhất (consumer) bất kể K; K chỉ điều chỉnh độ trễ (item chờ trong hàng). Đây đúng đánh đổi throughput–latency của batching: K là kích thước đệm, đệm lớn cho throughput ổn định hơn khi tải giật cục nhưng latency cao hơn.

Một lần tôi đo hớ: hàng đợi lớn chỉ dời sụp đổ

Tôi vào đo với hai niềm tin. Thứ nhất: "cứ dùng hàng đợi lớn (hoặc không giới hạn) để không bao giờ mất việc". Sai — nếu producer bền bỉ nhanh hơn consumer, hàng đợi không giới hạn dẫn tới độ sâu, latency, bộ nhớ tăng không trần; hàng đợi lớn không sửa mất cân bằng, chỉ trì hoãn lúc nổ (và làm latency tệ dần trong lúc chờ nổ). Thứ hai: "producer cứ đẩy nhanh nhất có thể là tốt". Sai — đẩy nhanh hơn consumer xử lý chỉ chất đống công việc chưa làm; nhanh của producer vô nghĩa nếu consumer là nút thắt.

Bài học đo lường: trong một đường ống, tốc độ bị giới hạn ở mắt xích chậm nhất — và nếu không có cơ chế ghìm, sự mất cân bằng biến thành hàng đợi phình vô hạn, không phải throughput cao. Cái đúng là làm cho sự chậm của consumer truyền ngược về producer (backpressure), buộc producer chỉ đẩy đúng bằng tốc độ consumer tiêu thụ. Điều này biến một hệ sẽ sụp (hàng đợi nổ) thành một hệ ổn định (hàng đợi bị chặn) — đổi lấy việc producer phải chờ, và throughput bị giới hạn ở tốc độ thật của consumer. Đúng tinh thần đo lường: con số "producer đẩy 10 triệu/giây" trông ấn tượng nhưng vô nghĩa nếu consumer chỉ tiêu 4 triệu — thứ đáng đo là hệ có ổn định dưới tải không, không phải tốc độ một đầu.

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

Hệ quả đầu tiên: dùng hàng đợi có chặn (bounded), không dùng hàng đợi không giới hạn. Bất cứ khi nào có producer và consumer với tốc độ có thể lệch nhau (nhận request mạng, đọc file đẩy vào xử lý, event stream), đặt một giới hạn cho hàng đợi. Khi đầy, chặn producer (hoặc từ chối/loại bớt có kiểm soát). Hàng đợi không giới hạn là một quả bom nổ chậm — nó chạy tốt lúc tải nhẹ rồi nổ (hết RAM, latency vô cực) đúng lúc tải cao nhất.

Hệ quả thứ hai: để sự chậm truyền ngược — đừng nuốt backpressure. Nhiều tầng che giấu backpressure bằng buffer riêng (mỗi tầng một hàng đợi lớn), khiến producer đầu nguồn không bao giờ biết consumer cuối nguồn đang quá tải. Kết quả: công việc chất đống ở đâu đó giữa đường. Thiết kế để tín hiệu "chậm lại" đi suốt chuỗi — từ consumer chậm nhất về tận producer nhanh nhất. Các hệ reactive (Reactive Streams, gRPC flow control, TCP window) đều xây quanh nguyên tắc này.

Hệ quả thứ ba là tinh thần đo lường: đo hệ có ổn định dưới tải không, đừng chỉ đo tốc độ một đầu. Con số mang theo: producer nhanh hơn consumer + hàng đợi VÔ HẠN -> độ sâu phình vô hạn (đo: 5,46 triệu item sau 500ms), latency NỔ (270 ms) và bộ nhớ tăng mãi (43 MB) — thảm họa; hàng đợi lớn KHÔNG cứu, chỉ dời sụp đổ. Backpressure (hàng đợi có chặn K, chặn producer khi đầy) -> producer tự ghìm về tốc độ consumer (sản xuất = tiêu thụ), độ sâu ổn định ~K, latency có TRẦN (K=100 -> 199µs, K=1000 -> 381µs), bộ nhớ ~0. K nhỏ = latency nhỏ hơn, throughput như nhau (= tốc độ consumer, mắt xích chậm nhất). Đánh đổi: throughput bị giới hạn ở consumer + chút overhead phối hợp chặn.

Thử ba mươi giây

Nhìn một chỗ trong hệ của bạn có một luồng/tiến trình đẩy việc cho một luồng/tiến trình khác xử lý — một hàng đợi tác vụ, một buffer sự kiện, một kênh. Hỏi: hàng đợi đó có giới hạn kích thước không, và khi đầy thì chuyện gì xảy ra? Nếu nó không giới hạn (một list cứ append, một channel không bound), bạn có một quả bom: hôm nào producer đẩy nhanh hơn consumer (một đợt tải cao, một consumer bị chậm), hàng đợi phình tới hết RAM và latency tăng vô cực. Cách kiểm nhanh: producer nhanh nhất có thể đẩy bao nhiêu/giây, consumer tiêu được bao nhiêu/giây; nếu producer có thể vượt, bạn cần backpressure. Cách chữa: đặt giới hạn hàng đợi, và khi đầy thì chặn producer (hoặc từ chối có kiểm soát). Ba mươi giây hỏi "hàng đợi này có trần không" đó phân biệt một hệ ổn định dưới tải với một hệ chạy tốt tới đúng lúc tải cao rồi nổ — và nhắc rằng trong đường ống, đẩy nhanh không giúp gì nếu đầu kia không theo kịp; điều giúp là để cái chậm ghìm cái nhanh lại.