4 คะแนน โดย GN⁺ 2026-02-28 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • มาตรฐาน 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 ความคิดเห็น

 
GN⁺ 2026-02-28
ความเห็นจาก Hacker News
  • ได้ออกแบบ อินเทอร์เฟซ Stream ที่ดีกว่า API ที่บทความนี้เสนอไว้เอง
    ข้อเสนอเดิมอยู่ในรูป async iterator of UInt8Array แต่ฉันเสนอให้ next() สามารถคืนค่าผลลัพธ์ได้ทั้งแบบ synchronous และ asynchronous
    แบบนี้จะทำให้
    สามารถวนลูปได้ง่ายขึ้นด้วย iterator ตัวเดียว เมื่อเทียบกับโครงสร้างเดิม
    ถ้านำการแปลงแบบ synchronous ไปใช้กับอินพุตแบบ synchronous ก็สามารถประมวลผลทั้งหมดแบบ synchronous ได้ ช่วยลดการเขียนโค้ดซ้ำ
    ลดการสร้าง Promise ที่ไม่จำเป็น จึงมีประสิทธิภาพดีขึ้น
    สามารถ ควบคุม concurrency ได้ จึงแก้ข้อจำกัดของ async iterator ได้

    • แม้คุณจะบอกว่าวิธีที่คุณเสนอจะดีกว่า แต่จริง ๆ แล้วผมคิดว่าวิธีของอีกฝ่ายดีกว่าในฐานะ รูปแบบ primitive พื้นฐาน
      จากแนวทางของคุณสร้างโครงสร้างของพวกเขาได้ไม่ง่าย แต่ในทางกลับกันทำได้
      iterator ที่เน้น I/O ควรคืนค่าเป็นชังก์ระดับ T เพื่อป้องกัน การสิ้นเปลืองบัฟเฟอร์
    • แนวคิดเรื่องสตรีมที่เสนอไว้น่าสนใจ แต่ดีไซน์ของพวกเขาตั้งอยู่บนสมมติฐานเรื่อง ความเข้ากันได้กับ AsyncIterator
      เหตุผลที่ใช้ Uint8Array ก็เพื่อให้สอดคล้องกับ byte stream ระดับ OS
      ในโปรเจ็กต์ที่อิง C จริง ๆ โครงสร้างแบบนี้ก็มีประสิทธิภาพที่สุดอยู่แล้ว ดังนั้นโปรโตคอลที่มีข้อมูลชนิดจึงควรซ้อนอยู่ข้างบนแบบนี้อย่างเป็นธรรมชาติ
    • ผมวัดด้วยไมโครบेंช์มาร์กว่าใน Node 24 ความเร็วของการเรียกฟังก์ชัน synchronous กับ async ต่างกันมากแค่ไหน แล้วพบว่าช้ากว่าราว 90 เท่า
      ในเวอร์ชันเก่าความต่างเคยไปถึง 105 เท่า
      จำได้ว่าเคยมีการปรับแต่ง async ให้ดีขึ้นใน Node 16 และตอนนั้นเทสต์บางตัวก็พังไป
    • ไม่มีชนิดชื่อ Uint8Array
      Uint8Array เป็นเพียงชนิดพื้นฐานที่ใช้แทนอาร์เรย์ของไบต์ และข้อมูลชนิดควรถูกจัดการในระดับ แอปพลิเคชัน ไม่ใช่ระดับโปรโตคอล
    • โครงสร้างนี้คล้ายกับแนวคิด transducer ของ Clojure
      อ้างอิง: เอกสาร Clojure Transducers
  • Async iterable ก็ไม่ใช่คำตอบที่สมบูรณ์แบบเช่นกัน
    overhead จาก Promise และการสลับสแตกสูง ทำให้ประสิทธิภาพแย่เมื่อจัดการข้อมูลชิ้นเล็ก ๆ
    ใน Lit-SSR มีการใช้วิธี ใส่ thunk ไว้ใน synchronous iterable เพื่อแก้ปัญหานี้
    จะเรียก thunk และ await เฉพาะตอนที่ต้องมีงาน async เท่านั้น ทำให้ประสิทธิภาพ SSR ดีขึ้น 12 ถึง 18 เท่า
    แต่ Streams API คงรับเอา โครงสร้างสัญญาที่เปราะบาง แบบนี้มาใช้ได้ยาก จึงคิดว่าโครงสร้างที่รองรับ async แบบเลือกได้ เช่น write() และ writeAsync() จะเหมาะกว่า

    • stream iterator ของผมแก้ปัญหาที่คุณพูดถึงได้
      ผมแชร์ตัวอย่างที่ใช้ synchronous generator ไว้ใน โค้ดบน GitHub
      แก่นสำคัญคือส่วน step.value.then(value => this.next(value))
    • ชอบข้อเสนอของ conartist6 (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 เน้น ความเป็นนามธรรม มากเกินไป จนใช้งานจริงได้ไม่ดี

    • น่าแปลกใจที่มีคนใช้ Web Streams บน Node จริง ๆ
      ผมนึกว่ามันมีไว้แค่เพื่อ ความเข้ากันได้ระหว่างไคลเอนต์กับเซิร์ฟเวอร์ เท่านั้น
  • ข้อได้เปรียบที่แท้จริงไม่ได้มีแค่เรื่องประสิทธิภาพ แต่รวมถึง ความสอดคล้องกันข้ามสภาพแวดล้อม (convergence) ด้วย
    ถ้า ReadableStream ทำงานเหมือนกันในเบราว์เซอร์, Worker และรันไทม์อื่น ๆ
    ก็จะช่วยทั้งเรื่องการย้ายโค้ดและ ลดบั๊กจาก backpressure
    การทำให้ชั้นสตรีมเป็นมาตรฐานคือหัวใจสำคัญของการสร้างระบบสตรีมมิงที่เชื่อถือได้

    • ใช่แล้ว คุณค่าที่สำคัญไม่ได้อยู่ที่ประสิทธิภาพอย่างเดียว แต่คือ คุณค่าของการทำให้เป็นมาตรฐาน
  • เมื่อก่อนเคยสร้าง abstraction ชื่อ Repeater
    มันคือแนวคิดที่ย้าย Promise constructor มาอยู่ใน async iterable โดยควบคุมอีเวนต์ผ่าน push/stop
    ไลบรารี Repeater มีความเสถียรมากพอจนมียอดดาวน์โหลดต่อสัปดาห์ถึง 6.5 ล้านครั้ง
    ช่วงหลังผมหันไปชอบ streams มากกว่า แต่คำวิจารณ์เกี่ยวกับ tee() ก็ยังคงใช้ได้อยู่
    ผมคิดว่าทิศทางที่ถูกต้องคือ ใช้ async iterable เป็น abstraction พื้นฐาน

    • รู้สึกว่าน่าสนใจที่ stop ของ Repeater ทำงานได้ทั้งเป็นฟังก์ชันและ Promise
      หลังจากดู ซอร์สโค้ด แล้ว
      แม้จะต่างจากแพตเทิร์นดั้งเดิม แต่ก็คิดว่าอาจเป็นตัวเลือกที่ตั้งใจทำเพื่อ การออกแบบที่ใช้งานสะดวก
    • แม้จะไม่เกี่ยวกับประเด็นหลัก แต่ตัวอย่าง Konami code ทำให้รู้สึกดีมาก
      ถึงขั้นใช้ “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 ที่ขาดการควบคุมคุณภาพของการติดตั้งใช้งาน