A mutex, short for mutual exclusion, is a lock that only one holder can have at a time. Code that's about to work with something shared asks for the lock first. If the lock is free, it gets it and goes ahead; if not, it waits. When the holder is done, it releases the lock, and the next one waiting gets it. Waiters usually get their turn in the order they asked.
Picture a shop with one fitting room. One person goes in at a time, everyone else queues, and the next person goes in only when the one inside comes out. If someone never comes out, the queue never moves: a holder must always release the lock, even when its work fails.
| What happens | Who holds the lock | Who's waiting, in order |
|---|---|---|
| A asks for the lock. | A | Nobody |
| B asks for it. | A | B |
| C asks for it. | A | B, then C |
| A releases it. | B | C |
| B releases it. | C | Nobody |
| C releases it. | Nobody: the lock is free | Nobody |
Why would JavaScript need one, when it runs only one piece of code at a time? Because an async function pauses at every await, and while it's paused, other code runs. Say two withdrawals from a balance of 100, of 30 and 50, each read the balance, await a slow save, then write the new balance. Without a lock, both read 100, and the balance ends at 50 instead of 20.
With a mutex held from the read to the write, the second withdrawal can't read the balance until the first has written 70 and released the lock. It reads 70 and writes 20. The lock turns the read, the wait, and the write into one turn that nothing else can cut into.
Browsers have a mutex built in, the Web Locks API. navigator.locks.request(name, callback) waits for the lock called name, calls the callback once it has it, and releases the lock when the callback has finished, which for an async callback means when its promise settles. It works across all the tabs and workers of the same origin.
A race condition is a bug where the result depends on the order in which overlapping operations happen to run or finish. The code works when things happen in one order and goes wrong in another, and since the order depends on timing, such as how fast the network is at that moment, the bug can come and go.
JavaScript runs one piece of code at a time, but async operations still overlap: while a request is on its way, or a function is paused at an await, other code runs. Two cases come up again and again. The first is a stale response, an answer to an old request that replaces a newer one. You type j, then js, into a search box that sends a request for each, and the request for j happens to be slow:
| Time | What happens | Results on the screen |
|---|---|---|
| 0 ms | You type j, and the request for j is sent. | None yet |
| 200 ms | You type js, and the request for js is sent. | None yet |
| 500 ms | The response for js arrives and is shown. | For js |
| 900 ms | The response for j arrives late and is shown. | For j, while the box says js |
Responses don't have to arrive in the order their requests were sent. Only the latest request matches what's in the box, so only its response should be used. The answer to an older request is out of date as soon as a newer request is made, and has to be ignored or cancelled. You meet this in search boxes, autocomplete, and pages that load data for whichever tab or item you clicked last.
The second is a lost update. Two async tasks read the same value, each pauses at an await, and each then writes back a new value based on what it read. Here, two withdrawals, A of 30 and B of 50, run against a balance of 100, and each awaits a slow save between reading the balance and writing it:
| Step | What happens | balance afterwards |
|---|---|---|
| 1 | A reads balance, 100, and pauses at its await. | 100 |
| 2 | B reads balance. A hasn't written yet, so B also gets 100. It pauses too. | 100 |
| 3 | A resumes and writes 100 - 30. | 70 |
| 4 | B resumes and writes 100 - 50, from the value it read in step 2. | 50 |
Both withdrawals ran, yet the balance is 50 instead of 20: A's update was lost. Each step is fine on its own; the bug is in how they interleave. Had B read the balance after A had written it, B would have read 70 and written 20.
Write a class Mutex: a lock for async code that lets only one holder run at a time.
lock() returns a promise that resolves to an unlock function once the mutex is yours: right away if it's free, otherwise when your turn comes. Waiters get the mutex in the order they called lock(), and nobody who calls lock() later can get it before them.
Calling unlock hands the mutex to the next waiter, or frees it if nobody is waiting. Calling the same unlock a second time does nothing, so it can never release someone else's turn.
locked is a getter that's true while someone holds the mutex.
runExclusive(fn) waits for the mutex, calls fn(), which may return a promise, and unlocks once it has finished, whether it succeeded or failed. It returns a promise of fn's result, or rejects with its error.
const mutex = new Mutex();
let balance = 100;
function withdraw(amount) {
return mutex.runExclusive(async () => {
const current = balance;
await new Promise((resolve) => setTimeout(resolve, 10)); // a slow save
balance = current - amount;
});
}
await Promise.all([withdraw(30), withdraw(50)]);
balance; // 20. Without the mutex it's 50: both read 100.
const mutex = new Mutex();
expect(mutex.locked).toBe(false);
const unlock = await mutex.lock();
expect(typeof unlock).toBe("function");
expect(mutex.locked).toBe(true);
unlock();
expect(mutex.locked).toBe(false);
const mutex = new Mutex();
const unlock = await mutex.lock();
let second = false;
mutex.lock().then(() => {
second = true;
});
await flush();
expect(second).toBe(false);
unlock();
await flush();
expect(second).toBe(true);
expect(mutex.locked).toBe(true);
const mutex = new Mutex();
const unlock = await mutex.lock();
const order = [];
for (const name of ["a", "b", "c"]) {
mutex.lock().then((release) => {
order.push(name);
release();
});
}
unlock();
await flush();
expect(order).toEqual(["a", "b", "c"]);
expect(mutex.locked).toBe(false);
const mutex = new Mutex();
const unlock = await mutex.lock();
const order = [];
mutex.lock().then((release) => {
order.push("waiter");
release();
});
unlock();
mutex.lock().then((release) => {
order.push("newcomer");
release();
});
await flush();
expect(order).toEqual(["waiter", "newcomer"]);
const mutex = new Mutex();
const unlockFirst = await mutex.lock();
let unlockSecond;
mutex.lock().then((release) => {
unlockSecond = release;
});
let third = false;
mutex.lock().then(() => {
third = true;
});
unlockFirst();
await flush();
unlockFirst();
await flush();
expect(third).toBe(false);
expect(mutex.locked).toBe(true);
unlockSecond();
await flush();
expect(third).toBe(true);
const mutex = new Mutex();
let running = 0;
let most = 0;
const task = async () => {
running++;
most = Math.max(most, running);
await flush();
running--;
};
await Promise.all([mutex.runExclusive(task), mutex.runExclusive(task), mutex.runExclusive(task)]);
expect(most).toBe(1);
expect(mutex.locked).toBe(false);
const mutex = new Mutex();
await expect(mutex.runExclusive(() => 42)).resolves.toBe(42);
await expect(mutex.runExclusive(async () => "done")).resolves.toBe("done");
const mutex = new Mutex();
await expect(mutex.runExclusive(() => {
throw new TypeError("bad");
})).rejects.toThrow(TypeError);
await expect(mutex.runExclusive(async () => {
throw new RangeError("worse");
})).rejects.toThrow(RangeError);
expect(mutex.locked).toBe(false);
await expect(mutex.runExclusive(() => "still works")).resolves.toBe("still works");
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Async mutex. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.