Efficiently Managing Concurrent Async Tasks with a Promise Pool

JSDev SpaceJSDev Space
3 min read

In modern JavaScript applications, managing multiple asynchronous tasks efficiently is crucial for performance optimization. When dealing with a large number of async functions, running too many at once can lead to resource exhaustion, while running too few can slow down execution.

The Promise Pool pattern helps control concurrency by limiting the number of Promises that execute simultaneously. In this article, we’ll explore how to implement a Promise Pool in JavaScript, ensuring efficient task execution while maintaining order and preventing overload.

Problem Statement:

Given an array of asynchronous functions (functions) and a maximum pool size (n), write an asynchronous function promisePool that returns a Promise. This Promise should resolve when all functions in the array have completed execution.

The pool size determines the maximum number of Promises that can run concurrently. The promisePool function should start executing the maximum possible number of functions from the array and initiate new ones as existing Promises complete. Functions must be executed in the order they appear in the array. Once the last Promise resolves, promisePool should also resolve.

Example:

const functions = [
  () => new Promise(res => setTimeout(res, 300)),
  () => new Promise(res => setTimeout(res, 400)),
  () => new Promise(res => setTimeout(res, 200))
];
const n = 2;

Explanation:

  • At t=0, the first two functions start executing.

  • At t=300, the first function completes, and the third function starts.

  • At t=400, the second function completes.

  • At t=500, the third function completes, and the resulting Promise resolves.

Solution:

The solution involves managing indices and counters to track the execution of functions:

  • i: Index of the currently executing function.

  • availPool: Number of available resources for executing Promises.

  • completedCount: Number of completed Promises.

If the functions array is empty, the resulting Promise resolves immediately. Otherwise, a recursive function executeNext is used to:

  1. Select the next k functions, where k is the number of available resources.

  2. Decrease availPool by k and start executing these k functions.

  3. Upon completion of each function, increment availPool and completedCount.

  4. If all functions have completed, resolve the final Promise; otherwise, recursively call executeNext.

Here's the implementation:

var promisePool = function(functions, n) {
    let i = 0;
    let availPool = n;
    let completedCount = 0;

    return new Promise((resolve) => {
        if (functions.length === 0) {
            resolve();
            return;
        }

        const executeNext = () => {
            const pendingFunctions = functions.slice(i, i + availPool);
            i += pendingFunctions.length;
            availPool = 0;
            pendingFunctions.forEach(func => {
                func().then(() => {
                    availPool++;
                    completedCount++;
                    if (completedCount === functions.length) {
                        resolve();
                    } else {
                        executeNext();
                    }
                });
            });
        };

        executeNext();
    });
};

Usage Example:

const sleep = (t) => new Promise(res => setTimeout(res, t));
promisePool([() => sleep(500), () => sleep(400)], 1)
  .then(() => console.log('All functions have completed.'));

This function ensures that no more than n Promises are running concurrently, adhering to the specified pool size.

0
Subscribe to my newsletter

Read articles from JSDev Space directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

JSDev Space
JSDev Space