- Chuẩn Web Streams được thiết kế để truyền dữ liệu theo luồng một cách nhất quán giữa trình duyệt và máy chủ, nhưng hiện nay độ phức tạp và các giới hạn về hiệu năng đang làm giảm trải nghiệm của lập trình viên
- API hiện tại tạo ra gánh nặng không cần thiết cả trong việc sử dụng lẫn triển khai do các ràng buộc thiết kế như quản lý khóa (lock), BYOB, backpressure
- Cloudflare đề xuất một mô hình stream mới dựa trên async iteration, và cách tiếp cận này cho thấy hiệu năng nhanh hơn 2 lần đến tối đa 120 lần
- API mới nâng cao hiệu quả và tính nhất quán thông qua cấu trúc async iterable đơn giản, chính sách backpressure tường minh, và hỗ trợ song song đồng bộ/bất đồng bộ
- Cách tiếp cận này có thể cho phép một mô hình streaming thống nhất trên mọi runtime như Node.js, Deno, Bun, trình duyệt..., đồng thời có thể trở thành điểm khởi đầu cho các thảo luận tiêu chuẩn trong tương lai
Những giới hạn mang tính cấu trúc của Web Streams
- Chuẩn WHATWG Streams được phát triển trong giai đoạn 2014~2016 và được thiết kế xoay quanh trình duyệt; vào thời điểm đó async iteration chưa tồn tại, nên đã đưa vào mô hình reader/writer riêng biệt
- Điều này tạo ra những thủ tục không cần thiết như quản lý khóa, vòng lặp đọc phức tạp, xử lý bộ đệm BYOB
- Mô hình khóa (locking) chiếm dụng stream theo kiểu độc quyền, ngăn việc tiêu thụ song song; nếu quên
releaseLock() thì stream có thể bị khóa vĩnh viễn
- Tính năng BYOB (Bring Your Own Buffer) nhắm tới việc tái sử dụng bộ nhớ, nhưng do mô hình tách/chuyển bộ đệm phức tạp, nó ít được dùng trong thực tế và khó triển khai
- Backpressure được hỗ trợ trên lý thuyết, nhưng cấu trúc hiện tại không cho phép kiểm soát thực tế, ví dụ
enqueue() vẫn thành công ngay cả khi giá trị desiredSize là số âm
- Mỗi lần gọi
read() đều bắt buộc tạo Promise, khiến các tác vụ streaming tần suất cao bị giảm hiệu năng và tăng tải GC
Các vấn đề bộc lộ trong thực tế
- Nếu không tiêu thụ phần thân phản hồi của
fetch(), có thể xảy ra cạn kiệt connection pool; khi dùng tee(), sẽ phát sinh bộ đệm bộ nhớ không giới hạn
TransformStream sẽ xử lý ngay lập tức bất kể đã sẵn sàng để đọc hay chưa, gây ra bùng nổ bộ đệm trong môi trường có bên tiêu thụ chậm
- Trong server-side rendering (SSR), GC thrashing xảy ra do phải xử lý hàng nghìn chunk nhỏ, khiến hiệu năng sụt giảm mạnh
- Mỗi runtime (Node.js, Deno, Bun, Workers) đều đã đưa vào các đường tối ưu hóa phi tiêu chuẩn để giảm nhẹ vấn đề này, nhưng điều đó lại làm giảm khả năng tương thích và tính nhất quán
- Web Platform Tests yêu cầu hơn 70 tệp kiểm thử phức tạp, phản ánh việc quản lý trạng thái nội bộ quá mức và hành vi khó trực quan
Các nguyên tắc thiết kế của Streams API mới
- Stream được định nghĩa như một async iterable đơn giản, có thể được tiêu thụ trực tiếp bằng
for await...of
- Áp dụng biến đổi pull-through, chỉ xử lý khi bên tiêu thụ thực sự yêu cầu dữ liệu
- Cung cấp chính sách backpressure tường minh (strict, block, drop-oldest, drop-newest) để ngăn bùng nổ bộ nhớ
- Truyền dữ liệu theo đơn vị chunk theo lô (Uint8Array[]) để giảm chi phí tạo Promise
- Được đơn giản hóa thành xử lý chuyên cho byte, loại bỏ BYOB và các khái niệm controller phức tạp
- Hỗ trợ đường đi đồng bộ (synchronous) để loại bỏ overhead của Promise trong các tác vụ thiên về CPU
Ví dụ và đặc điểm của API mới
- Có thể dễ dàng tạo cặp writer/readable bằng
Stream.push(), và thu thập toàn bộ văn bản bằng Stream.text()
Stream.pull() xây dựng pipeline trì hoãn (lazy) và chỉ thực thi tại thời điểm tiêu thụ
Stream.share() và Stream.broadcast() hỗ trợ quản lý nhiều bên tiêu thụ một cách tường minh
- API sync/async song song (
Stream.pullSync(), Stream.textSync()) tối đa hóa hiệu năng cho các phép toán không có I/O
- Để tương tác với Web Streams, có thể chuyển đổi thông qua các hàm adapter đơn giản
So sánh hiệu năng và triển vọng
- Trong benchmark trên Node.js, tốc độ xử lý được xác nhận là nhanh hơn tối đa 80~90 lần; trong trình duyệt có thể nhanh hơn tối đa trên 100 lần
- Ví dụ: trong chuỗi biến đổi 3 tầng, đạt 275GB/s so với 3GB/s
- Mức tăng hiệu năng đến từ việc loại bỏ overhead bất đồng bộ, xử lý theo lô và thiết kế dựa trên pull
- Phần triển khai này được viết hoàn toàn bằng TypeScript/JavaScript, và còn có thể cải thiện thêm nếu được triển khai native
- Cloudflare đưa cách tiếp cận này ra như điểm khởi đầu cho thảo luận tiêu chuẩn, đồng thời kêu gọi phản hồi từ cộng đồng lập trình viên
Kết luận
- Web Streams từng là một thiết kế hợp lý trong bối cảnh bị giới hạn lúc đó, nhưng không còn phù hợp với các tính năng ngôn ngữ và mô hình phát triển của JavaScript hiện đại
- Mô hình mới dựa trên async iterable đáp ứng đồng thời tính đơn giản, hiệu năng và khả năng kiểm soát tường minh, đồng thời mở ra khả năng xây dựng một hệ sinh thái streaming nhất quán giữa các runtime
- Cloudflare đã công bố bản triển khai tham chiếu, tài liệu và mã ví dụ trên GitHub tại jasnell/new-streams
- Mục tiêu không phải là lập ra một tiêu chuẩn mới ngay lập tức, mà là tạo một điểm xuất phát thực tế để thảo luận về “một Streams API tốt hơn”
1 bình luận
Ý kiến trên Hacker News
Đã tự thiết kế một giao diện Stream tốt hơn cả API được đề xuất trong bài này
Đề xuất hiện tại có dạng
async iterator of UInt8Array, còn tôi đề xuất một cấu trúc mànext()có thể trả về cả kết quả đồng bộ lẫn bất đồng bộLàm như vậy thì
có thể duyệt đơn giản hơn bằng một iterator duy nhất so với cấu trúc hiện tại
nếu áp dụng phép biến đổi đồng bộ cho đầu vào đồng bộ thì toàn bộ quá trình có thể chạy đồng bộ, giúp giảm trùng lặp mã
giảm việc tạo Promise không cần thiết nên cải thiện hiệu năng
có thể kiểm soát tính đồng thời, vượt qua giới hạn của async iterator
Với cách của bạn thì không dễ để dựng lại cấu trúc của họ, còn chiều ngược lại thì được
Iterator thiên về I/O cần trả về các chunk theo đơn vị T để tránh lãng phí bộ đệm
Lý do dùng
Uint8Arraylà để khớp với byte stream ở cấp hệ điều hànhTrên thực tế, ngay cả trong các dự án dựa trên C thì cấu trúc như vậy cũng hiệu quả nhất, nên việc để giao thức có thông tin kiểu được xây trên nó là tự nhiên
Ở các phiên bản cũ hơn thì từng chênh tới 105 lần
Tôi nhớ đã có tối ưu cho xử lý async ở Node 16, và khi đó một số bài test từng bị hỏng
Uint8Arraykhông hề tồn tạiUint8Arraychỉ đơn thuần là một kiểu nguyên thủy biểu diễn mảng byte, còn thông tin kiểu nên được xử lý ở tầng ứng dụng chứ không phải tầng giao thứcTham khảo: tài liệu về Clojure Transducers
Async iterable cũng không phải lời giải hoàn hảo
Promise và overhead chuyển stack khá lớn nên hiệu năng kém khi xử lý dữ liệu nhỏ
Trong Lit-SSR, để giải quyết việc này, họ dùng cách nhúng thunk vào iterable đồng bộ
chỉ khi cần tác vụ async mới gọi thunk và await, nhờ đó cải thiện hiệu năng SSR lên 12~18 lần
Tuy vậy, Streams API khó chấp nhận kiểu hợp đồng mong manh như thế, nên tôi nghĩ cấu trúc cho phép xử lý bất đồng bộ tùy chọn như
write()vàwriteAsync()sẽ là lý tưởngTôi đã chia sẻ ví dụ dùng generator đồng bộ trong mã trên GitHub
Điểm cốt lõi là đoạn
step.value.then(value => this.next(value))next(): {done, value: T} | Promise)Từ sau cuộc tranh luận “Do not unleash Zalgo” năm 2013, người ta có xu hướng né dạng
MaybeAsync, nhưngtôi nghĩ nỗi sợ này bị phóng đại quá mức và đang cản trở thiết kế API nhanh và linh hoạt
Cũng có thể làm utility để kéo nhiều giá trị cùng lúc, và trên thực tế tôi thấy vấn đề tốc độ của generator không lớn đến vậy
Làm việc với Web Streams trong Node.js thật sự rất đau đớn
Vì được thiết kế xoay quanh trình duyệt nên dùng trong môi trường máy chủ rất bất tiện
Ngay cả biến đổi đơn giản cũng phải bọc trong transform stream, và khó chain trực quan như
.pipe()Cách tiếp cận async iterable tự nhiên hơn nhiều và rất hợp với
for-await-ofĐặc tả Web Streams quá thiên về trừu tượng hóa nên kém thực dụng
Tôi cứ nghĩ nó chỉ để tương thích giữa client và server
Lợi ích thật sự không chỉ là hiệu năng mà còn là tính nhất quán giữa các môi trường (convergence)
Nếu ReadableStream hoạt động giống nhau trên trình duyệt, Worker và các runtime khác
thì khả năng di chuyển mã sẽ cao hơn và cũng giúp giảm lỗi backpressure
Việc chuẩn hóa tầng stream là cốt lõi để xây dựng hệ thống streaming đáng tin cậy
Trước đây tôi từng tạo ra một abstraction tên là Repeater
Đó là ý tưởng chuyển Promise constructor sang async iterable, với việc điều khiển sự kiện bằng push/stop
Thư viện Repeater đủ ổn định để đạt 6,5 triệu lượt tải mỗi tuần
Gần đây tôi thiên về streams hơn, nhưng phê bình liên quan đến
tee()vẫn còn nguyên giá trịTôi cho rằng hướng đi đúng là xem async iterable như abstraction mặc định
stopcủa Repeater vừa là hàm vừa là Promise khá thú vịSau khi xem mã nguồn
tôi nghĩ tuy khác với mẫu truyền thống, nhưng đó có thể là lựa chọn có chủ đích để tăng tính công thái học
Tôi còn hoài niệm đến mức dùng “Up, Up, Down, Down, Left, Right, Left, Right, B, A” trong chữ ký email
Tôi cũng từng làm một wrapper để dùng AsyncIterable gọn hơn
Đó là fluent-async-iterator,
khá hữu ích cho streaming dữ liệu quy mô nhỏ trong Lambda hay pipeline CLI
Đến giờ tôi vẫn mong đã có một API tốt hơn xuất hiện
Hành vi backpressure của
ReadableStream.tee()gây bối rối vì ngược vớipipe()của Node.jsTrong đặc tả ghi rằng “đầu ra chậm nhất phải quyết định tốc độ”, nhưng triển khai thực tế lại bị chặn ngay cả khi phía nhanh hơn chưa được tiêu thụ
Tôi nghĩ một cấu trúc ngắn gọn dựa trên push như Stream API mới sẽ tốt hơn
Node và Web Streams dùng hàng đợi vô hạn để có thể gọi
res.write()đồng bộ liên tục, nhưngAPI này buộc theo luồng
yielddựa trên generator nên an toàn hơnViệc cạn pool kết nối khi dùng undici(fetch) trong Node.js
là do giới hạn của ngôn ngữ có garbage collection
Nếu không đóng tài nguyên một cách tường minh thì có thể rò rỉ tùy theo thời điểm GC chạy
Cách tiếp cận RAII (reference counting) của C++ lại an toàn hơn
Về giải phóng tài nguyên, tôi hy vọng mẫu
using/await usingsẽ ngày càng phổ biếnTôi đang áp dụng cấu trúc hỗ trợ dispose/disposeAsync vào driver DB, tương tự
usingcủa C#Các con số benchmark (ví dụ: 530GB/s) vượt quá băng thông bộ nhớ của M1 Pro (200GB/s) nên khó đáng tin
Rất có thể đó là một benchmark kiểu vibe-coded với chất lượng triển khai kém