Skip to content

Instantly share code, notes, and snippets.

@fasiha
Last active June 13, 2024 04:53
Show Gist options
  • Save fasiha/7f20043a12ce93401d8473aee037d90a to your computer and use it in GitHub Desktop.
Save fasiha/7f20043a12ce93401d8473aee037d90a to your computer and use it in GitHub Desktop.
How to efficiently and compactly limit concurrency in JavaScript's Promise.all. A simplified and convincing demo for https://stackoverflow.com/a/51020535/ that you can run on TypeScript Playground
const sleep = (t: number) => new Promise((rs) => setTimeout(rs, t));
/**
* This function will return an array of the same length as the input,
* but not in the same order. It's straightforward to extend it to
* preserve order, but I left it like this because this way the timing
* is clearly visible. This helped me confirm that it works.
*/
async function processArray(input: number[], numWorkers: number) {
const ret: { date: number; description: string }[] = [];
const iterator = input.values();
await Promise.all(
Array.from(Array(numWorkers), async () => {
for (const x of iterator) {
const ms = x % 3 === 0 ? 1000 : 500;
await sleep(ms);
ret.push({
date: Date.now(),
description: `x=${x}, ${x % 3 === 0 ? "slow" : "fast"}`,
});
}
})
);
return ret;
}
/**
* Given an array of promise-generating functions, run them concurrently with a
* maximum of `numWorkers` running at the same time.
*
* (This is useful for example for fetching lots of URLs and avoiding spamming
* servers.)
*/
async function promiseAllLimited<T>(
generators: (() => Promise<T>)[],
numWorkers: number
): Promise<T[]> {
// This has to be done upfront. We *CANNOT* do this in a loop because we want
// all workers to pull from a single iterator. That's key to the magic.
const iterator = generators.entries();
// This is what actually gets returned. It'll grow as the workers add to it,
// so potentially later elements will be added first and earlier ones filled
// in later. JavaScript makes this kind of array-growth manageable.
const ret: T[] = [];
// Sentinel value telling other workers to bail. There's not an easy way to
// tell the `Array.prototype.entries` iterator to finish "early" (other than
// iterating over it, which might be memory-wasteful), so we resort to this
// ugly hack.
let done = false;
// This Promise.all doesn't generate anything data-relevant to the input or
// output. It's just coordinating the `numWorkers` async functions pulling at
// an iterator of `generators` like individual strings of a twizzler. Each
// string (each worker) is a promise but one that consumes potentially many of
// the promises in `generators`.
await Promise.all(
Array.from(Array(numWorkers), async (_, numWorker) => {
for (const [idx, f] of iterator) {
if (done) {
break;
}
try {
ret[idx] = await f();
} catch (e) {
done = true;
throw e;
}
}
})
);
return ret;
}
(async function main() {
{
console.log("Initial demo");
const numbers = Array.from(Array(10), (_, n) => 1 + n);
const start = Date.now();
const ret = await processArray(numbers, 2);
for (const { date, description } of ret) {
console.log(`- ${date - start} ms after start: ${description}`);
}
}
{
console.log("Utility function promiseAllLimited");
const generators = Array.from(Array(10), (_, n) => async () => {
const x = 1 + n;
// the children promises can throw, no problem: other workers will stop.
// Try setting this to 2
if (x === 20) {
throw new Error("OH THE AGONY!");
}
await sleep(x % 3 === 0 ? 1000 : 500);
return {
date: Date.now(),
n: n,
description: `x=${x}, ${x % 3 === 0 ? "slow" : "fast"}`,
};
});
const secondStart = Date.now();
try {
const second = await promiseAllLimited(generators, 2);
for (const o of second) {
console.log(
`* ${o.date - secondStart} ms after start: ${o.description}`
);
}
} catch (e) {
console.error("Exception encountered!", e);
}
}
})();
@fasiha
Copy link
Author

fasiha commented Jun 13, 2024

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment