Hilos
En resumen: Un hilo es una línea de ejecución separada dentro del programa; varios hilos trabajan al mismo tiempo y comparten la memoria, por eso un contador compartido se protege con lock o Interlocked.
Problema
Tres informes, uno tras otro
Thread.Sleep(ms) detiene el programa durante esos milisegundos. Thread está en System.Threading, que se incluye por defecto.static void Download(string file, int ms){ Thread.Sleep(ms); Console.WriteLine($"Descargué {file}");}
static void Main(){ Download("marzo.pdf", 600); Download("abril.pdf", 200); Download("mayo.pdf", 400); Console.WriteLine("Listo");}Descargué marzo.pdfDescargué abril.pdfDescargué mayo.pdfListoLa idea
Proceso e hilo
Cuando ejecutas el programa, el sistema operativo le da un proceso: su propia memoria, sus propios archivos abiertos. Dentro del proceso trabajan uno o más hilos (threads). Un hilo es una secuencia de instrucciones que se ejecutan una tras otra. Hasta ahora, cada uno de tus programas tenía un solo hilo: el que ejecuta Main.
Los hilos de un mismo proceso comparten su memoria: ven los mismos objetos. Eso los hace prácticos (se pasan datos con facilidad) y peligrosos (pueden estorbarse entre sí, como verás en un momento).
- En paralelo significa de verdad al mismo tiempo, en distintos núcleos del procesador.
- Concurrente significa en un solo núcleo, turnándose tan rápido que parece al mismo tiempo.
Para quien programa, la diferencia rara vez importa: en los dos casos no sabes qué hilo continuará y cuándo.
Más precisamente, para curiosos
- El navegador Chrome lanza un proceso aparte para cada pestaña. Por eso un sitio que se cuelga no detiene a los demás, y también por eso el navegador usa mucha memoria.
- Todo programa que hace varias cosas a la vez usa hilos: un juego reproduce el sonido en un hilo y ejecuta la lógica en otro; un servidor web atiende a cada cliente en su propio hilo.
- El precio: cada hilo tiene su propia pila, y la memoria para ella se reserva por separado. Miles de hilos cuestan mucho. Por eso los programas reales usan un grupo de hilos (la última idea de la lección).
Míralo
El primer hilo
La clase Thread es un hilo. A su constructor le pasas qué hacer: un delegado ThreadStart, un método sin parámetros que no devuelve nada. Lo más corto es con una lambda:
Thread worker = new Thread(() => Download("abril.pdf", 200));worker.Start();Console.WriteLine("Main no espera");worker.Join();Console.WriteLine("El informe está descargado");Main no esperaDescargué abril.pdfEl informe está descargadoStart()lanza el hilo nuevo y vuelve enseguida.Mainsigue sin esperar, por eso «Main no espera» se imprime primero.Join()hace queMainespere a que el hilo termine.
Download es el método de la primera tarjeta. En C# las excepciones no son comprobadas, así que aquí no hay throws y no hay que capturar nada: Main sigue siendo static void Main().
Más precisamente, para curiosos
class ReportJob{ public void Run() { Console.WriteLine("2. un método de un objeto"); }}
static void PrintReport(){ Console.WriteLine("1. un método estático por su nombre");}
static void Main(){ Thread a = new Thread(PrintReport); Thread b = new Thread(new ReportJob().Run); Thread c = new Thread(delegate () { Console.WriteLine("3. un método anónimo"); }); Thread d = new Thread(() => Console.WriteLine("4. una lambda")); foreach (Thread t in new Thread[] { a, b, c, d }) { t.Start(); t.Join(); }}1. un método estático por su nombre2. un método de un objeto3. un método anónimo4. una lambdaThread no puede tener subclases: la clase es sealed (sellada). Tampoco hay clases anónimas; su antiguo equivalente es el método anónimo delegate () { ... }. Por eso la tarea siempre va separada del hilo: un delegado que también se puede dar a un grupo de hilos. Si el hilo necesita un argumento, también existe ParameterizedThreadStart: new Thread(file => ...) y Start("marzo.pdf"). Ahí el argumento es un object, así que una lambda que captura una variable es más práctica.Míralo
Tres a la vez
long start = Environment.TickCount64;Thread march = new Thread(() => Download("marzo.pdf", 600));Thread april = new Thread(() => Download("abril.pdf", 200));Thread may = new Thread(() => Download("mayo.pdf", 400));march.Start();april.Start();may.Start();march.Join();april.Join();may.Join();long ms = Environment.TickCount64 - start;Console.WriteLine($"Listo en menos de un segundo: {ms < 1000}");Descargué abril.pdfDescargué mayo.pdfDescargué marzo.pdfListo en menos de un segundo: TrueLos hilos arrancan en el orden marzo, abril, mayo, pero terminan en el orden de las pausas: abril (0,2 s), mayo (0,4 s), marzo (0,6 s). La espera completa dura lo que la más larga, 0,6 segundos en lugar de 1,2.
El sistema operativo decide en qué orden llegan los hilos aWriteLine. Aquí las pausas son lo bastante distintas, así que el orden es siempre el mismo. Si fueran iguales, podría cambiar en cada ejecución.Environment.TickCount64 es el número de milisegundos desde que se encendió la computadora: la diferencia entre dos lecturas es el tiempo que ha pasado.Pruébalo
Start o una llamada
¿Qué imprimirá el programa? (Name es el nombre del hilo)
Thread.CurrentThread.Name = "Main";ThreadStart job = () => Console.WriteLine($"Se ejecuta en: {Thread.CurrentThread.Name}");Thread t = new Thread(job);t.Name = "descarga";job();t.Start();t.Join();job() es una llamada normal al delegado: la lambda se ejecuta enseguida, en el hilo de Main. No hay ningún hilo nuevo. Solo t.Start() lanza el hilo «descarga», que ejecuta la misma lambda. El hilo principal en .NET no tiene nombre, por eso la primera línea le da «Main». Thread en C# no tiene un método Run() que puedas confundir con Start(), pero el mismo error es llamar al método tú mismo en lugar de dárselo a un hilo: el programa funciona, solo que nada va en paralelo.La idea
La vida de un hilo
ThreadState te dice en cuál está:Thread t = new Thread(() => Download("mayo.pdf", 500));Console.WriteLine(t.ThreadState);t.Start();Thread.Sleep(100);Console.WriteLine(t.ThreadState);t.Join();Console.WriteLine(t.ThreadState);try{ t.Start();}catch (ThreadStateException){ Console.WriteLine("Segundo Start: ThreadStateException");}UnstartedWaitSleepJoinDescargué mayo.pdfStoppedSegundo Start: ThreadStateException| Estado | Cuándo |
|---|---|
Unstarted |
creado, todavía no lanzado con Start() |
Running |
trabajando o listo para trabajar |
WaitSleepJoin |
esperando: un cerrojo, otro hilo (Join) o un tiempo fijo (Sleep) |
Stopped |
su método ha terminado |
Un hilo se lanza una sola vez. Un segundo Start() sobre el mismo hilo lanza ThreadStateException. Para una respuesta más corta, solo «¿está trabajando?», existe IsAlive.
Más precisamente, para curiosos
Más sobre Thread:
Interrupt()le pide al hilo que se detenga. Si está durmiendo o esperando,Sleep/JoinlanzaThreadInterruptedException. En C# no es comprobada: si el hilo no la captura, el programa se detiene con un error. Detener un hilo desde fuera conAbort()no funciona en el .NET actual.Name,ManagedThreadId: el nombre y el número del hilo.Thread.CurrentThreadte da el hilo en el que se está ejecutando el código.Thread.Yield()sugiere que el hilo puede ceder el procesador a otro. Rara vez hace falta.
Problema
Las visitas perdidas
Increment() 100 000 veces:class Counter{ private int count = 0;
public void Increment() { count++; }
public int Count => count;}
static void Main(){ Counter visits = new Counter(); ThreadStart work = () => { for (int i = 0; i < 100_000; i++) { visits.Increment(); } }; Thread first = new Thread(work); Thread second = new Thread(work); first.Start(); second.Start(); first.Join(); second.Join(); Console.WriteLine($"Visitas: {visits.Count}");}Esperas 200 000. Cinco ejecuciones:
Visitas: 146528Visitas: 120201Visitas: 178089Visitas: 152070Visitas: 153205Cada vez menos, y cada vez distinto. La razón es que count++ no es un solo paso, sino tres:
int value = count; // 1. leervalue = value + 1; // 2. sumar 1count = value; // 3. escribirclass Counter{ private int count = 5; private readonly object sync = new object();
public void Increment() { count++; // leer, sumar 1, escribir }
public void SafeIncrement() { lock (sync) { count++; } }
public int Count => count;}Paso a paso, en palabras
- count vale 5. Los hilos A y B llaman a Increment() al mismo tiempo.
- A lee count: 5.
- B lee count antes de que A haya escrito: también 5.
- A suma 1 en su propia copia y escribe 6.
- B también escribe 6. Dos llamadas, y count vale 6, no 7: una de las sumas se ha perdido.
- Otra vez desde el principio, con lock: A toma el cerrojo, lee 5 y escribe 6.
- B llega a lock (sync) y espera hasta que A suelte el cerrojo.
- B toma el cerrojo, lee 6 y escribe 7: no se pierde nada.
Cuando los dos hilos leen el mismo valor antes de que alguno haya escrito, una de las sumas se pierde. Esto se llama condición de carrera (race condition). Que ocurra o no depende de cuándo el sistema operativo cambia de hilo, por eso el resultado es distinto en cada ejecución.
La idea
lock: de uno en uno
lock hace justo eso:class Counter{ private int count = 0; private readonly object sync = new object();
public void Increment() { lock (sync) { count++; } }
public int Count { get { lock (sync) { return count; } } }}Visitas: 200000sync es un objeto normal que solo sirve de llave: cualquier objeto en C# puede ser un cerrojo (lock). El hilo lo toma al entrar en lock (sync) y lo suelta al salir, incluso si hay una excepción. El otro hilo que quiere la misma llave espera en la entrada. Ahora el resultado es 200 000 en cada ejecución. La lectura de Count también está en un lock: así el hilo que lee ve el último valor escrito.
La llave es un campo privado, no this: así ningún código de fuera puede cerrar el mismo objeto y hacerte esperar. C# no tiene la palabra synchronized delante de un método como Java. Lo más parecido es el atributo [MethodImpl(MethodImplOptions.Synchronized)], pero cierra el objeto entero (this), por eso no se recomienda:
using System.Runtime.CompilerServices;
class Counter{ private int count = 0;
[MethodImpl(MethodImplOptions.Synchronized)] public void Increment() { count++; }
public int Count => count;}Para un contador hay además una clase lista: Interlocked. Interlocked.Increment(ref count) hace los tres pasos de una vez, sin cerrojo. ref pasa el propio campo, no una copia de su valor:
class Counter{ private int count = 0;
public void Increment() { Interlocked.Increment(ref count); }
public int Count => count;}Visitas: 200000Aquí Count se lee sin protección, porque Main lo lee solo después de Join. Join garantiza que ve todo lo que el hilo ha escrito.
Más precisamente, para curiosos
Monitor.WaityMonitor.PulseAll: un hilo dentro de un bloque lock puede soltar el cerrojo y dormirse hasta que otro hilo lo despierte. Así la cocina espera un pedido, y la caja espera a que la cocina recoja el anterior (el problema del «productor y consumidor»):
class OrderBox{ private string order; private readonly object sync = new object();
public void Put(string newOrder) { lock (sync) { while (order != null) { Monitor.Wait(sync); } order = newOrder; Monitor.PulseAll(sync); } }
public string Take() { lock (sync) { while (order == null) { Monitor.Wait(sync); } string result = order; order = null; Monitor.PulseAll(sync); return result; } }}
static void Main(){ OrderBox box = new OrderBox(); Thread kitchen = new Thread(() => { for (int i = 0; i < 3; i++) { Console.WriteLine($"La cocina recibió {box.Take()}"); } }); kitchen.Start(); box.Put("pizza"); box.Put("ensalada"); box.Put("sopa"); kitchen.Join();}La cocina recibió pizzaLa cocina recibió ensaladaLa cocina recibió sopaMonitor.Wait está dentro de un while, no de un if: el hilo puede despertarse sin motivo, así que vuelve a comprobar la condición. BlockingCollection<string> con boundedCapacity: 1 hace lo mismo ya hecho: Add espera si está llena, y Take espera si está vacía.
- Interbloqueo (deadlock): dos hilos se esperan mutuamente para siempre. La oficina tiene la impresora y espera el escáner, y la recepción tiene el escáner y espera la impresora:
object printer = new object();object scanner = new object();
Thread office = new Thread(() =>{ lock (printer) { Thread.Sleep(100); lock (scanner) { Console.WriteLine("La oficina está copiando"); } }});Thread reception = new Thread(() =>{ lock (scanner) { Thread.Sleep(100); lock (printer) { Console.WriteLine("La recepción está copiando"); } }});El programa se cuelga y nunca imprime nada. La forma de evitarlo es que todos los hilos tomen los cerrojos en el mismo orden.
- Prioridades e hilos en segundo plano:
Prioritysugiere lo importante que es el hilo, pero el sistema operativo puede ignorarlo, así que no dependas de ella para que el programa sea correcto.IsBackground = trueconvierte el hilo en un hilo en segundo plano: el programa termina sin esperarlo:
Thread report = new Thread(() => Download("anual.pdf", 100));report.Priority = ThreadPriority.Lowest; // Lowest, BelowNormal, Normal, AboveNormal, Highestreport.IsBackground = true; // el programa no espera a los hilos en segundo planoConsole.WriteLine($"{report.Priority}, en segundo plano: {report.IsBackground}");Lowest, en segundo plano: True- Colecciones listas para muchos hilos están en
System.Collections.Concurrent:ConcurrentDictionary,ConcurrentQueue,BlockingCollection. Sus métodos hacen la comprobación y el cambio de una vez, sin unlocktuyo.
Pruébalo
Dos cajeros automáticos
Elige la línea con el error.
La comprobación balance >= amount y el retiro no son un todo. Los dos hilos comprueban al mismo tiempo, los dos ven 100 y los dos retiran. En este ejemplo pequeño ocurre pocas veces, y eso hace el error traicionero: pasa mil veces y falla en casa del cliente. La corrección es un lock alrededor de la comprobación y el retiro (y alrededor de la lectura de Balance):
class Account{ private int balance = 100; private readonly object sync = new object();
public void Withdraw(int amount) { lock (sync) { if (balance >= amount) { balance -= amount; } } }
public int Balance { get { lock (sync) { return balance; } } }}Entonces el segundo cajero espera al primero, ve 20 € y rechaza el retiro. El saldo se queda en 20 €.
La idea
Grupo de hilos y Task
ThreadPool, y él mismo decide cuántos hilos tener. Task.Run (de System.Threading.Tasks) le da una tarea:static int SizeOf(string file, int ms){ Thread.Sleep(ms); return file.Length * 100;}string[] files = { "marzo.pdf", "abril.pdf", "mayo.pdf", "junio.pdf" };List<Task<int>> sizes = new List<Task<int>>();foreach (string file in files){ sizes.Add(Task.Run(() => SizeOf(file, 300)));}for (int i = 0; i < files.Length; i++){ Console.WriteLine($"{files[i]}: {sizes[i].Result} KB");}marzo.pdf: 900 KBabril.pdf: 900 KBmayo.pdf: 800 KBjunio.pdf: 900 KBTask.Runrecibe una tarea que devuelve un resultado, se la da al grupo y enseguida devuelve unTask<int>: una promesa de que el resultado llegará (en Java es unFuture)..Resultespera a que la tarea termine y devuelve el resultado. Si la tarea lanzó una excepción,.Resultla lanza envuelta en unaAggregateException.- El grupo no se detiene al final: sus hilos son hilos en segundo plano, así que el programa termina cuando termina
Main. Una tarea que todavía esté trabajando en ese momento simplemente se corta.
El grupo decide cuántas tareas van a la vez. Si quieres limitarlas, como con un grupo de 2 hilos, Parallel.For ejecuta el cuerpo para cada i en el grupo, como mucho MaxDegreeOfParallelism a la vez, y espera a todas:
string[] files = { "marzo.pdf", "abril.pdf", "mayo.pdf", "junio.pdf" };int[] sizes = new int[files.Length];ParallelOptions options = new ParallelOptions();options.MaxDegreeOfParallelism = 2;Parallel.For(0, files.Length, options, i =>{ sizes[i] = SizeOf(files[i], 300);});for (int i = 0; i < files.Length; i++){ Console.WriteLine($"{files[i]}: {sizes[i]} KB");}marzo.pdf: 900 KBabril.pdf: 900 KBmayo.pdf: 800 KBjunio.pdf: 900 KB.Result espera manteniendo ocupado el hilo. En el módulo de web verás await, que espera la misma tarea sin ocupar un hilo.
Más precisamente, para curiosos
Task.Run te da un Task sin tipo. Entonces esperas a todas con Task.WaitAll:List<Task> jobs = new List<Task>();for (int i = 1; i <= 5; i++){ string file = $"informe-{i}.pdf"; jobs.Add(Task.Run(() => Download(file, 100)));}Task.WaitAll(jobs.ToArray());Console.WriteLine("Todos terminaron.");Descargué informe-2.pdfDescargué informe-1.pdfDescargué informe-4.pdfDescargué informe-5.pdfDescargué informe-3.pdfTodos terminaron.ThreadPool.QueueUserWorkItem, pero no te da una tarea que puedas esperar.Pruébalo
¿Qué resultado sale primero?
«archivo.zip» tarda 0,6 s en descargarse, y «nota.txt», 0,1 s. ¿Qué imprimirá el programa?
Task<int> big = Task.Run(() => SizeOf("archivo.zip", 600));Task<int> small = Task.Run(() => SizeOf("nota.txt", 100));Console.WriteLine($"Grande: {big.Result}");Console.WriteLine($"Pequeño: {small.Result}");El archivo pequeño de verdad está listo primero. Pero big.Result es la primera línea, y espera al grande. Cuando este llega, small.Result devuelve enseguida, porque el resultado ya está ahí. El orden de la salida es el orden de las lecturas de .Result, así que es el mismo en cada ejecución.
Ejercicio
Programas con hilos
Resuélvelos en tu editor.
1. Suma en paralelo. El array sales tiene 1 000 000 de ventas, sales[i] = i % 100. Divídelo en 4 partes iguales y suma cada una en su propio hilo. Cada hilo escribe en su propio lugar de el array long[] partial, así que no necesitas sincronización. Una lambda dentro de un for captura la propia variable del bucle, no su valor (la lección «Lambdas»): copia el número de la parte en una variable nueva antes de la lambda. Espéralos con Join y suma las partes:
Partes: [12375000, 12375000, 12375000, 12375000]Total: 495000002. Descarga con un grupo. Descarga seis informes (marzo.pdf, abril.pdf, mayo.pdf, junio.pdf, julio.pdf, agosto.pdf) con Parallel.For, 3 a la vez. Usa SizeOf(file, 300) de la tarjeta del grupo. Imprime los tamaños en el orden de los archivos, el total, y si todo tardó menos de 1,5 segundos (uno tras otro serían 1,8 s):
marzo.pdf: 900 KBabril.pdf: 900 KBmayo.pdf: 800 KBjunio.pdf: 900 KBjulio.pdf: 900 KBagosto.pdf: 1000 KBTotal: 5400 KBMenos de 1,5 segundos: True3. Un contador sin pérdidas. Cuatro hilos, cada uno con 50 000 visitas, aumentan un contador compartido. Escribe el contador de forma que el resultado sea siempre 200 000. Pruébalo primero sin protección y mira cuánto se pierde, luego corrígelo una vez con lock y otra con Interlocked.
Visitas: 200000Más tareas
- Un test para el contador. Escribe un test de xUnit (la lección «Tests con xUnit») que lance 8 hilos con 100 000 aumentos cada uno y compruebe 800 000 con
Assert.Equal. Ejecútalo varias veces contra el contador sin protección, y luego contra el contador conlock. Un test que falla solo a veces es la señal típica de una condición de carrera. Con 10 000 por hilo en .NET, un hilo a menudo termina antes de que el siguiente haya arrancado, y el test pasa: la condición de carrera está ahí, pero no la ves. - El interbloqueo. Ejecuta el ejemplo de la impresora y el escáner de «Más precisamente» (con
StartyJoin) y mira cómo se cuelga. Corrígelo de forma que los dos hilos tomen primero la impresora.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: try, catch y finally · Listas · Lambdas y delegados · Tests con xUnit
- Continúa en: Programación en red · async y await · WebAPI: datos para otros programas · Quicksort y el mal pivote
- Ejercicios sobre el tema: Programas en el mundo real