Buenas prácticas de multithreading en la práctica — Edición .NET: qué decidir antes de aumentar los hilos

· Actualizado el: · · Windows, Multithreading, C#, .NET, Aplicaciones empresariales, Investigación de fallos, Diseño

«El proceso era lento, así que lancé un hilo para paralelizarlo, y ahora el resultado del cálculo se descuadra de vez en cuando», «Añadí un procesamiento en segundo plano y ahora la aplicación se queda congelada una vez al mes», «Me dicen que en depuración no se reproduce, pero en el cliente ocurre sin duda» — lo que hace temible la programación multihilo es que, justo después de escribirla, parece funcionar correctamente. Los errores de concurrencia dependen del orden de ejecución (timing), se cuelan por las pruebas y solo asoman la cabeza en producción.

Por otro lado, ahora que el multinúcleo es la norma, en las aplicaciones empresariales hay escenarios en los que resulta inevitable recurrir al multihilo: requisitos como «ejecutar procesos pesados sin congelar la interfaz» o «procesar varios dispositivos o archivos en paralelo». Lo importante es fijar los principios de diseño antes de aumentar los hilos. Los errores de multihilo no se eliminan depurando: se eliminan quitándoles el espacio para aparecer desde el propio diseño.

Este artículo es la edición .NET de la serie práctica sobre multihilo. Está dirigido a desarrolladores que trabajan en aplicaciones empresariales para Windows y necesitan introducir multihilo, y organiza tanto los principios de diseño válidos con independencia del lenguaje o el sistema operativo como las herramientas concretas de C#/.NET, basándose en fuentes primarias vigentes en agosto de 2026. Los principios en sí no cambian ni en Linux ni en C++. Si escribe código nativo, consulte la «edición C++» o la «edición C», que trasladan los mismos principios a las herramientas de cada lenguaje; si escribe en Java, consulte la «edición Java».

1. La conclusión primero

  • No crear hilos por su cuenta es la primera buena práctica. En lugar de new Thread, apóyese en APIs de nivel superior como Task, el grupo de subprocesos (thread pool) o la clase Parallel, y deje que el runtime gestione el número de hilos.12
  • Lo primero que hay que eliminar al paralelizar es el “estado mutable compartido”. Los puntos donde varios hilos escriben en la misma variable son el origen de las condiciones de carrera; antes de protegerlos con bloqueos, reduzca el propio hecho de compartir mediante la división de datos, la inmutabilidad o el paso de datos entre hilos.3
  • Aplique disciplina a los bloqueos (locks). Decida una correspondencia uno a uno entre «qué datos protege cada lock» y use como objeto de bloqueo una instancia dedicada que no sea visible desde fuera. lock(this) y lock(typeof(X)) están prohibidos. A partir de .NET 9, use el tipo dedicado System.Threading.Lock.4
  • Encauce el paso de datos entre hilos a través de una cola. Una configuración productor/consumidor con System.Threading.Channels o colecciones concurrentes resulta más sencilla de diseñar que sembrar el código de locks, y además deja los límites de responsabilidad claros.56
  • Diseñe la forma de detener el proceso desde el principio. La única respuesta correcta para detener un hilo es la cancelación cooperativa mediante CancellationToken; Thread.Abort genera una excepción en tiempo de ejecución en .NET (la familia Core).78
  • La interfaz de usuario (UI) es propiedad exclusiva del hilo de UI. Ni los controles de WinForms ni los elementos de WPF deben tocarse desde un hilo distinto del que los creó. Desde otro hilo, delegue la operación a través de Control.Invoke / Dispatcher.910
  • Paralelizar no siempre significa más rápido. Los bucles cuyo trabajo por iteración es pequeño pueden volverse más lentos por la sobrecarga de la paralelización. Adopte el paralelismo solo después de medir.3

2. Por qué el multihilo es difícil — condiciones de carrera y interbloqueos

En el fondo, los problemas que introduce el multihilo se reducen a dos tipos.4

Una condición de carrera (race condition) es un error en el que el resultado cambia según el orden en que varios hilos llegan a un fragmento de código concreto. El ejemplo clásico es incrementar un contador compartido: la línea count++ en realidad se descompone en tres pasos: «leer → sumar → volver a escribir». Si dos hilos ejecutan estos tres pasos al mismo tiempo, la suma de uno queda sobrescrita por la escritura del otro y ese incremento se pierde. El resultado cambia en cada ejecución y no se puede predecir cuál será.4

Hilo BVariable compartida countHilo AHilo BVariable compartida countHilo Acount = 10Se sumó dos veces pero count = 11se perdió la suma del hilo ALectura (10)Lectura (10)Suma local (11)Suma local (11)Escritura (11)Escritura (11)

Figura 1: condición de carrera típica en la que se pierde un incremento sobre un contador compartido. Si otro hilo se entromete entre los tres pasos de count++, el que escribe después sobrescribe al otro.

Un interbloqueo (deadlock) es un estado en el que dos hilos esperan mutuamente el lock que tiene el otro, de modo que ninguno puede avanzar. Basta con que el hilo A tenga el lock 1 y espere el lock 2, y el hilo B tenga el lock 2 y espere el lock 1, para que ambos queden detenidos para siempre.4

esperando la liberación del lock 2esperando la liberación del lock 1Hilo Amantiene el lock 1Hilo Bmantiene el lock 2

Figura 2: espera circular de un interbloqueo. En el momento en que las flechas de espera forman un círculo, todos los hilos dentro de ese círculo quedan detenidos para siempre.

Lo complicado es que ambos dependen del timing. Es habitual que un entrelazado (interleaving, una combinación concreta del orden de ejecución) que en la máquina de desarrollo solo se produce una vez entre decenas de miles de ejecuciones, ocurra a diario en la máquina del cliente, con un número de núcleos y un timing distintos. Que «no se reproduzca con el depurador conectado» o que «desaparezca al añadir registros (logs)» se debe a que la propia observación altera el timing, y es un comportamiento típico de los errores de concurrencia.

Precisamente por eso, todos los principios que siguen apuntan en una misma dirección: antes que “sincronizar correctamente”, “reducir los lugares donde hace falta sincronizar” — esta es la columna vertebral del diseño multihilo.

3. Principio 1: no crear hilos por su cuenta

3.1. Apóyese en Task y en el grupo de subprocesos

Escribir código que crea hilos directamente con new Thread(...) es hoy, en .NET, un recurso excepcional de último recurso. Desde .NET Framework 4, el medio recomendado para código multihilo y paralelo es la TPL (Task Parallel Library), es decir, el conjunto de APIs centradas en Task. La TPL ajusta dinámicamente el grado de paralelismo según los procesadores disponibles y se encarga de todos los detalles de bajo nivel: dividir el trabajo, programarlo en el grupo de subprocesos, gestionar la cancelación y administrar el estado.1

El grupo de subprocesos (thread pool) es la base que el propio .NET utiliza ampliamente para ejecutar Task, completar operaciones de E/S asíncronas, ejecutar retrollamadas de temporizadores, etc.; mientras se le envíen trabajos cortos, el desarrollador no necesita gestionar el ciclo de vida de los hilos.2

// Trabajo pesado de CPU en segundo plano
var result = await Task.Run(() => HeavyCalculation(input));

// Lanzar varios procesos independientes en paralelo y esperar a todos (cuando son pocos)
// Nota: esta forma es para cuando ProcessAsync es un método asíncrono orientado a E/S.
//   WhenAll solo "espera Tasks que ya están en marcha", así que si quiere paralelizar
//   cálculo de CPU, envuelva cada proceso en Task.Run(() => Calc(x)) para llevarlo al thread pool
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));

// Si hay muchos elementos, limite el grado de concurrencia
await Parallel.ForEachAsync(items,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
    async (x, ct) => await ProcessAsync(x, ct));
// Dos puntos clave: conectar el token del llamador a ParallelOptions
// (si se olvida, el ct del cuerpo siempre es None) y pasar ese ct también al cuerpo (no descartarlo)

Hay un detalle a tener en cuenta. Task.WhenAll(items.Select(...)) inicia el procesamiento de todos los elementos a la vez en el momento de enumerarlos. Con un trabajo fijo de unas pocas a unas decenas de unidades no hay problema, pero si se usa sobre una colección con muchos elementos, agota de golpe los sockets, las conexiones a la base de datos y la memoria. Cuando no se puede prever el número de elementos, limite la concurrencia como en el Parallel.ForEachAsync anterior, o controle el caudal con un canal acotado (bounded channel), como se explica más adelante.

Crear un hilo propio solo se justifica en casos casi exclusivamente limitados a requisitos que dependen de la naturaleza del propio hilo, como «tener un bucle de mensajes dedicado», «necesitar especificar el apartamento del hilo (STA)» o «tener que seguir en ejecución durante toda la vida de la aplicación».

3.2. El paralelismo de datos se hace con Parallel.For / ForEach

Para el paralelismo de datos —«aplicar el mismo procesamiento a cada elemento de una colección para acelerar el conjunto»—, use Parallel.For / Parallel.ForEach en lugar de repartir usted mismo el bucle entre hilos. La TPL se encarga de dividir (particionar) la fuente de datos y de redistribuir la carga, y en un bucle básico ni siquiera hace falta un lock.11

Sin embargo, la documentación oficial señala explícitamente dos trampas.3

  • No dé por hecho que paralelo siempre es más rápido. Un bucle con pocas iteraciones o cuyo procesamiento por iteración es ligero puede volverse más lento porque la sobrecarga de la paralelización supera al propio trabajo. El rendimiento depende de muchos factores, así que decida siempre tras medir.
  • No haga que las iteraciones se esperen entre sí. No hay garantía de que cada iteración de Parallel.For se ejecute realmente en paralelo. Un código en el que una iteración espera un evento que establece otra iteración puede acabar en interbloqueo según cómo se planifique.

3.3. Los procesos de “espera” van con E/S asíncrona, no con hilos

Los procesos dominados por la espera de E/S —archivos, red, bases de datos— no son candidatos a resolverse aumentando hilos. Ocupar un hilo entero mientras se espera es puro desperdicio; con E/S asíncrona mediante async/await no se consume ningún hilo durante la espera. Esta distinción —paralelizar lo que depende de CPU, hacer asíncrono lo que depende de E/S— es la primera línea que hay que trazar en la puerta de entrada al diseño multihilo.

Dominado por la espera de E/Sarchivo, red, base de datosCálculo que usa CPUAplicar el mismo procesoa todos los elementos de una colecciónUn bloque de procesoindependiente en segundo planoRequisitos propios del hilo, comobucle de mensajes o STAHay un proceso que se quiere paralelizar¿Cuál es la naturaleza del proceso?E/S asíncrona con async/awaitno se aumentan hilos¿Cuál es la forma del trabajo?Parallel.For / ForEachTask.Run / Task.WhenAllnew Thread(recurso excepcional de último recurso)

Figura 3: la bifurcación antes de “lanzar un hilo”. La mayoría de los procesos empresariales terminan en una de las tres primeras salidas, y solo en casos excepcionales se llega a new Thread.

Las decisiones prácticas sobre async/await se tratan en la «tabla de decisión práctica de C# async/await», y cómo se conectan por debajo el grupo de subprocesos y la E/S asíncrona se detalla en «IOCP y el grupo de subprocesos de .NET».

4. Principio 2: minimizar el estado mutable compartido

Las condiciones de carrera solo ocurren cuando coinciden “varios hilos” y “datos mutables compartidos”. El número de hilos lo determinan los requisitos, así que lo que se puede reducir con el diseño es lo compartido. Hay tres formas de hacerlo.

4.1. Dividir — que cada hilo toque solo sus propios datos

La forma más simple y potente es separar los datos por hilo. En una agregación dentro de un bucle paralelo, en lugar de escribir cada vez en una variable de total compartida, use la sobrecarga de Parallel.For que recibe un estado local por hilo: cada hilo genera su propio subtotal y solo se combinan una vez al final. Las escrituras sobre lo compartido pasan de “cada iteración” a “una vez por hilo”, y tanto el coste de la sincronización como la ventana de posible condición de carrera se reducen en un orden de magnitud.3

long total = 0;
Parallel.For(0, items.Length,
    () => 0L,                                  // Valor inicial local del hilo
    (i, state, local) => local + Weigh(items[i]), // Cada iteración suma solo a su propio local
    local => Interlocked.Add(ref total, local));  // La combinación ocurre una vez por hilo
Matriz de datos (a procesar)Hilo 1procesa su parte ysuma solo a su propio subtotalHilo 2procesa su parte ysuma solo a su propio subtotalHilo 3procesa su parte ysuma solo a su propio subtotalCombinación: con Interlocked.Addse refleja en el total una vez por hilo

Figura 4: agregación local por hilo. Durante el procesamiento, cada hilo solo toca sus propios datos, así que no hay margen para condiciones de carrera, y la escritura sobre lo compartido se reduce a una vez por hilo en el momento de combinar.

4.2. Hacer inmutable — lo que no se modifica se puede compartir

Los datos que solo se leen son seguros de leer simultáneamente desde cualquier número de hilos. Los valores de configuración, los datos maestros o las entradas de un cálculo se pueden compartir libremente sin sincronización si no se modifican tras construirse (si se hacen inmutables). En C#, los tipos record y las propiedades init favorecen este diseño. Basta con decidir que «si hace falta un cambio, se crea una nueva instancia y se sustituye, en lugar de modificar la existente» para eliminar un estado mutable más que proteger.

Sin embargo, «parecer de solo lectura» y «ser inmutable» son cosas distintas. Una interfaz de solo lectura como IReadOnlyList<T> únicamente significa que «no se puede modificar a través de esa interfaz»; no impide que el List<T> subyacente se modifique desde otra referencia. La garantía de record / init también es superficial: no protege los objetos a los que apuntan las propiedades. Para datos que realmente quiera compartir de forma segura entre hilos, use colecciones inmutables de System.Collections.Immutable, como ImmutableArray<T>, o entregue una copia en el momento de compartir, cortando así la propia vía de modificación. Aquí la condición es que el tipo de elemento T también sea inmutable. Una colección inmutable solo protege “el orden”: las referencias a objetos elemento mutables se comparten tal cual, así que si el contenido de un elemento se puede modificar por otra vía, la condición de carrera persiste. Haga inmutable hasta el final del grafo de objetos, o entregue una copia profunda.

4.3. Entregar — enviar por una cola en lugar de compartir

Aun así, es necesario mover datos entre hilos. En ese caso, en lugar de «que ambos lados toquen una variable compartida», use una configuración productor/consumidor en la que coloca una cola en medio: un lado escribe y el otro lee.

La primera opción en .NET es System.Threading.Channels. Es una cola FIFO en la que el productor escribe datos de forma asíncrona y el consumidor los lee de forma asíncrona; todos los detalles de sincronización los gestiona el canal.5

var channel = Channel.CreateBounded<WorkItem>(100); // Capacidad 100, aplica contrapresión

// Lado productor
await channel.Writer.WriteAsync(item, ct); // Si está lleno, espera a que haya espacio
// ...cuando todos los productores terminen de escribir:
channel.Writer.Complete();   // Declara "no llegará nada más". Sin esto el bucle del lector nunca termina

// Lado consumidor
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
    Process(item);
}
si está lleno, hace esperar la escritura(contrapresión)si está vacío, hace esperar la lecturaProductor 1WriteAsyncCanal acotado (capacidad 100)cola FIFOla sincronización la gestiona el canalProductor 2WriteAsyncConsumidor 1ReadAllAsyncConsumidor 2ReadAllAsync

Figura 5: configuración productor/consumidor con un canal de por medio. Ninguno de los dos lados toca directamente una variable compartida; tanto la espera mutua como el control de capacidad quedan a cargo del canal.

En la práctica, lo importante es elegir un canal con capacidad limitada (bounded). El comportamiento por defecto al alcanzar el límite es que «el lado de escritura espera a que haya espacio», lo cual constituye una contrapresión (backpressure) natural. Si en una configuración donde la producción es más rápida que el consumo se usa una cola ilimitada, se obtiene una bomba de relojería que sigue funcionando mientras la memoria crece sin parar.5

En el mundo síncrono, quien cumple el mismo papel que un canal acotado es BlockingCollection<T> con una capacidad especificada. Incorpora bloqueo y control de capacidad: el límite de capacidad evita que el productor se adelante demasiado al consumidor, y cuando está vacía bloquea y hace esperar al consumidor.12 Por otro lado, ConcurrentQueue<T> / ConcurrentStack<T> son colecciones rápidas que logran ser thread-safe sin usar locks, solo con operaciones Interlocked6, pero son colas thread-safe puras, sin límite de capacidad ni el mecanismo de «esperar cuando está vacía». Considérelas piezas, no la protagonista de la entrega de trabajo. Además, BlockingCollection<T> no está diseñada pensando en acceso asíncrono, así que si se combina con async/await, elija Channel<T>.12

Tenga cuidado también con la suposición de que «cambiar un diccionario a ConcurrentDictionary ya lo hace thread-safe». Aunque cada operación individual sea thread-safe, una operación compuesta, como «comprobar si existe y luego añadir», sigue teniendo condiciones de carrera (para operaciones compuestas use métodos como GetOrAdd). Y ese mismo GetOrAdd tiene una salvedad: aunque el valor almacenado queda determinado de forma única, la función factoría que crea el valor puede llamarse varias veces si hay concurrencia. Si la factoría tiene efectos secundarios (abrir una conexión, crear un archivo, etc.), la ejecución duplicada provoca fugas, así que use una función sin efectos secundarios, o bien, si necesita garantizar una única inicialización, guarde un Lazy<T> como valor. Cambiar el tipo de colección no sustituye a reducir el estado mutable compartido.

5. Principio 3: aplicar disciplina a los bloqueos

Aunque se reduzca el estado mutable compartido, en muchos casos no se puede llegar a cero. Para lo que queda compartido se usa control de exclusión mutua (locks), pero un lock no es una herramienta para «envolver con lock, por si acaso, cualquier sitio sospechoso». Hay cuatro reglas de disciplina.

5.1. Decidir “qué se protege” y bloquear con un objeto dedicado

La unidad de un lock hay que pensarla en términos de «datos», no de «un tramo de código». Asigne un objeto de bloqueo a cada conjunto de datos mutables que quiera proteger y tome ese mismo lock en todos los lugares que tocan esos datos — la realidad de los errores de concurrencia es que esta correspondencia se rompe en algún punto.

El objeto de bloqueo debe ser una instancia dedicada que no se exponga hacia fuera. lock(this) comparte el lock con cualquier código externo que pueda referenciar la propia instancia, y lock(typeof(X)) lo comparte con todo el dominio de la aplicación; ambos son caldo de cultivo para interbloqueos. A partir de .NET 9 / C# 13, se recomienda usar como objeto de bloqueo una instancia del tipo dedicado System.Threading.Lock.4

public class OrderBook
{
    private readonly Lock _gate = new();          // .NET 9+ (antes de eso, readonly object)
    private readonly List<Order> _orders = [];    // Datos que protege _gate

    public void Add(Order order)
    {
        lock (_gate) { _orders.Add(order); }
    }
}

La instrucción lock de C# garantiza que el lock se libera aunque se produzca una excepción. La forma en que se expande depende del tipo del objeto de bloqueo: con un objeto normal se traduce en una llamada a Monitor.Exit dentro de un finally, y con el tipo Lock se traduce en EnterScope() y su liberación (dispose).413 Es decir, un campo de tipo Lock es un mecanismo distinto de Monitor, y si solo una parte del código escribe a mano Monitor.Enter(_gate), la exclusión mutua con lock (_gate) deja de cumplirse. Con cualquiera de los dos tipos, es más seguro dejar de escribir a mano Monitor.Enter / Exit y unificarlo siempre con la sintaxis lock.4

5.2. No hacer, dentro del lock, “cosas que tardan” ni “cosas externas”

Cuanto más corto sea el tiempo que se mantiene un lock, mejor; dentro del lock solo debería hacerse la lectura y escritura de los datos que protege. Realizar E/S mientras se mantiene el lock, o llamar a código externo mediante eventos o retrollamadas, no solo alarga el tiempo de posesión, sino que abre una vía por la que el código llamado intenta tomar otro lock y provoca un interbloqueo. La forma básica es preparar todo fuera del lock y, dentro de él, limitarse a sustituir.

Tenga en cuenta que no se puede hacer await dentro de un lock (produce un error de compilación). No es una limitación arbitraria, sino una protección: Monitor tiene afinidad de hilo, es decir, debe liberarlo el mismo hilo que lo tomó, y eso es incompatible con código asíncrono en el que el hilo puede cambiar antes y después de un await. Para la exclusión mutua en código asíncrono, use SemaphoreSlim con un contador inicial de 1.14

private readonly SemaphoreSlim _asyncGate = new(1, 1);

public async Task SaveAsync(Data data, CancellationToken ct)
{
    await _asyncGate.WaitAsync(ct);
    try   { await WriteToFileAsync(data, ct); }
    finally { _asyncGate.Release(); }
}

5.3. Con varios locks, siempre en el mismo orden

Cuando hay dos o más locks, el patrón clásico de interbloqueo es que el orden de adquisición se invierte según el hilo. La solución es simple: convierta en regla que todos los hilos tomen los locks en el mismo orden. En los puntos donde no se pueda garantizar el orden, use la sobrecarga de Monitor.TryEnter con tiempo de espera (timeout); si no se consigue el lock, suéltelo y vuelva a intentarlo (o registre la anomalía), de modo que un bloqueo eterno se convierta en un fallo detectable.4

5.4. Interlocked para actualizaciones simples, ReaderWriterLockSlim cuando predominan las lecturas

Para una actualización atómica de una sola variable, como incrementar un contador o intercambiar un indicador, la clase Interlocked (Increment / Add / CompareExchange) es más rápida que lock. Sin contención, basta con un único prefijo de instrucción de CPU.4 Por el contrario, hasta ahí llega lo que puede hacer Interlocked: no sirve para mantener coherentes varias variables a la vez. Las estructuras lock-free propias combinadas con volatile son una herramienta avanzada que exige un conocimiento profundo del modelo de memoria, y no algo que deba escribirse en una aplicación empresarial.

Para datos compartidos en los que «se lee con frecuencia pero se escribe rara vez», también existe la opción de ReaderWriterLockSlim, que solo excluye la escritura y permite lecturas simultáneas.13 </content>

6. Principio 4: diseñar primero la forma de detenerse

La primera pregunta que hay que hacer en una revisión de diseño multihilo es «¿cómo se detiene esto?». Es posible escribir código que se ponga en marcha sin más, pero código que se detenga con seguridad solo surge si se diseña.

6.1. La cancelación cooperativa (CancellationToken) es la única respuesta correcta

El modelo de detención de .NET está unificado en la cancelación cooperativa. Quien detiene crea un CancellationTokenSource y pasa su Token a cada proceso. Cuando quiere detenerlo, llama a Cancel(). El proceso vigila el token y termina limpiando lo que corresponda en el punto que le resulte adecuado — como no es forzoso sino cooperativo, el proceso puede terminar manteniendo un estado coherente.7

private CancellationTokenSource? _cts;
private Task? _worker;

public void Start()
{
    if (_worker is { IsCompleted: false })    // Rechaza un doble Start mientras ya está en marcha
        throw new InvalidOperationException("El worker ya está en ejecución.");
    if (_worker is { IsFaulted: true })       // No recrear sin más, ocultando el fallo anterior
        throw new InvalidOperationException("El worker anterior ha fallado.", _worker.Exception);
    _cts = new CancellationTokenSource();
    var token = _cts.Token;   // Se captura primero en una variable local para no chocar con un reStart tras detenerse
    _worker = Task.Run(() => WorkLoop(token), token);
}

private void WorkLoop(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)   // Vigilancia por sondeo (polling)
    {
        ProcessNextItem(ct);              // Pasa ct a las llamadas bloqueantes para interrumpir de inmediato
    }
}

public async Task StopAsync()
{
    var cts = _cts;          // Aunque el campo se reemplace mientras se espera,
    var worker = _worker;    // se fija en variables locales para no confundir el objetivo a detener
    if (cts is null || worker is null) return;

    Exception? cancelFailure = null;
    try { cts.Cancel(); }    // Una retrollamada registrada en el token puede lanzar una excepción
    catch (Exception ex) { cancelFailure = ex; }   // Se captura, se reporta tras completar la unión (join)

    try
    {
        try { await worker; }    // La unión siempre se completa, sea cual sea el resultado de Cancel, y también se observa un fallo intermedio
        catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
        { }                      // Solo se trata como "normal" la cancelación que hemos solicitado nosotros
        catch (Exception ex) when (cancelFailure is not null)
        {
            throw new AggregateException(cancelFailure, ex);  // No se pierde ninguno de los dos fallos
        }
    }
    finally
    {
        cts.Dispose();           // Se libera la fuente ya unida (libera recursos del SO como el WaitHandle).
        if (ReferenceEquals(_cts, cts))
        {
            _cts = null;         // No dejar que un StopAsync posterior use una fuente ya liberada
            _worker = null;
        }
    }
    if (cancelFailure is not null)
        throw new AggregateException(cancelFailure);
}

Tenga en cuenta que este Start / StopAsync es una construcción mínima que asume que se llama en secuencia desde un mismo hilo (por ejemplo, el hilo de UI). Si varios hilos pueden operar el ciclo de vida al mismo tiempo, serialice el propio Start / StopAsync con algo como SemaphoreSlim — sería absurdo proteger al worker si la propia operación de gestión del worker queda expuesta a condiciones de carrera.

Incluso en este ejemplo pequeño se han incorporado detalles que dan resultado en la práctica. En primer lugar, Start rechaza una segunda llamada mientras ya está en ejecución. Sobrescribir _cts y _worker sin condiciones haría que se perdiera la referencia al worker anterior, dejando en paralelo un “hilo huérfano” que ni se puede detener ni unir (join). Lo básico es que la propia API del ciclo de vida (Start/Stop) se encargue de garantizar “solo uno a la vez”. Además, tres puntos más. Primero, la API de detención espera a que termine. Cancel() solo “solicita” la cancelación; en el instante en que retorna, el worker aún podría estar a mitad de ProcessNextItem. Si se hiciera un Stop() que solo solicita y retorna, se crearía una nueva condición de carrera: el llamador empieza a limpiar mientras el worker todavía está en marcha. Segundo, conserve el Task, no lo descarte. Si lo lanza con _ = Task.Run(...) y lo tira, nadie se entera si el worker muere por una excepción. Tercero, en lugar de referenciar _cts.Token dentro de la lambda, captúrelo en una variable local antes de pasarlo. Si se referencia dentro de la lambda, se evalúa en tiempo de ejecución, y si se reinicia justo después de detenerse, se produce una confusión en la que el worker antiguo agarra el token nuevo. Además, si se pasa ese mismo token también como segundo argumento de Task.Run, cuando el proceso termina con ThrowIfCancellationRequested o con el OperationCanceledException de una API compatible con cancelación, el Task se clasifica como “cancelado (Canceled)” en lugar de “fallido (Faulted)” (en este ejemplo, si sale con normalidad por la condición del bucle, se considera una finalización normal). Otro detalle: el catch de StopAsync usa un filtro when para capturar solo la cancelación que procede de su propio token. Si se atrapara OperationCanceledException sin condiciones, incluso un fallo real lanzado por otro token interno del proceso (por ejemplo, un timeout por elemento) parecería “normal, porque se detuvo”. Tenga en cuenta que esta identificación por coincidencia de token se rompe si dentro de WorkLoop se usa un token enlazado (la composición por enlace de la sección 6.1), porque llega una excepción que porta el token del lado enlazado. En esa configuración, elija explícitamente, como decisión de diseño, una de dos opciones: en la salida de WorkLoop, llamar a ct.ThrowIfCancellationRequested() para “traducir” al token externo antes de salir, o relajar el filtro a when (cts.IsCancellationRequested) y asumir que “una cancelación durante una solicitud de detención es normal”.

llama a Cancel() una vezpasa el Tokenpasa el Tokenpasa el Tokencomprueba IsCancellationRequestedlimpia y termina por sí mismoThrowIfCancellationRequestedse interrumpe al instante aun en esperaQuien detieneCancellationTokenSourceProceso worker 1Proceso worker 2API de bibliotecacompatible con cancelaciónFinalización normalOperationCanceledException= se trata como cancelación completadaCancelación completada

Figura 6: esquema de la cancelación cooperativa. Quien detiene solo llama a Cancel(); “cuándo y cómo terminar” lo decide cada proceso por sí mismo. Por eso se puede detener manteniendo un estado coherente.

También hay convenciones establecidas para el lado de la biblioteca. Las operaciones cancelables deben ofrecer un método público que reciba un CancellationToken, y en los bucles de cálculo hay que comprobar periódicamente IsCancellationRequested o llamar a ThrowIfCancellationRequested(). Esta última lanza OperationCanceledException, y Task la trata como “cancelación completada”, no como “fallo”. Si se quiere poder detener tanto por un token pasado desde fuera como por una circunstancia interna (por ejemplo, un timeout), se combinan mediante un token enlazado (linked token).7

6.2. Considere que Thread.Abort no existe

Thread.Abort, pensado para “matar desde fuera un hilo que no obedece”, en .NET Core / .NET 5 en adelante se limita a lanzar PlatformNotSupportedException y ya no se puede usar. Inyectar una excepción en un hilo sin saber en qué punto se encuentra su ejecución provoca interrupciones en la liberación de recursos y corrupción de estado. Si es necesario forzar la terminación de código de terceros que no responde a la cancelación cooperativa (o que no se puede reescribir para que responda), la directriz oficial es ejecutarlo en un proceso aparte y detenerlo con Process.Kill.8

6.3. Para esperar, use un handle de espera, no sondeo (polling)

Escribir un bucle que «espera con Sleep(100) hasta que se active un indicador» desperdicia tanto CPU como capacidad de respuesta. Para las señales entre hilos existen primitivas de sincronización como ManualResetEventSlim o SemaphoreSlim, que permiten dejar el hilo correctamente en reposo hasta que llega el estado de señal.13 La elección entre precisión de temporizador y espera de eventos en Windows se trata en detalle en «Por qué en Windows conviene preferir la espera de eventos a Sleep(1)».

7. La particularidad del hilo de UI — la regla de las aplicaciones de escritorio en Windows

Las aplicaciones de escritorio de Windows tienen, además de los principios generales, otra restricción muy fuerte: la regla de que la UI solo puede tocarla el hilo que la creó (el hilo de UI).

Los controles de WinForms no son thread-safe, y operarlos desde varios hilos los lleva a un estado incoherente, causando condiciones de carrera, interbloqueos y congelamientos. Windows exige que la aplicación disponga de un único hilo dedicado a recibir los mensajes del sistema, y la creación y manipulación de la UI debe concentrarse en ese hilo.9 WPF tiene exactamente la misma estructura: solo el hilo de UI puede modificar los elementos de la interfaz.10

Cuando se quiere actualizar la UI desde otro hilo, en lugar de tocarla directamente, hay que convertirlo en una “solicitud al hilo de UI”.

solicita vía Control.Invoke /Dispatcher.InvokeAsynctocar el control directamenteWindowsratón, teclado, repintadoCola de mensajesdel hilo de UIHilo en segundo plano(proceso pesado, comunicación)Hilo de UIel único que puede tocar los controlesProhibidocausa condiciones de carrera, interbloqueos y congelamientos

Figura 7: la actualización de la UI se convierte en una “solicitud”. El trabajo del hilo en segundo plano llega solo hasta conseguir que su petición entre en la cola de mensajes; quien toca los controles siempre es el propio hilo de UI.

Framework Medio para solicitar
WinForms Control.Invoke (síncrono) / Control.BeginInvoke (asíncrono) / a partir de .NET 9, Control.InvokeAsync9
WPF Dispatcher.Invoke (síncrono) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (asíncrono)10

De estas, hay que tener cuidado con las formas síncronas (Control.Invoke / Dispatcher.Invoke). Si el hilo de UI está esperando de forma síncrona a que termine ese worker y el worker llama a Invoke, se produce un interbloqueo en el que ambos se esperan mutuamente (exactamente la espera circular del capítulo 2). Para las notificaciones y los informes de progreso desde segundo plano, use por defecto la forma asíncrona (BeginInvoke / InvokeAsync), y reserve la forma síncrona solo para los casos en los que pueda garantizar que el hilo de UI no está esperándolo.

En la práctica hay una respuesta todavía mejor. Si escribe con async/await un proceso iniciado en el hilo de UI, el await captura el SynchronizationContext del hilo de UI y reanuda automáticamente el proceso siguiente en ese mismo hilo, con lo que se reducen mucho las situaciones en las que hay que escribir Invoke a mano. Ahora bien, esto no es una propiedad incondicional. El código que entra desde una retrollamada en segundo plano, o la continuación después de un ConfigureAwait(false), no vuelve al hilo de UI, así que si por esa vía se toca la UI, sigue haciendo falta un despacho explícito. La forma básica de una aplicación de Windows moderna es organizarlo así: «el proceso pesado va a Task.Run o a E/S asíncrona, y el reflejo del resultado en pantalla va en la continuación del await». La relación entre el hilo de UI y async/await se resume en un solo diagrama en «Async de WPF/WinForms y el hilo de UI, resumidos en una sola hoja».

Además, cuando interviene COM —por integración con Office o componentes heredados—, se añade otra capa: el modelo de hilos propio de COM (STA/MTA). El accidente típico de «creé el objeto COM en el hilo de UI, pero lo llamé desde otro hilo y se quedó congelado» pertenece a esta capa, y se explica en «Fundamentos de COM STA/MTA».

8. Si se escribe en código nativo (C++/C)

Los principios vistos hasta aquí —no crear hilos directamente, reducir el estado mutable compartido, disciplina de locks, diseñar la forma de detenerse— se aplican tal cual también en código nativo. Lo que cambia es el juego de herramientas. En C++, los equivalentes son RAII junto con std::jthread / std::mutex / std::atomic; en C, la API de Win32 con _beginthreadex, los locks SRW, las variables de condición y el patrón de evento de parada. Cada uno se trata, incluyendo las trampas específicas del lenguaje (el destructor de std::thread, el peligro de TerminateThread, DllMain y el loader lock, etc.), en la «edición C++» y la «edición C» de esta serie.

9. Verificación y depuración — prepararse asumiendo que “no se reproducirá”

No se puede confiar en que las pruebas encuentren errores de multihilo. Una prueba unitaria normal cuenta como éxito una ejecución en la que “por casualidad no hubo condición de carrera”. La preparación conviene pensarla en tres capas.

La primera línea de defensa son los propios principios de diseño vistos hasta aquí. Entre una aplicación con 5 elementos de estado mutable compartido y otra con 50, el número de lugares sospechosos difiere en un factor de diez. En la revisión, verifique con una tabla «cuáles son los datos mutables compartidos», «qué lock protege a cada uno», «si el orden de adquisición de los locks es único» y «cuál es la ruta de detención». Un diseño que no pueda plasmarse en esa tabla no está terminado, aunque funcione.

En segundo lugar, no oculte las anomalías: hágalas observables. Detecte con el timeout de Monitor.TryEnter las esperas anómalas de un lock y déjelas en el log4, registre en lugar de tragarse las excepciones no observadas de un proceso enviado al grupo de subprocesos, y prepárese, en caso de congelamiento, para tomar un volcado completo (dump) que permita revisar las pilas de todos los hilos — la lucha contra los errores que “ocurren solo de vez en cuando” se decide por cuánta información se puede extraer de la única vez que ocurrieron. La organización de los dumps y los logs se trata en «Diseño para conservar logs y dumps al fallar una aplicación de Windows».

En tercer lugar, sacúdalo con carga. Ejecutarlo mucho tiempo con un grado de paralelismo superior al número de núcleos, aleatorizar el orden de procesamiento, insertar retardos artificiales… este tipo de pruebas de estrés, pensadas para que en la máquina de desarrollo sea más fácil “acertar” con un entrelazado problemático, es un medio realista para sacar a la luz las condiciones de carrera antes del lanzamiento. Un error que desaparece en ejecución con depurador a menudo se reproduce con una compilación de release bajo carga alta.

10. Resumen — lista de comprobación antes de aumentar los hilos

En el fondo, las buenas prácticas de la programación multihilo no son “la técnica de escribir bien la sincronización”, sino “un diseño que permita prescindir de escribirla”. Si antes de empezar puede responder a las ocho preguntas siguientes, prácticamente evitará los grandes accidentes.

  1. ¿El proceso está limitado por CPU o por E/S? (si es lo segundo, la respuesta es async/await, no hilos)
  2. ¿Está a punto de escribir new Thread? (¿no se puede expresar con Task, Parallel o el grupo de subprocesos?)
  3. ¿Cuáles son los datos mutables compartidos entre hilos? ¿Puede enumerarlos?
  4. ¿No se puede eliminar ese compartir mediante “división”, “inmutabilidad” o “paso por cola”?
  5. ¿Cada dato compartido restante tiene asignado un único lock correspondiente?
  6. ¿El orden de adquisición de los locks es único en todos los hilos? ¿No se hacen llamadas externas dentro de un lock?
  7. ¿CancellationToken llega a todos los procesos de larga duración? ¿Puede explicar la ruta de detención?
  8. ¿El código que toca la UI está concentrado en el hilo de UI?

Los errores de multihilo no aparecen el día en que se escriben, sino que muestran los dientes en casa del cliente cuando ya los ha olvidado. Dicho de otro modo: si repasa esta lista de comprobación en la etapa de diseño, puede eliminar antes de escribir una sola línea de código el tipo de incidente más costoso — “a veces se cae”, “se congela una vez al mes”.

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC se ocupa de revisiones de diseño de aplicaciones empresariales que incluyen la introducción de multihilo, la investigación de la causa raíz de fallos poco reproducibles como “a veces se cae o se congela” (análisis de dumps, identificación de puntos de condición de carrera) y la consultoría técnica sobre paralelización y asincronía de aplicaciones ya existentes. Puede empezar incluso en una etapa tan temprana como «quiero que revisen si con este diseño no habrá condiciones de carrera».

Referencias

  1. Microsoft Learn, Task Parallel Library (TPL). Sobre que la TPL es el medio recomendado para código multihilo y paralelo desde .NET Framework 4; sobre que ajusta dinámicamente el grado de paralelismo según los procesadores disponibles; sobre que se encarga de dividir el trabajo, programarlo en el grupo de subprocesos, gestionar la cancelación y administrar el estado; sobre que un bucle con poco trabajo por iteración puede volverse más lento por la sobrecarga de la paralelización; y sobre que, aun usando la TPL, se recomienda una comprensión básica de locks, interbloqueos y condiciones de carrera.  2

  2. Microsoft Learn, The managed thread pool. Sobre que la clase ThreadPool proporciona un conjunto de hilos de trabajo gestionado por el sistema, permitiendo al desarrollador centrarse en las tareas de la aplicación en lugar de en la gestión de hilos; y sobre que .NET usa ampliamente el grupo de subprocesos para operaciones de la TPL, finalización de E/S asíncrona, retrollamadas de temporizador, esperas registradas y conexiones de sockets, entre otros.  2

  3. Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. Sobre que un bucle paralelo puede llegar a ser más lento que uno secuencial, por lo que siempre hay que medir; sobre que hay que evitar escribir en memoria compartida dentro de un bucle paralelo, y que se recomienda usar la sobrecarga que emplea estado local por hilo; y sobre que no hay garantía de que cada iteración de For/ForEach se ejecute en paralelo, por lo que un código que espera entre iteraciones puede acabar en interbloqueo.  2 3 4

  4. Microsoft Learn, Managed threading best practices. Sobre la definición de las condiciones de carrera (el ejemplo del incremento de un contador que se descompone en lectura, suma y reescritura, y se pierde por sobrescritura) y de los interbloqueos; sobre que hay que usar cancelación cooperativa en lugar de Thread.Abort; sobre que no se debe usar como objeto de bloqueo ni un tipo ni this, y que a partir de .NET 9/C# 13 se debe usar una instancia dedicada de System.Threading.Lock; sobre que la instrucción lock de C# garantiza Monitor.Exit en un finally; sobre la detección de interbloqueos mediante el timeout de Monitor.TryEnter; sobre que la clase Interlocked es rápida para cambios de estado simples; y sobre la directriz de diseño de que los datos estáticos son thread-safe por defecto y los datos de instancia no lo son por defecto.  2 3 4 5 6 7 8 9 10

  5. Microsoft Learn, System.Threading.Channels library. Sobre que los canales son una cola FIFO del modelo productor/consumidor que gestiona internamente la sincronización; sobre que CreateBounded permite crear un canal con un límite de capacidad; sobre que el comportamiento por defecto al alcanzar el límite es que el lado de escritura espera (Wait), aunque también se puede elegir otro FullMode como DropOldest; y sobre que se genera contrapresión cuando la escritura es más rápida que la lectura.  2 3

  6. Microsoft Learn, Thread-safe collections. Sobre que las colecciones de System.Collections.Concurrent logran ser thread-safe mediante locks de grano fino o mecanismos lock-free; y sobre que ConcurrentQueue y ConcurrentStack están implementadas sin locks, con operaciones Interlocked, y resisten adiciones y eliminaciones frecuentes desde varios hilos.  2

  7. Microsoft Learn, Cancellation in Managed Threads. Sobre el procedimiento del modelo de cancelación cooperativa con CancellationTokenSource y CancellationToken; sobre que la cancelación no es forzosa sino cooperativa, y que la forma de detenerse la decide el lado que escucha; sobre los tres medios de vigilancia (sondeo, registro de retrollamada y handle de espera); sobre que ThrowIfCancellationRequested lanza OperationCanceledException y que Task lo trata como cancelación completada; sobre la composición de varios tokens mediante un token enlazado; y sobre que las bibliotecas deberían ofrecer métodos públicos que reciban un CancellationToken.  2 3

  8. Microsoft Learn, Using threads and threading. Sobre que para detener un hilo hay que usar CancellationToken; sobre que en .NET Core y en .NET 5 en adelante Thread.Abort se limita a lanzar PlatformNotSupportedException, y que desde .NET 5 también genera una advertencia de obsolescencia en tiempo de compilación (SYSLIB0006); y sobre que para forzar la terminación de código de terceros que no responde a la cancelación cooperativa, la directriz es ejecutarlo en un proceso aparte y usar Process.Kill.  2

  9. Microsoft Learn, How to handle cross-thread operations with controls. Sobre que el acceso a los controles de WinForms no es thread-safe, y que operarlos desde varios hilos provoca estados incoherentes, condiciones de carrera, interbloqueos y congelamientos; sobre que todos los controles deben crearse y accederse desde el mismo hilo, y que Windows exige un hilo de UI dedicado que reciba los mensajes del sistema; y sobre que desde otro hilo hay que llamar de forma segura mediante Control.Invoke, Control.InvokeAsync (desde .NET 9) o BackgroundWorker.  2 3

  10. Microsoft Learn, Threading model (WPF). Sobre que en WPF solo un hilo puede modificar la UI, y que un hilo en segundo plano debe registrar el trabajo en el Dispatcher del hilo de UI y solicitarlo; sobre que Dispatcher.Invoke es síncrono, mientras que InvokeAsync y BeginInvoke son asíncronos; y sobre que el Dispatcher procesa el trabajo mediante una cola con prioridades.  2 3

  11. Microsoft Learn, Data Parallelism (Task Parallel Library). Sobre que Parallel.For / Parallel.ForEach ofrecen paralelismo de datos con una sintaxis casi idéntica a un bucle for; sobre que no hace falta crear hilos ni encolar elementos de trabajo, y que en un bucle básico tampoco hace falta un lock; y sobre que la TPL divide la fuente de datos y la procesa en varios hilos, redistribuyendo la carga si se desequilibra. 

  12. Microsoft Learn, BlockingCollection<T> Class. Sobre que BlockingCollection es una implementación productor/consumidor con bloqueo y límite de capacidad; sobre que el límite de capacidad evita que el lado productor se adelante demasiado al consumidor; y sobre que no está diseñada pensando en acceso asíncrono, por lo que para un productor/consumidor asíncrono se recomienda considerar Channel<T>.  2

  13. Microsoft Learn, Overview of synchronization primitives. Sobre que Monitor ofrece exclusión mutua a través de un objeto de bloqueo y tiene afinidad de hilo; sobre que en C# se debe usar la instrucción lock en lugar de Monitor directamente; sobre que ReaderWriterLockSlim excluye la escritura pero permite acceso simultáneo en lectura; y sobre que SemaphoreSlim es un semáforo ligero exclusivo de un proceso, mientras que Semaphore, con nombre, puede usarse para sincronización entre procesos.  2 3

  14. Microsoft Learn, Async semaphores, locks, and reader/writer coordination. Sobre que la instrucción lock de C# y el tipo Lock tienen afinidad de hilo, por lo que no se pueden usar a través de un await (porque el hilo que ejecuta la continuación puede cambiar antes y después del await); sobre que para la exclusión mutua en código asíncrono se debe usar un SemaphoreSlim con contador 1, mediante WaitAsync y Release en un try/finally; y sobre que, para fines de limitación de caudal (throttling), un Channel acotado puede ser una alternativa. 

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Por qué se debe evitar lock(this) o lock(typeof(MyClass))?
Porque el objeto sobre el que se bloquea queda visible también desde fuera de su propio código. this es la propia instancia, así que cualquier código externo que pueda referenciar esa instancia puede bloquear el mismo objeto, lo que provoca condiciones de carrera o interbloqueos no deseados. typeof(MyClass) es todavía más peligroso, porque el objeto Type es único dentro del dominio de la aplicación, de modo que se acaba compartiendo el lock con código completamente ajeno. Use como objeto de bloqueo una instancia dedicada que no se exponga al exterior. A partir de .NET 9 / C# 13 se recomienda usar como objeto de bloqueo una instancia del tipo dedicado System.Threading.Lock.
¿Cuántos hilos como máximo se pueden crear? ¿Cuál es el número óptimo de hilos?
La respuesta actual es «no decidir usted mismo el número de hilos». Si usa Task o la clase Parallel, el grupo de subprocesos ajusta automáticamente el grado de paralelismo según el número de núcleos de CPU y la carga. Un diseño que repite new Thread por su cuenta tiende a resultar excesivo o insuficiente en máquinas de cliente con un número de núcleos distinto. Lo que conviene tener presente como referencia no es la cantidad, sino el tipo de trabajo: un cálculo que satura la CPU no se vuelve más rápido paralelizándolo por encima del número de núcleos, y un proceso dominado por la espera de E/S no se resuelve aumentando hilos, sino con E/S asíncrona mediante async/await.
¿Añadir volatile hace que algo sea thread-safe?
No. Lo que garantiza volatile es un ordenamiento (semántica de adquisición/liberación) según el cual el acceso a ese campo no se reordena con las operaciones de memoria anteriores o posteriores, pero no la atomicidad de una operación compuesta como «leer, calcular y volver a escribir». Por ejemplo, si varios hilos hacen ++ sobre un contador volatile int, se pierden incrementos igualmente. Use la clase Interlocked para incrementar, decrementar o intercambiar mediante comparación, y use lock cuando haga falta proteger varias variables a la vez. volatile solo resulta razonable en casos casi exclusivamente simples, como un indicador de parada en el que «un hilo escribe y los demás solo leen», e incluso ese indicador se expresa hoy, como práctica estándar, con CancellationToken.
¿Cómo se distingue si un fallo poco frecuente tiene su origen en el multihilo?
Hay tres indicios que deben hacer sospechar: «la misma operación a veces se reproduce y a veces no», «deja de reproducirse al conectar el depurador o añadir logs» y «solo ocurre con carga alta o justo tras el arranque». La característica de un error que depende del timing es que el resultado cambia en cada ejecución, y eso es precisamente la definición de una condición de carrera. Para acotarlo, primero identifique todos los datos mutables compartidos y elabore una tabla con «qué lock protege a cada uno». Si hay aunque sea un solo acceso sin proteger, es sospechoso. En caso de congelamiento, obtenga las pilas de todos los hilos y compruebe si hay un ciclo de esperas mutuas de locks. Pause con el depurador de Visual Studio y observe las «pilas paralelas», o bien, en producción, tome un dump y analícelo.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog