Skip to the code
cf.

Async mutex

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.

What the tests check

  1. lock resolves to an unlock function when the mutex is free
    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);
    
  2. a second lock waits until the holder unlocks
    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);
    
  3. waiters get the mutex in the order they asked
    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);
    
  4. nobody jumps the queue while the mutex is handed over
    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"]);
    
  5. calling an unlock twice doesn't release the next holder's turn
    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);
    
  6. runExclusive runs one task at a time
    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);
    
  7. runExclusive resolves with the task's result
    const mutex = new Mutex();
    await expect(mutex.runExclusive(() => 42)).resolves.toBe(42);
    await expect(mutex.runExclusive(async () => "done")).resolves.toBe("done");
    
  8. runExclusive unlocks after a task fails and passes the error on
    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");
    

Learn more

solution.jsSaved in this browser

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).