BattlefyBlogHistoryOpen menu
Close menuHistory

Limiting concurrency with promises

Ronald Chen September 26th 2022

One of the reasons why Javascript has become such a success is due to its lack of concurrency problems as a result of the awesome event loop.

However, when talking to external systems one may run into concurrency problems all over again.

For example, let's say we have some-command that does not support concurrency and we happen to be calling in a request.

app.get(async (req, res, next) => {
  ...
  const {stdout} = await exec('some-command');
  ...
});

How do we ensure our Node.js backend serializes calls to some-command?

Whenever I have a concurrency problem, the first thing I reach for is throat. But throat is tricky to use here. In order to use throat we would need to gather all the requests to some-command into a common queue, then re-map it back to the request. This is doable, but there is a simpler way.

Return of the mutex

What we really want is mutual exclusion, aka a mutex. What would be nice is if the mutex was just a simple promise.

app.get(async (req, res, next) => {
  ...
  try {
    await acquire();
    const {stdout} = await exec('some-command');
    ...
  } finally {
    release();
  }
  ...
});

Now if there are multiple concurrent requests, they would all attempt to resolve acquire, but if we only let one through at a time, we have achieved our goal.

There are a few ways to implement acquire/release and the implementations I've found tend to be really bad. Often they are overly complicated using EventEmitter or pointlessly implemented with class.

Here is how I would implement acquire/release

let semaphore;
let unlock;
const acquire = async () => {
  await semaphore;
  semaphore = new Promise((resolve) => {
    unlock = resolve;
  });
};

const release = () => {
  unlock();
  semaphore = undefined;
  unlock = undefined;
};

Note await undefined does resolve, which is why await semaphore is fine.

Making it nice

That showed the mechanics, but this code isn't reusable. Also the try/finally is annoying to write every single time. We can clean all this up with a factory function.

const Mutex = () => {
  let semaphore;
  let unlock;
  const acquire = async () => {
    await semaphore;
    semaphore = new Promise((resolve) => {
      unlock = resolve;
    });
  };

  const release = () => {
    unlock();
    semaphore = undefined;
    unlock = undefined;
  };

  return async (closure) => {
    try {
      await acquire();
      return closure();
    } finally {
      release();
    }
  };
};

Then the usage would look like,

const someCommandMutex = Mutex();

app.get(async (req, res, next) => {
  ...
  await someCommandMutex(async () => {
    const {stdout} = await exec('some-command');
    ...
  });
  ...
});

Can you gork race conditions? We'd like to hear from you, Battlefy is hiring.

Don't break the Internet
September 19th 2022
Smuggling web standards
October 3rd 2022

2022

Powered by
BATTLEFY