Concurrency
Event loops, goroutines, thread pools, async/await — how runtimes handle parallel work and the bugs that come with it
Concurrency
Concurrency is doing multiple things at once — or at least appearing to. Every server handles concurrent requests; the question is how the runtime manages them and what traps it creates for you.
This page covers the concurrency models of the four major back-end runtimes, the classic bugs (race conditions, deadlocks), and the synchronization primitives that prevent them.
The Models
Event Loop (Node.js)
Node.js runs JavaScript on a single thread. Concurrency comes from non-blocking I/O: while one request waits for a database response, the event loop picks up the next request.
┌──────────────────────────────────────┐
│ Call Stack (single thread) │
└──────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Event Loop │
│ ┌─────────────────────────────┐ │
│ │ Timers (setTimeout) │ │
│ │ I/O callbacks │ │
│ │ Idle, prepare │ │
│ │ Poll (incoming connections) │ │
│ │ Check (setImmediate) │ │
│ │ Close callbacks │ │
│ └─────────────────────────────┘ │
└──────────────────────────────────────┘// This is fine — non-blocking I/O
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id); // yields to event loop
const orders = await db.findOrders(user.id); // yields again
res.json({ user, orders });
});
// This blocks the event loop — every other request waits
app.get('/compute', (req, res) => {
const result = fibonacciSync(1_000_000); // CPU-bound, no yielding
res.json({ result });
});The rule: Never block the event loop. CPU-intensive work goes to a worker thread (worker_threads) or a separate process.
import { Worker } from 'worker_threads';
function runInWorker(data: unknown): Promise<unknown> {
return new Promise((resolve, reject) => {
const worker = new Worker('./heavy-computation.js', { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
});
}Parallel I/O: Use Promise.all when requests are independent:
// Sequential: ~200ms (100 + 100)
const user = await getUser(id);
const prefs = await getPreferences(id);
// Parallel: ~100ms (max of both)
const [user, prefs] = await Promise.all([
getUser(id),
getPreferences(id),
]);Goroutines & Channels (Go)
Go multiplexes lightweight goroutines onto a small number of OS threads. You write sequential code; the runtime handles the scheduling.
func handleRequest(w http.ResponseWriter, r *http.Request) {
// Each request runs in its own goroutine — automatic
user, err := db.FindUser(r.PathValue("id"))
if err != nil {
http.Error(w, "not found", 404)
return
}
json.NewEncoder(w).Encode(user)
}Goroutines are cheap — ~2KB initial stack, scheduled by the Go runtime, not the OS. You can run millions concurrently.
Channels are the primary coordination primitive:
func fetchBoth(ctx context.Context, userID string) (*Result, error) {
ch := make(chan *User, 1)
errCh := make(chan error, 1)
go func() {
user, err := db.FindUser(ctx, userID)
if err != nil {
errCh <- err
return
}
ch <- user
}()
orders, err := db.FindOrders(ctx, userID)
if err != nil {
return nil, err
}
select {
case user := <-ch:
return &Result{User: user, Orders: orders}, nil
case err := <-errCh:
return nil, err
case <-ctx.Done():
return nil, ctx.Err()
}
}The select statement multiplexes across channels — it's Go's answer to "wait for whichever finishes first."
Common mistake: launching goroutines without a way to stop them. Always pass a context.Context and check ctx.Done().
Thread Pools (Java)
Traditional Java uses one OS thread per request. Virtual threads (Project Loom, Java 21+) change this — they're lightweight, like goroutines.
// Traditional: platform threads (heavy, limited)
ExecutorService executor = Executors.newFixedThreadPool(200);
executor.submit(() -> handleRequest(request));
// Modern: virtual threads (lightweight, millions possible)
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> handleRequest(request));
// With Spring Boot 3.2+, just set:
// spring.threads.virtual.enabled=trueVirtual threads make blocking code performant again — you don't need reactive frameworks (WebFlux) for I/O-bound workloads.
// This is fine with virtual threads — blocking is cheap
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(
() -> db.findUser(id), executor
);
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(
() -> db.findOrders(id), executor
);
CompletableFuture.allOf(userFuture, ordersFuture).join();
User user = userFuture.get();
List<Order> orders = ordersFuture.get();Async/Await (Python)
Python's asyncio is an event loop, like Node.js, but bolted onto a language that was synchronous for 20 years.
import asyncio
import aiohttp
async def fetch_user_and_orders(user_id: str):
async with aiohttp.ClientSession() as session:
# Parallel requests
user_task = asyncio.create_task(fetch_user(session, user_id))
orders_task = asyncio.create_task(fetch_orders(session, user_id))
user, orders = await asyncio.gather(user_task, orders_task)
return {"user": user, "orders": orders}The trap: mixing sync and async code. A synchronous library call inside an async function blocks the entire event loop.
# BAD — blocks the event loop
async def get_data():
result = requests.get("https://api.example.com") # sync!
return result.json()
# GOOD — run sync code in a thread pool
async def get_data():
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, requests.get, "https://api.example.com")
return result.json()
# BEST — use an async-native library
async def get_data():
async with aiohttp.ClientSession() as session:
async with session.get("https://api.example.com") as resp:
return await resp.json()Process-Based (Python multiprocessing, PM2 cluster)
For CPU-bound work in languages with a GIL (Python) or single-threaded runtime (Node.js), spin up multiple processes:
# Python multiprocessing
from multiprocessing import Pool
def cpu_intensive(data):
return heavy_computation(data)
with Pool(processes=4) as pool:
results = pool.map(cpu_intensive, data_chunks)# Node.js with PM2
pm2 start app.js -i max # one process per CPU coreEach process has its own memory space — no shared state, no race conditions, but also no shared cache without external storage (Redis).
Classic Bugs
Race Condition
Two operations read-modify-write the same data, and the result depends on timing:
// Two concurrent requests for the same user
async function incrementLoginCount(userId: string) {
const user = await db.findUser(userId); // Both read count=5
user.loginCount += 1; // Both compute 6
await db.saveUser(user); // Both write 6 (should be 7)
}Fix: atomic operations or optimistic locking:
-- Atomic update (no read-modify-write)
UPDATE users SET login_count = login_count + 1 WHERE id = $1;
-- Optimistic locking
UPDATE users SET login_count = $1, version = version + 1
WHERE id = $2 AND version = $3;
-- If 0 rows affected, someone else updated first — retryDeadlock
Two goroutines/threads each hold a lock the other needs:
// Goroutine A // Goroutine B
mu1.Lock() mu2.Lock()
mu2.Lock() // blocks mu1.Lock() // blocks — deadlockFix: always acquire locks in the same order. If every goroutine locks mu1 before mu2, deadlocks cannot happen.
Goroutine/Promise Leak
Launching concurrent work without cleanup:
// Leaks a goroutine if ctx is cancelled before ch receives
go func() {
result := expensiveCall()
ch <- result // blocks forever if nobody reads ch
}()Fix: use buffered channels or select on ctx.Done():
go func() {
result := expensiveCall()
select {
case ch <- result:
case <-ctx.Done():
}
}()Synchronization Primitives
| Primitive | What it does | When to use |
|---|---|---|
| Mutex | Exclusive access to a resource | Protecting shared state (counters, maps) |
| RWMutex | Multiple readers, exclusive writer | Read-heavy workloads |
| Semaphore | Limits concurrent access to N | Connection pools, rate limiting |
| Channel (Go) | Typed message passing between goroutines | Coordinating pipeline stages |
| WaitGroup (Go) | Waits for N goroutines to finish | Fan-out/fan-in patterns |
| Atomic | Lock-free single-variable operations | Counters, flags |
// Mutex example
var (
mu sync.Mutex
count int
)
func increment() {
mu.Lock()
defer mu.Unlock()
count++
}
// Semaphore pattern (limit concurrency to 10)
sem := make(chan struct{}, 10)
for _, item := range items {
sem <- struct{}{} // acquire
go func(item Item) {
defer func() { <-sem }() // release
process(item)
}(item)
}Practical Patterns
Fan-Out / Fan-In
Distribute work across goroutines, collect results:
func processAll(ctx context.Context, items []Item) ([]Result, error) {
g, ctx := errgroup.WithContext(ctx)
results := make([]Result, len(items))
for i, item := range items {
i, item := i, item
g.Go(func() error {
r, err := process(ctx, item)
if err != nil {
return err
}
results[i] = r
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}Worker Pool
Fixed number of workers processing a shared queue:
func workerPool(ctx context.Context, jobs <-chan Job, numWorkers int) <-chan Result {
results := make(chan Result, numWorkers)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
select {
case <-ctx.Done():
return
case results <- process(job):
}
}
}()
}
go func() {
wg.Wait()
close(results)
}()
return results
}Rules of Thumb
- Share memory by communicating, don't communicate by sharing memory. (Go proverb, but applies everywhere.)
- If you can avoid shared mutable state, do. Immutable data + message passing eliminates entire categories of bugs.
- Always set timeouts on concurrent operations. A goroutine waiting forever is a memory leak.
- Limit concurrency. Unbounded
Promise.allon 10,000 items will exhaust connections or memory. Use a semaphore or batch. - Know your runtime's model. The event loop trick that works in Node.js is wrong in Go. The thread pool that works in Java is unnecessary in Node.js. Learn the model before you write concurrent code.