Ir al contenido
wannacode

async no es un hilo

En resumen: async y await no crean un hilo: await suelta el hilo mientras espera y el código que sigue continúa en el grupo de hilos, así que una carrera entre dos esperas es posible.

Problema

La última unidad, vendida dos veces

Las rebajas van a todo gas. Queda un par de auriculares. checkout comprueba las existencias, espera a que el banco confirme el pago y solo entonces reduce las existencias. Dos clientes pulsan «Comprar» en el mismo momento:

int stock = 1;
int sold = 0;
async Task Checkout(string buyer)
{
int left = stock;
if (left == 0) return;
await Pay(buyer);
stock = left - 1;
Interlocked.Increment(ref sold);
}
await Task.WhenAll(Checkout("Ana"), Checkout("Beto"));
Console.WriteLine($"Vendidos: {sold}, existencias: {stock}");
// El banco confirma el pago en 100 ms.
async Task Pay(string buyer)
{
await Task.Delay(100);
}
Salida
Vendidos: 2, existencias: 0

Se venden dos, y hay una unidad. Un cliente recibirá un correo «Tu pedido ha sido enviado» por unos auriculares que no existen.

Aquí no hay Thread ni Parallel. En la lección Condición de carrera dos hilos chocaron en count++. Aquí los dos pedidos ni siquiera están a la vez en el procesador: los dos solo esperan al banco.

La salida es la misma en cada ejecución: no es mala suerte, es el orden en que se ejecuta el código. Para verlo, hay que saber qué hace await.

La idea

Un hilo y muchas esperas

La mayor parte del trabajo en una tienda en línea es trabajo que espera (mira trabajo que espera y trabajo que calcula): el pago espera al banco, el correo espera al servidor de correo, las existencias esperan al almacén. Un hilo que se queda sentado esperando es un hilo desperdiciado.

Un bucle de eventos es un hilo y una cola de trozos de código que están listos. El hilo toma el siguiente trozo y lo ejecuta hasta el final. Cuando el código llega a una espera, no retiene el hilo: dice «despiértame cuando responda el banco» y lo suelta. El hilo toma el siguiente trozo listo. Cuando el banco responde, el resto de la función (la continuación) entra en la cola y espera su turno.

En C# await también suelta el hilo mientras la tarea espera. Pero en un programa de consola y en ASP.NET Core no hay un único hilo de bucle de eventos. La continuación después de await se ejecuta en el grupo de hilos (mira la lección Grupo de hilos y tareas): en cualquier hilo que esté libre. Así que dos continuaciones pueden ejecutarse realmente a la vez. WPF y WinForms son distintos: allí la ventana tiene su propio hilo con una cola de mensajes, y la continuación vuelve a él, como con un bucle de eventos.

Tres peticiones a tres servicios, lanzadas a la vez:

int waiting = 0;
int most = 0;
async Task<string> Call(string name, int ms)
{
int now = Interlocked.Increment(ref waiting);
most = Math.Max(most, now);
await Task.Delay(ms);
Interlocked.Decrement(ref waiting);
return $"{name}: listo tras {ms} ms";
}
string[] done = await Task.WhenAll(
Call("pago", 300),
Call("correo", 100),
Call("almacén", 200));
foreach (string line in done)
{
Console.WriteLine(line);
}
Console.WriteLine($"Esperaron a la vez: {most}");
Salida
pago: listo tras 300 ms
correo: listo tras 100 ms
almacén: listo tras 200 ms
Esperaron a la vez: 3
Cada Call se ejecuta hasta await Task.Delay(ms) y devuelve una tarea sin terminar. Task.WhenAll espera a las tres y da los resultados en el orden de las tareas, por eso la salida sale en ese orden. waiting se reduce con Interlocked, porque las continuaciones están en el grupo y pueden ejecutarse a la vez.

«Esperaron a la vez: 3»: las tres peticiones esperan al mismo tiempo, y solo hay un hilo. Todo tarda unos 300 ms (la más larga), no 600 (la suma). El hilo no esperó a ninguna de ellas.

Pruébalo

Cuántas esperan a la vez

El mismo call, pero la primera petición se espera sola:

¿Qué imprimirá el programa?

string first = await Call("pago", 300);
string[] rest = await Task.WhenAll(Call("correo", 100), Call("almacén", 200));
Console.WriteLine($"Esperaron a la vez: {most}");
Respuesta

Míralo

Una carrera entre dos awaits

Volvamos a la última unidad. Así se entrelazan los dos pedidos:

int stock = 1;
int sold = 0;
async Task Checkout(string buyer)
{
int left = stock;
if (left == 0) return;
await Pay(buyer);
stock = left - 1;
Interlocked.Increment(ref sold);
}
Paso a paso, en palabras
  1. Queda una unidad. Dos pedidos llegan en el mismo momento: el de Ana y el de Beto.
  2. El checkout de Ana lee las existencias: left es 1. En el await suelta el hilo hasta que responda el banco.
  3. El hilo está libre, así que se ejecuta el checkout de Beto. También lee 1: Ana todavía no ha escrito nada.
  4. El banco responde a Ana. Su código continúa después de await: stock = left - 1 = 0, sold = 1. Hasta aquí es correcto.
  5. Beto continúa con el left = 1 antiguo: otra vez stock = 0, sold = 2. Una unidad, dos ventas: el hueco es el await, no un segundo hilo.

Entre dos await el código se ejecuta sin interrupción. Pero en un await la función suelta el hilo, y mientras tanto puede ejecutarse cualquier otra cosa. Cuando continúa, el mundo puede ser distinto: left es un valor leído antes de la espera.

Es el mismo «leer, cambiar, escribir» que con hilos, solo que la interrupción no es en cualquier parte, sino exactamente en el await. Por eso la salida es la misma en cada ejecución: la interrupción no es aleatoria.

C# tiene algo más: las continuaciones de Ana y Beto se ejecutan en el grupo y pueden ejecutarse a la vez en dos hilos. Aquí el banco les responde en momentos distintos, así que no chocan. Pero sold++ en dos hilos a la vez pierde incrementos, como en la lección sobre condiciones de carrera. Por eso sold se aumenta con Interlocked.Increment.

La idea

La solución: reservar la unidad antes del await

La comprobación y la reducción deben ser un solo trozo de código sin ningún await en medio. El pedido reserva la unidad de inmediato y solo después espera al banco:

int stock = 1;
int sold = 0;
object sync = new object();
async Task Checkout(string buyer)
{
lock (sync)
{
if (stock == 0) return;
stock--; // reserva la unidad antes del await
}
await Pay(buyer);
Interlocked.Increment(ref sold);
}
Salida
Vendidos: 1, existencias: 0
La comprobación y stock-- van dentro de un lock, porque Checkout puede llamarse desde varios hilos a la vez: las peticiones de un servidor web vienen del grupo. C# no permite await dentro de un lock (error CS1996): el cerrojo pertenece a un hilo, y tras await la continuación puede estar en otro. Así que el lock protege solo el trozo sin await.

Si el pago falla, hay que devolver la unidad. Ese es el ejercicio del final.

Más precisamente, para curiosos

A veces hay que bloquear todo el trozo junto con la espera: por ejemplo, solo un pedido a la vez puede escribir en un sistema de almacén antiguo que no soporta dos. Entonces necesitas un cerrojo que entienda await:

SemaphoreSlim(1, 1): await gate.WaitAsync() espera sin retener un hilo, y gate.Release() va en finally. Así también se puede proteger un trozo con await dentro.

Cómo es en los otros lenguajes: en JavaScript y en asyncio hay un hilo, y el trozo entre dos await se ejecuta solo. En C# las continuaciones están en el grupo de hilos, así que allí también hace falta un lock. Java no tiene await: allí se hace lo mismo con CompletableFuture, y desde Java 21 también con hilos virtuales, donde escribes código bloqueante normal y la JVM desmonta el hilo por sí sola mientras espera.

Pruébalo

Encuentra el error

El carrito suma los precios de tres pedidos. El precio viene del almacén con await. Los tres pedidos cuestan 25 € cada uno, y el total sale como 25:

int total = 0;
async Task AddOrder(int id)
{
total += await PriceOf(id);
}
await Task.WhenAll(AddOrder(1), AddOrder(2), AddOrder(3));
Console.WriteLine($"Total: {total} €");
Salida
Total: 25 €

Elige la línea con el error.

La idea

No bloquees el bucle de eventos

El bucle de eventos solo funciona si ningún trozo de código retiene el hilo durante mucho tiempo. El informe de ventas se hace con Thread.Sleep(300): duerme sin await. A la vez, un cliente abre la página de un producto, que espera 50 ms al almacén:

var watch = Stopwatch.StartNew();
await Task.WhenAll(Report(), ProductPage("Ana"));
async Task Report()
{
Thread.Sleep(300); // bloquea: espera 300 ms sin await
Console.WriteLine("El informe está listo");
await Task.Delay(10);
}
async Task ProductPage(string buyer)
{
await Task.Delay(50);
Console.WriteLine($"{buyer} espera más de 0,25 s: {watch.ElapsedMilliseconds > 250}");
}
Salida
El informe está listo
Ana espera más de 0,25 s: True

La página esperó más de 0,25 s en lugar de 50 ms. Mientras el informe retiene el hilo, ninguna otra continuación puede ejecutarse: ni esta página, ni los pagos, ni los temporizadores. En un servidor con miles de clientes, todos se congelan a la vez.

En C# Thread.Sleep en un método async bloquea el hilo que lo ejecuta. Aquí es el hilo principal, antes del primer await, así que ProductPage ni siquiera ha empezado. Esperar de forma síncrona (Thread.Sleep, .Result, .Wait()) en código async es el mismo error: usa await Task.Delay y await. En una aplicación WPF o WinForms lo mismo congela la ventana: los botones no responden mientras el hilo de la ventana está ocupado.

La solución es sacar el trabajo pesado del hilo del bucle de eventos. Va a un grupo de hilos, y el bucle de eventos solo espera el resultado:

await Task.WhenAll(Report(), ProductPage("Ana"));
async Task Report()
{
await Task.Run(() => Thread.Sleep(300));
Console.WriteLine("El informe está listo");
}
async Task ProductPage(string buyer)
{
await Task.Delay(50);
Console.WriteLine($"{buyer}: la página está lista");
}
Salida
Ana: la página está lista
El informe está listo
await Task.Run(...) manda el trabajo al grupo y suelta el hilo. La página está lista a los 50 ms, el informe a los 300.

Pruébalo

Completa

Lanza las tres peticiones a la vez y espéralas juntas:

string[] done = await (
    Call("pago", 300),
    Call("correo", 100),
    Call("almacén", 200));
foreach (string line in done)
{
    Console.WriteLine(line);
}
Console.WriteLine($"Esperaron a la vez: {most}");

Ejercicio

Reservar, cobrar, devolver

Resuélvelo en tu propio editor.

Quedan dos unidades. Cuatro clientes piden a la vez: Ana, Beto, Vera y Gala. El pago tarda 100 ms. La tarjeta de Beto es rechazada: el pago lanza una excepción con el texto «tarjeta rechazada».

checkout reserva la unidad antes del await, luego cobra. Si el pago falla, la unidad se devuelve. Imprime el resultado de cada cliente en el orden de los pedidos, y al final las existencias y el número de vendidos:

Salida
Ana: pagado
Beto: tarjeta rechazada, la unidad se devuelve
Vera: sin existencias
Gala: sin existencias
Existencias: 1, vendidos: 1

Vera y Gala ven «sin existencias», porque la unidad de Beto sigue reservada hasta que responde el banco. Es justo: la unidad devuelta va al siguiente cliente.

Más tareas

  • Intentar de nuevo. Si un cliente no encuentra existencias pero otro podría devolver una unidad, déjalo esperar hasta 200 ms y volver a intentarlo.
  • Timeout. El banco a veces no responde. Si el pago tarda más de 1 s, rechaza el pedido y devuelve la unidad. (Task.WaitAsync)
  • Informe. Calcula el total de las ventas (25 € cada una) e imprímelo al final.

Test

Ponte a prueba

1. ¿En cuántos hilos se ejecuta tu código en JavaScript (Node) y en Python asyncio?
2. Tres peticiones esperan 100, 200 y 300 ms. ¿Cuánto tarda todo si las esperas una por una con await, y cuánto con Promise.all, gather, Task.WhenAll o allOf?
3. En JavaScript y Python una función async lee datos compartidos, espera con await y luego escribe. ¿Cuándo puede otro código cambiar los datos?
4. Atender una petición incluye un cálculo que dura 2 segundos, sin un solo await. ¿Qué pasa con las demás peticiones en Node o asyncio?
5. ¿Cómo evitas vender dos veces la última unidad?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·