There are some (rare) cases when you want to forbid a block of code to be executed in “parallel,” or let’s say, that since JavaScript is single-threaded, you want to have more on the execution flow and force execution of a block of code to be sequential.
A really simple definition of a using a lock can be that, once a block of code acquires a lock, it becomes locked and no one else can run the same block until the lock is released regardless of the size of the block. It could be one line or 100.
interface Lock {
await(): Promise<void>;
acquire(): void;
release(): void;
}
To get the lock, you first need to await for it, then acquire it, and finally release it once done.
Implementing this with Promise is pretty straightforward. The await function needs to return a Promise which has been created by the acquire call, and resolved by the release call. The implementation also needs to initialize the Promise as resolved since the first one that needs to await for it does not have to be locked. This can be implemented as follows:
class LockImpl implements Lock {
private promise: Promise<void>;
private resolve: (() => void) | undefined;
constructor() {
this.promise = Promise.resolve(); //Line 7
this.resolve = undefined;
}
await() {
console.log("await lock");
return this.promise; // Line 13
}
acquire() {
console.log("acquire");
// eslint-disable-next-line no-return-assign, no-promise-executor-return
this.promise = new Promise<void>((resolve) => (this.resolve = resolve)); //Line 19
}
release() {
console.log("release");
if (this.resolve) {
this.resolve(); // Line 25
}
}
}
const lock = new LockImpl();
export default lock;
Here’s some explanations about what is happening in the Lock implementation:
- Line 7: Create a resolved Promise
- Line 13, await(): Return the Promise. The consumer can await for it to be resolved
- Line 19, acquire(): instantiate a new Promise, and save the resolve reference in the lock instance
- Line 25, release(): resolve the Promise, unlock the current consumer block, and allow another block of code to acquire the lock.
Using it is as easy as awaiting the lock by calling await lock.await() , then locking the block by calling lock.acquire() and finally releasing it by calling lock.release(). Here’s the code:
import lock from "./lock";
const send = async (id: number) => {
await lock.await();
lock.acquire();
try {
console.log("Calling", id);
} catch (err) {
console.log(err);
} finally {
lock.release();
}
};
Promise.all(Array.from(Array(20), (v, k) => k).map((v) => send(v)));
In the code above, the send function is expected to be called 20 times from a Promise.all call which ‘virtually’ make parallel calls since sendreturns a Promise.
By using the Lock, we force the block between lock.acquire() and lock.release() to be called one after the other. This is pretty useless in the current example, but imagine if you replace line 8 with something more complex that takes time, shares a scope, or does anything you cannot control, like a third-party library, a remote service, etc.
You can now force everyone calling send to wait until the previous call is complete, even if it has been called from several places at the same time, just by using a really simple Lock, based on a Promise and a resolve callback.
Using the web locks API
Web Locks API is more powerful than just our Promise-based implementation. By using it, you can lock code running on the same origin, on multiple tabs, and even more on workers opening the door to create really advanced process control and coordination.
The API is pretty simple and can be used as follows:
navigator.locks.request("my_resource", async (lock) => {
// The lock has been acquired.
await do_something();
await do_something_else();
// Now the lock will be released.
});
Once the lock is acquired, the callback (async (lock) => {}) will be executed. The callback is the block of code to be locked, which is something really clear. It does not force you to call acquire and release like we did in our implementation. Once the callback is executed, the next one can be, since the lock is released and can be acquired.
Notice the first parameter of the locks.request function, which is a string. This allows us to use locks from many places (tabs and workers) and also allows us to use the same lock name for different blocks, which is something more powerful than our single lock instance.
Top comments (0)