for await...of sequential execution limits with async iterators
0 reputation · 12 Jul 2024, 02:28 UTC
0 reputation · 12 Jul 2024, 02:28 UTC
Designing a streaming pipeline that consumes an async iterable with for await...of raises questions about execution guarantees and resource cleanup. The documented behavior is sequential pause at each yield point until the promise from next() resolves, with no parallel iteration unless explicitly composed.
A specific concern is the interaction with synchronous iterables and early termination. Using for await...of on a non-async iterable wraps each value in a resolved promise, adding overhead. Early exit via break or exception should trigger the iterator's return() method for cleanup, but the conditions for reliable invocation are unclear.
Is the loop body guaranteed to run only after next() resolves for each iteration? Is return() reliably called on break or thrown error to allow cleanup of the async iterator? What performance characteristics arise when for await...of is applied to a plain synchronous iterable versus a true async iterable?
33775 reputation · 12 Jul 2024, 04:22 UTC
Yes, the loop body is guaranteed to run only after the promise returned by next() resolves. The ECMAScript specification defines for await...of as a strictly sequential construct: each iteration awaits the result of next() before executing the body, and the next next() call is not made until the current body completes. There is no parallel iteration unless you explicitly compose multiple iterators.
On break, return, or a thrown error, the loop calls the iterator's return() method if it exists. This invocation is reliable in conformant runtimes, but the cleanup is only as robust as the return() implementation itself.
When applied to a synchronous iterable, for await...of wraps each value in Promise.resolve(), adding a microtask per iteration. A true async iterable avoids that wrapping, resulting in fewer microtasks and lower per-item latency, though end-to-end throughput still depends on the underlying async source.
The sequential execution guarantee and the automatic return() invocation are well-established in the ECMAScript async iteration proposal and implemented in Node.js 10+ and current browsers. The performance difference between sync and async iterables is also confirmed: the spec mandates a promise-wrapping step for sync iterables.
One area that varies by runtime is finalization during abrupt process termination or certain edge-case rejections. While the spec requires return() to be called on normal early exit, some environments may not fully finalize an async iterator if the process exits immediately after the loop. Treat deep cleanup guarantees in crash scenarios as runtime-dependent and verify in your target environment.
To confirm the guarantees in your codebase, add logging inside the iterator methods and observe the ordering:
async function* source() {
try {
for (const v of items) {
console.log('next before yield', v);
yield v;
}
} finally {
console.log('return() called');
}
}
async function consume() {
try {
for await (const v of source()) {
console.log('body', v);
if (v === 'stop') break;
}
} catch (e) {
console.error('loop error', e);
}
}Expected output: each body log appears only after the corresponding next before yield log, and return() called appears after the break.
For high-volume synchronous arrays, prefer a plain for...of loop. The microtask overhead of for await...of scales linearly with item count and can become measurable when processing tens of thousands of items. For genuine streaming sources (network sockets, file readers, database cursors), a true async iterable is the correct choice and avoids the extra wrapping.
Does the async iterable you plan to consume provide its own return() method for cleanup? If it does not, the automatic cleanup path will be a no-op and you will need to manage resource release manually in a finally block.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.