Steven's Knowledge

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=true

Virtual 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 core

Each 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 — retry

Deadlock

Two goroutines/threads each hold a lock the other needs:

// Goroutine A          // Goroutine B
mu1.Lock()              mu2.Lock()
mu2.Lock() // blocks    mu1.Lock() // blocks — deadlock

Fix: 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

PrimitiveWhat it doesWhen to use
MutexExclusive access to a resourceProtecting shared state (counters, maps)
RWMutexMultiple readers, exclusive writerRead-heavy workloads
SemaphoreLimits concurrent access to NConnection pools, rate limiting
Channel (Go)Typed message passing between goroutinesCoordinating pipeline stages
WaitGroup (Go)Waits for N goroutines to finishFan-out/fan-in patterns
AtomicLock-free single-variable operationsCounters, 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.all on 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.

On this page