Skip to content

Commit 6f9a2f3

Browse files
committed
stream: speed up async iteration of Readable
Replace the async generator backing Symbol.asyncIterator with a hand-rolled iterator. The generator machinery costs several extra promise allocations and microtask hops per chunk: yield awaits the yielded value and resolves the pending request through separate promises. Buffered chunks are now delivered as an already-resolved promise, one microtask sooner than before. Thenable chunks are still awaited before delivery, requests received while a next() is outstanding are queued, and return()/throw() before the first next() complete the iterator without touching the stream. The earlier delivery is observable by code racing an abort against the first chunk. The flatMap AbortSignal test relied on such a race; it is reworked to abort deterministically while two mappers are in flight, asserting the concurrency limit, in-flight cancellation and rejection, without depending on delivery timing or timers. streams/readable-async-iterator.js sync='yes': +32.59% (***) streams/readable-async-iterator.js sync='no': +9.84% (***) Assisted-by: Claude Fable 5 Signed-off-by: Matteo Collina <matteo.collina@gmail.com>
1 parent 8a3b11c commit 6f9a2f3

3 files changed

Lines changed: 307 additions & 35 deletions

File tree

lib/internal/streams/readable.js

Lines changed: 197 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ const {
3030
ObjectKeys,
3131
ObjectSetPrototypeOf,
3232
Promise,
33+
PromisePrototypeThen,
34+
PromiseReject,
35+
PromiseResolve,
3336
ReflectApply,
3437
SafeSet,
3538
Symbol,
@@ -1382,10 +1385,21 @@ function streamToAsyncIterator(stream, options) {
13821385
return iter;
13831386
}
13841387

1385-
async function* createAsyncIterator(stream, options) {
1388+
// Async iterator over a Readable. Requests received while another is
1389+
// outstanding are queued and processed in order.
1390+
function createAsyncIterator(stream, options) {
13861391
let callback = nop;
1387-
1388-
function next(resolve) {
1392+
let error; // undefined: active, null: ended cleanly, else: Error
1393+
let started = false;
1394+
let completed = false;
1395+
let inFlight = false; // An asynchronous request is outstanding
1396+
let queue = null; // Requests received while inFlight
1397+
let cleanup;
1398+
1399+
// Used both as the 'readable' listener (where `this === stream`) and
1400+
// as a promise executor storing the resolver that wakes up a pending
1401+
// pump().
1402+
function wakeup(resolve) {
13891403
if (this === stream) {
13901404
callback();
13911405
callback = nop;
@@ -1394,32 +1408,23 @@ async function* createAsyncIterator(stream, options) {
13941408
}
13951409
}
13961410

1397-
stream.on('readable', next);
1411+
function start() {
1412+
started = true;
13981413

1399-
let error;
1400-
const cleanup = eos(stream, { writable: false }, (err) => {
1401-
error = err ? aggregateTwoErrors(error, err) : null;
1402-
callback();
1403-
callback = nop;
1404-
});
1414+
stream.on('readable', wakeup);
1415+
1416+
cleanup = eos(stream, { writable: false }, (err) => {
1417+
error = err ? aggregateTwoErrors(error, err) : null;
1418+
callback();
1419+
callback = nop;
1420+
});
1421+
}
1422+
1423+
// Complete the iterator and either destroy the stream or detach
1424+
// from it.
1425+
function finalize() {
1426+
completed = true;
14051427

1406-
try {
1407-
while (true) {
1408-
const chunk = stream.destroyed ? null : stream.read();
1409-
if (chunk !== null) {
1410-
yield chunk;
1411-
} else if (error) {
1412-
throw error;
1413-
} else if (error === null) {
1414-
return;
1415-
} else {
1416-
await new Promise(next);
1417-
}
1418-
}
1419-
} catch (err) {
1420-
error = aggregateTwoErrors(error, err);
1421-
throw error;
1422-
} finally {
14231428
const preserveHalfOpenDuplex =
14241429
error === null &&
14251430
stream.allowHalfOpen === true &&
@@ -1433,10 +1438,174 @@ async function* createAsyncIterator(stream, options) {
14331438
) {
14341439
destroyImpl.destroyer(stream, null);
14351440
} else {
1436-
stream.off('readable', next);
1441+
stream.off('readable', wakeup);
14371442
cleanup();
14381443
}
14391444
}
1445+
1446+
function settleError(err, reject) {
1447+
error = aggregateTwoErrors(error, err);
1448+
finalize();
1449+
reject(error);
1450+
}
1451+
1452+
function drain() {
1453+
while (!inFlight && queue.length > 0) {
1454+
const req = queue.shift();
1455+
if (req.type === 'next') {
1456+
processNext(req.resolve, req.reject);
1457+
} else if (req.type === 'return') {
1458+
processReturn(req.value, req.resolve);
1459+
} else {
1460+
processThrow(req.value, req.reject);
1461+
}
1462+
}
1463+
}
1464+
1465+
// Thenable chunks are unwrapped before delivery; a rejection tears
1466+
// down the iterator and the stream.
1467+
function onChunkFulfilled(value) {
1468+
inFlight = false;
1469+
if (queue !== null) drain();
1470+
return { done: false, value };
1471+
}
1472+
1473+
function onChunkRejected(err) {
1474+
inFlight = false;
1475+
error = aggregateTwoErrors(error, err);
1476+
finalize();
1477+
if (queue !== null) drain();
1478+
throw error;
1479+
}
1480+
1481+
// Runs with inFlight === true; settles the request and hands over to
1482+
// any requests that queued up behind it.
1483+
function pump(resolve, reject) {
1484+
const chunk = stream.destroyed ? null : stream.read();
1485+
if (chunk !== null) {
1486+
if (typeof chunk.then === 'function') {
1487+
PromisePrototypeThen(PromiseResolve(chunk), (value) => {
1488+
inFlight = false;
1489+
resolve({ done: false, value });
1490+
if (queue !== null) drain();
1491+
}, (err) => {
1492+
inFlight = false;
1493+
settleError(err, reject);
1494+
if (queue !== null) drain();
1495+
});
1496+
return;
1497+
}
1498+
inFlight = false;
1499+
resolve({ done: false, value: chunk });
1500+
if (queue !== null) drain();
1501+
} else if (error) {
1502+
inFlight = false;
1503+
settleError(error, reject);
1504+
if (queue !== null) drain();
1505+
} else if (error === null) {
1506+
inFlight = false;
1507+
finalize();
1508+
resolve({ done: true, value: undefined });
1509+
if (queue !== null) drain();
1510+
} else {
1511+
// No data buffered yet; wait for 'readable' or end-of-stream and
1512+
// retry.
1513+
PromisePrototypeThen(new Promise(wakeup), () => pump(resolve, reject));
1514+
}
1515+
}
1516+
1517+
function processNext(resolve, reject) {
1518+
if (completed) {
1519+
resolve({ done: true, value: undefined });
1520+
return;
1521+
}
1522+
if (!started) start();
1523+
inFlight = true;
1524+
pump(resolve, reject);
1525+
}
1526+
1527+
function processReturn(value, resolve) {
1528+
if (!completed) {
1529+
if (started) {
1530+
finalize();
1531+
} else {
1532+
// Never started: complete without touching the stream.
1533+
completed = true;
1534+
}
1535+
}
1536+
resolve({ done: true, value });
1537+
}
1538+
1539+
function processThrow(err, reject) {
1540+
if (completed || !started) {
1541+
completed = true;
1542+
reject(err);
1543+
return;
1544+
}
1545+
settleError(err, reject);
1546+
}
1547+
1548+
return {
1549+
next() {
1550+
if (!inFlight && !completed) {
1551+
if (!started) start();
1552+
// Fast path: a chunk is already buffered.
1553+
const chunk = stream.destroyed ? null : stream.read();
1554+
if (chunk !== null) {
1555+
if (typeof chunk.then === 'function') {
1556+
inFlight = true;
1557+
return PromisePrototypeThen(
1558+
PromiseResolve(chunk), onChunkFulfilled, onChunkRejected);
1559+
}
1560+
return PromiseResolve({ done: false, value: chunk });
1561+
}
1562+
if (error) {
1563+
finalize();
1564+
return PromiseReject(error);
1565+
}
1566+
if (error === null) {
1567+
finalize();
1568+
return PromiseResolve({ done: true, value: undefined });
1569+
}
1570+
// No data buffered yet; wait for 'readable' or end-of-stream.
1571+
inFlight = true;
1572+
return new Promise((resolve, reject) => {
1573+
PromisePrototypeThen(new Promise(wakeup), () => pump(resolve, reject));
1574+
});
1575+
}
1576+
return new Promise((resolve, reject) => {
1577+
if (inFlight) {
1578+
queue ??= [];
1579+
queue.push({ type: 'next', value: undefined, resolve, reject });
1580+
} else {
1581+
resolve({ done: true, value: undefined });
1582+
}
1583+
});
1584+
},
1585+
return(value) {
1586+
return new Promise((resolve, reject) => {
1587+
if (inFlight) {
1588+
queue ??= [];
1589+
queue.push({ type: 'return', value, resolve, reject });
1590+
} else {
1591+
processReturn(value, resolve);
1592+
}
1593+
});
1594+
},
1595+
throw(err) {
1596+
return new Promise((resolve, reject) => {
1597+
if (inFlight) {
1598+
queue ??= [];
1599+
queue.push({ type: 'throw', value: err, resolve, reject });
1600+
} else {
1601+
processThrow(err, reject);
1602+
}
1603+
});
1604+
},
1605+
[SymbolAsyncIterator]() {
1606+
return this;
1607+
},
1608+
};
14401609
}
14411610

14421611
let composeImpl;

test/parallel/test-stream-flatMap.js

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,23 @@ function oneTo5() {
7272

7373
{
7474
// Concurrency + AbortSignal
75+
// Two mappers are started concurrently and block until their signal
76+
// is aborted. Aborting while both are in flight must cancel them and
77+
// reject the iteration, without ever starting a third mapper.
7578
const ac = new AbortController();
76-
const stream = oneTo5().flatMap(common.mustNotCall(async (_, { signal }) => {
77-
await setTimeout(100, { signal });
78-
}), { signal: ac.signal, concurrency: 2 });
79+
const stream = oneTo5().flatMap(common.mustCall(async (x, { signal }) => {
80+
if (x === 2) {
81+
// Both mappers allowed by `concurrency` are now in flight.
82+
ac.abort();
83+
}
84+
await new Promise((resolve, reject) => {
85+
if (signal.aborted) {
86+
reject(signal.reason);
87+
return;
88+
}
89+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
90+
});
91+
}, 2), { signal: ac.signal, concurrency: 2 });
7992
// pump
8093
assert.rejects(async () => {
8194
for await (const item of stream) {
@@ -85,10 +98,6 @@ function oneTo5() {
8598
}, {
8699
name: 'AbortError',
87100
}).then(common.mustCall());
88-
89-
queueMicrotask(() => {
90-
ac.abort();
91-
});
92101
}
93102

94103
{

test/parallel/test-stream-readable-async-iterators.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,5 +869,99 @@ async function tests() {
869869
}));
870870
}
871871

872+
{
873+
// Thenable chunks are awaited before delivery.
874+
(async () => {
875+
const r = new Readable({ objectMode: true, read() {} });
876+
r.push(Promise.resolve('unwrapped'));
877+
r.push(null);
878+
879+
const it = r[Symbol.asyncIterator]();
880+
const { value, done } = await it.next();
881+
assert.strictEqual(done, false);
882+
assert.strictEqual(value, 'unwrapped');
883+
})().then(common.mustCall());
884+
}
885+
886+
{
887+
// A rejected thenable chunk tears down the iterator and the stream.
888+
(async () => {
889+
const r = new Readable({ objectMode: true, read() {} });
890+
const rejected = Promise.reject(new Error('kaboom'));
891+
rejected.catch(() => {});
892+
r.push(rejected);
893+
r.push(null);
894+
895+
const it = r[Symbol.asyncIterator]();
896+
await assert.rejects(it.next(), { message: 'kaboom' });
897+
assert.strictEqual((await it.next()).done, true);
898+
assert.strictEqual(r.destroyed, true);
899+
})().then(common.mustCall());
900+
}
901+
902+
{
903+
// throw() rejects with the passed error, destroys the stream and
904+
// completes the iterator.
905+
(async () => {
906+
const r = new Readable({ objectMode: true, read() {} });
907+
r.push('a');
908+
909+
const it = r[Symbol.asyncIterator]();
910+
assert.strictEqual((await it.next()).value, 'a');
911+
await assert.rejects(it.throw(new Error('kaboom')), { message: 'kaboom' });
912+
assert.strictEqual(r.destroyed, true);
913+
assert.strictEqual((await it.next()).done, true);
914+
})().then(common.mustCall());
915+
}
916+
917+
{
918+
// throw() before the first next() completes the iterator without
919+
// touching the stream.
920+
(async () => {
921+
const r = new Readable({ objectMode: true, read() {} });
922+
const it = r[Symbol.asyncIterator]();
923+
await assert.rejects(it.throw(new Error('kaboom')), { message: 'kaboom' });
924+
assert.strictEqual(r.destroyed, false);
925+
assert.strictEqual(r.listenerCount('readable'), 0);
926+
assert.strictEqual((await it.next()).done, true);
927+
r.destroy();
928+
})().then(common.mustCall());
929+
}
930+
931+
{
932+
// Concurrent next() calls while waiting for data are served in order.
933+
(async () => {
934+
const r = new Readable({ objectMode: true, read() {} });
935+
const it = r[Symbol.asyncIterator]();
936+
937+
const p1 = it.next();
938+
const p2 = it.next();
939+
r.push('a');
940+
r.push('b');
941+
r.push(null);
942+
943+
assert.strictEqual((await p1).value, 'a');
944+
assert.strictEqual((await p2).value, 'b');
945+
assert.strictEqual((await it.next()).done, true);
946+
})().then(common.mustCall());
947+
}
948+
949+
{
950+
// return() while a next() is pending is processed after the pending
951+
// next() settles, and destroys the stream.
952+
(async () => {
953+
const r = new Readable({ objectMode: true, read() {} });
954+
const it = r[Symbol.asyncIterator]();
955+
956+
const p1 = it.next();
957+
const p2 = it.return();
958+
r.push('a');
959+
960+
assert.strictEqual((await p1).value, 'a');
961+
assert.deepStrictEqual(await p2, { done: true, value: undefined });
962+
assert.strictEqual(r.destroyed, true);
963+
})().then(common.mustCall());
964+
}
965+
872966
// To avoid missing some tests if a promise does not resolve
873967
tests().then(common.mustCall());

0 commit comments

Comments
 (0)