- มาตรฐาน Web Streams ถูกออกแบบมาเพื่อให้การสตรีมข้อมูลระหว่างเบราว์เซอร์และเซิร์ฟเวอร์มีความสอดคล้องกัน แต่ปัจจุบันกลับทำให้ประสบการณ์นักพัฒนาลดลงจาก ความซับซ้อนและข้อจำกัดด้านประสิทธิภาพ
- API เดิมสร้างภาระที่ไม่จำเป็นทั้งต่อการใช้งานและการอิมพลีเมนต์ เนื่องจากข้อจำกัดด้านการออกแบบ เช่น การจัดการล็อก (lock), BYOB, แบ็กเพรสเชอร์ (backpressure)
- Cloudflare เสนอโมเดลสตรีมแบบใหม่ที่อิงกับ การวนซ้ำแบบอะซิงก์ (async iteration) และแนวทางนี้แสดงให้เห็นถึง ประสิทธิภาพที่เร็วขึ้น 2 เท่าจนถึงสูงสุด 120 เท่า
- API ใหม่นี้เพิ่มประสิทธิภาพและความสอดคล้องด้วย โครงสร้าง async iterable ที่เรียบง่าย, นโยบายแบ็กเพรสเชอร์แบบชัดเจน, และ การรองรับทั้งแบบซิงก์/อะซิงก์ควบคู่กัน
- แนวทางนี้ทำให้เกิด โมเดลการสตรีมแบบรวมศูนย์สำหรับทุก runtime เช่น Node.js, Deno, Bun และเบราว์เซอร์ และอาจเป็นจุดเริ่มต้นของการหารือเรื่องมาตรฐานในอนาคต
ข้อจำกัดเชิงโครงสร้างของ Web Streams
- มาตรฐาน WHATWG Streams ถูกพัฒนาขึ้นในช่วงปี 2014~2016 โดยออกแบบโดยยึดเบราว์เซอร์เป็นศูนย์กลาง และในเวลานั้น ยังไม่มี async iteration จึงต้องนำโมเดล reader/writer แบบแยกมาใช้
- ส่งผลให้เกิดขั้นตอนที่ไม่จำเป็น เช่น การจัดการล็อก, ลูปการอ่านที่ซับซ้อน, การจัดการบัฟเฟอร์ BYOB
- โมเดลล็อก (locking) ทำให้สตรีมถูกยึดครองแบบเอกสิทธิ์และขัดขวางการบริโภคแบบขนาน อีกทั้งหากลืม
releaseLock() ก็อาจเกิดปัญหาที่สตรีมถูกล็อกถาวร
- ฟีเจอร์ BYOB (Bring Your Own Buffer) มีเป้าหมายเพื่อใช้หน่วยความจำซ้ำ แต่เนื่องจาก โมเดลการแยกและส่งต่อบัฟเฟอร์ที่ซับซ้อน ทำให้การใช้งานจริงต่ำและอิมพลีเมนต์ได้ยาก
- แม้ในทางทฤษฎีจะรองรับ แบ็กเพรสเชอร์ (backpressure) แต่ด้วยโครงสร้างที่แม้ค่า
desiredSize จะติดลบ enqueue() ก็ยังสำเร็จได้ จึง ไม่สามารถควบคุมได้จริง
- ทุกการเรียก
read() จะถูก บังคับให้สร้าง Promise ทำให้การสตรีมความถี่สูงเกิด ประสิทธิภาพตกและเพิ่มภาระต่อ GC
ปัญหาที่พบในงานจริง
- หากไม่บริโภคเนื้อหา response body จาก
fetch() จะเกิด การใช้ connection pool จนหมด และเมื่อใช้ tee() จะเกิด การบัฟเฟอร์หน่วยความจำแบบไม่จำกัด
TransformStream จะ ประมวลผลทันทีโดยไม่สนใจว่าฝั่งอ่านพร้อมหรือไม่ ทำให้ในสภาพแวดล้อมที่ผู้บริโภคช้าเกิด บัฟเฟอร์พุ่งสูง
- ในการเรนเดอร์ฝั่งเซิร์ฟเวอร์ (SSR) จะเกิด GC thrashing จากการจัดการชังก์ขนาดเล็กหลายพันชิ้น ส่งผลให้ประสิทธิภาพลดลงอย่างมาก
- แต่ละ runtime (Node.js, Deno, Bun, Workers) พยายามบรรเทาปัญหาด้วย เส้นทางเพิ่มประสิทธิภาพแบบนอกมาตรฐาน แต่สิ่งนี้กลับทำให้ ความเข้ากันได้และความสม่ำเสมอลดลง
- Web Platform Tests ต้องใช้ไฟล์ทดสอบที่ซับซ้อนมากกว่า 70 ไฟล์ ซึ่งเป็นผลจาก การจัดการสถานะภายในที่มากเกินไปและพฤติกรรมที่ไม่เป็นธรรมชาติ
หลักการออกแบบของ Streams API ใหม่
- สตรีมถูกนิยามเป็น async iterable แบบเรียบง่าย จึงสามารถบริโภคได้โดยตรงด้วย
for await...of
- ใช้แนวคิด pull-through transform เพื่อให้ประมวลผลเฉพาะตอนที่ผู้บริโภคร้องขอข้อมูลเท่านั้น
- มี นโยบายแบ็กเพรสเชอร์แบบชัดเจน (strict, block, drop-oldest, drop-newest) เพื่อป้องกันหน่วยความจำพุ่ง
- ส่งข้อมูลเป็นหน่วย ชังก์แบบแบตช์ (Uint8Array[]) เพื่อลดต้นทุนจากการสร้าง Promise
- ทำให้ง่ายขึ้นด้วย การประมวลผลเฉพาะระดับไบต์ และตัดแนวคิดอย่าง BYOB หรือคอนโทรลเลอร์ที่ซับซ้อนออก
- รองรับเส้นทางแบบซิงโครนัส (synchronous) เพื่อตัด overhead ของ Promise ในงานที่เน้น CPU
ตัวอย่างและจุดเด่นของ API ใหม่
Stream.push() ใช้สร้าง คู่ writer/readable ได้อย่างง่ายดาย และ Stream.text() สามารถรวบรวมข้อความทั้งหมดได้
Stream.pull() ใช้สร้าง pipeline แบบ lazy ที่จะทำงานเมื่อถึงเวลาบริโภคเท่านั้น
Stream.share() และ Stream.broadcast() รองรับ การจัดการผู้บริโภคหลายรายแบบชัดเจน
- API ที่รองรับทั้ง Sync/Async (
Stream.pullSync(), Stream.textSync()) ช่วยรีดประสิทธิภาพสูงสุดในงานที่ไม่มี I/O
- เพื่อให้ทำงานร่วมกับ Web Streams ได้ จึงสามารถแปลงผ่าน ฟังก์ชัน adapter แบบเรียบง่าย
การเปรียบเทียบประสิทธิภาพและแนวโน้ม
- จากเบนช์มาร์กบน Node.js พบว่าเร็วขึ้น สูงสุด 80~90 เท่า และบนเบราว์เซอร์เร็วขึ้น มากกว่า 100 เท่าในบางกรณี
- ตัวอย่าง: ในเชนแปลงข้อมูล 3 ขั้น ได้ 275GB/s เทียบกับ 3GB/s
- การเพิ่มขึ้นของประสิทธิภาพมาจาก การตัด overhead ของอะซิงก์, การประมวลผลแบบแบตช์, และการออกแบบแบบ pull-based
- อิมพลีเมนต์นี้เขียนด้วย TypeScript/JavaScript ล้วน และ ยังมีโอกาสเพิ่มประสิทธิภาพได้อีกหากทำเป็น native implementation
- Cloudflare นำเสนอแนวทางนี้ในฐานะ จุดเริ่มต้นของการหารือเรื่องมาตรฐาน และขอฟีดแบ็กจากชุมชนนักพัฒนา
บทสรุป
- Web Streams อาจสมเหตุสมผลภายใต้ข้อจำกัดในยุคนั้น แต่ ไม่สอดคล้องกับฟีเจอร์ของภาษาและรูปแบบการพัฒนาของ JavaScript ยุคใหม่
- โมเดลใหม่ที่อิง async iterable ตอบโจทย์ทั้ง ความเรียบง่าย, ประสิทธิภาพ, และการควบคุมอย่างชัดเจน พร้อมชี้ให้เห็นความเป็นไปได้ของ ระบบนิเวศการสตรีมที่สอดคล้องกันข้าม runtime
- Cloudflare เผยแพร่ รีเฟอเรนซ์อิมพลีเมนเตชัน, เอกสาร, และตัวอย่างโค้ด ไว้ที่ GitHub ของ jasnell/new-streams
- เป้าหมายไม่ใช่การกำหนดมาตรฐานใหม่ทันที แต่คือ การวางจุดเริ่มต้นที่ใช้งานได้จริงเพื่อหารือเรื่อง “Streams API ที่ดีกว่า”
1 ความคิดเห็น
ความเห็นจาก Hacker News
ได้ออกแบบ อินเทอร์เฟซ Stream ที่ดีกว่า API ที่บทความนี้เสนอไว้เอง
ข้อเสนอเดิมอยู่ในรูป
async iterator of UInt8Arrayแต่ฉันเสนอให้next()สามารถคืนค่าผลลัพธ์ได้ทั้งแบบ synchronous และ asynchronousแบบนี้จะทำให้
สามารถวนลูปได้ง่ายขึ้นด้วย iterator ตัวเดียว เมื่อเทียบกับโครงสร้างเดิม
ถ้านำการแปลงแบบ synchronous ไปใช้กับอินพุตแบบ synchronous ก็สามารถประมวลผลทั้งหมดแบบ synchronous ได้ ช่วยลดการเขียนโค้ดซ้ำ
ลดการสร้าง Promise ที่ไม่จำเป็น จึงมีประสิทธิภาพดีขึ้น
สามารถ ควบคุม concurrency ได้ จึงแก้ข้อจำกัดของ async iterator ได้
จากแนวทางของคุณสร้างโครงสร้างของพวกเขาได้ไม่ง่าย แต่ในทางกลับกันทำได้
iterator ที่เน้น I/O ควรคืนค่าเป็นชังก์ระดับ T เพื่อป้องกัน การสิ้นเปลืองบัฟเฟอร์
เหตุผลที่ใช้
Uint8Arrayก็เพื่อให้สอดคล้องกับ byte stream ระดับ OSในโปรเจ็กต์ที่อิง C จริง ๆ โครงสร้างแบบนี้ก็มีประสิทธิภาพที่สุดอยู่แล้ว ดังนั้นโปรโตคอลที่มีข้อมูลชนิดจึงควรซ้อนอยู่ข้างบนแบบนี้อย่างเป็นธรรมชาติ
ในเวอร์ชันเก่าความต่างเคยไปถึง 105 เท่า
จำได้ว่าเคยมีการปรับแต่ง async ให้ดีขึ้นใน Node 16 และตอนนั้นเทสต์บางตัวก็พังไป
Uint8ArrayUint8Arrayเป็นเพียงชนิดพื้นฐานที่ใช้แทนอาร์เรย์ของไบต์ และข้อมูลชนิดควรถูกจัดการในระดับ แอปพลิเคชัน ไม่ใช่ระดับโปรโตคอลอ้างอิง: เอกสาร Clojure Transducers
Async iterable ก็ไม่ใช่คำตอบที่สมบูรณ์แบบเช่นกัน
overhead จาก Promise และการสลับสแตกสูง ทำให้ประสิทธิภาพแย่เมื่อจัดการข้อมูลชิ้นเล็ก ๆ
ใน Lit-SSR มีการใช้วิธี ใส่ thunk ไว้ใน synchronous iterable เพื่อแก้ปัญหานี้
จะเรียก thunk และ await เฉพาะตอนที่ต้องมีงาน async เท่านั้น ทำให้ประสิทธิภาพ SSR ดีขึ้น 12 ถึง 18 เท่า
แต่ Streams API คงรับเอา โครงสร้างสัญญาที่เปราะบาง แบบนี้มาใช้ได้ยาก จึงคิดว่าโครงสร้างที่รองรับ async แบบเลือกได้ เช่น
write()และwriteAsync()จะเหมาะกว่าผมแชร์ตัวอย่างที่ใช้ synchronous generator ไว้ใน โค้ดบน GitHub
แก่นสำคัญคือส่วน
step.value.then(value => this.next(value))next(): {done, value: T} | Promise)หลังการถกเถียงเรื่อง “Do not unleash Zalgo” ในปี 2013 ผู้คนมักหลีกเลี่ยงรูปแบบ
MaybeAsyncแต่ผมคิดว่าความกลัวนี้ถูกขยายเกินจริงจนไปขัดขวาง การออกแบบ API ที่เร็วและยืดหยุ่น
ยังสามารถสร้างยูทิลิตีสำหรับดึงค่าหลายค่าในครั้งเดียวได้ด้วย และรู้สึกว่าปัญหาเรื่องความเร็วของ generator ในทางปฏิบัติก็ไม่ได้ใหญ่ขนาดนั้น
การจัดการ Web Streams ใน Node.js เป็นเรื่องน่าปวดหัว
มันถูกออกแบบโดยยึดเบราว์เซอร์เป็นศูนย์กลาง จึงใช้งานในฝั่งเซิร์ฟเวอร์ได้ไม่สะดวก
แม้แต่การแปลงข้อมูลแบบง่าย ๆ ก็ยังต้องห่อ transform stream และการเชนแบบตรงไปตรงมาอย่าง
.pipe()ก็ทำได้ยากแนวทางแบบ Async iterable เป็นธรรมชาติกว่ามาก และเข้ากับ
for-await-ofได้ดีสเปกของ Web Streams เน้น ความเป็นนามธรรม มากเกินไป จนใช้งานจริงได้ไม่ดี
ผมนึกว่ามันมีไว้แค่เพื่อ ความเข้ากันได้ระหว่างไคลเอนต์กับเซิร์ฟเวอร์ เท่านั้น
ข้อได้เปรียบที่แท้จริงไม่ได้มีแค่เรื่องประสิทธิภาพ แต่รวมถึง ความสอดคล้องกันข้ามสภาพแวดล้อม (convergence) ด้วย
ถ้า ReadableStream ทำงานเหมือนกันในเบราว์เซอร์, Worker และรันไทม์อื่น ๆ
ก็จะช่วยทั้งเรื่องการย้ายโค้ดและ ลดบั๊กจาก backpressure
การทำให้ชั้นสตรีมเป็นมาตรฐานคือหัวใจสำคัญของการสร้างระบบสตรีมมิงที่เชื่อถือได้
เมื่อก่อนเคยสร้าง abstraction ชื่อ Repeater
มันคือแนวคิดที่ย้าย Promise constructor มาอยู่ใน async iterable โดยควบคุมอีเวนต์ผ่าน push/stop
ไลบรารี Repeater มีความเสถียรมากพอจนมียอดดาวน์โหลดต่อสัปดาห์ถึง 6.5 ล้านครั้ง
ช่วงหลังผมหันไปชอบ streams มากกว่า แต่คำวิจารณ์เกี่ยวกับ
tee()ก็ยังคงใช้ได้อยู่ผมคิดว่าทิศทางที่ถูกต้องคือ ใช้ async iterable เป็น abstraction พื้นฐาน
stopของ Repeater ทำงานได้ทั้งเป็นฟังก์ชันและ Promiseหลังจากดู ซอร์สโค้ด แล้ว
แม้จะต่างจากแพตเทิร์นดั้งเดิม แต่ก็คิดว่าอาจเป็นตัวเลือกที่ตั้งใจทำเพื่อ การออกแบบที่ใช้งานสะดวก
ถึงขั้นใช้ “Up, Up, Down, Down, Left, Right, Left, Right, B, A” ในลายเซ็นอีเมลเลย เพราะมีความคิดถึงอย่างมาก
ผมก็เคยทำ wrapper เพื่อให้เขียน AsyncIterable ได้กระชับขึ้น เหมือนกัน
มันคือ fluent-async-iterator
ซึ่งมีประโยชน์กับการสตรีมข้อมูลขนาดเล็กใน Lambda หรือ CLI pipeline
หวังว่าป่านนี้น่าจะมี API ที่ดีกว่านี้ออกมาแล้ว
พฤติกรรม backpressure ของ
ReadableStream.tee()ชวนสับสน เพราะตรงข้ามกับpipe()ของ Node.jsในสเปกระบุว่า “เอาต์พุตที่ช้าที่สุดควรเป็นตัวกำหนดความเร็ว” แต่การทำงานจริงกลับติดขัดแม้ฝั่งที่เร็วจะยังไม่ถูก consume
ผมคิดว่าโครงสร้างแบบ push-based ที่กระชับ อย่าง Stream API ใหม่จะดีกว่า
ทั้ง Node และ Web Streams ใช้คิวไม่สิ้นสุด ทำให้สามารถเรียก
res.write()แบบ synchronous รัว ๆ ได้แต่ API นี้บังคับ ลำดับการไหลแบบ yield ที่อิง generator จึงปลอดภัยกว่า
การเกิดปัญหา connection pool หมดเมื่อใช้ undici(fetch) ใน Node.js
เป็นเพราะ ข้อจำกัดของภาษาที่มี garbage collection
ถ้าไม่ปิดรีซอร์สอย่างชัดเจน ก็จะเกิดการรั่วได้ตามจังหวะของ GC
แนวทางแบบ RAII(reference counting) ของ C++ กลับปลอดภัยกว่าเสียอีก
ในเรื่องการปล่อยรีซอร์ส ก็หวังว่าจะเห็นแพตเทิร์น
using/await usingถูกใช้อย่างแพร่หลายมากขึ้นเหมือน
usingของ C# ที่รองรับ dispose/disposeAsync และตอนนี้กำลังนำโครงสร้างนี้ไปใช้กับ DB driverตัวเลข benchmark (เช่น 530GB/s) เกิน memory bandwidth ของ M1 Pro (200GB/s) จึงไม่น่าเชื่อถือ
มีความเป็นไปได้สูงว่าจะเป็น benchmark แบบ vibe-coded ที่ขาดการควบคุมคุณภาพของการติดตั้งใช้งาน