Backpressure
ปัญหาที่ backpressure แก้
หัวข้อที่มีชื่อว่า “ปัญหาที่ backpressure แก้”ลองนึกภาพสายฉีดน้ำดับเพลิงที่ต่อเข้ากับหัวฉีดน้ำสวน ถ้าน้ำมาถึงเร็วกว่าที่หัวฉีดจะพ่นออกได้ สายจะแตกในที่สุด สิ่งเดียวกันเกิดขึ้นใน stream ของ Node: ถ้า Readable source ที่เร็วสร้างข้อมูลเร็วกว่าที่ Writable ที่ช้าจะบริโภคได้ chunk ที่ buffer ไว้จะสะสมใน memory จนกว่า process จะหมด RAM หรือ crash
Backpressure คือกลไกที่ consumer ที่ช้าบอกให้ producer ที่เร็วชะลอตัวลง มันคือ stream เวอร์ชันของ flow control ใน TCP
Backpressure ทำงานอย่างไรใน Node
หัวข้อที่มีชื่อว่า “Backpressure ทำงานอย่างไรใน Node”method write() ของ Writable stream คือสัญญาณสำคัญ:
- คืนค่า
true— internal buffer อยู่ใต้ high-water mark (ดีฟอลต์บน Node 24: 65536 byte (64 KiB) สำหรับ stream แบบ byte-mode — ทั้ง Readable และ Writable — หรือ 16 object สำหรับ stream แบบ object-mode;fs.createReadStreamใช้ดีฟอลต์เดียวกันนี้ ไม่มีข้อยกเว้นแยกต่างหาก) producer เดินหน้าส่งต่อไปได้ - คืนค่า
false— buffer ถึงหรือเกิน high-water mark แล้ว producer ต้อง pause และรอ eventdrainก่อนที่จะเขียนต่อ
high-water mark ไม่ใช่ hard limit; มันเป็นคำแนะนำแบบ soft Node ยัง buffer chunk ที่เกินมันอยู่ดี แต่การทำแบบนั้นมีต้นทุนเป็น memory การเคารพสัญญาณนี้ทำให้การใช้ memory คงที่
Backpressure แบบจัดการเอง
หัวข้อที่มีชื่อว่า “Backpressure แบบจัดการเอง”ก่อนที่จะมี pipe และ pipeline คุณต้องจัดการเรื่องนี้ด้วยตัวเอง:
function pumpWithBackpressure(readable, writable) { readable.on('data', function(chunk) { var ok = writable.write(chunk); if (!ok) { readable.pause(); // stop the source writable.once('drain', function() { readable.resume(); // resume when ready }); } });
readable.on('end', function() { writable.end(); });}รูปแบบนี้ยาวและพลาดง่าย การลืมเรียก readable.pause() แค่จุดเดียวทำให้ buffer โตขึ้นโดยไม่มีขอบเขต
pipe และ pipeline จัดการมันให้อัตโนมัติอย่างไร
หัวข้อที่มีชื่อว่า “pipe และ pipeline จัดการมันให้อัตโนมัติอย่างไร”readable.pipe(writable) และ stream.pipeline() implement รูปแบบข้างบนไว้ภายในให้แล้ว มัน:
- ส่งต่อแต่ละ chunk จาก readable ไปยัง writable
- pause readable เมื่อ
write()คืนfalse - resume readable เมื่อ event
drainยิง
คุณไม่ต้องจัดการ pause และ resume เองเลยเมื่อใช้ pipe หรือ pipeline นี่คือเหตุผลหลักที่ควรเลือกใช้มันแทน raw event listener
วัดต้นทุนจริงของการเพิกเฉยต่อ backpressure
หัวข้อที่มีชื่อว่า “วัดต้นทุนจริงของการเพิกเฉยต่อ backpressure”การพูดถึง “memory ที่โตไม่มีขอบเขต” เป็นเรื่องนามธรรม demo นี้ทำให้มันเป็นรูปธรรม: source ที่มี 2,000 chunk เดียวกันถูก drain เข้า sink ที่ช้าตัวเดียวกันสองครั้ง — ครั้งหนึ่งผ่าน data listener ที่เพิกเฉยต่อ return value ของ write() และอีกครั้งผ่าน .pipe() ที่เคารพมัน ทั้งสองรอบติดตามจำนวน สูงสุด ของ chunk ที่ buffer ไว้ใน sink ในแต่ละครั้ง:
Output (verified on Node 24)
ignoring backpressure -- peak buffered chunks: 2000
using pipe() -- peak buffered chunks: 1การเพิกเฉยต่อ source ทั้งหมด (ไม่เคยเรียก .pause()) ทำให้ทุก chunk ในจำนวน 2,000 ตัวเข้าคิวใน sink พร้อมกันได้ — source ไม่เคยได้รับสัญญาณให้ชะลอตัวเลย .pipe() รักษา buffer ของ sink ไว้ที่ high-water mark ของมันคือ 1 ตลอดเวลา เพราะมัน pause source ทันทีที่ write() คืน false
มองเห็น backpressure ด้วย high-water mark ที่กำหนดเอง
หัวข้อที่มีชื่อว่า “มองเห็น backpressure ด้วย high-water mark ที่กำหนดเอง”demo ด้านล่างสร้าง Readable และ Writable ที่มี high-water mark เล็กมาก (1 byte) เพื่อให้ backpressure trigger ทุก chunk ดูว่า readable ถูก pause และ resume อัตโนมัติอย่างไร
Output (verified on Node 24)
sink writing: a
sink writing: b
sink writing: c
sink writing: d
sink writing: e
done — pipeline handled backpressure automaticallyMental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart TB Fast["fast Readable source"] -->|"data listener, no pause()"| Ignored["chunks queue up in the sink without limit"] Fast -->|".pipe() / pipeline()"| Managed["source is paused the instant write() returns false"] Managed -->|"sink emits 'drain'"| Resume["source is resumed"] Resume --> Managed
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
เพิกเฉยต่อ return value ของ write() | code ง่ายที่สุดเท่าที่จะทำได้ | memory โตไม่มีขอบเขตทันทีที่ source เร็วกว่า sink — ท้ายที่สุด OOM crash ภายใต้ load จริง |
pause/resume เองบน drain | ควบคุมได้เต็มที่ว่าจะชะลอตัวเมื่อไหร่ | ยาวและทุก stream เพิ่มเติมใน chain เพิ่มโอกาสพลาดกรณีใดกรณีหนึ่ง |
.pipe() / pipeline() | จัดการ backpressure ได้ถูกต้องโดยไม่ต้องทำ bookkeeping เอง | มองเห็นได้น้อยลงว่า pause เกิดขึ้นตอนไหนแน่ ๆ — คุณต้องเชื่อ implementation |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
datalistener ปลอดภัยตราบใดที่ฉันไม่console.logมากเกินไป” — ความจริง: อันตรายไม่ใช่การ log แต่คือการเขียน chunk ไปยังปลายทางที่ ช้ากว่า (socket, ไฟล์, API อื่น) โดยไม่ตรวจว่ามันตามทันหรือเปล่า — นั่นคือสิ่งที่สะสมใน memory จริง ๆ - “Backpressure เป็นแนวคิดเฉพาะของ Node” — ความจริง: มันคือปัญหาทั่วไปของ producer ที่เร็วท่วม consumer ที่ช้า — แนวคิดเดียวกับ TCP flow control และ stream ของ Node แก้มันด้วยวิธีเดียวกัน (สัญญาณให้ pause และสัญญาณให้ resume)
- “high-water mark เล็ก ๆ แปลว่าการใช้ memory โดยรวมเล็กด้วย” — ความจริง: มันจำกัดว่า stream ตัวเดียว buffer ได้เท่าไหร่ แต่ถ้าคุณเพิกเฉยต่อสัญญาณ
write()ของมันทั้งหมด (ตามที่ demo ข้างบนแสดง) high-water mark ก็ไม่มีความหมาย — ผู้เรียกยังคงเขียนต่อไปอยู่ดี
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”datalistener เขียน (หรือส่งต่อ) ไปยังสิ่งที่อาจช้า — socket, stream อื่น, การเรียก external API — โดยไม่เคยตรวจสัญญาณ backpressure ของปลายทางนั้นเองเลยหรือเปล่า?- code implement pump ที่ทำเองระหว่าง stream สองตัว (แทนที่จะใช้
.pipe()/pipeline()) โดยไม่มีdrainlistener อยู่เลยหรือเปล่า? - มี comment ที่อ้างว่า “จัดการ backpressure” อยู่ข้าง ๆ code ที่ไม่เคยเรียก
.pause(), ตรวจ return value ของwrite()หรือมอบหมายให้.pipe()/pipeline()เลยหรือเปล่า?
🐛 หา bug ในโค้ดนี้
const { Readable } = require('node:stream');const net = require('node:net');
function forwardToSocket(readable, socket) {readable.on('data', function(chunk) { socket.write(chunk); // return value never checked, readable never paused});}เฉลย
forwardToSocket ไม่เคยตรวจ return value ของ socket.write(chunk) และไม่เคยเรียก readable.pause() ถ้า network socket ช้ากว่าความเร็วข้อมูลของ readable (สถานการณ์ทั่วไปบน network จริง เทียบกับ source ในตัว process ที่เร็ว) Node จะยังคง buffer chunk ที่ยังไม่ได้ส่งไว้ใน write buffer ภายในของ socket โดยไม่มีขอบเขต และ memory จะโตขึ้นจนกว่า process จะหมดหรือการเชื่อมต่อถูกตัด วิธีแก้คือรูปแบบ pause/drain ที่ทำเองจากก่อนหน้านี้ในบทนี้ หรือแค่ readable.pipe(socket) ซึ่งใช้รูปแบบนั้นให้อัตโนมัติเป๊ะ