Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Does this JavaScript example create “race conditions”? (To the extent that they can exist in JavaScript)

I am aware JavaScript is single-threaded and technically can’t have race conditions, but it supposedly can have some uncertainty because of async and the event loop. Here’s an oversimplified example:

class TestClass {
  // ...

  async a(returnsValue) {
     this.value = await returnsValue()
  }
  b() {
     this.value.mutatingMethod()
     return this.value
  }
  async c(val) {
     await this.a(val)
     // do more stuff
     await otherFunction(this.b())
  }
}

Assume that b() relies on this.value not having been changed since the call to a(), and c(val) is being called many times in quick succession from multiple different places in the program. Could this create a data race where this.value changes between calls to a() and b()?

For reference, I have preemptively fixed my issue with a mutex, but I’ve been questioning whether there was an issue to begin with.

like image 312
hryrbn Avatar asked Sep 05 '25 03:09

hryrbn


2 Answers

Yes, race conditions can and do occur in JS as well. Just because it is single-threaded it doesn't mean race conditions can't happen (although they are rarer). JavaScript indeed is single-threaded but it is also asynchronous: a logical sequence of instructions is often divided into smaller chunks executed at different times. This makes interleaving possible, and hence race conditions arise.


For the simple example consider...

var x = 1;

async function foo() {
    var y = x;
    await delay(100); // whatever async here
    x = y+1;
}

...which is the classical example of the non-atomic increment adapted to JavaScript's asynchronous world.

Now compare the following "parallel" execution:

await Promise.all([foo(), foo(), foo()]);
console.log(x);  // prints 2

...with the "sequential" one:

await foo();
await foo();
await foo();
console.log(x);  // prints 4

Note that the results are different, i.e. foo() is not "async safe".


Even in JS you sometimes have to use "async mutexes". And your example might be one of those situations, depending on what happens in between (e.g. if some asynchronous call occurs). Without an asynchronous call in do more stuff it looks like mutation occurs in a single block of code (bounded by asynchronous calls, but no asynchronous call inside to allow interleaving), and should be OK I think. Note that in your example the assignment in a is after await, while b is called before the final await.

like image 191
freakish Avatar answered Sep 07 '25 20:09

freakish


Expanding on the example code in @freakish's answer, this category of race conditions can be solved by implementing an asynchronous mutex. Below is a demonstration of a function I decided to name using, inspired by C#'s using statement syntax:

const lock = new WeakMap();
async function using(resource, borrow) {
  while (lock.has(resource)) {
    try {
      await lock.get(resource);
    } catch {}
  }

  // acquire
  lock.set(resource, borrow());

  try {
    return await lock.get(resource);
  } finally {
    // release
    lock.delete(resource);
  }
}

const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

let x = 1;
const mutex = {};

async function foo() {
  await delay(500);
  await using(mutex, async () => {
    let y = x;
    await delay(500);
    x = y + 1;
  });
  await delay(500);
}

async function main() {
  console.log(`initial x = ${x}`);
  await Promise.all([foo(), foo(), foo()]);
  console.log(`final x = ${x}`);
}

main();
const lock = new WeakMap();
async function using(resource, borrow) {
  while (lock.has(resource)) {
    try {
      await lock.get(resource);
    } catch {}
  }

  // acquire
  lock.set(resource, borrow());

  try {
    return await lock.get(resource);
  } finally {
    // release
    lock.delete(resource);
  }
}

using allows a context to acquire a resource by setting the lock with the promise returned by borrow and then releases it after the promise resolves. The remaining concurrent contexts will attempt to acquire the resource each time the pending promise returned by the lock resolves. The first context resumed by the event loop will succeed in acquiring the resource because it will observe that lock.has(resource) is false. The rest will observe that lock.has(resource) is true after the first context has set the lock with a new pending promise, and await it, repeating the cycle.

let x = 1;
const mutex = {};

Here, an empty object is created as the designated mutex because x is a primitive, making it indistinguishable from any other variable that happens to bind the same value. It doesn't make sense to "use 1", because 1 doesn't refer to a binding, it's just a value. It does make sense to "use x" though, so in order to express that, mutex is used with the understanding that it represents ownership of x. This is why lock is a WeakMap -- it prevents a primitive value from accidentally being used as a mutex.

async function foo() {
  await delay(500);
  await using(mutex, async () => {
    let y = x;
    await delay(500);
    x = y + 1;
  });
  await delay(500);
}

In this example, only the 0.5s time slice that actually increments x is made to be mutually exclusive, which can be confirmed by the approximately 2.5s time difference between the two printed outputs in the demo above. Incrementing x is guaranteed to be an atomic operation because this section is mutually exclusive.

async function main() {
  console.log(`initial x = ${x}`);
  await Promise.all([foo(), foo(), foo()]);
  console.log(`final x = ${x}`);
}

main();

If each foo() were running fully concurrent, the time difference would be 1.5s, but because 0.5s of that is mutually exclusive among 3 concurrent calls, the additional 2 calls introduce another 1s of delay for a total of 2.5s.


For completeness, here's the baseline example without using a mutex, which demonstrates the failure of non-atomically incrementing x:

const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

let x = 1;
// const mutex = {};
main();

async function foo() {
  await delay(500);
  // await using(mutex, async () => {
  let y = x;
  await delay(500);
  x = y + 1;
  // });
  await delay(500);
}

async function main() {
  console.log(`initial x = ${x}`);
  await Promise.all([foo(), foo(), foo()]);
  console.log(`final x = ${x}`);
}

Notice that the total time is 1.5s, and that the final value of x is not correct due to the race condition introduced by removing the mutex.

like image 42
Patrick Roberts Avatar answered Sep 07 '25 21:09

Patrick Roberts