Skip to content

Understanding Concurrency Models: Threads, Async, and Actors

A comparison of threading, async/await, and actor-based concurrency models — when each fits, their tradeoffs, and how to avoid common pitfalls.

5 min read
Diagram comparing thread-based, async event loop, and actor message-passing concurrency

Every backend system eventually hits a concurrency problem. A database query takes 200ms. An external API call takes 500ms. Without concurrency, thirty users making requests means the thirtieth user waits fifteen seconds. The question is not whether to use concurrency, but which model fits your system.

Three models dominate modern server-side development: threads (Java, Go, C#), async/await (Node.js, Python asyncio, Rust), and actors (Erlang, Akka). Each makes different tradeoffs between simplicity, safety, and performance.

Threads: Shared Memory Concurrency

Thread-based concurrency runs multiple execution paths simultaneously, sharing the same memory space. This is the default in Java, C#, and Go (goroutines are lightweight threads).

javajava
// Java: Thread per request — simple but expensive
public class RequestHandler {
    private final UserRepository userRepo;
    private final OrderRepository orderRepo;
 
    public UserDashboard handleRequest(String userId) {
        // Each method blocks the thread while waiting for I/O
        User user = userRepo.findById(userId);          // blocks ~50ms
        List<Order> orders = orderRepo.findByUser(userId); // blocks ~100ms
        return new UserDashboard(user, orders);
    }
}
 
// With 200 concurrent requests and 150ms per request:
// Need ~200 threads active simultaneously
// Each thread: ~1MB stack = 200MB just for stacks

The danger of shared memory: two threads modifying the same data simultaneously produce race conditions.

javajava
// ❌ Race condition — counter increments are not atomic
public class RequestCounter {
    private int count = 0;
 
    public void increment() {
        count++;  // Read count, add 1, write count
        // Two threads can read the same value, both write count+1
        // Result: one increment lost
    }
}
 
// ✅ Thread-safe with synchronization
public class RequestCounter {
    private final AtomicInteger count = new AtomicInteger(0);
 
    public void increment() {
        count.incrementAndGet();  // Atomic operation, no race condition
    }
}
gogo
// Go: goroutines are lightweight threads with channels for communication
func handleDashboard(userId string) *Dashboard {
    userCh := make(chan *User, 1)
    orderCh := make(chan []*Order, 1)
    
    // Fetch user and orders concurrently
    go func() {
        user, _ := userRepo.FindById(userId)
        userCh <- user
    }()
    
    go func() {
        orders, _ := orderRepo.FindByUser(userId)
        orderCh <- orders
    }()
    
    // Wait for both results
    user := <-userCh
    orders := <-orderCh
    
    return &Dashboard{User: user, Orders: orders}
}
 
// Goroutines cost ~8KB each (vs ~1MB for OS threads)
// 100,000 concurrent goroutines: ~800MB (feasible)
// 100,000 OS threads: ~100GB (impossible)

Go's approach — cheap goroutines with channels — gives thread-like semantics without the resource overhead. The motto "share memory by communicating" steers developers toward channels instead of shared mutable state.

Async/Await: Cooperative Concurrency

Async I/O uses a single thread (or small thread pool) that switches between tasks when they are waiting for I/O. Node.js popularized this model. Python, Rust, and C# also support it.

tstypescript
// Node.js: single thread, non-blocking I/O
async function handleDashboard(userId: string): Promise<Dashboard> {
    // These run concurrently — both I/O operations start immediately
    const [user, orders] = await Promise.all([
        userRepo.findById(userId),        // non-blocking
        orderRepo.findByUser(userId),     // non-blocking
    ]);
 
    return { user, orders };
}
 
// One thread handles thousands of concurrent requests
// While request A waits for database, the thread processes request B
// No thread synchronization needed — only one thing runs at a time

The event loop processes tasks cooperatively. Each await suspends the current function and lets other work run. This means no race conditions on shared data — but it also means CPU-heavy work blocks everything.

tstypescript
// ❌ CPU-intensive work blocks the entire event loop
async function handleRequest(data: string): Promise<Result> {
    const parsed = JSON.parse(data);
    const result = heavyComputation(parsed);  // 500ms of CPU work
    // Every other request waits 500ms while this runs
    return result;
}
 
// ✅ Offload CPU work to a worker thread
import { Worker } from 'worker_threads';
 
async function handleRequest(data: string): Promise<Result> {
    return new Promise((resolve, reject) => {
        const worker = new Worker('./compute-worker.js', {
            workerData: { data },
        });
        worker.on('message', resolve);
        worker.on('error', reject);
    });
}

The async model excels at I/O-heavy workloads: API servers, proxies, real-time applications. It struggles with CPU-bound tasks unless they are offloaded to worker threads or separate processes.

Actors: Message-Passing Concurrency

The actor model avoids shared state entirely. Each actor is an isolated unit with its own state that communicates exclusively through messages. Erlang/Elixir built the model. Akka brings it to the JVM.

elixirelixir
# Elixir: each actor (GenServer) manages its own state
defmodule UserSession do
  use GenServer
 
  # State is private to this actor — no shared memory
  def init(user_id) do
    {:ok, %{user_id: user_id, cart: [], last_active: DateTime.utc_now()}}
  end
 
  # Messages arrive one at a time — no concurrent access to state
  def handle_call(:get_cart, _from, state) do
    {:reply, state.cart, state}
  end
 
  def handle_cast({:add_item, item}, state) do
    new_state = %{state | cart: [item | state.cart]}
    {:noreply, new_state}
  end
 
  def handle_cast(:checkout, state) do
    # Send message to OrderProcessor actor
    OrderProcessor.process(state.user_id, state.cart)
    {:noreply, %{state | cart: []}}
  end
end
elixirelixir
# Supervision: if an actor crashes, restart it automatically
defmodule SessionSupervisor do
  use Supervisor
 
  def start_link(init_arg) do
    Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
  end
 
  def init(_init_arg) do
    children = [
      {DynamicSupervisor, name: SessionManager, strategy: :one_for_one}
    ]
    Supervisor.init(children, strategy: :one_for_one)
  end
end
 
# Start a new session actor
DynamicSupervisor.start_child(SessionManager, {UserSession, user_id})
 
# If the session crashes (bug, external failure), the supervisor
# restarts it automatically. Other sessions are unaffected.

Actors never share state, so race conditions are structurally impossible. Failures are isolated — one actor crashing does not bring down the system. The Erlang VM runs millions of actors concurrently, each with its own heap and garbage collector.

Choosing the Right Model

The tradeoffs map to specific workload characteristics:

                │ Threads        │ Async/Await    │ Actors
────────────────┼────────────────┼────────────────┼────────────────
I/O concurrency │ Good (costly)  │ Excellent      │ Excellent
CPU concurrency │ Excellent      │ Poor (1 thread)│ Good
Memory per task │ ~1MB (OS)      │ ~1KB (promise) │ ~2KB (actor)
Shared state    │ Explicit locks │ Not needed     │ Not possible
Failure isolat. │ Manual         │ Manual         │ Built-in
Debugging       │ Complex        │ Stack traces ✓ │ Message logs
Best for        │ CPU + I/O mix  │ I/O-heavy APIs │ Distributed sys
Languages       │ Java, Go, C#  │ Node, Python   │ Erlang/Elixir
tstypescript
// Decision framework in code
function chooseModel(workload: Workload): ConcurrencyModel {
  if (workload.isCPUBound && workload.needsParallelism) {
    return 'threads';  // Java, Go, Rust
  }
 
  if (workload.isIOBound && workload.highConcurrency) {
    return 'async';  // Node.js, Python asyncio
  }
 
  if (workload.needsFaultIsolation && workload.isDistributed) {
    return 'actors';  // Erlang/Elixir, Akka
  }
 
  // Most web APIs: async is the pragmatic default
  return 'async';
}

Common Pitfalls Across Models

Regardless of the model, certain concurrency bugs recur:

tstypescript
// Pitfall 1: Accidental sequential execution
// ❌ Each await waits for the previous one
async function fetchAll(ids: string[]) {
    const results = [];
    for (const id of ids) {
        results.push(await fetchItem(id));  // Sequential!
    }
    return results;
}
 
// ✅ All fetches start concurrently
async function fetchAll(ids: string[]) {
    return Promise.all(ids.map(id => fetchItem(id)));
}
tstypescript
// Pitfall 2: Unbounded concurrency
// ❌ 10,000 concurrent requests overwhelm the database
async function processAll(items: Item[]) {
    return Promise.all(items.map(item => processItem(item)));
}
 
// ✅ Bounded concurrency with a semaphore
async function processAll(items: Item[]) {
    const limit = 10;  // Max 10 concurrent operations
    const results: Result[] = [];
    
    for (let i = 0; i < items.length; i += limit) {
        const batch = items.slice(i, i + limit);
        const batchResults = await Promise.all(
            batch.map(item => processItem(item))
        );
        results.push(...batchResults);
    }
    
    return results;
}

Concurrency without backpressure creates cascading failures. Whether using threads, promises, or actors, always limit the number of concurrent operations to what downstream systems can handle.

Key Takeaways

  1. Threads excel at CPU-parallel work — Go's goroutines are the most ergonomic option for mixed CPU/IO workloads
  2. Async/await dominates I/O-heavy servers — one thread handling thousands of connections with minimal memory
  3. Actors eliminate shared-state bugs structurally — if race conditions are your biggest pain, actors remove the possibility
  4. Never do unbounded concurrency — 10,000 simultaneous promises or goroutines will overwhelm downstream services
  5. Most web APIs should default to async — the I/O-bound nature of HTTP handlers fits perfectly
  6. CPU work in async runtimes must be offloaded — worker threads, child processes, or separate services
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX