Buenas prácticas de multithreading en la práctica: edición .NET — qué decidir antes de aumentar los hilos
· Actualizado el: · Go Komura · Windows, Multithreading, C#, .NET, Aplicaciones empresariales, Investigación de fallos, Diseño
Historial de revisiones (primera versión, publicada el 2 Aug 2026)
- Primera publicación
Citar este artículo(DOI (archivo registrado): 10.5281/zenodo.22175820)
Los DOI siguientes remiten a versiones ya archivadas y pueden diferir del texto actual. Para citar el texto actual, utilice la URL de esta página.
Go Komura (2026). Buenas prácticas de multithreading en la práctica: edición .NET — qué decidir antes de aumentar los hilos. KomuraSoft LLC. https://comcomponent.com/es/blog/multithreading-best-practices-dotnet/
- DOI (archivo registrado)
- 10.5281/zenodo.22175820
- DOI (última versión registrada)
- 10.5281/zenodo.22175821
«El procesamiento iba lento, así que levanté hilos para paralelizarlo, y ahora los totales de vez en cuando no cuadran», «añadimos procesamiento en segundo plano y ahora la aplicación se congela una vez al mes», «dicen que no se reproduce en depuración, pero en el cliente ocurre de verdad». Lo que da miedo de la programación con varios hilos es que, en el momento de terminar de escribir, parece correcto. Los fallos de condición de carrera dependen del momento: se cuelan en las pruebas y solo muestran la cara en producción.
Al mismo tiempo, ahora que el hardware multinúcleo es lo habitual, también en las aplicaciones empresariales hay situaciones en las que el multithreading no se puede evitar: requisitos como «correr un procesamiento pesado sin congelar la IU» o «tratar a la vez varios dispositivos o archivos». Lo importante es decidir los principios de diseño antes de aumentar los hilos. Los fallos de multithreading no se aplastan depurando: se diseñan de modo que no tengan sitio por donde entrar.
Este artículo es la edición .NET de la serie práctica sobre multithreading. Dirigido a quienes desarrollan aplicaciones empresariales en Windows y se ven en la necesidad de añadir multithreading, organiza principios de diseño que valen con independencia del lenguaje o del SO, junto con las herramientas concretas de C#/.NET, a partir de fuentes primarias vigentes en agosto de 2026. Los principios en sí no cambian en Linux ni en C++. Si se escribe código nativo, véanse la «edición C++» y la «edición C», que bajan los mismos principios a las herramientas de cada lenguaje; si se escribe Java, la «edición Java».
1. Primero la conclusión
- La primera buena práctica es no crear hilos uno mismo. En lugar de
new Threadse usan API de nivel superior como Task, el grupo de subprocesos y Parallel, y la gestión del número de hilos se deja al runtime.12 - Lo primero que hay que recortar al paralelizar es el «estado mutable compartido». Los sitios en los que varios hilos escriben en la misma variable son el origen de la contención, así que antes de protegerlos con bloqueos se reduce el propio compartir mediante partición, inmutabilidad y traspaso.3
- Se da disciplina a los bloqueos. Se decide uno a uno qué bloqueo protege qué datos, y el objeto de bloqueo es una instancia dedicada que no se ve desde fuera.
lock(this)ylock(typeof(X))están prohibidos. Desde .NET 9 se usa el tipo dedicadoSystem.Threading.Lock.4 - El traspaso de datos entre hilos se acerca a una cola. Una configuración productor/consumidor con
System.Threading.Channelso una colección concurrente es más simple de diseñar que tender bloqueos por todas partes, y la frontera también queda clara.56 - Cómo se detiene se diseña primero. La detención tiene como única respuesta correcta la cancelación cooperativa con
CancellationToken, yThread.Abortse convierte en una excepción en tiempo de ejecución en .NET (la línea Core).78 - La IU es exclusiva del hilo de IU. Ni los controles de WinForms ni los elementos de WPF se tocan desde un hilo distinto del que los creó. Desde otro hilo se pide vía
Control.Invoke/Dispatcher.910 - «Si se paraleliza, va más rápido» no siempre se cumple. Un bucle cuyo trabajo por iteración es pequeño se vuelve más lento por la sobrecarga de la paralelización. Siempre se mide antes de adoptarlo.3
En el diagrama, una línea continua marca una relación que siempre se cumple y una línea discontinua una relación condicional (las condiciones están en la explicación de cada relación en la página de detalle). La lista completa de relaciones (28 en total, con evidencia y grado de certeza) y las definiciones de los conceptos principales están reunidas en la página de detalle del mapa de conocimiento (en japonés). Datos: JSON-LD / Turtle
2. Por qué el multithreading es difícil: condiciones de carrera e interbloqueos
Los problemas que introduce el multithreading, en el fondo, son de 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 código concreto. El ejemplo clásico es el incremento de un contador compartido: la línea count++ se descompone en realidad en tres pasos —«leer → sumar → escribir de vuelta»—. Si dos hilos ejecutan a la vez esos tres pasos, el incremento de uno queda sobrescrito por la escritura de vuelta del otro y se pierde. El resultado cambia en cada ejecución y no se puede predecir cuál saldrá.4
sequenceDiagram
participant A as Hilo A
participant M as Variable compartida count
participant B as Hilo B
Note over M: count = 10
A->>M: Lectura(10)
B->>M: Lectura(10)
A->>A: Suma en local(11)
B->>B: Suma en local(11)
A->>M: Escritura de vuelta(11)
B->>M: Escritura de vuelta(11)
Note over M: Se sumó dos veces, pero count = 11<br/>Se perdió el incremento del hilo A
Figura 1: La condición de carrera típica en la que un contador compartido pierde un incremento. Si otro hilo se cuela entre los tres pasos de count++, quien escribe de vuelta después sobrescribe al otro
Un interbloqueo es el estado en el que dos hilos se esperan mutuamente por el bloqueo que tiene el otro y ninguno puede avanzar. El hilo A retiene el bloqueo 1 y espera el 2, el hilo B retiene el 2 y espera el 1: con eso ambos se detienen para siempre.4
flowchart LR
A["Hilo A<br/>reteniendo el bloqueo 1"] -->|"espera la liberación del bloqueo 2"| B["Hilo B<br/>reteniendo el bloqueo 2"]
B -->|"espera la liberación del bloqueo 1"| A
Figura 2: La espera circular del interbloqueo. En el instante en que las flechas de espera forman un anillo, todos los hilos del anillo se detienen para siempre
Lo delicado es que ambos dependen del momento. Un intercalado (combinación de órdenes de ejecución) que en la máquina de desarrollo solo acierta una de decenas de miles de veces, en la máquina del cliente, con otro número de núcleos y otro momento, ocurre todos los días. Que «no se reproduce al conectar un depurador» o «desapareció al añadir registros» también se debe a que la observación cambia el momento: es el comportamiento típico de un fallo de contención.
Por eso todos los principios que siguen apuntan en una sola dirección. «Reducir los lugares que necesitan sincronización» antes de «sincronizar correctamente»: ese es el principio básico del diseño de multithreading.
3. Principio 1: no crear hilos uno mismo
3.1. Apoyarse en Task y en el grupo de subprocesos
La forma de crear un hilo de forma directa con new Thread(...) es, en el .NET de hoy, un último recurso excepcional. Desde .NET Framework 4, el medio recomendado para código de multithreading y paralelo es TPL (Task Parallel Library), es decir, el conjunto de API centrado en Task. TPL ajusta de forma dinámica el grado de paralelismo a los procesadores disponibles y asume todo el trabajo de bajo nivel: partir el trabajo, programarlo en el grupo de subprocesos, la cancelación y la gestión de estado.1
El grupo de subprocesos es la base que el propio .NET usa de forma amplia para ejecutar Task, completar E/S asíncrona, devoluciones de llamada de temporizador, etc. Mientras se lance trabajo corto, el desarrollador no tiene que gestionar el ciclo de vida de los hilos.2
// Procesamiento pesado que usa la CPU, en segundo plano
var result = await Task.Run(() => HeavyCalculation(input));
// Varios procesamientos independientes en paralelo y esperar a todos (cuando hay pocos)
// * Esta forma es para cuando ProcessAsync es un método asíncrono dominado por E/S.
// WhenAll solo «espera Task que ya corren», así que si se quiere paralelizar
// cálculo de CPU, cada procesamiento se envuelve en Task.Run(() => Calc(x)) y se sube al grupo
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));
// Si hay muchos elementos, se hace fluir con un tope de ejecuciones simultáneas
await Parallel.ForEachAsync(items,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
async (x, ct) => await ProcessAsync(x, ct));
// Hay dos puntos: conectar el token de quien llama a ParallelOptions
// (si se olvida, el ct del cuerpo es siempre None) y pasar ese ct también al cuerpo (no tirarlo)
Hay una precaución. Task.WhenAll(items.Select(...)) arranca a la vez el procesamiento de todos los elementos en el momento de enumerar. Con unos pocos o unas decenas de trabajos fijos no hay problema, pero usarlo sobre una colección de muchos elementos agota de una vez sockets, conexiones a BD y memoria. Un procesamiento cuyo número de elementos no se puede leer pone un tope de ejecuciones simultáneas como el Parallel.ForEachAsync de arriba, o controla el caudal con el canal acotado que se ve más adelante.
Los hilos propios se justifican casi solo cuando la naturaleza del propio hilo es un requisito: «tiene un bucle de mensajes dedicado», «hace falta indicar el apartamento (STA) del hilo», «corre durante toda la vida de la aplicación».
3.2. El paralelismo de datos, Parallel.For / ForEach
Para el paralelismo de datos del tipo «aplicar el mismo procesamiento a cada elemento de una colección y acelerar el conjunto», no se parte el bucle en hilos uno mismo: se usa Parallel.For / Parallel.ForEach. La partición de la fuente de datos y la redistribución de la carga las hace TPL, y en un bucle básico tampoco hace falta bloqueo.11
Ahora bien, la documentación oficial consigna dos trampas.3
- No pensar que lo paralelo es siempre más rápido. Un bucle con pocas iteraciones o cuyo procesamiento por vez es ligero se vuelve más lento porque la sobrecarga de la paralelización supera el cuerpo. El rendimiento depende de muchos factores, así que siempre se mide y se decide.
- No hacer esperar unas iteraciones a otras. No hay garantía de que cada iteración de
Parallel.Forse ejecute realmente en paralelo. Un código en el que una iteración espera a que otra ponga un evento se interbloquea según la planificación.
3.3. El procesamiento que «espera», a E/S asíncrona, no a más hilos
El procesamiento dominado por la espera de E/S —archivo, red, BD— no es objeto de aumentar hilos. Ocupar un hilo entero mientras se espera es solo desperdicio; con E/S asíncrona mediante async/await no se consume un hilo durante la espera. Esta distinción (lo limitado por CPU se paraleliza, lo limitado por E/S se hace asíncrono) es la primera línea que hay que trazar a la entrada del diseño de multithreading.
flowchart TB
S["Hay un procesamiento que se quiere ejecutar en paralelo"] --> Q1{"¿Qué domina el procesamiento?"}
Q1 -->|"Espera de E/S<br/>archivo, red, BD"| ASYNC["E/S asíncrona con async/await<br/>no se aumentan hilos"]
Q1 -->|"Cálculo que usa la CPU"| Q2{"¿Cuál es la forma del trabajo?"}
Q2 -->|"Aplicar el mismo procesamiento<br/>a todos los elementos de una colección"| PAR["Parallel.For / ForEach"]
Q2 -->|"Un bloque independiente de<br/>procesamiento en segundo plano"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"Bucle de mensajes o STA, etc.:<br/>la naturaleza del hilo es el requisito"| TH["new Thread<br/>(último recurso excepcional)"]
Figura 3: La ramificación antes de «levantar un hilo». Casi todo el procesamiento de negocio cae en una de las tres salidas de arriba; llegar a new Thread es un caso excepcional
Las decisiones prácticas de async/await se tratan en «Tabla práctica de decisiones para C# async/await», y cómo se conectan debajo el grupo de subprocesos y la E/S asíncrona, en «IOCP y el grupo de subprocesos de .NET».
4. Principio 2: minimizar el estado mutable compartido
La contención solo ocurre cuando coinciden «varios hilos» y «datos mutables compartidos». El número de hilos lo fija el requisito, así que lo que el diseño puede recortar es el compartir. Hay tres medios.
4.1. Partir: que cada hilo toque solo sus datos
Lo más simple y potente es partir los datos por hilo. En la agregación de un bucle paralelo, en lugar de escribir cada vez en una variable de total compartida, se usa la sobrecarga de Parallel.For que recibe estado local al hilo: cada hilo construye un subtotal a mano y al final se reúne una sola vez. Las escrituras al compartir bajan de «en cada iteración» a «una vez por hilo», y tanto el coste de sincronización como la ventana de contención se hacen de otro orden de magnitud.3
long total = 0;
Parallel.For(0, items.Length,
() => 0L, // valor inicial local al hilo
(i, state, local) => local + Weigh(items[i]), // cada iteración solo suma a su local
local => Interlocked.Add(ref total, local)); // la reunión, una vez por hilo
flowchart TB
SRC["Matriz de datos (objeto del procesamiento)"] --> T1["Hilo 1<br/>procesa su parte y<br/>suma solo al subtotal de mano"]
SRC --> T2["Hilo 2<br/>procesa su parte y<br/>suma solo al subtotal de mano"]
SRC --> T3["Hilo 3<br/>procesa su parte y<br/>suma solo al subtotal de mano"]
T1 --> M["Reunión: Interlocked.Add<br/>refleja en el total una vez por hilo"]
T2 --> M
T3 --> M
Figura 4: Agregación local al hilo. Durante el procesamiento cada hilo toca solo sus datos, no hay sitio para la contención, y la escritura al compartir queda en una vez por hilo al reunir
4.2. Hacerlo inmutable: lo que no se reescribe se puede compartir
Los datos que solo se leen son seguros aunque los lean a la vez cualquier número de hilos. Valores de configuración, datos maestros, entrada de un cálculo, etc., al no reescribirse tras construirse (hacerlos inmutables) se pueden compartir con libertad sin sincronización. En C#, los tipos record y las propiedades init empujan este diseño. Con solo decidir «si hace falta un cambio, no se reescribe: se crea una instancia nueva y se sustituye», el estado mutable que hay que proteger baja en uno.
Ahora bien, «parece de solo lectura» y «es inmutable» no son lo mismo. Una interfaz de solo lectura como IReadOnlyList<T> solo dice «por esa interfaz no se puede reescribir»; no impide reescribir la List<T> de detrás desde otra referencia. La garantía de record / init también es superficial y no cubre el objeto al que apunta la propiedad. Los datos que se quieren compartir de verdad con seguridad entre hilos usan una colección inmutable de System.Collections.Immutable como ImmutableArray<T>, o se pasa una copia en el momento de compartir y se corta el propio camino de reescritura. En ese caso la condición es que el tipo elemento T también sea inmutable. Lo que protege la colección inmutable es solo «la secuencia»; las referencias a objetos elemento mutables se siguen compartiendo tal cual, y si por otro camino se reescribe el interior del elemento, la contención permanece. Hay que hacer inmutable el grafo de objetos hasta el extremo, o pasar una copia profunda.
4.3. Traspasar: en lugar de compartir, enviar por una cola
Aun así hace falta mover datos entre hilos. Entonces, en lugar de «tocar una variable compartida desde ambos lados», se arma una configuración productor/consumidor poniendo en medio una cola en la que uno escribe y el otro lee.
La primera opción en .NET es System.Threading.Channels. Es un FIFO en el que el productor escribe de forma asíncrona y el consumidor lee de forma asíncrona, y todo el trabajo de sincronización lo gestiona el canal.5
var channel = Channel.CreateBounded<WorkItem>(100); // capacidad 100 para aplicar contrapresión
// Lado productor
await channel.Writer.WriteAsync(item, ct); // si está llena, espera a que haya hueco
// …cuando todos los productores han terminado de escribir:
channel.Writer.Complete(); // declara «ya no viene más». Sin esto el bucle del lector no termina
// Lado consumidor
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
Process(item);
}
flowchart LR
P1["Productor 1<br/>WriteAsync"] --> CH["Canal acotado (capacidad 100)<br/>cola FIFO<br/>la sincronización la gestiona el canal"]
P2["Productor 2<br/>WriteAsync"] --> CH
CH --> C1["Consumidor 1<br/>ReadAllAsync"]
CH --> C2["Consumidor 2<br/>ReadAllAsync"]
CH -.->|"si está llena, hace esperar la escritura<br/>(contrapresión)"| P1
CH -.->|"si está vacía, hace esperar la lectura"| C1
Figura 5: Configuración productor/consumidor con un canal en medio. Ningún lado toca de forma directa una variable compartida; la espera y el control de capacidad se dejan al canal
En la práctica lo importante es elegir un canal con tope de capacidad (bounded). El comportamiento por defecto al alcanzar el tope es «el lado que escribe espera un hueco», y eso se convierte en contrapresión (backpressure) natural. Usar una cola sin límite en una configuración en la que la producción es más rápida que el consumo es una bomba de relojería: funciona, pero la memoria no deja de crecer.5
En el mundo síncrono, el que cumple el mismo papel que un canal acotado es un BlockingCollection<T> con capacidad indicada. Impide que el lado que produce adelante demasiado al que consume gracias al límite de capacidad, y cuando está vacía bloquea al consumidor y lo hace esperar: tiene bloqueo y control de capacidad.12 En cambio ConcurrentQueue<T> / ConcurrentStack<T> son colecciones rápidas que logran ser seguras para hilos solo con operaciones Interlocked, sin bloqueos,6 pero no tienen ni límite de capacidad ni un mecanismo de «esperar cuando queda vacía»: son colas seguras para hilos en crudo. Piénselas como pieza, no como protagonista del traspaso de trabajo. Además, BlockingCollection<T> no está pensada para acceso asíncrono, así que si se combina con async/await se elige Channel<T>.12
Hay que tener cuidado, además, con la idea de «cambié el diccionario a ConcurrentDictionary, así que es seguro para hilos». Aunque cada operación sea segura para hilos, una operación compuesta como «comprobar si existe y luego añadir» sigue compitiendo (se usan métodos para operaciones compuestas como GetOrAdd). Ese GetOrAdd también tiene letra pequeña: el valor que se almacena queda en uno, pero la función de fábrica que crea el valor puede llamarse más de una vez bajo contención. Si se mete un efecto secundario en la fábrica (abrir una conexión, crear un archivo, etc.), la ejecución duplicada provoca fugas, así que se deja como función sin efectos secundarios o, si se quiere con certeza una sola vez, se almacena un Lazy<T> como valor. Cambiar el tipo de la colección no sustituye reducir el estado mutable compartido.
5. Principio 3: dar disciplina a los bloqueos
Aunque se reduzca el estado mutable compartido, a menudo no se puede dejar en cero. En lo que queda compartido se usa exclusión mutua (bloqueo), pero el bloqueo no es una herramienta de «envolver por si acaso lo sospechoso con lock». Hay cuatro disciplinas.
5.1. Decidir «qué se protege» y bloquear con un objeto dedicado
La unidad del bloqueo se piensa en «datos», no en «tramo de código». A cada conjunto de datos mutables que se quiere proteger se le hace corresponder un objeto de bloqueo, y se toma el mismo bloqueo en todos los sitios que tocan esos datos: que esa tabla de correspondencia se rompa es la realidad de un fallo de contención.
El objeto sobre el que se bloquea es una instancia dedicada que no se publica al exterior. lock(this) comparte el bloqueo con código externo que puede referenciar la propia instancia; lock(typeof(X)), con todo el dominio de la aplicación: ambos son caldo de cultivo de interbloqueos. Desde .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, 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 bloqueo se libera con certeza aunque haya una excepción. La forma de expandirse cambia con el tipo del objeto de bloqueo: si es un objeto habitual, se llama Monitor.Exit en finally; si es el tipo Lock, se usan EnterScope() y su destrucción.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), no se establece exclusión mutua con lock (_gate). Con cualquiera de los dos tipos, lo seguro es dejar de escribir a mano Monitor.Enter / Exit y unificar siempre en la sintaxis lock.4
5.2. No hacer, mientras se retiene el bloqueo, «algo que lleva tiempo» ni «algo externo»
Cuanto más corto el tiempo con el bloqueo, mejor, y lo único que cabe mientras se retiene es leer y escribir los datos que se protegen. Hacer E/S reteniendo el bloqueo, o llamar a código externo con un evento o una devolución de llamada, no solo alarga el tiempo de retención: también crea un camino en el que quien se llama intenta tomar otro bloqueo y se interbloquea. La forma básica es preparar fuera del bloqueo y, dentro, solo sustituir.
Además, no se puede hacer await dentro de lock (es un error de compilación). No es una limitación, sino una protección: Monitor tiene afinidad de hilo —el hilo que tomó el bloqueo debe liberarlo—, así que no convive con código asíncrono en el que el hilo puede cambiar antes y después del await. Para la exclusión en código asíncrono se usa SemaphoreSlim con recuento inicial 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. Varios bloqueos, siempre en el mismo orden
Cuando hay dos o más bloqueos, que el orden de adquisición se invierta según el hilo es el patrón clásico de interbloqueo. La contramedida es simple: se convierte en regla que todos los hilos tomen los bloqueos en el mismo orden. Donde no se puede garantizar el orden se usa la sobrecarga de Monitor.TryEnter con tiempo de espera y, si no se obtiene, se suelta y se reintenta (o se registra la anomalía), de modo que un cuelgue eterno se convierte en un fallo detectable.4
5.4. Actualización simple, Interlocked; si hay muchas lecturas, ReaderWriterLockSlim
La actualización atómica de una sola variable —incrementar o decrementar un contador, intercambiar una bandera— es más rápida con la clase Interlocked (Increment / Add / CompareExchange) que con lock. Si no hay contención, basta un prefijo de instrucción de CPU.4 A la inversa, hasta ahí llega lo que puede Interlocked: no sirve para dejar consistentes varias variables a la vez. Una estructura lock-free propia combinada con volatile es una herramienta de experto que exige una comprensión profunda del modelo de memoria, y no es algo que deba escribirse en una aplicación empresarial.
Para datos compartidos en los que «la lectura es frecuente pero la escritura es rara» también hay la opción de ReaderWriterLockSlim, que excluye solo la escritura y deja pasar las lecturas a la vez.13
6. Principio 4: diseñar primero cómo se detiene
La primera pregunta que hay que hacer en una revisión de diseño de multithreading es «¿cómo se detiene esto?». El código que arranca se puede escribir; el código que se detiene con seguridad no nace si no 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 procesamiento. Cuando quiere detener, llama Cancel(). El lado del procesamiento vigila el token, limpia en un punto razonable y termina: como no es forzada sino cooperativa, el lado del procesamiento puede terminar manteniendo un estado coherente.7
private CancellationTokenSource? _cts;
private Task? _worker;
public void Start()
{
if (_worker is { IsCompleted: false }) // rechazar un segundo Start mientras corre
throw new InvalidOperationException("El trabajador ya está en ejecución.");
if (_worker is { IsFaulted: true }) // no reconstruir dejando tragado el fallo anterior
throw new InvalidOperationException("El trabajador anterior falló.", _worker.Exception);
_cts = new CancellationTokenSource();
var token = _cts.Token; // capturar antes en local para no competir con un reStart tras detener
_worker = Task.Run(() => WorkLoop(token), token);
}
private void WorkLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested) // vigilar por sondeo
{
ProcessNextItem(ct); // a una llamada que bloquea se le pasa ct para interrumpir al momento
}
}
public async Task StopAsync()
{
var cts = _cts; // aunque el campo se sustituya mientras se espera,
var worker = _worker; // fijar en local el objeto a detener para no confundirlo
if (cts is null || worker is null) return;
Exception? cancelFailure = null;
try { cts.Cancel(); } // la devolución de llamada registrada en el token puede lanzar una excepción
catch (Exception ex) { cancelFailure = ex; } // guardarla y comunicar tras cumplir la reunión
try
{
try { await worker; } // con independencia del éxito de Cancel, la reunión se cumple siempre y también se observa un fallo a mitad
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{ } // tratar como «normal» solo la detención que pedimos nosotros
catch (Exception ex) when (cancelFailure is not null)
{
throw new AggregateException(cancelFailure, ex); // no perder ninguno de los dos fallos
}
}
finally
{
cts.Dispose(); // destruir la fuente ya reunida (liberar recursos del SO como WaitHandle).
if (ReferenceEquals(_cts, cts))
{
_cts = null; // no dejar que un StopAsync posterior use una fuente ya destruida
_worker = null;
}
}
if (cancelFailure is not null)
throw new AggregateException(cancelFailure);
}
Este Start / StopAsync es una configuración mínima bajo el supuesto de que se llama en serie desde el mismo hilo (el de IU, etc.). Si varios hilos pueden operar el ciclo de vida a la vez, serialice el propio Start / StopAsync con SemaphoreSlim o similar: antes de proteger al trabajador, que las operaciones de gestión del trabajador compitan entre sí es poner el carro delante de los bueyes.
En este ejemplo pequeño también hay recursos que funcionan en la práctica. Primero, Start rechaza una llamada doble mientras corre. Si se sobrescriben _cts y _worker sin condición, se pierde la referencia al trabajador anterior y corre en paralelo un «hilo suelto» al que no se puede detener ni reunir. Las API de ciclo de vida (Start/Stop) se hacen guardar ellas mismas el «solo uno a la vez». Además, tres puntos. Primero, la API de detención espera a que termine. Cancel() solo «pide» la cancelación; en el instante en que vuelve, el trabajador puede seguir a mitad de ProcessNextItem. Un Stop() que pide y vuelve crea una contención nueva: quien llamó ya empezó a limpiar y el trabajador aún corre. Segundo, se retiene el Task y no se tira. Si se tira con _ = Task.Run(...), aunque el trabajador muera por una excepción nadie se entera. Tercero, el token no se referencia como _cts.Token dentro de la lambda: se captura en una variable local y luego se pasa. Si se referencia dentro de la lambda, la evaluación es en tiempo de ejecución y, si se hace reStart justo después de detener, el trabajador antiguo agarra el token nuevo. Además, pasar ese token también como segundo argumento de Task.Run hace que, si el lado del procesamiento termina con ThrowIfCancellationRequested o con OperationCanceledException de una API que admite cancelación, el Task se clasifique como «cancelado (Canceled)» y no como «fallido (Faulted)» (si, como en este ejemplo, se sale con normalidad por la condición del bucle, se trata como finalización normal). Y otra: el catch de StopAsync solo traga, con un filtro when, la cancelación originada en el propio token. Si se traga OperationCanceledException sin condición, un fallo real lanzado por otro token interno del procesamiento (un tiempo de espera por elemento, etc.) también parece «se detuvo, así que es normal». Esta identificación por coincidencia de token se rompe si dentro de WorkLoop se usa un token enlazado (la composición de enlaces de 6.1), porque llega una excepción que lleva el token del lado del enlace. En esa configuración hay que elegir de forma explícita como diseño o bien llamar ct.ThrowIfCancellationRequested() a la salida de WorkLoop para «traducir» al token exterior antes de salir, o bien relajar el filtro a when (cts.IsCancellationRequested) y aceptar que «una cancelación mientras hay petición de parada es normal».
flowchart TB
OWNER["Quien detiene"] -->|"llama Cancel() una vez"| CTS["CancellationTokenSource"]
CTS -->|"pasa Token"| W1["Procesamiento trabajador 1"]
CTS -->|"pasa Token"| W2["Procesamiento trabajador 2"]
CTS -->|"pasa Token"| W3["API de biblioteca<br/>que admite cancelación"]
W1 -->|"comprueba IsCancellationRequested<br/>limpia y termina por sí mismo"| E1["Finalización normal"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>= se trata como cancelación completada"]
W3 -->|"se interrumpe al momento aunque esté en espera"| E3["Cancelación completada"]
Figura 6: Esquema de la cancelación cooperativa. Quien detiene solo llama Cancel(); «cuándo y cómo termina» lo decide cada procesamiento. Por eso se puede detener manteniendo un estado coherente
También está fijada la etiqueta del lado de la biblioteca. Una operación que se puede cancelar ofrece un método público que recibe CancellationToken y, en un bucle de cálculo, comprueba de forma periódica IsCancellationRequested o llama ThrowIfCancellationRequested(). Esta última envía OperationCanceledException y Task la trata no como «fallo» sino como «cancelación completada». Si se quiere detener tanto por un token pasado desde fuera como por una razón interna (tiempo de espera, etc.), se componen con un token enlazado.7
6.2. Tratar Thread.Abort como si no existiera
Thread.Abort, «matar desde fuera un hilo que no hace caso», a partir de .NET Core / .NET 5 solo lanza PlatformNotSupportedException y ya no se puede usar. Meter una excepción en un hilo sin saber dónde está ejecutando provoca interrupción de la liberación de recursos y destrucción de estado. Si hace falta forzar la terminación de código de terceros que no responde a la cancelación cooperativa (o que no se puede escribir para que responda), la pauta oficial es ejecutarlo en otro proceso y detenerlo con Process.Kill.8
6.3. Al esperar, no sondear: usar un identificador de espera
La forma de «esperar en un bucle de Sleep(100) hasta que se levante una bandera» desperdicia CPU y capacidad de respuesta. Para un aviso entre hilos hay primitivos de sincronización como ManualResetEventSlim o SemaphoreSlim, que dejan descansar el hilo de forma correcta hasta el estado señalizado.13 La distinción entre precisión de temporizador y espera de evento en Windows se trata con detalle en «Por qué en Windows conviene priorizar la espera de un evento frente a Sleep(1)».
7. La circunstancia especial del hilo de IU: la regla de las aplicaciones de escritorio de Windows
En las aplicaciones de escritorio de Windows hay, además de los principios generales, otra restricción fuerte. La regla es que la IU solo la toca el hilo que la creó (el hilo de IU).
Los controles de WinForms no son seguros para hilos, y operarlos desde varios hilos empuja el control a un estado incoherente y origina contención, interbloqueo y congelación. Windows exige a la aplicación un hilo dedicado que reciba los mensajes del sistema, y la creación y operación de la IU tienen que concentrarse en ese hilo.9 WPF tiene exactamente la misma estructura: solo el hilo de IU puede cambiar los elementos de IU.10
Cuando se quiere actualizar la IU desde otro hilo, no se toca de forma directa: se convierte en «una petición al hilo de IU».
flowchart LR
OS["Windows<br/>ratón, teclado, redibujo"] --> Q["Cola de mensajes<br/>del hilo de IU"]
BG["Hilo en segundo plano<br/>(procesamiento pesado, comunicación)"] -->|"pedir con Control.Invoke /<br/>Dispatcher.InvokeAsync"| Q
Q --> UI["Hilo de IU<br/>el único hilo que toca los controles"]
BG -.->|"tocar el control de forma directa"| NG["Prohibido<br/>causa de contención, interbloqueo y congelación"]
Figura 7: La actualización de IU se convierte en una «petición». El trabajo del hilo en segundo plano llega hasta hacer que lo suban a la cola de mensajes; quien toca el control es siempre el propio hilo de IU
| Marco | Medio de petición |
|---|---|
| WinForms | Control.Invoke (síncrono) / Control.BeginInvoke (asíncrono) / desde .NET 9, Control.InvokeAsync9 |
| WPF | Dispatcher.Invoke (síncrono) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (asíncrono)10 |
De estos, las formas síncronas (Control.Invoke / Dispatcher.Invoke) requieren precaución. Si el hilo de IU espera de forma síncrona a que termine ese trabajador y el trabajador llama Invoke, se produce un interbloqueo en el que se esperan mutuamente (la espera circular del capítulo 2 tal cual). Los avisos y los informes de progreso desde segundo plano toman como valor por defecto la forma asíncrona (BeginInvoke / InvokeAsync), y la síncrona se limita a un caso en el que se puede afirmar que el hilo de IU no se está esperando a sí mismo.
En la práctica hay una respuesta un peldaño mejor. Si se escribe con async/await un procesamiento iniciado en el hilo de IU, await captura el SynchronizationContext del hilo de IU y reanuda el procesamiento posterior de forma automática en el hilo de IU, de modo que las ocasiones de escribir Invoke a mano bajan mucho. Ahora bien, no es una propiedad incondicional. El código que entra desde una devolución de llamada en segundo plano, o la continuación después de un ConfigureAwait(false), no vuelve al hilo de IU, así que si en ese camino se toca la IU sigue haciendo falta un despacho explícito. Alinearlo en la forma «el procesamiento pesado, a Task.Run o a E/S asíncrona; el reflejo en pantalla del resultado, en la continuación del await» es la forma básica de una aplicación Windows moderna. La relación entre el hilo de IU y async/await está reunida en una sola figura en «Organizar en una hoja async y el hilo de IU de WPF/WinForms».
Además, cuando entra COM —colaboración con Office, componentes heredados, etc.— se añade otra capa: el modelo de hilos propio de COM (STA/MTA). El accidente de «creé el objeto COM en el hilo de IU y al llamarlo desde otro hilo se quedó colgado» es de esta capa, y se explica en «Conocimientos básicos de COM STA/MTA».
8. Si se escribe en nativo (C++/C)
Los principios hasta aquí —no crear hilos de forma directa, reducir el estado mutable compartido, la disciplina de los bloqueos, diseñar cómo se detiene— valen tal cual en código nativo. Lo que cambia es el conjunto de herramientas. En C++, RAII y std::jthread / std::mutex / std::atomic; en C, _beginthreadex de la API Win32, bloqueos SRW, variables de condición y el patrón de evento de detención son los correspondientes. Cada uno se trata, incluidas las trampas propias del lenguaje (el destructor de std::thread, el peligro de TerminateThread, DllMain y el bloqueo del cargador, etc.), en la «edición C++» y la «edición C» de esta serie.
9. Verificación y depuración: prepararse partiendo de que «no se reproduce»
No se puede esperar encontrar los fallos de multithreading con pruebas. Una prueba unitaria habitual cuenta como éxito una ejecución que «esta vez no compitió». La preparación se piensa en tres capas.
La primera línea de defensa son los propios principios de diseño hasta aquí. En una aplicación con 5 datos mutables compartidos y en una con 50, el número de sitios que hay que sospechar difiere en un factor 10. En la revisión se comprueba en una tabla «qué datos mutables se comparten», «qué bloqueo protege cada uno», «si el orden de adquisición de los bloqueos es único» y «dónde está el camino de detención». Un diseño que no puede escribir esa tabla, aunque funcione, aún no está terminado.
Segundo, no se esconde la anomalía: se deja observable. Detectar con el tiempo de espera de Monitor.TryEnter una anomalía de espera de bloqueo y dejarla en el registro,4 no tragarse las excepciones no observadas de un procesamiento lanzado al grupo de subprocesos y registrarlas, dejar preparado un volcado completo al colgarse para poder confirmar las pilas de todos los hilos: la pelea con un fallo que «solo ocurre de vez en cuando» se decide por cuánta información se puede sacar de esa única vez que ocurre. La preparación de volcados y registros se trata en «Diseño para dejar registro y volcado cuando se cae una aplicación Windows».
Tercero, se carga y se agita. Una prueba de estrés que facilita acertar el intercalado en la máquina de desarrollo —correr largo tiempo con más paralelismo que núcleos, aleatorizar el orden de procesamiento, insertar retardos artificiales— es un medio realista de sacar la contención a la luz antes de publicar. Un fallo que desaparece en ejecución de depuración a menudo se reproduce en una compilación de publicación con carga alta.
10. Resumen: lista de verificación antes de aumentar hilos
Las buenas prácticas de la programación con varios hilos, en el fondo, no son «la técnica de escribir bien la sincronización» sino «el diseño que permite no tener que escribirla». Si antes de empezar se pueden responder estas 8 preguntas, se evitan casi los accidentes grandes.
- ¿Ese procesamiento está limitado por CPU o por E/S (si es lo segundo, la respuesta no es un hilo sino async/await)?
- ¿Se está a punto de escribir
new Thread(se puede expresar con Task,Parallelo el grupo de subprocesos)? - ¿Qué datos mutables se comparten entre hilos, se pueden enumerar?
- ¿Ese compartir se puede borrar con «partición», «inmutabilidad» o «traspaso por cola»?
- ¿A cada dato compartido que queda le corresponde un bloqueo?
- ¿El orden de adquisición de los bloqueos es único en todos los hilos, y no se llama al exterior mientras se retiene el bloqueo?
- ¿
CancellationTokense pasa a todos los procesamientos largos, y se puede explicar el camino de detención? - ¿El código que toca la IU está concentrado en el hilo de IU?
Los fallos de multithreading no aparecen el día en que se escriben: enseñan los dientes en el cliente cuando ya se habían olvidado. Dicho al revés, si en la etapa de diseño se pasa esta lista de verificación, el tipo de incidencia más caro —«de vez en cuando se cae», «una vez al mes se queda colgado»— se puede recoger antes de escribir el código.
Artículos relacionados
- Buenas prácticas de multithreading en la práctica: edición C++
- Buenas prácticas de multithreading en la práctica: edición C
- Buenas prácticas de multithreading en la práctica: edición Java
- Tabla práctica de decisiones para C# async/await: Task.Run y ConfigureAwait
- Organizar en una hoja async y el hilo de IU de WPF/WinForms
- Windows I/O en profundidad (3.ª parte): puerto de finalización de E/S (IOCP) y el grupo de subprocesos de .NET
- Conocimientos básicos de COM STA/MTA: el modelo de hilos y cómo evitar cuelgues
- Por qué en Windows conviene priorizar la espera de un evento frente a Sleep(1)
- Trampas de la memoria compartida y buenas prácticas en la práctica
Áreas de consultoría relacionadas
KomuraSoft LLC se ocupa de la revisión de diseño de aplicaciones empresariales que incluyen multithreading, de la investigación de la causa de fallos de baja reproducibilidad —«de vez en cuando se cae o se queda colgado»— (análisis de volcado, localización del punto de contención) y de la consultoría técnica para paralelizar o hacer asíncrona una aplicación existente. Basta con una fase como «quiero que miren si con este diseño no hay contención».
- Consultoría técnica y revisión de diseño
- Investigación de fallos y análisis de causas
- Desarrollo de aplicaciones Windows
- Contacto
Referencias
-
Microsoft Learn, Task Parallel Library (TPL). Sobre que TPL es el medio recomendado para código de multithreading y paralelo desde .NET Framework 4; que ajusta de forma dinámica el grado de paralelismo a los procesadores disponibles; que asume la partición del trabajo, la programación en el grupo de subprocesos, la cancelación y la gestión de estado; que un bucle cuyo trabajo por iteración es pequeño puede volverse más lento por la sobrecarga de la paralelización; y que también para usar TPL se recomienda una comprensión básica de bloqueos, interbloqueos y condiciones de carrera. ↩ ↩2
-
Microsoft Learn, The managed thread pool. Sobre que la clase ThreadPool ofrece un grupo de hilos trabajadores gestionado por el sistema y permite al desarrollador concentrarse en las tareas de la aplicación, no en la gestión de hilos; y que .NET usa el grupo de subprocesos de forma amplia para operaciones de TPL, finalización de E/S asíncrona, devoluciones de llamada de temporizador, esperas registradas, conexiones de socket, etc. ↩ ↩2
-
Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. Sobre que un bucle paralelo a veces es más lento que el secuencial y siempre hay que medir; que se debe evitar escribir en memoria compartida dentro de un bucle paralelo y se recomienda la sobrecarga que usa estado local al hilo; y que no hay garantía de que cada iteración de For/ForEach se ejecute en paralelo, de modo que un código que hace esperar unas iteraciones a otras puede interbloquearse. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Managed threading best practices. Sobre la definición de condición de carrera (el ejemplo en el que el incremento de un contador se descompone en leer, sumar y escribir de vuelta y se pierde por sobrescritura) y de interbloqueo; que no se debe usar Thread.Abort y sí la cancelación cooperativa; que no se debe tomar como objeto de bloqueo un tipo ni this, y que desde .NET 9 / C# 13 se usa una instancia dedicada de System.Threading.Lock; que la instrucción lock de C# garantiza Monitor.Exit en finally; la detección de interbloqueo con el tiempo de espera de Monitor.TryEnter; que para un cambio de estado simple la clase Interlocked es más rápida; y la pauta de diseño de que los datos estáticos son seguros para hilos por defecto y los de instancia no. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
Microsoft Learn, System.Threading.Channels library. Sobre que el canal es un FIFO del modelo productor/consumidor que gestiona la sincronización por dentro; que con CreateBounded se puede crear un canal con tope de capacidad; que el comportamiento por defecto al alcanzar el tope es que el lado que escribe espera (Wait), y también se puede elegir FullMode como DropOldest; y que si la escritura es más rápida que la lectura se aplica contrapresión. ↩ ↩2 ↩3
-
Microsoft Learn, Thread-safe collections. Sobre que las colecciones de System.Collections.Concurrent logran ser seguras para hilos con bloqueos de grano fino o mecanismos lock-free; y que ConcurrentQueue y ConcurrentStack están implementadas con operaciones Interlocked, sin bloqueos, y resisten altas frecuencias de adición y eliminación desde varios hilos. ↩ ↩2
-
Microsoft Learn, Cancellation in Managed Threads. Sobre el procedimiento del modelo de cancelación cooperativa con CancellationTokenSource y CancellationToken; que la cancelación no es forzada sino cooperativa y la forma de detener la decide el lado que escucha; los tres medios de vigilancia (sondeo, registro de devolución de llamada, identificador de espera); que Task trata el envío de OperationCanceledException con ThrowIfCancellationRequested como cancelación completada; la composición de varios tokens con un token enlazado; y que una biblioteca debe ofrecer un método público que reciba CancellationToken. ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading. Sobre que para detener un hilo se debe usar CancellationToken; que en .NET Core y .NET 5 en adelante Thread.Abort lanza PlatformNotSupportedException y, desde .NET 5, también una advertencia de obsoleto en compilación (SYSLIB0006); y que para forzar la terminación de código de terceros que no responde a la cancelación cooperativa se debe ejecutar en otro proceso y usar Process.Kill. ↩ ↩2
-
Microsoft Learn, How to handle cross-thread operations with controls. Sobre que el acceso a los controles de WinForms no es seguro para hilos y que operar desde varios hilos provoca estado incoherente, contención, interbloqueo y congelación; que todos los controles deben crearse y accederse en el mismo hilo, y que Windows exige un hilo de IU dedicado que reciba los mensajes del sistema; y que desde otro hilo se llama con seguridad con Control.Invoke, Control.InvokeAsync desde .NET 9, o BackgroundWorker. ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF). Sobre que en WPF solo un hilo puede cambiar la IU y que un hilo en segundo plano registra un elemento de trabajo en el Dispatcher del hilo de IU y se lo pide; que Dispatcher.Invoke es síncrono e InvokeAsync y BeginInvoke son asíncronos; y que Dispatcher procesa el trabajo con una cola con prioridad. ↩ ↩2 ↩3
-
Microsoft Learn, Data Parallelism (Task Parallel Library). Sobre que Parallel.For / Parallel.ForEach ofrecen paralelismo de datos con una escritura casi igual a un bucle for; que no hace falta crear hilos ni poner elementos de trabajo en cola y, en un bucle básico, tampoco bloqueo; y que TPL parte la fuente de datos, la procesa en varios hilos y redistribuye si la carga se desequilibra. ↩
-
Microsoft Learn, BlockingCollection<T> Class. Sobre que BlockingCollection es una implementación productor/consumidor con bloqueo y límite de capacidad; que el límite de capacidad impide que el lado que produce adelante demasiado al que consume; y que no está pensada para acceso asíncrono, de modo que para un productor/consumidor asíncrono se debe considerar Channel<T>. ↩ ↩2
-
Microsoft Learn, Overview of synchronization primitives. Sobre que Monitor ofrece exclusión mutua a través del objeto de bloqueo y tiene afinidad de hilo; que en C# no se debe usar Monitor de forma directa sino la instrucción lock; que ReaderWriterLockSlim excluye la escritura y permite el acceso simultáneo a la lectura; y que SemaphoreSlim es un semáforo ligero exclusivo del proceso, mientras que Semaphore, con nombre, sirve para sincronización entre procesos. ↩ ↩2 ↩3
-
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 y 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); que para la exclusión mutua en código asíncrono se usa un SemaphoreSlim de recuento 1 con WaitAsync y Release en try/finally; y que para limitar el caudal un Channel acotado es una alternativa. ↩
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Buenas prácticas de multithreading en la práctica: edición C — escribir con seguridad al estilo de la API Win32
En C con Win32 la práctica establecida es _beginthreadex, bloqueos SRW y variables de condición, Interlocked, y un evento de detención má...
Buenas prácticas de multithreading en la práctica: edición C++ — eliminar los fallos desde la estructura con RAII y jthread
En C++ una condición de carrera de datos es comportamiento indefinido. Se cubren la trampa del destructor de std::thread, la parada con j...
Despertares espurios — por qué una variable de condición despierta «sin notificación» y cómo esperar correctamente en Windows
El wait de una variable de condición puede volver sin notificación (despertar espurio). El artículo explica, a partir de la implementació...
Buenas prácticas de multithreading en la práctica: edición Java — convenciones de la era de los hilos virtuales
En Java no se crean hilos a mano: las tareas van a ExecutorService y a hilos virtuales. Se cubren synchronized frente a ReentrantLock, la...
Cómo elegir la comunicación entre procesos en Windows — canalizaciones con nombre / TCP / gRPC / memoria compartida / COM: tabla de decisión
Cómo elegir la comunicación entre procesos en Windows: comparamos en una tabla de decisión las canalizaciones con nombre, el TCP local, g...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Investigación de fallos y problemas prolongados
Fallos intermitentes, diagnóstico de comunicaciones, bloqueos prolongados y pruebas de rutas de error.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
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 seguro para hilos?
- No. Lo que volatile garantiza es el orden —que el acceso a ese campo no se reordene respecto a las operaciones de memoria de alrededor (semántica acquire/release)—, no la atomicidad de una operación compuesta como «leer, calcular y volver a escribir». Por ejemplo, aplicar ++ a un contador volatile int desde varios hilos sigue perdiendo incrementos. Use la clase Interlocked para incrementar o decrementar un contador y para comparar e intercambiar, y use lock cuando necesite proteger varias variables a la vez. volatile solo merece considerarse casi exclusivamente en el caso simple de una bandera de parada, donde un hilo escribe y los demás solo leen, y aun esa bandera hoy se expresa de forma estándar como CancellationToken.
- ¿Cómo se distingue si un fallo que ocurre solo de vez en cuando viene del multithreading?
- Las tres señales que hay que sospechar son «la misma operación a veces se reproduce y a veces no», «deja de reproducirse al conectar un depurador o al añadir registros» y «solo ocurre con carga alta o justo después de arrancar». Un fallo que depende del momento se caracteriza porque el resultado cambia en cada ejecución, que es precisamente la definición de una condición de carrera. Para acotar, primero se enumeran todos los datos mutables que se comparten y se tabula, para cada uno, qué bloqueo lo protege. Un solo acceso sin proteger ya es sospechoso. En un cuelgue se capturan las pilas de todos los hilos y se comprueba si forman un ciclo esperando el bloqueo del otro. En el depurador de Visual Studio se pausa y se mira Parallel Stacks o, en producción, se toma un volcado y se analiza.
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.