28

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.

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
hryrbn
  • 303
  • 3
  • 11
  • 3
    This is indeed oversimplified, since there's simply no reason for `a` to be `async`. You might want to make it `await` something – Bergi Aug 02 '22 at 07:20
  • 1
    As an aside, it's very easy to write your own "mutex" to enforce mutual exclusion of async contexts. If you're interested, I can provide an answer containing an example implementation and demo. – Patrick Roberts Aug 02 '22 at 07:42
  • 14
    Race conditions is about *concurrency* and not parallelism. As you noted JavaScript **does** have concurrency in the form of async/await where you can help multiple "logical" threads alternating. JavaScript lacks parallelism (i.e. having multiple thread of execution running in the same instant). https://stackoverflow.com/questions/1050222/what-is-the-difference-between-concurrency-and-parallelism – GACy20 Aug 02 '22 at 14:53
  • @Bergi You are correct… although I think it was clear enough to me, I have changed the example to be a “real” async function to make it more clear to future readers. – hryrbn Aug 02 '22 at 17:38
  • Ah, that's a though one - yes there is a race condition still, but it's very rare since you need to get the exact microtask timing right. In particular, `this.value` could be changed by some other microtask during the `await` in front of `this.a(val)`, otherwise it looks like `.value` is used "immediately" after it is assigned. (Notice there wouldn't be an issue if you wrote `this.value = await val; this.b()` in the same method). The race condition would be more obvious if `a` did `this.value = returnsValue(); await delay(1000)` – Bergi Aug 03 '22 at 09:15

4 Answers4

38

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.

freakish
  • 54,167
  • 9
  • 132
  • 169
7

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, then) {
  while (lock.has(resource)) {
    try {
      await lock.get(resource);
    } catch {}
  }

  const promise = Promise.resolve(then(resource));
  lock.set(resource, promise);

  try {
    return await promise;
  } finally {
    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, then) {
  while (lock.has(resource)) {
    try {
      await lock.get(resource);
    } catch {}
  }

  const promise = Promise.resolve(then(resource));
  lock.set(resource, promise);

  try {
    return await promise;
  } finally {
    lock.delete(resource);
  }
}

using works by associating a promise with a resource when a context has acquired the resource, and then removing that promise when it resolves due to the resource being subsequently released by the context. The remaining concurrent contexts will attempt to acquire the resource each time the associated promise resolves. The first context 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 acquired it, and await the new promise, 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.

Patrick Roberts
  • 49,224
  • 10
  • 102
  • 153
  • 1
    You should probably use `lock.set(resource, promise.catch(e => void e))` to avoid a promise rejection (e.g. `using(…, async () => { throw new Error(); })` affecting other contexts waiting for the same resource – Bergi Aug 03 '22 at 01:15
  • @Bergi that's a good point but I want to be careful because that would introduce a microtask where calling `foo()` would skip the queue and immediately acquire the resource just before all the pending calls can resume one microtask later. – Patrick Roberts Aug 03 '22 at 03:30
  • Nothing can "skip" the queue because it's still the same for every caller? Sure, the resource is locked for one microtask longer than `then()` took, but that shouldn't matter. – Bergi Aug 03 '22 at 07:42
  • @Bergi I decided to catch the error within the spin lock. I believe that unlike `.catch()`, it doesn't increase the number of microtasks used, but also it just felt slightly cleaner to handle it there. – Patrick Roberts Aug 04 '22 at 05:27
  • Mutexes introduce the danger of deadlocks and resource contention bottlenecks. I'd only use them sparingly in 'core system' code, and would discourage them elsewhere. – James Aug 25 '22 at 09:10
  • @James for sure! All programming patterns have the potential to be misused, part of the job as a developer is to discern when a pattern is appropriate and to what extent. – Patrick Roberts Aug 25 '22 at 12:55
1

First, What is a Race Condition?

A race condition is an undesirable situation that occurs when a device or system attempts to perform two or more operations at the same time, but because of the nature of the device or system, the operations must be done in the proper sequence to be done correctly.

A simple example of race condition would be one light source that is attached to two different light controllers. When we target the light source with two simultaneous actions - one switches on, and the other switches off. The uncertain end result whether the light source is on or off.

JavaScript asynchronization can't be in parallel as JavaScript executes on a single thread, but it works with concurrency.

What is the difference between concurrency and parallelism?

Difference between concurrency and parallelism

If we run this program on a computer with a single CPU core, the OS would be switching between the two threads, allowing one thread to run at a time.

If we run this program on a computer with a multi-core CPU then we would be able to run the two threads in parallel - side by side at the exact same time.

Difference between concurrency and parallelism

But a race condition can occur with any async operations whether it happens with concurrency or parallelism

Let’s have a clearer scenario which can produce a race condition in JavaScript.

Let’s assume we have a scenario like this one:

  • We have a map
  • We want to show some information related to the location of the user
  • Every time a user is moving on the map we fetch for the data
  • We are smart and we even debounce the fetches for 0.5 seconds
  • The data is returned after 5 seconds (we are on a slow network).

Now let’s think of the next case:

  • The user opens the map
  • The first fetch request is fired
  • After 2 seconds, the user moves on the map again, because the user felt like the user found something interesting
  • After another 0.5 seconds, another fetch is fired

Now, if for some reason for the first fetch it took 5 seconds to return and for the second fetch it took 1 second to return (the network signal got better or the fetch/computation size is much smaller or we got not so busy server for the second time), what will happen now?

We have this method to fetch the data and update it via updateState.

fetch(url)
.then(data => data.json())
.then(data => updateState(data));

In this case, some weird async bug will happen. We look at area2, but see the items of area1.

Some solutions…

So you ask me what can I do?

You can save the last request and cancel it on the next one. For the moment of writing this article, fetch doesn’t have a cancellation API. But for the sake of the argument here is a code with setTimeout:

if (this.lastRequest) {
  clearTimeout(this.lastRequest);
}
this.lastRequest = setTimeout(() => {
  updateState([1,2,3]);
  this.lastRequest = null;
}, 5000);

You can create a session object on every new request and save it, and on response check, the saved one is still the same as the one you have:

const currentSession ={};
this.lastSession = currentSession;
fetch(url)
  .then(data => data.json())
  .then(items =>{
    if (this.lastSession !== currentSession) {
      return;
    }
    updateState(items);
  }).catch(error =>{
    if (this.lastSession !== currentSession){
      return;
    }
    setError(error);
  });

Resources.

Is there Race Condition in JavaScript: Yes and No

What is the difference between concurrency and parallelism?

How to avoid async race conditions in JavaScript

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Mina
  • 14,386
  • 3
  • 13
  • 26
0

In this example, you could use a promise-queue in the body of c() so that no matter how many times it is called concurrently, the code inside will always run in sequence.

$ npm install promise-queue
const Queue = require('promise-queue');

class TestClass {
  constructor() {
     this.cQueue = new Queue(1);
  }

  // ...

  async c(val) {
     // The queued function will not execute until all
     // earlier functions in the queue have finished.
     //
     // So execution will always be: a, b, a, b
     // and never: a, a, b, b
     //
     await this.cQueue.add(async () => {
        await this.a(val)
        // do more stuff
        await otherFunction(this.b())
     });
  }
}

You could also move the queue outside of the class, if you need one queue for all instances of the class.

const globalCQueue = new Queue(1);

That might be preferable, for example, if a() and b() are mutating and reading a singular external resource.


Using queue.add() with concurrency 1 is equivalent to using semaphore.runExclusive() with concurrency 1, or using mutex.runExclusive() from the async-mutex library.

Pros and cons of queues and mutex:

  • The promise-queue library provides a simple API, and uses terms which are less technical.

    It may be preferable in simple applications, to ease comprehension.

  • The async-mutex library provides additional features, such as manually locking or releasing a lock, cancelling all pending callbacks, or specifying a timeout for lock aquisition.

  • Conceptually, a mutex is about locking a resource, rather than queueing execution. As a result, more interleaving is possible using mutexes, as many different flows of execution can take place concurrently, with each locking and unlocking the resources only as it needs them.

    By contrast, the queuing approach forces us to block over an entire function. For example, queueing would make it impossible for a function to: lock resource A, lock resource B, release resource A, release resource B. This would be sub-optimal in some applications, because it would block other code from accessing resource A until after the entire function has completed.

    So in general, using mutexes to lock and unlock resources can be more efficient than queueing execution.

Here is the OP's code, now using the async-mutex library:

const Mutex = require('async-mutex').Mutex;

class TestClass {
  constructor() {
    this.valueMutex = new Mutex();
  }

  // ...

  async c(val) {
     // Any code which wants to mutate value, should use the mutex to lock it
     const releaseValue = await this.valueMutex.aquire();

     await this.a(val)
     // do more stuff
     await otherFunction(this.b())

     // Releasing the mutex will unlock the resource, so another function
     // which was waiting to acquire the lock, can now resume execution
     releaseValue();
  }
}
joeytwiddle
  • 29,306
  • 13
  • 121
  • 110
  • I'm guessing the downvotes were due to the superior performance of mutexes in more general/complex situations. I have now added some discussion about that at the bottom of the answer. And an example using a mutex instead of a queue. Please let me know if I've missed anything! – joeytwiddle Aug 20 '22 at 03:05