Async Race Conditions (on JavaScript example)
đ Wiki page | đ Last updated: Jan 4, 2024The term "race condition" is usually applied to the conflict in accessing shared variables in a multi-threading environment. In Javascript, your JS code is executed only by a single thread at a time, but it's still possible to create similar issues.
This is a common problem when people are just blindly making their functions async, without thinking about the consequences.
What is a race condition?
Since many people associate the term "race condition" with pre-emptive multitasking, it's debatable whether this is the right term to use here, but the resulting issues can be very similar, especially when people don't understand what's going on.
The definition of a "race condition" from Wikipedia:
"A race condition or race hazard is the condition of an electronics, software, or other system where the system's substantive behavior is dependent on the sequence or timing of other uncontrollable events. It becomes a bug when one or more of the possible behaviors is undesirable."
This joke illustrates probably the issue a bit better:
"What do we want?""Now!"
"When do we want it?"
"Fewer race conditions!"
Multitasking environments
There are two basic types of multitasking environments:
- Preemptive multitasking
- Cooperative (Non-preemptive) multitasking
In preemptive multitasking (i.e. classical multi-threading environments with shared memory), the execution of your code can be preempted at any point and you have no control over that. It's extremely easy to create race conditions in such environments, and you usually need to do explicit synchronization to prevent race conditions.
On the other hand, async/await is a form of cooperative multitasking, where the execution will yield only when you request that (i.e. an await
keyword).
An example
Let's take a very simple example - lazy-loading some kind of single-instance resource.
The synchronous version is simple:
let res;
function get_resource() {
if(!res) res = init_resource();
return res;
}
Asynchronous version:
let res;
async function get_resource() {
if(!res) res = await init_resource();
return res;
}
Imagine get_resource()
being called in the context of a web server, on every request. If enough time passes between the first and the second request, everything will work fine. But what happens if you get more requests, while the first one is still waiting for the resource?
This can lead to serious problems that are very hard to debug.
More examples
Here are some examples (from this HN thread):
Account balance:
async function deduct(amt) {
var balance = await getBalance();
if (balance >= amt)
return await setBalance(balance - amt);
}
And more subtle example:
async function totalSize(fol) {
const files = await fol.getFiles();
let totalSize = 0;
await Promise.all(files.map(async file => {
totalSize += await file.getSize();
}));
// totalSize is now way too small
return totalSize;
}
Possible solutions
The best way to avoid these types of problems is to avoid async functions where it's not absolutely necessary (see: Functional core, imperative shell).
If that's not possible, you may want to consider using Mutex (from Mutual Exclusion) - once someone acquires the lock, other requests will be blocked, until the original holder release the lock.
i.e. with async-mutex package, our previous example may look like this:
let res;
async function get_resource() {
await mutex.runExclusive(async () => {
if(!res) res = await init_resource();
});
return res;
}
async-mutex
has also support for semaphores - it's a similar concept, but multiple locks can be acquired.
Ask me anything / Suggestions
If you find this site useful in any way, please consider supporting it.