wandres.dev
DURABLE OBJECTS · el modelo

Single-threaded: por qué desaparecen las condiciones de carrera

Un Durable Object procesa sus peticiones de una en una. Dentro de una misma instancia tu código nunca corre en paralelo consigo mismo: las llamadas concurrentes se forman en cola y se atienden en orden. Esa serialización, casi banal de enunciar, tiene una consecuencia enorme: el clásico leer-modificar-escribir se vuelve atómico sin un solo cerrojo, y las condiciones de carrera que envenenan el estado compartido desaparecen por construcción. Vemos la carrera clásica, el modelo de un hilo, el matiz del await domesticado por el input gating y la reubicación de la concurrencia por identidad.

⏱ 19 min

Hay un bug que todo backend ha sufrido: dos peticiones leen el mismo contador, ambas ven 41, ambas escriben 42, y un incremento se pierde en el aire. Es la condición de carrera, el azote del estado mutable compartido, y la industria ha inventado un arsenal para combatirla —cerrojos, transacciones, compare-and-swap—, todo correcto y todo difícil, sembrado de interbloqueos y contención. El Durable Object no combate esa carrera: la hace imposible. Como atiende sus peticiones de una en una, en un solo hilo, el problema no se resuelve, sencillamente no puede ocurrir.

🎯 Al terminar esta lección sabrás
  • Reconocer la condición de carrera de leer-modificar-escribir sobre estado compartido.
  • Entender que un Durable Object atiende sus peticiones de una en una, en un solo hilo.
  • Explicar por qué esa serialización elimina las condiciones de carrera sin cerrojos.
  • Situar el matiz del await y por qué el modelo mental “una a una” se sostiene.

La condición de carrera clásica

Mira el bug en su forma más pura. Un contador guardado en un almacén compartido, incrementado por un Worker sin estado:

// SIN un Durable Object: dos invocaciones se pisan
const v = Number(await env.KV.get("contador"));
await env.KV.put("contador", String(v + 1));

Si dos peticiones llegan a la vez, sus pasos se entrelazan. La primera lee 41. Antes de que escriba, la segunda también lee 41. La primera escribe 42. La segunda, que sigue creyendo que iba por 41, escribe 42. Dos incrementos entraron; el contador solo subió uno. El dato se corrompió sin que nadie fallara: cada petición hizo su trabajo, pero el entrelazado las traicionó.

La cura tradicional es serializar el acceso con un cerrojo, una transacción o una operación atómica de comparar-e-intercambiar. Funciona, pero abre su propia caja de Pandora: los cerrojos se olvidan, se toman en orden distinto y producen interbloqueos, o se mantienen demasiado y estrangulan el rendimiento. Gestionar concurrencia a mano es una de las fuentes más fértiles de errores sutiles de toda la programación.

Un objeto, un hilo de ejecución

Un Durable Object cambia las reglas de raíz: es de un solo hilo. Dentro de una instancia, tu código nunca se ejecuta en paralelo consigo mismo. Cuando llegan muchas llamadas al mismo objeto, la plataforma las forma en una cola y las atiende una tras otra: cada una se completa antes de que la siguiente empiece a tocar el estado. El leer-modificar-escribir se vuelve atómico gratis.

export class Contador extends DurableObject {
  async incrementar(): Promise<number> {
    const v = (await this.ctx.storage.get<number>("v")) ?? 0;
    await this.ctx.storage.put("v", v + 1);
    return v + 1;
  }
}

Aunque mil peticiones lleguen en el mismo milisegundo, ninguna ejecución de incrementar se solapa con otra sobre el mismo objeto. El “leer 41, escribir 42” ocurre entero, sin interrupción, antes de que empiece el “leer 42, escribir 43”. No se pierde ninguna cuenta, y no ha hecho falta un solo cerrojo: la exclusión mutua no la programas, viene de serie en la naturaleza del objeto.

sequenceDiagram
participant A as Peticion A
participant B as Peticion B
participant DO as Contador
A->>DO: incrementar
DO-->>A: 42
B->>DO: incrementar
DO-->>B: 43

El diagrama dice lo esencial: aunque A y B lleguen a la vez, el objeto las ordena. B no ve un estado a medias porque, sencillamente, no empieza hasta que A termina. Lo que en el mundo sin estado exigía disciplina y cerrojos, aquí es la física del sitio.

Esa misma serialización tiene un uso deliberado en el arranque. Si un objeto necesita cargar estado de forma asíncrona antes de atender nada, envuelve esa inicialización en ctx.blockConcurrencyWhile: mientras la promesa no se resuelva, la plataforma retiene todas las peticiones entrantes, de modo que ninguna llegue a ver un objeto a medio inicializar.

export class Sesion extends DurableObject {
  private datos!: Estado;
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      this.datos = (await ctx.storage.get<Estado>("datos")) ?? inicial();
    });
  }
}

Es el hilo único puesto a tu servicio: no solo te protege de las carreras que no buscabas, también te da una herramienta explícita para declarar “nadie entra hasta que yo esté listo”.

📝
El matiz del await, domesticado

Hilemos fino, que hay una sutileza real. En JavaScript un await cede el control, así que cabría temer que entre dos await de una misma petición se cuele otra y observe el estado a medias. El runtime de los Durable Objects cierra esa puerta con dos mecanismos, el input gating y el output gating: mientras una operación de almacenamiento está en curso, las peticiones entrantes se retienen, y las escrituras no se hacen visibles hacia afuera hasta que el estado es coherente. El resultado práctico es que el modelo mental sencillo —una petición se completa antes de que empiece la siguiente— se sostiene para el leer-modificar-escribir que escribes con naturalidad, sin obligarte a razonar sobre entrelazados finos.

Concurrencia por identidad, no por cerrojo

Cuidado con la conclusión errónea: el objeto no elimina la concurrencia de tu sistema, la reubica. Distintos objetos corren en paralelo sin estorbarse; solo se serializan las llamadas a un mismo objeto. Obtienes, así, dos cosas a la vez que parecían enemigas: paralelismo entre objetos y seguridad dentro de cada uno.

De ahí sale el principio operativo del nivel: reparte la contención por identidad. Un millón de usuarios, cada uno con su objeto, son un millón de contadores independientes, todos serializados en su interior y todos ejecutándose en paralelo entre sí. La serialización nunca es global; es local a cada nombre. Por eso “un objeto por usuario” escala: no hay un punto único que ordene el mundo entero, hay millones de puntos diminutos que ordenan cada uno su parcela.

Compara las dos filosofías cara a cara. La contención clásica dice: comparte el estado y protégelo con cerrojos. El actor dice: no compartas el estado, dáselo a un dueño y háblale por mensajes. La primera reparte la responsabilidad de la corrección entre todos los que tocan el dato y confía en que ninguno olvide su parte; la segunda concentra esa responsabilidad en un solo lugar y la vuelve imposible de violar desde fuera. Por eso el modelo de actor no es solo más cómodo de escribir: es estructuralmente más difícil de romper.

🧵

Un hilo por objeto

Dentro de una instancia, tu código no corre en paralelo consigo mismo. Las llamadas se atienden en orden, una tras otra.

⚛️

Atómico sin cerrojos

Leer, modificar y escribir ocurre sin interrupción. La exclusión mutua es una propiedad del objeto, no código que escribes.

🗂️

Contención por nombre

La serialización es local a cada identidad. Reparte la carga eligiendo bien la entidad por la que particionas.

El actor no resuelve la concurrencia: le da un dueño al estado

El problema más viejo y más traicionero de la programación concurrente es el estado mutable compartido: varias líneas de ejecución tocando el mismo dato sin un orden acordado. Medio siglo de informática ha respondido con herramientas de contención —mutexes, semáforos, secciones críticas, transacciones, memoria transaccional— y todas comparten un vicio: reparten la responsabilidad de la corrección entre todos los que tocan el dato. Basta que un solo camino de código olvide tomar el cerrojo, o lo tome en el orden equivocado, para que el sistema entero quede envenenado por un bug que solo aparece bajo carga, a las tres de la madrugada, imposible de reproducir. El modelo de actor ataca el problema por la raíz opuesta. No pone guardias alrededor del estado compartido: elimina la compartición. Le da a cada porción de estado un único dueño —el actor— que la posee en exclusiva y la toca en un solo hilo, y obliga a todos los demás a pedirle las cosas por mensaje en vez de manipularla directamente. La condición de carrera no se previene con disciplina; se vuelve inexpresable, porque ya no hay dos líneas de ejecución que puedan tocar el mismo dato a la vez. Es la misma intuición que hizo célebre a Erlang y que Hoare persiguió con los procesos secuenciales comunicantes, ahora fundida con la identidad global del edge. Y trae un giro de perspectiva que hay que interiorizar: la concurrencia no desaparece, se traslada del interior de cada objeto al espacio entre objetos, donde es benigna porque no comparten nada. Así, controlar la concurrencia deja de ser un problema de código —qué cerrojo tomo y cuándo lo suelto— y se convierte en un problema de nomenclatura —por qué entidad particiono el estado para que cada actor sea dueño exclusivo de lo suyo—. Quien hace ese salto deja de temer las carreras y empieza a diseñar fronteras.

⚔️ Provoca y luego elimina una carrera
  1. Monta un contador en KV con leer-modificar-escribir y bombardéalo con peticiones concurrentes; mide cuántos incrementos se pierden.
  2. Reescríbelo como un método incrementar de un DurableObject y repite el bombardeo. Comprueba que la cuenta final ya es exacta.
  3. Explica, sin usar la palabra cerrojo, por qué la segunda versión no pierde incrementos aunque llegue todo a la vez.
  4. Describe el matiz del await y cómo el input gating sostiene el modelo mental de “una petición se completa antes de que empiece la siguiente”.
  5. Argumenta por qué “un objeto por usuario” reparte la contención en vez de crear un cuello de botella global.