Un Task es un objeto que representa un trabajo en curso. Puede estar ejecutándose en un hilo del pool, esperando a un temporizador o esperando una respuesta de la red; en cualquier caso, puedes preguntar si ha terminado, obtener su resultado, esperarlo o añadirle más trabajo. async y await son la sintaxis del lenguaje sobre las tareas. Esta página trata la propia API de Task, y los hilos.
Task.Run: trabajo en el pool de hilos
Task.Run recibe una lambda y la pone en cola en el pool de hilos, un conjunto de hilos que .NET mantiene disponibles para no crear uno nuevo para cada trabajo. Devuelve un Task, o un Task<T> si la lambda devuelve un valor.
Salida:
Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion
Result bloquea el hilo que llama hasta que la tarea termina. Eso es aceptable en el Main de un programa de consola, pero dentro de código async escribirías int primes = await task;, que espera sin bloquear. Lo mismo vale para task.Wait() en un Task sin resultado.
Task.Run es para trabajo que depende de la CPU: cálculos que mantienen ocupado un núcleo. Para la E/S, como leer un archivo o llamar a una API web, usa el método async que ya ofrece la biblioteca (File.ReadAllTextAsync, HttpClient.GetStringAsync) y espéralo directamente con await. Mientras la E/S está pendiente, una llamada async esperada no retiene ningún hilo, así que envolverla en Task.Run no aporta nada y solo añade un salto por el pool de hilos. Lo que sí ocupa un hilo del pool es Task.Run alrededor de una llamada síncrona bloqueante como File.ReadAllText: ese hilo queda parado hasta que responden el disco o la red.
Varias tareas a la vez: WhenAll
Divide el trabajo independiente en tareas, inícialas todas y combina los resultados con Task.WhenAll. En una máquina con varios núcleos, las partes se ejecutan en paralelo.
Salida:
Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997
Cada trozo trabaja sobre su propio rango y devuelve su propia suma, así que las tareas no comparten nada y no necesitan bloqueos. WhenAll devuelve los resultados en el orden de las tareas, terminen los hilos como terminen. Cuando las tareas sí necesitan actualizar un estado compartido, consulta lock.
WhenAny y tiempos límite
Task.WhenAny termina cuando termina la primera de sus tareas y devuelve esa tarea. Hacer competir el trabajo real con Task.Delay da un tiempo límite:
Salida:
fast mirror answered after 100 ms
Timed out after 300 ms
WhenAny devuelve la propia tarea ganadora, no su resultado, así que vuelves a hacer await sobre ella para obtener el valor (o la excepción, si falló). Ten en cuenta que las tareas perdedoras siguen ejecutándose; WhenAny no las detiene. Para detener de verdad el trabajo lento, cancélalo con un token como se muestra más abajo. En .NET 6 y posteriores, await work.WaitAsync(TimeSpan.FromMilliseconds(300)) expresa el mismo tiempo límite en una sola llamada.
Continuaciones con ContinueWith
Antes de await, la forma de ejecutar código después de que terminara una tarea era ContinueWith. Todavía lo verás en código antiguo:
Salida:
Report total: 42
Cada continuación recibe la tarea anterior y se ejecuta cuando esta termina. await hace lo mismo con mucha menos ceremonia, maneja las excepciones de forma natural y vuelve al contexto correcto, así que prefiérelo en el código nuevo. ContinueWith se ejecuta incluso cuando la tarea anterior falló o se canceló, así que el código antiguo tiene que revisar t.IsFaulted o pasar TaskContinuationOptions para no llamar a .Result sobre una tarea fallida.
Cancelación con CancellationToken
Las tareas se cancelan de forma cooperativa. Un CancellationTokenSource emite un token; el código que hace el trabajo comprueba el token y se detiene él mismo. Nada se aborta a la fuerza.
Salida:
Cancelled before finishing all 10 orders
Processed 3
El token se pasa a través de todas las capas, y las API async como Task.Delay, HttpClient y las llamadas a bases de datos también lo aceptan, así que una cancelación las detiene en mitad de la espera. Captura OperationCanceledException; la TaskCanceledException que lanzan algunas API deriva de ella. CancellationTokenSource es IDisposable, de ahí el using.
Thread frente a Task
Un Thread es un hilo del sistema operativo que creas tú. Es más pesado: cada uno tiene su propia pila (1 MB por defecto en Windows), iniciarlo lleva un tiempo apreciable y no te da ni resultado ni manejo de excepciones.
Salida:
Thread started
Result: 42
Thread | Task | |
|---|---|---|
| Se ejecuta en | Un hilo del SO nuevo y dedicado | Un hilo del pool (o ningún hilo, en la E/S) |
| Coste de inicio | Alto | Bajo |
| Valor de resultado | No, comparte una variable | Task<T>.Result / await |
| Excepciones | Las no manejadas hacen caer el proceso | Se guardan en la tarea y se vuelven a lanzar en await |
| Cancelación | Indicadores manuales | CancellationToken incorporado |
| Esperarlo | Join() | await, Wait(), WhenAll |
Recurre a Thread solo cuando necesitas algo que el pool no te da: un bucle de larga duración que nunca debe tomar prestado un hilo del pool, una prioridad concreta o un apartamento de un solo hilo para COM. Para tareas de larga duración, Task.Factory.StartNew(work, TaskCreationOptions.LongRunning) obtiene un hilo dedicado conservando la API de tareas.
Parallel.For y Parallel.ForEach
Para "haz esto con cada elemento usando todos los núcleos", System.Threading.Tasks.Parallel es más sencillo que crear tareas a mano. Reparte el rango entre hilos y vuelve cuando todas las iteraciones han terminado:
Salida de ejemplo:
image 4 on thread 7
image 3 on thread 6
image 5 on thread 8
image 0 on thread 1
image 1 on thread 4
image 2 on thread 5
Total pixels: 385425
Las líneas de cada imagen salen en el orden en que los hilos las ejecutaron, y los ID de hilo cambian de una ejecución a otra; solo el total es fijo. Así son los bucles paralelos: las iteraciones no deben depender del orden de las demás, y cualquier estado compartido necesita sincronización (Interlocked.Add aquí). Parallel.ForEach hace lo mismo sobre cualquier colección, y .NET 6 añade Parallel.ForEachAsync para cuerpos async.
Errores comunes
.Resulto.Wait()dentro de código async. Bloquea un hilo y puede provocar interbloqueos en aplicaciones con interfaz; usaawait.- Capturar el tipo de excepción original alrededor de
.Result. Los fallos llegan envueltos enAggregateException;awaitlos desenvuelve. - Lanzar y olvidar.
Task.Run(...)sin esperar ni guardar la tarea pierde sus excepciones. Task.Runalrededor de E/S. Alrededor de un método async solo añade un salto por el pool de hilos; alrededor de un método bloqueante comoFile.ReadAllTextretiene un hilo del pool durante toda la espera. Espera con await directamente el método de E/S async.- Ignorar el token. Pasar un
CancellationTokenno sirve de nada salvo que el trabajo lo compruebe. - Estado compartido sin sincronizar en código paralelo. Usa
Interlocked,locko da a cada tarea sus propios datos.
Preguntas frecuentes
¿Qué es un Task en C#?
Un Task representa una operación que puede terminar en el futuro: código ejecutándose en el pool de hilos, un temporizador, una petición de E/S. Se puede esperar, indica si terminó, falló o se canceló, y Task<T> además lleva un resultado. Las tareas son la pieza sobre la que se construyen async/await.
¿Qué diferencia hay entre un Task y un Thread en C#?
Un Thread es un hilo del sistema operativo que creas y gestionas tú, con su propia pila; es caro de iniciar y no devuelve ningún valor. Un Task describe un trabajo, y Task.Run lo programa en un pool compartido de hilos reutilizables; puede devolver un resultado, propagar excepciones, cancelarse y esperarse. Usa tareas salvo que necesites un hilo dedicado de larga vida con una configuración especial.
¿Cuándo debo usar Task.Run?
Para el trabajo que depende de la CPU y que quieres sacar del hilo actual: procesar imágenes, un cálculo grande, parsear en memoria un archivo grande, sobre todo para mantener una interfaz receptiva. No envuelvas la E/S en Task.Run: los métodos de E/S async como ReadAllTextAsync ya liberan el hilo mientras esperan.
¿Cómo cancelo un Task en C#?
Crea un CancellationTokenSource, pasa su Token al trabajo y llama a Cancel() (o a CancelAfter(timeout)) sobre el origen. El trabajo debe colaborar: comprobar token.IsCancellationRequested o llamar a token.ThrowIfCancellationRequested() en su bucle, y pasar el token a las API async como Task.Delay. Esperar una tarea cancelada lanza OperationCanceledException.
¿Qué diferencia hay entre Task.WhenAll y Task.WhenAny?
Task.WhenAll termina cuando han terminado todas las tareas y te da todos los resultados. Task.WhenAny termina en cuanto termina la primera tarea y devuelve esa tarea. WhenAny es la forma habitual de añadir un tiempo límite: haz competir el trabajo con un Task.Delay.
¿Por qué Task.Result lanza AggregateException?
.Result y .Wait() envuelven cualquier fallo en una AggregateException, porque en general una tarea puede contener varias excepciones. await la desenvuelve y vuelve a lanzar directamente la primera excepción interna, lo que es un motivo para preferir await. Si tienes que bloquear, .GetAwaiter().GetResult() también lanza la excepción original.