Clean Code: The Tricky World of Concurrency
In a world where we want our applications to be fast and responsive, we often need to do multiple things at the same time. This is concurrency. For example, fetching data from multiple sources, or processing user input while a file is uploading. But when multiple processes try to access and change the same piece of data at the same time, things can get messy.
The Race Condition
One of the most common problems in concurrent programming is the "race condition". This happens when the final result of an operation depends on the unpredictable timing of separate, uncoordinated events.
Let's imagine a simple counter that can be incremented by multiple operations at once.
// Bad: A classic race condition.
let sharedCounter = 0;
async function incrementCounter() {
const currentValue = sharedCounter;
// Simulate some async work, like a network request
await new Promise(resolve => setTimeout(resolve, 10));
sharedCounter = currentValue + 1;
}
// If we call this multiple times concurrently...
await Promise.all([
incrementCounter(),
incrementCounter(),
incrementCounter()
]);
// We expect sharedCounter to be 3, but it will likely be 1!
// Each function reads the value of 0 before any of them can update it.
How to Handle Shared State
To solve this, you need a way to manage access to the shared state. There are many strategies, like using transactions, queues, or other synchronization mechanisms. For client-side code, it's often best to avoid shared mutable state altogether.
// Good: Using a function that encapsulates the update logic.
// This is a simplified example. In a real app, this logic might live on a server
// and be protected by a database transaction.
async function atomicIncrement(currentValue: number): Promise<number> {
// In a real system, this would be an atomic operation
return currentValue + 1;
}
let counter = 0;
// These would likely be API calls
counter = await atomicIncrement(counter);
counter = await atomicIncrement(counter);
counter = await atomicIncrement(counter);
// counter is now 3
Keep Your Async Code Clean
When working with promises and async/await
, it's important to be consistent. Mixing old patterns with new ones can lead to confusion.
// Bad: Mixing async/await with .then() and .catch()
async function fetchData(url: string): Promise<unknown> {
return fetch(url)
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
return null;
});
}
// Good: Using async/await with a try/catch block is cleaner and more consistent.
async function fetchData(url: string): Promise<unknown | null> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching data:', error);
return null;
}
}
Quick Tips
- Avoid shared mutable state. This is the biggest source of concurrency bugs.
- Use atomic operations. When you do need to modify shared state, use operations that are guaranteed to complete without being interrupted.
- Keep your async patterns consistent. Stick to
async/await
withtry/catch
for clean and readable code.
Concurrency is a deep topic, but by understanding the basic pitfalls and patterns, you can write safer and more reliable asynchronous code.