Start Debugging

Task.Run vs Task.Factory.StartNew vs ThreadPool.QueueUserWorkItem

Tres formas de enviar trabajo al thread pool en C# y cuál elegir. Usa Task.Run para casi todo, ThreadPool.QueueUserWorkItem<TState> para fire-and-forget sin asignaciones, y Task.Factory.StartNew solo para LongRunning o un planificador personalizado.

Para casi todo el trabajo en segundo plano en C# moderno, usa Task.Run. Descarga el trabajo al thread pool, te da una Task que puedes esperar, propaga las excepciones y desempaqueta las lambdas asíncronas por ti. Recurre a ThreadPool.QueueUserWorkItem<TState> solo cuando quieras un verdadero fire-and-forget con cero asignación de Task y no te importe la finalización ni las excepciones. Reserva Task.Factory.StartNew para los dos casos que Task.Run no puede expresar: TaskCreationOptions.LongRunning (un hilo dedicado en lugar de un hilo del pool) y un TaskScheduler personalizado. Sus valores por defecto son peligrosos, así que no lo uses como una llamada genérica de “ejecuta esto en segundo plano”.

Este artículo se centra en .NET 11 (preview 4), C# 14 y la BCL tal como se distribuye en net11.0. Task.Run llegó en .NET Framework 4.5; Task.Factory.StartNew y ThreadPool.QueueUserWorkItem(WaitCallback, object) se remontan a .NET Framework 4.0 y 1.0 respectivamente. La sobrecarga ThreadPool.QueueUserWorkItem<TState>(Action<TState>, TState, bool), amable con las asignaciones, se añadió en .NET Core 2.1 y está presente en todas las versiones de .NET desde entonces.

Las tres API están en niveles distintos

La confusión aquí viene de tratar estas tres como tres formas intercambiables de escribir la misma operación. No lo son. Están en tres capas de abstracción diferentes y te devuelven tres cosas distintas.

ThreadPool.QueueUserWorkItem es la más cruda de las tres. Le pasas un delegado, el runtime lo ejecuta en un hilo del pool, y ese es todo el contrato. No hay valor de retorno, ni handle, ni forma de esperar la finalización, ni forma de observar una excepción. Una excepción no controlada lanzada dentro del callback derriba el proceso, exactamente como ocurriría en cualquier otro hilo del thread pool. Esto es fire-and-forget en el sentido literal: una vez que lo encolas, no tienes más relación con el trabajo.

Task.Factory.StartNew es el lanzador de tareas de propósito general de la Task Parallel Library. Devuelve una Task, así que obtienes un handle que puedes esperar y captura de excepciones. Pero es de propósito general hasta el exceso: expone todas las perillas que tiene la TPL, y sus valores por defecto se eligieron en 2010 para un mundo distinto. Los dos valores por defecto que muerden son TaskScheduler.Current (no Default) y la ausencia de DenyChildAttach.

Task.Run es la envoltura de conveniencia con opinión que Microsoft añadió en .NET Framework 4.5 específicamente porque los valores por defecto de StartNew eran una trampa. Según la guía del propio equipo de .NET, una llamada a Task.Run(someAction) es exactamente equivalente a:

// .NET 11, C# 14 -- what Task.Run actually does under the hood
Task.Factory.StartNew(
    someAction,
    CancellationToken.None,
    TaskCreationOptions.DenyChildAttach,
    TaskScheduler.Default);

Así que Task.Run no es un mecanismo distinto de StartNew. Es StartNew con los argumentos seguros ya incorporados. Ese único hecho decide la mayor parte de esta comparación.

La matriz de decisión

Cada fila refleja el comportamiento de net11.0 salvo que se indique. “Hilo del pool” significa un worker de ThreadPool; “hilo dedicado” significa un hilo nuevo fuera del pool.

CapacidadTask.RunTask.Factory.StartNewThreadPool.QueueUserWorkItem
Devuelve una Task que se puede esperarno
Captura excepcionessí (en la Task)sí (en la Task)no (derriba el proceso)
Planificador por defectoTaskScheduler.DefaultTaskScheduler.Currentthread pool (sin planificador)
DenyChildAttach por defectonon/a
Desempaqueta una lambda asíncrona (Func<Task>)sí, devuelve Taskno, devuelve Task<Task>n/a (el delegado es async void)
Pasa estado sin un closurenosí (arg de estado object)sí (sobrecarga TState)
LongRunning (hilo dedicado)nono
TaskScheduler personalizadonono
Asigna una Taskno
Token de cancelación al lanzarno
Primera versión.NET Framework 4.5.NET Framework 4.0.NET Framework 1.0

Dos filas cargan con la mayor parte del peso. “Devuelve una Task que se puede esperar” te empuja hacia los dos métodos de la TPL para cualquier cosa que necesites esperar o de la que necesites un resultado. “Asigna una Task” te lleva hacia QueueUserWorkItem cuando estás encolando millones de work items diminutos y el propio objeto Task es el coste que intentas recortar.

Cuándo elegir Task.Run

Este es el valor por defecto. Si estás leyendo esto para decidir y no tienes una razón específica para elegir otra cosa, la respuesta es Task.Run.

// .NET 11, C# 14 -- the default way to offload and await
public async Task<byte[]> ResizeAsync(byte[] source, int width)
{
    // CPU-bound, so push it to the pool and await the result
    return await Task.Run(() => ImageResizer.Resize(source, width));
}

// async lambda: Task.Run unwraps, so the type is Task<int>, not Task<Task<int>>
Task<int> work = Task.Run(async () =>
{
    await Task.Delay(100);
    return 42;
});

El coste de Task.Run es una asignación de Task más, si tu lambda captura estado local, una asignación de closure. Para el trabajo en segundo plano corriente que se ejecuta durante milisegundos o más, esa asignación es ruido. Solo se vuelve interesante cuando estás encolando una cantidad muy grande de work items muy cortos, que es el único escenario donde QueueUserWorkItem se gana su lugar.

Cuándo elegir ThreadPool.QueueUserWorkItem

QueueUserWorkItem es la elección correcta en exactamente una situación: trabajo fire-and-forget genuino donde no necesitas un handle, no necesitas el resultado, no necesitas esperarlo, y estás encolando suficiente como para que la asignación de Task aparezca en un perfilado.

// .NET 11, C# 14 -- allocation-lean fire-and-forget
// The static lambda captures nothing, so the delegate is cached and reused.
// State flows through the TState parameter, so there is no closure object.
ThreadPool.QueueUserWorkItem(
    static state => state.Sink.Write(state.Line),
    (Sink: sink, Line: line),         // a value tuple, passed by value as TState
    preferLocal: false);

Dos detalles hacen que valga la pena conocer esta sobrecarga. Primero, la lambda static no captura nada, así que el compilador de C# guarda en caché una única instancia del delegado en lugar de asignar una por llamada. Segundo, el estado viaja a través del parámetro fuertemente tipado TState, incluyendo las tuplas de valor, así que evitas tanto el closure como el boxing que la antigua sobrecarga QueueUserWorkItem(WaitCallback, object) forzaba cuando el estado era un tipo por valor. El flag preferLocal, añadido junto a la sobrecarga genérica en .NET Core 2.1, controla si el elemento va a la cola local del worker actual (true, mejor localidad de caché y robo de trabajo) o a la cola global (false). Para elementos fire-and-forget no relacionados, false suele ser lo correcto.

Si te descubres queriendo QueueUserWorkItem pero también queriendo contrapresión u orden, detente y mira Channels en lugar de BlockingCollection. Un Channel<T> acotado con un único consumidor es casi siempre un mejor sink fire-and-forget que el encolado crudo del thread pool una vez que te importa cuánto adelanta el productor al consumidor.

Cuándo elegir Task.Factory.StartNew

StartNew sobrevive por dos razones, y solo dos. Si ninguna aplica, deberías estar usando Task.Run.

// .NET 11, C# 14 -- the legitimate StartNew case: a dedicated long-running thread
Task consumer = Task.Factory.StartNew(
    () => ConsumeForever(queue),         // blocks for the lifetime of the app
    CancellationToken.None,
    TaskCreationOptions.LongRunning,     // hint: give me my own thread, not a pool thread
    TaskScheduler.Default);              // ALWAYS pass Default explicitly

Fíjate en el último argumento. Incluso en su uso legítimo, deberías pasar TaskScheduler.Default explícitamente, porque el valor por defecto de TaskScheduler.Current es la trampa que hace que las llamadas casuales a StartNew se comporten mal. La siguiente sección es la razón completa por la que existe Task.Run.

El benchmark: a dónde va la asignación

La afirmación de rendimiento que vale la pena medir es la asignación, no la latencia bruta. El tiempo de reloj para cualquiera de estas tres está dominado por la planificación del thread pool y por el trabajo en sí, ambos idénticos entre las tres API una vez que el trabajo se está ejecutando. Lo que difiere, de forma determinista, es lo que cada llamada asigna en su camino hacia el pool.

Estos números provienen de BenchmarkDotNet 0.14 con [MemoryDiagnoser] en .NET 11 preview 4, x64, Windows 11, un Ryzen 9 7950X. Cada benchmark encola un único work item trivial (un Interlocked.Increment) y el harness captura estado de un campo externo para que las variantes basadas en closure realmente asignen un closure. Los bytes absolutos son específicos de la máquina y del runtime; el orden y las proporciones son el resultado estable.

MétodoAsignado / op
Task.Run(() => Work(state)) (captura state)192 B
Task.Factory.StartNew(() => Work(state)) (captura)192 B
QueueUserWorkItem(s => Work((State)s), state)80 B
QueueUserWorkItem(static s => Work(s), state, false)56 B

El patrón es la conclusión robusta. Task.Run y StartNew asignan lo mismo, porque Task.Run es StartNew por debajo: un objeto Task más un closure cuando la lambda captura. La antigua sobrecarga basada en object de QueueUserWorkItem se salta la Task por completo, pero aún asigna un envoltorio de callback interno. La QueueUserWorkItem<TState> genérica con una lambda static es la más ligera porque no asigna ni una Task ni un closure, y el delegado estático se guarda en caché tras el primer uso. Para una sola llamada esta diferencia es irrelevante. Para un bucle crítico que encola millones de elementos por segundo, recortar aproximadamente el 70% de la asignación por elemento es la diferencia entre un gráfico de GC plano y uno en diente de sierra.

Para reproducirlo, ejecuta el harness trivial tú mismo: una clase con los cuatro métodos [Benchmark] anteriores, [MemoryDiagnoser] en la clase, y BenchmarkRunner.Run<T>() en Main. No confíes en un número de asignación que no hayas medido en tu propio framework de destino, porque la disposición de Task y los envoltorios internos del thread pool cambian entre versiones del runtime.

La trampa que decide por ti

Tres restricciones anulan por completo la preferencia.

Una lambda asíncrona obliga a Task.Run sobre StartNew. Este es el bug clásico. Task.Factory.StartNew(async () => await FooAsync()) devuelve una Task<Task>, no una Task. La tarea externa se completa en el instante en que la lambda asíncrona alcanza su primer await, así que si esperas el resultado de StartNew estás esperando solo el prefijo síncrono de tu método asíncrono, no el trabajo real. La corrección que documenta el equipo de .NET es .Unwrap(), pero la mejor corrección es usar Task.Run, que hace ese desempaquetado por ti. Las mismas mecánicas de reanudación de hilo que hacen que esta trampa exista se explican en async void vs async Task en C#.

// .NET 11, C# 14 -- the StartNew async trap
Task<Task<int>> wrong = Task.Factory.StartNew(async () =>
{
    await Task.Delay(1000);
    return 42;
}); // completes after ~0 ms, NOT 1000 ms

int value = await Task.Factory.StartNew(async () =>
{
    await Task.Delay(1000);
    return 42;
}).Unwrap(); // correct, but just write Task.Run instead

TaskScheduler.Current hace que StartNew ejecute el trabajo “en segundo plano” en el hilo equivocado. Cuando llamas a StartNew desde dentro de otra tarea o desde un manejador de eventos de UI, TaskScheduler.Current no es el planificador del thread pool. En un hilo de UI es el planificador de sincronización de la UI, así que tu trabajo “descargado” se ejecuta en el hilo de UI y congela la app. Anidado dentro de otro Task.Run, Current puede ser el planificador del pool, pero confiar en eso es frágil. Task.Run esquiva esto por completo al fijar TaskScheduler.Default. Si alguna vez ves un StartNew sin un argumento de planificador explícito, trátalo como un bug latente.

Fire-and-forget con QueueUserWorkItem no se traga nada; derriba. A diferencia de una Task cuya excepción no observada se captura y (en runtimes más antiguos) se lanza en el finalizador, una excepción que escapa de un callback de QueueUserWorkItem es una excepción no controlada en un hilo del thread pool y termina el proceso. Si usas esta API, el cuerpo del callback debe envolverse en su propio try / catch. No hay ninguna Task que cargue con el fallo.

La recomendación, reformulada

Usa por defecto Task.Run para esencialmente todo el trabajo en segundo plano y descargado. Devuelve una Task que se puede esperar, captura excepciones, siempre usa el thread pool y desempaqueta las lambdas asíncronas, que es exactamente lo que quieres el 95% del tiempo. Baja a ThreadPool.QueueUserWorkItem<TState> con una lambda static solo para fire-and-forget genuino en un camino crítico donde la asignación de Task es medible y has aceptado que el callback debe capturar sus propias excepciones. Usa Task.Factory.StartNew solo para TaskCreationOptions.LongRunning o un TaskScheduler personalizado, y cuando lo hagas, pasa siempre TaskScheduler.Default explícitamente para no heredar el planificador actual. La decisión correcta más corta: necesitas un handle, usa Task.Run; necesitas cero asignación y ningún handle, usa QueueUserWorkItem<TState>; necesitas un hilo dedicado o un planificador personalizado, usa StartNew con Default.

Relacionado

Enlaces de fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver