I am aware JavaScript is single-threaded and technically can’t have race conditions, but 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.
CodePudding user response:
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.
CodePudding user response:
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.