Flow Control & Backpressure
ปัญหาที่ stream สร้างขึ้น
หัวข้อที่มีชื่อว่า “ปัญหาที่ stream สร้างขึ้น”stream ปล่อยให้ producer ส่งเร็วเท่าที่ทำได้ แต่ถ้า consumer อ่านช้าล่ะ? ถ้าไม่มีเบรก ฝั่งที่เร็วจะกอง message ไว้ใน memory เร็วกว่าที่ฝั่งช้าจะระบายออก — จนกระทั่งบางอย่าง memory หมดแล้วล้ม
เบรกนั้นคือ flow control และแรงที่ดันกลับไปที่ producer คือ backpressure
HTTP/2 flow control แบบได้มาฟรี
หัวข้อที่มีชื่อว่า “HTTP/2 flow control แบบได้มาฟรี”เพราะ gRPC วิ่งบน HTTP/2 จึงได้ flow control ของ HTTP/2 มาอัตโนมัติ แต่ละฝั่งประกาศ window — จำนวน byte ที่ตอนนี้ยินดีรับ เมื่อ data มาถึงและถูกบริโภค ผู้รับก็เติม window; ถ้าผู้รับหยุดบริโภค window ก็ระบายจนเป็นศูนย์แล้วผู้ส่งต้องหยุด
flowchart LR prod["producer ที่เร็ว"] -->|writes| win["flow-control window"] win -->|reads| cons["consumer ที่ช้า"] win -. "window ว่าง → Send block" .-> prod cons -. "consume → เติม window" .-> win
ผลก็คือ: เมื่อคุณเรียก Send แล้ว window เต็ม Send จะ block (หรือใน callback API จะส่งสัญญาณ “not ready”/drain) จนกว่า consumer จะตามทัน transport กำลังบอก producer ว่า “ช้าลงหน่อย” นั่นคือ backpressure ที่ทำงานถูกต้อง — และเกิดขึ้นโดยที่คุณไม่ต้องเขียนอะไรเลย
จุดที่พัง: การทำลาย backpressure ของตัวเอง
หัวข้อที่มีชื่อว่า “จุดที่พัง: การทำลาย backpressure ของตัวเอง”อันตรายไม่ใช่ HTTP/2 — ตัว protocol handle flow control ได้ดีอยู่แล้ว อันตรายคือ code ที่ buffer อ้อม backpressure แล้วดึงปัญหา memory ไม่จำกัดกลับมา:
- อ่านทุกอย่างเข้า slice/list ก่อนประมวลผล ถ้า receive loop ของคุณ append ทุก message เข้า list ใน memory คุณเปลี่ยน stream ที่มีขอบเขตให้กลายเป็น memory ไม่จำกัด ประมวลผลแต่ละ message ตอนที่มาถึง
- send loop ที่ไม่เคย block ใน runtime แบบ callback (Node) การเมิน return value ของ
write()/ eventdrainทำให้คุณ queue data ไม่จำกัดไว้ใน buffer ภายในของ library เคารพสัญญาณ “รอก่อน” - ไม่อ่านขณะที่เขียน บน bidi stream ถ้าคุณส่งอย่างเดียวไม่เคยรับ flow-control acknowledgement ของ peer จะกอง แล้วทั้งสองฝั่ง deadlock ได้ รัน receive loop พร้อมกันเสมอ
แนวทางการออกแบบ
หัวข้อที่มีชื่อว่า “แนวทางการออกแบบ”- ประมวลผล อย่าสะสม จัดการแต่ละ message ที่ stream มาแล้วปล่อยไป อย่าเก็บทั้งหมดไว้ก่อน
- ให้ backpressure ส่งถึงต้นทาง ถ้าคุณ relay stream อยู่ (อ่านจาก DB ส่งไป client) ให้
Sendที่ block หยุดการอ่านจาก DB ด้วย แล้ว consumer ที่ช้าจะ throttle ต้นทางที่เร็วเองโดยธรรมชาติ - จำกัดทุกอย่างที่ stream ไม่ได้ จำกัดจำนวนและขนาด message เพื่อกัน peer ที่ทำตัวไม่ดีบังคับงานไม่จำกัด
- รู้ว่าเมื่อไรไม่ควร stream ถ้า consumer ที่ช้าจะบังคับให้ server ถือ state หรือ memory ไว้นาน stream อาจกลายเป็นภาระ บางครั้ง unary call เล็ก ๆ หลายอัน (retry และ rebalance ได้อิสระ) เป็นดีไซน์ที่ปลอดภัยกว่า stream ยาว 1 อัน — trade-off เดียวกับใน intro ของโมดูลนี้