Entendiendo los modelos de concurrencia: threads, async y actores
Comparación de los modelos de concurrencia con threads, async/await y actores: cuándo conviene cada uno, sus tradeoffs y los errores más comunes.

Todo sistema backend termina enfrentando un problema de concurrencia. Una consulta a la base de datos tarda 200ms. Una llamada a una API externa tarda 500ms. Sin concurrencia, treinta usuarios haciendo peticiones significa que el usuario número treinta espera quince segundos. La pregunta no es si usar concurrencia, sino qué modelo encaja con tu sistema.
Tres modelos dominan el desarrollo moderno del lado del servidor: threads (Java, Go, C#), async/await (Node.js, Python asyncio, Rust) y actores (Erlang, Akka). Cada uno hace tradeoffs distintos entre simplicidad, seguridad y rendimiento.
Threads: concurrencia con memoria compartida
La concurrencia basada en threads ejecuta múltiples rutas de ejecución simultáneamente, compartiendo el mismo espacio de memoria. Es el modelo por defecto en Java, C# y Go (las goroutines son threads ligeros).
// 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 stacksEl peligro de la memoria compartida: dos threads modificando los mismos datos simultáneamente producen 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)El enfoque de Go —goroutines baratas con channels— ofrece una semántica similar a la de los threads sin el coste de recursos. El lema "share memory by communicating" orienta a los desarrolladores hacia los channels en lugar del estado mutable compartido.
Async/Await: concurrencia cooperativa
El I/O asíncrono usa un solo thread (o un pequeño pool de threads) que cambia entre tareas cuando estas están esperando I/O. Node.js popularizó este modelo. Python, Rust y C# también lo soportan.
// 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 timeEl event loop procesa las tareas de forma cooperativa. Cada await suspende la función actual y deja correr otro trabajo. Esto significa que no hay race conditions sobre datos compartidos, pero también que el trabajo intensivo en CPU lo bloquea todo.
// ❌ 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);
});
}El modelo async destaca en cargas de trabajo intensivas en I/O: servidores de API, proxies, aplicaciones en tiempo real. Sufre con tareas CPU-bound a menos que se deleguen a worker threads o procesos separados.
Actores: concurrencia por paso de mensajes
El modelo de actores evita por completo el estado compartido. Cada actor es una unidad aislada con su propio estado que se comunica exclusivamente mediante mensajes. Erlang/Elixir construyó el modelo. Akka lo lleva a la 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.Los actores nunca comparten estado, así que las race conditions son estructuralmente imposibles. Los fallos están aislados: que un actor se caiga no tumba el sistema. La VM de Erlang ejecuta millones de actores concurrentemente, cada uno con su propio heap y garbage collector.
Elegir el modelo correcto
Los tradeoffs corresponden a características concretas de la carga de trabajo:
│ 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';
}Errores comunes en todos los modelos
Independientemente del modelo, ciertos bugs de concurrencia se repiten:
// 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;
}La concurrencia sin backpressure crea fallos en cascada. Ya sea con threads, promises o actores, limita siempre el número de operaciones concurrentes a lo que los sistemas downstream pueden soportar.
Conclusiones clave
- Los threads destacan en trabajo paralelo de CPU — las goroutines de Go son la opción más ergonómica para cargas mixtas de CPU/IO
- Async/await domina los servidores intensivos en I/O — un solo thread manejando miles de conexiones con memoria mínima
- Los actores eliminan estructuralmente los bugs de estado compartido — si las race conditions son tu mayor dolor, los actores eliminan esa posibilidad
- Nunca uses concurrencia sin límites — 10.000 promises o goroutines simultáneas saturarán los servicios downstream
- La mayoría de las APIs web deberían usar async por defecto — la naturaleza I/O-bound de los handlers HTTP encaja a la perfección
- El trabajo de CPU en runtimes async debe delegarse — worker threads, procesos hijo o servicios separados


