Zum Inhalt springen

Concurrency-Modelle verstehen: Threads, Async und Actors

Ein Vergleich der Concurrency-Modelle Threads, async/await und Actors: wann welches passt, welche Tradeoffs gelten und welche Fallstricke lauern.

5 Min. Lesezeit
Diagramm zum Vergleich von Thread-basierter Concurrency, asynchronem Event Loop und Actor-Message-Passing

Jedes Backend-System stößt irgendwann auf ein Concurrency-Problem. Eine Datenbankabfrage dauert 200ms. Ein externer API-Call dauert 500ms. Ohne Concurrency bedeuten dreißig Nutzer mit Requests, dass der dreißigste Nutzer fünfzehn Sekunden wartet. Die Frage ist nicht, ob man Concurrency einsetzt, sondern welches Modell zum eigenen System passt.

Drei Modelle dominieren die moderne serverseitige Entwicklung: Threads (Java, Go, C#), async/await (Node.js, Python asyncio, Rust) und Actors (Erlang, Akka). Jedes trifft andere Tradeoffs zwischen Einfachheit, Sicherheit und Performance.

Threads: Concurrency mit Shared Memory

Thread-basierte Concurrency führt mehrere Ausführungspfade gleichzeitig aus, die sich denselben Speicherbereich teilen. Das ist der Standard in Java, C# und Go (Goroutines sind leichtgewichtige 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

Die Gefahr von Shared Memory: Zwei Threads, die gleichzeitig dieselben Daten verändern, erzeugen 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)

Gos Ansatz — billige Goroutines mit Channels — bietet Thread-ähnliche Semantik ohne den Ressourcen-Overhead. Das Motto "share memory by communicating" lenkt Entwickler zu Channels statt zu geteiltem mutablem State.

Async/Await: kooperative Concurrency

Async I/O nutzt einen einzelnen Thread (oder einen kleinen Thread-Pool), der zwischen Aufgaben wechselt, wenn diese auf I/O warten. Node.js hat dieses Modell populär gemacht. Python, Rust und C# unterstützen es ebenfalls.

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

Der Event Loop verarbeitet Aufgaben kooperativ. Jedes await suspendiert die aktuelle Funktion und lässt andere Arbeit laufen. Das bedeutet: keine Race Conditions auf geteilten Daten — aber auch, dass CPU-intensive Arbeit alles blockiert.

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);
    });
}

Das Async-Modell glänzt bei I/O-lastigen Workloads: API-Server, Proxies, Echtzeit-Anwendungen. Bei CPU-bound Aufgaben stößt es an Grenzen, es sei denn, diese werden auf Worker Threads oder separate Prozesse ausgelagert.

Actors: Concurrency durch Message Passing

Das Actor-Modell vermeidet Shared State vollständig. Jeder Actor ist eine isolierte Einheit mit eigenem State, die ausschließlich über Nachrichten kommuniziert. Erlang/Elixir hat das Modell aufgebaut. Akka bringt es auf die 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 teilen niemals State, also sind Race Conditions strukturell unmöglich. Fehler sind isoliert — ein abstürzender Actor reißt das System nicht mit. Die Erlang-VM führt Millionen von Actors gleichzeitig aus, jeder mit eigenem Heap und Garbage Collector.

Das richtige Modell wählen

Die Tradeoffs lassen sich konkreten Workload-Eigenschaften zuordnen:

                │ 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';
}

Typische Fallstricke in allen Modellen

Unabhängig vom Modell treten bestimmte Concurrency-Bugs immer wieder auf:

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 ohne Backpressure erzeugt kaskadierende Ausfälle. Ob mit Threads, Promises oder Actors: Begrenze die Anzahl gleichzeitiger Operationen immer auf das, was nachgelagerte Systeme verarbeiten können.

Die wichtigsten Erkenntnisse

  1. Threads glänzen bei CPU-paralleler Arbeit — Gos Goroutines sind die ergonomischste Option für gemischte CPU/IO-Workloads
  2. Async/await dominiert I/O-lastige Server — ein Thread, der Tausende Verbindungen mit minimalem Speicher bedient
  3. Actors eliminieren Shared-State-Bugs strukturell — wenn Race Conditions dein größtes Problem sind, nehmen Actors diese Möglichkeit komplett
  4. Niemals unbegrenzte Concurrency — 10.000 gleichzeitige Promises oder Goroutines überfordern nachgelagerte Dienste
  5. Die meisten Web-APIs sollten standardmäßig auf async setzen — die I/O-bound Natur von HTTP-Handlern passt perfekt
  6. CPU-Arbeit in Async-Runtimes muss ausgelagert werden — Worker Threads, Child Processes oder separate Services
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX