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.

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).
// 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 stacksDie Gefahr von Shared Memory: Zwei Threads, die gleichzeitig dieselben Daten verändern, erzeugen Race Conditions.
// ❌ 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
}
}// 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.
// 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 timeDer 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.
// ❌ 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.
# 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# 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
// 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:
// 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)));
}// 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
- Threads glänzen bei CPU-paralleler Arbeit — Gos Goroutines sind die ergonomischste Option für gemischte CPU/IO-Workloads
- Async/await dominiert I/O-lastige Server — ein Thread, der Tausende Verbindungen mit minimalem Speicher bedient
- Actors eliminieren Shared-State-Bugs strukturell — wenn Race Conditions dein größtes Problem sind, nehmen Actors diese Möglichkeit komplett
- Niemals unbegrenzte Concurrency — 10.000 gleichzeitige Promises oder Goroutines überfordern nachgelagerte Dienste
- Die meisten Web-APIs sollten standardmäßig auf async setzen — die I/O-bound Natur von HTTP-Handlern passt perfekt
- CPU-Arbeit in Async-Runtimes muss ausgelagert werden — Worker Threads, Child Processes oder separate Services


