Limit or throttle the concurrent execution of asynchronous code in separate iterations of the event loop.
Using npm:
npm install @chriscdn/promise-semaphore
Using yarn:
yarn add @chriscdn/promise-semaphore
Version 3 introduces two main changes:
- A new
GroupSemaphore
class has been added. It allows multiple tasks within the same group (identified by a key) to run concurrently while ensuring that only one group's tasks are active at a time. See below for documentation. - The default export has been replaced with a named export.
Change:
import Semaphore from "@chriscdn/promise-semaphore";
to:
import { Semaphore } from "@chriscdn/promise-semaphore";
import { Semaphore } from "@chriscdn/promise-semaphore";
const semaphore = new Semaphore([maxConcurrent]);
The maxConcurrent
parameter is optional and defaults to 1
(making it an exclusive lock or binary semaphore). An integer greater than 1
can be used to allow multiple concurrent executions from separate iterations of the event loop.
semaphore.acquire([options]);
This method returns a Promise
that resolves when the lock is acquired.
The options
parameter is optional and can be:
- A key (
string
ornumber
): This lets a singleSemaphore
instance manage locks in different contexts (see the second example forkey
usage). - An object with the following properties (all optional):
key
(string
ornumber
): Functions the same as above.priority
(number
): Determines the order in which queued requests are processed. Higher values are processed first.
semaphore.release([key]);
The release
method should be called within a finally
block (whether using promises or a try/catch
block) to ensure the lock is released. It's crucial to call release
with the same key
the lock was acquired with.
semaphore.canAcquire([key]);
This synchronous method returns true
if a lock can be immediately acquired, and false
otherwise.
semaphore.count([key]);
This function is synchronous, and returns the current number of locks.
const results = await semaphore.request(fn [, options]);
This function reduces boilerplate when using acquire
and release
. It returns a promise that resolves when fn
completes. It is functionally equivalent to:
try {
await semaphore.acquire([options]);
return await fn();
} finally {
semaphore.release([key]);
}
const results = await semaphore.requestIfAvailable(fn [, options]);
This is functionally equivalent to:
return semaphore.canAcquire([key])
? await semaphore.request(fn, [options])
: null;
This is useful in scenarios where only one instance of a function block should run while discarding additional attempts. For example, handling repeated button clicks.
import { Semaphore } from "@chriscdn/promise-semaphore";
const semaphore = new Semaphore();
// Using promises
semaphore
.acquire()
.then(() => {
// This block executes once a lock is acquired.
// If already locked, it waits and executes after all preceding locks are released.
//
// Critical operations
})
.finally(() => {
// The lock is released, allowing the next queued block to proceed.
semaphore.release();
});
// Using async/await
try {
await semaphore.acquire();
// Critical operations
} finally {
semaphore.release();
}
// Using the request function
await semaphore.request(() => {
// Critical operations
});
Consider an asynchronous function that downloads a file and saves it to disk:
const downloadAndSave = async (url) => {
const filePath = urlToFilePath(url);
if (await pathExists(filePath)) {
// The file is already on disk, so no action is required.
return filePath;
}
await downloadAndSaveToFilepath(url, filePath);
return filePath;
};
This approach works as expected until downloadAndSave()
is called multiple times in quick succession with the same url
. Without control, it could initiate simultaneous downloads that attempt to write to the same file at the same time.
This issue can be resolved by using a Semaphore
with the key
parameter:
import { Semaphore } from "@chriscdn/promise-semaphore";
const semaphore = new Semaphore();
const downloadAndSave = async (url) => {
try {
await semaphore.acquire(url);
// This block continues once a lock on url is acquired. This
// permits multiple simultaneous downloads for different urls.
const filePath = urlToFilePath(url);
if (await pathExists(filePath)) {
// the file is on disk, so no action is required
} else {
await downloadAndSaveToFilepath(url, filePath);
}
return filePath;
} finally {
semaphore.release(url);
}
};
The same outcome can be achieved by using the request
function:
const downloadAndSave = (url) => {
return semaphore.request(async () => {
const filePath = urlToFilePath(url);
if (await pathExists(filePath)) {
// The file is already on disk, so no action is required.
} else {
await downloadAndSaveToFilepath(url, filePath);
}
return filePath;
}, url);
};
The GroupSemaphore
class manages a semaphore for different groups of tasks. A group is identified by a key, and the semaphore ensures that only one group can run its tasks at a time. The tasks within a group can run concurrently.
The GroupSemaphore
class exposes acquire
and release
methods, which have the same interface as Semaphore
. The only difference is that the key
parameter is required.
import { GroupSemaphore } from "@chriscdn/promise-semaphore";
const groupSemaphore = new GroupSemaphore();
const RunA = async () => {
try {
await groupSemaphore.acquire("GroupA");
// Perform asynchronous operations for group A
} finally {
groupSemaphore.release("GroupA");
}
};
const RunB = async () => {
try {
await groupSemaphore.acquire("GroupB");
// Perform asynchronous operations for group B
} finally {
groupSemaphore.release("GroupB");
}
};
This setup allows RunA
to be called multiple times, and will run concurrently. However, calling RunB
will wait until all GroupA
tasks are completed before acquiring the lock for GroupB
. As soon as GroupB
acquires the lock, any subsequent calls to RunA
will wait until GroupB
releases the lock before it executes.