10. Los hilos de ejecución
10.1. La clase Thread
Cuando se inicia una aplicación, esta se ejecuta en un flujo de ejecución denominado «hilo». La clase .NET que modela un thread es la clase System.Threading.Thread y tiene la siguiente definición:
Constructores
![]() |
En los ejemplos siguientes solo utilizaremos los constructores [1,3]. El constructor [1] admite como parámetro un método con la firma [2], c.a.d, que tiene un parámetro de tipo object y no devuelve ningún resultado. El constructor [3] admite como parámetro un método con la firma [4], c.a.d, que no tiene parámetros y no devuelve ningún resultado.
Propiedades
Algunas propiedades útiles:
- Hilo CurrentThread: propiedad estática que proporciona una referencia al hilo en el que se encuentra el código que ha solicitado esta propiedad
- string Name: el nombre del hilo
- bool IsAlive: indica si el hilo se está ejecutando o no.
Métodos
Los métodos más utilizados son los siguientes:
- Start(), Start(object obj): inicia la ejecución asíncrona del hilo, pasandole, si es necesario, información en un tipo object.
- Abort(), Abort(object obj): para forzar la finalización de un hilo
- Join(): el hilo T1 que ejecuta T2.Join queda bloqueado hasta que finalice el hilo T2. Existen variantes para poner fin a la espera tras un tiempo determinado.
- Sleep(int n): método estático; el hilo que ejecuta el método queda suspendido durante n milisegundos. De este modo, pierde el control del procesador, que se cede a otro hilo.
Veamos una primera aplicación que pone de manifiesto la existencia de un hilo principal de ejecución, aquel en el que se ejecuta la función Main de una clase:
using System;
using System.Threading;
namespace Chap8 {
class Program {
static void Main(string[] args) {
// inicialización del hilo actual
Thread main = Thread.CurrentThread;
// visualización
Console.WriteLine("Thread courant : {0}", main.Name);
// se cambia el nombre
main.Name = "main";
// verificación
Console.WriteLine("Thread courant : {0}", main.Name);
// bucle infinito
while (true) {
// visualización
Console.WriteLine("{0} : {1:hh:mm:ss}", main.Name, DateTime.Now);
// parada temporal
Thread.Sleep(1000);
}//while
}
}
}
- línea 8: se obtiene una referencia al hilo en el que se ejecuta el método [main]
- líneas 10-14: se muestra y se modifica su nombre
- líneas 17-22: un bucle que muestra el resultado cada segundo
- línea 21: el hilo en el que se ejecuta el método [main] se suspenderá durante 1 segundo
Los resultados en pantalla son los siguientes:
- línea 1: el hilo actual no tenía nombre
- línea 2: ahora sí tiene uno
- líneas 3-7: la visualización que tiene lugar cada segundo
- línea 8: el programa se interrumpe con Ctrl-C.
10.2. Creación de subprocesos de ejecución
Es posible tener aplicaciones en las que fragmentos de código se ejecutan de forma «simultánea» en diferentes hilos de ejecución. Cuando se dice que los thread se ejecutan de forma simultánea, a menudo se comete un error de expresión. Si el equipo solo tiene un procesador, como suele ser aún el caso, los thread comparten ese procesador: disponen de él, cada uno por turnos, durante un breve instante (unos milisegundos). Esto es lo que da la ilusión de paralelismo en la ejecución. El tiempo asignado a un thread depende de diversos factores, entre ellos su prioridad, que tiene un valor por defecto pero que también puede fijarse mediante programación. Cuando un thread dispone del procesador, normalmente lo utiliza durante todo el tiempo que se le ha asignado. Sin embargo, puede liberarlo antes de tiempo:
- poniéndose en espera de un evento (Wait, Join)
- entrando en estado de suspensión durante un tiempo determinado (Sleep)
- Un hilo T se crea en primer lugar mediante uno de los constructores presentados anteriormente, por ejemplo:
donde Start es un método que tiene una de las dos firmas siguientes:
La creación de un hilo no lo inicia.
- La ejecución del hilo T se inicia mediante T.Start(): el método Start pasado al constructor de T será entonces ejecutado por el hilo T. El programa que ejecuta la instrucción T.Start() no espera a que finalice la tarea T: pasa inmediatamente a la instrucción siguiente. Así pues, tenemos dos tareas que se ejecutan en paralelo. A menudo deben poder comunicarse entre sí para saber en qué punto se encuentra el trabajo común que deben realizar. Este es el problema de la sincronización de los hilos.
- Una vez iniciado, el hilo T se ejecuta de forma autónoma. Se detendrá cuando el método Start que está ejecutando haya terminado su trabajo.
- Se puede forzar al hilo T a que finalice:
- T.Abort() solicita al hilo T que finalice.
- También se puede esperar a que finalice su ejecución mediante T.Join(). Se trata de una instrucción bloqueante: el programa que la ejecuta queda bloqueado hasta que la tarea T haya terminado su trabajo. Es un método de sincronización.
Analicemos el siguiente programa:
using System;
using System.Threading;
namespace Chap8 {
class Program {
public static void Main() {
// inicialización del hilo actual
Thread main = Thread.CurrentThread;
// asignación de un nombre al hilo
main.Name = "Main";
// creación de hilos de ejecución
Thread[] tâches = new Thread[5];
for (int i = 0; i < tâches.Length; i++) {
// se crea el hilo i
tâches[i] = new Thread(Affiche);
// se establece el nombre del hilo
tâches[i].Name = i.ToString();
// se inicia la ejecución del hilo i
tâches[i].Start();
}
// fin de la función
Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}",main.Name,DateTime.Now);
}
public static void Affiche() {
// se muestra el inicio de la ejecución
Console.WriteLine("Début d'exécution de la méthode Affiche dans le Thread {0} : {1:hh:mm:ss}",Thread.CurrentThread.Name,DateTime.Now);
// se pone en espera durante 1 s
Thread.Sleep(1000);
// Visualización del final de la ejecución
Console.WriteLine("Fin d'exécution de la méthode Affiche dans le Thread {0} : {1:hh:mm:ss}", Thread.CurrentThread.Name, DateTime.Now);
}
}
}
- líneas 8-10: se asigna un nombre al hilo que ejecuta el método [Main]
- líneas 13-21: se crean 5 subprocesos y se ejecutan. Las referencias de los subprocesos se almacenan en un array para poder recuperarlas posteriormente. Cada subproceso ejecuta el método Affiche de las líneas 27-35.
- línea 20: se inicia el hilo n.º i. Esta operación es no bloqueante. El hilo n.º i se ejecutará en paralelo al hilo del método [Main] que lo ha iniciado.
- Línea 24: el hilo que ejecuta el método [Main] finaliza.
- Líneas 27-35: el método [Affiche] muestra información. Muestra el nombre del hilo que lo ejecuta, así como las horas de inicio y fin de la ejecución.
- línea 31: cualquier hilo que ejecute el método [Affiche] se detendrá durante 1 segundo. A continuación, el procesador se cederá a otro hilo que esté a la espera del procesador. Al finalizar el segundo de parada, el hilo detenido volverá a ser candidato a utilizar el procesador. Lo obtendrá cuando le llegue el turno. Esto depende de diversos factores, entre ellos la prioridad de los demás hilos que esperan el procesador.
Los resultados son los siguientes:
Estos resultados son muy reveladores:
- en primer lugar, se observa que el inicio de la ejecución de un hilo no es bloqueante. El método Main inició la ejecución de 5 hilos en paralelo y terminó su ejecución antes que ellos. La operación
inicia la ejecución del hilo tareas[i], pero una vez hecho esto, la ejecución continúa inmediatamente con la instrucción siguiente sin esperar a que finalice la ejecución del hilo.
- Todos los subprocesos creados deben ejecutar el método Affiche. El orden de ejecución es impredecible. Aunque en el ejemplo el orden de ejecución parezca seguir el orden de las solicitudes de ejecución, no se pueden extraer conclusiones generales. El sistema operativo cuenta aquí con 6 subprocesos y un procesador. Distribuirá el procesador entre estos 6 subprocesos según sus propias reglas.
- En los resultados se observa una consecuencia del método Sleep. En el ejemplo, es el hilo 0 el que ejecuta en primer lugar el método Affiche. Se muestra el mensaje de inicio de ejecución y, a continuación, ejecuta el método Sleep, que lo suspende durante 1 segundo. Entonces pierde el procesador, que queda así disponible para otro hilo. El ejemplo muestra que es el hilo 1 el que lo va a obtener. El hilo 1 seguirá el mismo recorrido, al igual que los demás hilos. Cuando finalice el segundo de espera del hilo 0, su ejecución podrá reanudarse. El sistema le cede el procesador y puede terminar la ejecución del método Affiche.
Modifiquemos nuestro programa para finalizar el método Main con las instrucciones:
// fin de la rutina principal
Console.WriteLine("Fin du thread " + main.Name);
// se detienen todos los hilos
Environment.Exit(0);
La ejecución del nuevo programa da los siguientes resultados:
- líneas 1-5: los subprocesos creados por la función Main comienzan su ejecución y se interrumpen durante 1 segundo
- línea 6: el hilo [Main] recupera el procesador y ejecuta la instrucción:
Esta instrucción detiene todos los hilos de la aplicación y no solo el hilo Main.
Si el método Main quiere esperar a que finalice la ejecución de los hilos que ha creado, puede utilizar el método Join de la clase Thread:
public static void Main() {
...
// se espera a todos los hilos
for (int i = 0; i < tâches.Length; i++) {
// esperando a que finalice la ejecución del hilo i
tâches[i].Join();
}
// fin de main
Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}", main.Name, DateTime.Now);
}
- línea 6: el hilo [Main] espera a cada uno de los hilos. Primero queda bloqueado a la espera del hilo n.º 1, luego del hilo n.º 2, etc. Finalmente, cuando sale del bucle de las líneas 2-5, es porque los 5 hilos que ha iniciado han finalizado.
De este modo, se obtienen los siguientes resultados:
- línea 11: el hilo [Main] ha finalizado después de los hilos que había iniciado.
10.3. Interés de los hilos
Ahora que hemos puesto de manifiesto la existencia de un hilo por defecto —el que ejecuta el método Main— y que sabemos cómo crear otros, detengámonos en la utilidad que tienen para nosotros los hilos y en las razones por las que los presentamos aquí. Hay un tipo de aplicaciones que se prestan muy bien al uso de subprocesos: las aplicaciones cliente-servidor de Internet. Las presentaremos en el siguiente capítulo. En una aplicación cliente-servidor de Internet, un servidor ubicado en una máquina S1 responde a las solicitudes de clientes ubicados en máquinas remotas C1, C2, ..., Cn.
![]() |
Todos los días utilizamos aplicaciones de Internet que se ajustan a este esquema: servicios web, correo electrónico, consulta de foros, transferencia de archivos... En el esquema anterior, el servidor S1 debe atender a los clientes Ci de forma simultánea. Si tomamos el ejemplo de un servidor FTP (Protocolo de Transferencia de Archivos) que entrega archivos a sus clientes, sabemos que una transferencia de archivos puede tardar a veces varios minutos. Por supuesto, es impensable que un solo cliente monopolice el servidor durante tanto tiempo. Lo que se suele hacer es que el servidor cree tantos subprocesos de ejecución como clientes haya. Cada subproceso se encarga entonces de atender a un cliente concreto. Dado que el procesador se comparte cíclicamente entre todos los subprocesos activos de la máquina, el servidor dedica un poco de tiempo a cada cliente, garantizando así la simultaneidad del servicio.
![]() |
En la práctica, el servidor utiliza un grupo de subprocesos con un número limitado de subprocesos, por ejemplo, 50. El cliente número 51 debe esperar.
10.4. Intercambio de información entre subprocesos
En los ejemplos anteriores, un hilo se inicializaba de la siguiente manera:
donde Run era un método con la siguiente firma:
También es posible utilizar la siguiente firma:
Esto permite transmitir información al hilo iniciado. Así,
iniciará el hilo t, que a su vez ejecutará el método Run que se le ha asociado por defecto, pasándole el parámetro efectivo obj1. He aquí un ejemplo:
using System;
using System.Threading;
namespace Chap8 {
class Program4 {
public static void Main() {
// inicialización del hilo actual
Thread main = Thread.CurrentThread;
// Se asigna un nombre al hilo
main.Name = "Main";
// Creación de hilos de ejecución
Thread[] tâches = new Thread[5];
Data[] data = new Data[5];
for (int i = 0; i < tâches.Length; i++) {
// se crea el hilo i
tâches[i] = new Thread(Sleep);
// se establece el nombre del hilo
tâches[i].Name = i.ToString();
// se inicia la ejecución del hilo i
tâches[i].Start(data[i] = new Data { Début = DateTime.Now, Durée = i+1 });
}
// se espera a que terminen todos los hilos
for (int i = 0; i < tâches.Length; i++) {
// se espera a que finalice la ejecución del hilo i
tâches[i].Join();
// visualización del resultado
Console.WriteLine("Thread {0} terminé : début {1:hh:mm:ss}, durée programmée {2} s, fin {3:hh:mm:ss}, durée effective {4}",
tâches[i].Name,data[i].Début,data[i].Durée,data[i].Fin,(data[i].Fin-data[i].Début));
}
// fin de la rutina
Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}", main.Name, DateTime.Now);
}
public static void Sleep(object infos) {
// se recupera el parámetro
Data data = (Data)infos;
// suspensión durante «Duración» segundos
Thread.Sleep(data.Durée*1000);
// fin de la ejecución
data.Fin = DateTime.Now;
}
}
internal class Data {
// información diversa
public DateTime Début { get; set; }
public int Durée { get; set; }
public DateTime Fin { get; set; }
}
}
- líneas 45-50: la información de tipo [Data] pasada a los hilos:
- Début: hora de inicio de la ejecución del hilo, fijada por el hilo lanzador
- Durée: duración en segundos del Sleep ejecutado por el hilo lanzado —establecida por el hilo lanzador—
- Fin: hora de inicio de la ejecución del hilo —establecida por el hilo lanzador—
- líneas 35-43: el método Sleep ejecutado por los hilos tiene la firma void Sleep(object obj). El parámetro efectivo obj será del tipo [Data], definido en la línea 45.
- líneas 15-22: creación de 5 subprocesos
- línea 17: cada hilo se asocia al método Sleep de la línea 35
- línea 21: se pasa un objeto de tipo [Data] al método Start, que inicia el hilo. En este objeto se ha registrado la hora de inicio de la ejecución del hilo, así como la duración en segundos durante la cual debe permanecer inactivo. Este objeto se almacena en la matriz de la línea 14.
- líneas 24-30: el hilo [Main] espera a que finalicen todos los hilos que ha iniciado.
- líneas 28-29: el hilo [Main] recupera el objeto data[i] del hilo n.º i y muestra su contenido.
- líneas 35-42: el método Sleep ejecutado por los hilos
- línea 37: se recupera el parámetro de tipo [Data]
- línea 39: el campo Durée del parámetro se utiliza para establecer la duración del Sleep
- línea 41: se inicializa el campo Fin del parámetro
Los resultados de la ejecución son los siguientes:
Este ejemplo muestra que dos subprocesos pueden intercambiar información:
- el hilo lanzador puede controlar la ejecución del hilo lanzado proporcionándole información
- el hilo lanzado puede devolver resultados al hilo lanzador.
Para que el hilo lanzado sepa en qué momento están disponibles los resultados que espera, debe recibir una notificación de que el hilo lanzado ha finalizado. En este caso, ha esperado a que terminara utilizando el método Join. Hay otras formas de hacer lo mismo. Las veremos más adelante.
10.5. Acceso concurrente a recursos compartidos
10.5.1. Acceso concurrente no sincronizado
En el apartado sobre el intercambio de información entre subprocesos, la información solo se intercambiaba entre dos subprocesos y en momentos muy concretos. Se trataba de un clásico paso de parámetros. Existen otros casos en los que varios subprocesos comparten una información y pueden querer leerla o actualizarla al mismo tiempo. Entonces surge el problema de la integridad de dicha información. Supongamos que la información compartida es una estructura S con diversos datos: I1, I2, ... In.
- Un hilo T1 comienza a actualizar la estructura S: modifica el campo I1 y es interrumpido antes de haber completado la actualización de la estructura S
- A continuación, un hilo T2, que recupera el procesador, lee la estructura S para tomar decisiones. Lee una estructura en un estado inestable: algunos campos están actualizados, otros no.
A esta situación se le denomina «acceso a un recurso compartido» —en este caso, la estructura S— y suele ser bastante complicada de gestionar. Veamos el siguiente ejemplo para ilustrar los problemas que pueden surgir:
- una aplicación va a generar n subprocesos, siendo n un parámetro pasado
- el recurso compartido es un contador que deberá ser incrementado por cada hilo generado
- al final de la aplicación, se muestra el valor del contador. Por lo tanto, deberíamos encontrar n.
El programa es el siguiente:
using System;
using System.Threading;
namespace Chap8 {
class Program {
// variables de clase
static int cptrThreads = 0; // contador de subprocesos
//main
public static void Main(string[] args) {
// manual de instrucciones
const string syntaxe = "pg nbThreads";
const int nbMaxThreads = 100;
// comprobación del número de argumentos
if (args.Length != 1) {
// error
Console.WriteLine(syntaxe);
// parada
Environment.Exit(1);
}
// comprobación de la calidad del argumento
int nbThreads = 0;
bool erreur = false;
try {
nbThreads = int.Parse(args[0]);
if (nbThreads < 1 || nbThreads > nbMaxThreads)
erreur = true;
} catch {
// error
erreur = true;
}
// ¿error?
if (erreur) {
// error
Console.Error.WriteLine("Nombre de threads incorrect (entre 1 et 100)");
// fin
Environment.Exit(2);
}
// creación y generación de subprocesos
Thread[] threads = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creación
threads[i] = new Thread(Incrémente);
// denominación
threads[i].Name = "" + i;
// inicio
threads[i].Start();
}//para
// espera a que finalicen los subprocesos
for (int i = 0; i < nbThreads; i++) {
threads[i].Join();
}
// visualización del contador
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// incrementa el contador de subprocesos
// lectura del contador
int valeur = cptrThreads;
// seguimiento
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
// espera
Thread.Sleep(1000);
// incremento del contador
cptrThreads = valeur + 1;
// seguimiento
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a écrit la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
}
}
}
No nos detendremos en la parte relativa a la generación de subprocesos, que ya hemos estudiado. Centrémonos más bien en el método Incrémente, de la línea 59, utilizado por cada subproceso para incrementar el contador estático cptrThreads de la línea 8.
- línea 62: se lee el contador
- línea 66: el hilo se detiene durante 1 s. Por lo tanto, pierde el control del procesador
- línea 68: se incrementa el contador
El paso 2 solo sirve para obligar al hilo a ceder el procesador. Este se asignará a otro hilo. En la práctica, no hay garantía de que un hilo no vaya a ser interrumpido entre el momento en que lee el contador y el momento en que lo incrementa. Aunque escribamos cptrThreads++, dando así la impresión de que se trata de una única instrucción, existe el riesgo de perder el control del procesador entre el momento en que se lee el valor del contador y aquel en que se escribe su valor incrementado en 1. De hecho, la operación de alto nivel cptrThreads++ se traducirá en varias instrucciones elementales a nivel del procesador. Por lo tanto, la etapa 2 de espera de un segundo solo sirve para sistematizar este riesgo.
Los resultados obtenidos con 5 subprocesos son los siguientes:
Al analizar estos resultados, se ve claramente lo que ocurre:
- línea 1: un primer hilo lee el contador. Encuentra 0. Se detiene durante 1 s, por lo que pierde el control del procesador
- línea 2: un segundo hilo toma entonces el procesador y también lee el valor del contador. Sigue estando a 0, ya que el hilo anterior aún no lo ha incrementado. También se detiene durante 1 s y, a su vez, pierde el procesador.
- líneas 1-5: en 1 s, los 5 hilos tienen tiempo de ejecutarse todos y de leer el valor 0.
- líneas 6-10: cuando se reanuden uno tras otro, incrementarán el valor 0 que han leído y escribirán el valor 1 en el contador, lo que confirma el programa principal (Main) en la línea 11.
¿De dónde surge el problema? El segundo hilo ha leído un valor erróneo debido a que el primero se vio interrumpido antes de haber terminado su tarea, que consistía en actualizar el contador de la ventana. Esto nos lleva al concepto de recurso crítico y de sección crítica de un programa:
- un recurso crítico es un recurso que solo puede ser poseído por un hilo a la vez. En este caso, el recurso crítico es el contador.
- Una sección crítica de un programa es una secuencia de instrucciones en el flujo de ejecución de un hilo durante la cual este accede a un recurso crítico. Debemos asegurarnos de que, durante esta sección crítica, sea el único que tenga acceso al recurso.
En nuestro ejemplo, la sección crítica es el código situado entre la lectura del contador y la escritura de su nuevo valor:
// lectura del contador
int valeur = cptrThreads;
// en espera
Thread.Sleep(1000);
// incremento del contador
cptrThreads = valeur + 1;
Para ejecutar este código, debe garantizarse que un hilo esté solo. Puede ser interrumpido, pero durante esa interrupción, ningún otro hilo debe poder ejecutar ese mismo código. La plataforma .NET ofrece diversas herramientas para garantizar el acceso único a las secciones críticas del código. A continuación veremos algunas de ellas.
10.5.2. La cláusula «lock»
La cláusula «lock» permite delimitar una sección crítica de la siguiente manera:
obj debe ser una referencia a un objeto visible para todos los hilos que ejecuten la sección crítica. La cláusula «lock» garantiza que solo un hilo a la vez ejecutará la sección crítica. El ejemplo anterior se reescribe de la siguiente manera:
using System;
using System.Threading;
namespace Chap8 {
class Program2 {
// variables de clase
static int cptrThreads = 0; // contador de subprocesos
static object synchro = new object(); // objeto de sincronización
//main
public static void Main(string[] args) {
...
// espera a que finalicen los hilos
Thread.CurrentThread.Name = "Main";
for (int i = nbThreads - 1; i >= 0; i--) {
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} attend la fin du thread {2}", DateTime.Now, Thread.CurrentThread.Name, threads[i].Name);
threads[i].Join();
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a été prévenu de la fin du thread {2}", DateTime.Now, Thread.CurrentThread.Name, threads[i].Name);
}
// visualización del contador
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// incrementa el contador de subprocesos
// se solicita acceso exclusivo al contador
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} attend l'autorisation d'entrer dans la section critique", DateTime.Now, Thread.CurrentThread.Name);
lock (synchro) {
// lectura del contador
int valeur = cptrThreads;
// seguimiento
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
// espera
Thread.Sleep(1000);
// incremento del contador
cptrThreads = valeur + 1;
// seguimiento
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a écrit la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
}
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a quitté la section critique", DateTime.Now, Thread.CurrentThread.Name);
}
}
}
- línea 9: synchro es el objeto que permitirá la sincronización de todos los hilos.
- líneas 16-23: el método [Main] espera a los hilos en el orden inverso al de su creación.
- líneas 29-40: la sección crítica del método Incrémente ha sido enmarcada por la cláusula lock.
Los resultados obtenidos con 3 subprocesos son los siguientes:
- El hilo 0 es el primero en entrar en la sección crítica: líneas 1, 2, 6 y 8
- los otros dos hilos quedarán bloqueados hasta que el hilo 0 salga de la sección crítica: líneas 3 y 4
- A continuación, pasa el hilo 1: líneas 7, 9 y 10
- A continuación, pasa el hilo 2: líneas 11, 12 y 13
- línea 14: se avisa al hilo Main, que estaba esperando a que terminara el hilo 2
- línea 15: el hilo Main espera ahora a que termine el hilo 1. Este ya ha terminado. Se avisa inmediatamente al hilo Main, línea 16.
- líneas 17-18: se repite el mismo proceso con el hilo 0
- línea 19: el número de hilos es correcto
10.5.3. La clase Mutex
La clase System.Threading.Mutex también permite delimitar secciones críticas. Se diferencia de la cláusula lock en cuanto a la visibilidad:
- la cláusula «lock» permite sincronizar subprocesos de una misma aplicación
- la clase Mutex permite sincronizar subprocesos de diferentes aplicaciones.
Utilizaremos el constructor y los siguientes métodos:
crea un Mutex M | |
El hilo T1 que ejecuta la operación M.WaitOne() solicita la propiedad del objeto de sincronización M. Si ningún hilo posee el Mutex M (como ocurre inicialmente), se «cede» al hilo T1 que lo ha solicitado. Si, poco después, un hilo T2 realiza la misma operación, quedará bloqueado. De hecho, un Mutex solo puede pertenecer a un hilo. Se desbloqueará cuando el hilo T1 libere el Mutex M que tiene en su poder. De este modo, varios hilos pueden quedar bloqueados a la espera del Mutex M. | |
El hilo T1, que realiza la operación M.ReleaseMutex(), cede la propiedad del Mutex Mutex. Cuando el hilo T1 pierda el procesador, el sistema podrá asignárselo a uno de los hilos que estén a la espera del mutex M. Solo uno lo obtendrá a su vez, mientras que los demás que esperan a M permanecerán bloqueados |
Un Mutex M gestiona el acceso a un recurso compartido R. Un hilo solicita el recurso R mediante M.WaitOne() y lo devuelve mediante M.ReleaseMutex(). Una sección crítica de código que solo debe ser ejecutada por un único hilo a la vez es un recurso compartido. La sincronización de la ejecución de la sección crítica puede realizarse así:
donde M es un objeto Mutex. No hay que olvidar liberar un Mutex que ya no se necesita para que otro hilo pueda entrar en la sección crítica; de lo contrario, los hilos que esperan el Mutex que nunca se ha liberado nunca tendrán acceso al procesador.
Si aplicamos lo que acabamos de ver al ejemplo anterior, nuestra aplicación queda así:
using System;
using System.Threading;
namespace Chap8 {
class Program3 {
// variables de clase
static int cptrThreads = 0; // contador de subprocesos
static Mutex synchro = new Mutex(); // objeto de sincronización
//main
public static void Main(string[] args) {
...
}
public static void Incrémente() {
....
synchro.WaitOne();
try {
...
} finally {
...
synchro.ReleaseMutex();
}
}
}
}
- línea 9: el objeto de sincronización de los hilos es ahora un Mutex.
- línea 18: inicio de la sección crítica; solo un hilo debe entrar en ella. Nos quedamos bloqueados hasta que el Mutex synchro quede libre.
- línea 33: dado que un Mutex siempre debe liberarse, haya o no una excepción, se gestiona la sección crítica con un try / finally para liberar el Mutex en el finally.
- Línea 23: el Mutex se libera una vez superada la sección crítica.
Los resultados obtenidos son los mismos que los anteriores.
10.5.4. La clase AutoResetEvent
Un objeto AutoResetEvent es una barrera que solo deja pasar un hilo a la vez, al igual que las dos herramientas anteriores, lock y Mutex. Se crea un objeto AutoResetEvent de la siguiente manera:
La variable booleana état indica si la barrera está cerrada (false) o abierta (true). Un hilo que desee atravesar la barrera lo indicará de la siguiente manera:
- Si la barrera está abierta, el hilo pasa y la barrera se vuelve a cerrar tras él. Si hay varios hilos esperando, se garantiza que solo uno pasará.
- Si la barrera está cerrada, el hilo queda bloqueado. Otro hilo la abrirá cuando llegue el momento. Este momento depende totalmente del problema que se esté tratando. La barrera se abrirá mediante la operación:
Puede ocurrir que un hilo quiera cerrar una barrera. Podrá hacerlo mediante:
Si en el ejemplo anterior sustituimos el objeto Mutex por un objeto de tipo AutoResetEvent, el código queda así:
using System;
using System.Threading;
namespace Chap8 {
class Program4 {
// variables de clase
static int cptrThreads = 0; // contador de subprocesos
static EventWaitHandle synchro = new AutoResetEvent(false); // objeto de sincronización
//main
public static void Main(string[] args) {
....
// se abre la barrera de la sección crítica
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} ouvre la barrière de la section critique", DateTime.Now, Thread.CurrentThread.Name);
synchro.Set();
// espera a que terminen los hilos
...
// visualización del contador
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// incrementa el contador de subprocesos
// se solicita acceso exclusivo al contador
...
synchro.WaitOne();
try {
...
} finally {
// se libera el recurso
...
synchro.Set();
}
}
}
}
- línea 9: la barrera se crea cerrada. La abrirá el hilo Main en la línea 16.
- línea 27: el hilo encargado de incrementar el contador de hilos solicita permiso para entrar en la sección crítica. Los distintos hilos se acumularán ante la barrera cerrada. Cuando el hilo Main la abra, uno de los hilos en espera podrá pasar.
- Línea 33: cuando haya terminado su trabajo, vuelve a abrir la barrera, lo que permite que entre otro hilo.
Se obtienen resultados similares a los anteriores.
10.5.5. La clase Interlocked
La clase Interlocked permite que un grupo de operaciones sea atómico. En un grupo de operaciones atomique, o bien todas las operaciones son ejecutadas por el hilo que ejecuta el grupo, o bien ninguna. No se queda en un estado en el que algunas se hayan ejecutado y otras no. Los objetos de sincronización lock, Mutex y AutoResetEvent tienen todos el objetivo de convertir atomique en un grupo de operaciones. Este resultado se consigue a costa del bloqueo de subprocesos. La clase Interlocked permite, en el caso de operaciones sencillas pero bastante frecuentes, evitar el bloqueo de subprocesos. La clase Interlocked ofrece los siguientes métodos estáticos:

El método Increment tiene la siguiente firma:
Permite incrementar en 1 el parámetro location. La operación está garantizada atomique.
Nuestro programa de recuento de subprocesos podría ser entonces el siguiente:
using System;
using System.Threading;
namespace Chap8 {
class Program5 {
// variables de clase
static int cptrThreads = 0; // contador de subprocesos
//main
public static void Main(string[] args) {
...
}
public static void Incrémente() {
// incrementa el contador de subprocesos
Interlocked.Increment(ref cptrThreads);
}
}
}
- línea 17: el contador de subprocesos se incrementa de forma atómica.
10.6. Accesos concurrentes a múltiples recursos compartidos
10.6.1. Un ejemplo
En nuestros ejemplos anteriores, los distintos hilos compartían un único recurso. La situación puede complicarse si hay varios recursos y estos dependen unos de otros. En particular, puede producirse una situación de interbloqueo. Esta situación, también denominada deadlock, es aquella en la que dos subprocesos se esperan mutuamente. Consideremos las siguientes acciones que se suceden en el tiempo:
- un hilo T1 obtiene la propiedad de un mutex M1 para acceder a un recurso compartido R1
- un hilo T2 obtiene la propiedad de un mutex M2 para acceder a un recurso compartido R2
- el hilo T1 solicita el mutex M2. Queda bloqueado.
- El hilo T2 solicita el mutex M1. Queda bloqueado.
En este caso, los hilos T1 y T2 se esperan mutuamente. Este caso se produce cuando los hilos necesitan dos recursos compartidos: el recurso R1, controlado por el mutex M1, y el recurso R2, controlado por el mutex M2. Una posible solución es solicitar ambos recursos al mismo tiempo mediante un único mutex M. Sin embargo, esto no siempre es posible si, por ejemplo, conlleva una ocupación prolongada de un recurso costoso. Otra solución es que un hilo que tenga M1 y no pueda obtener M2 libere entonces M1 para evitar el interbloqueo.
- Tenemos una tabla en la que unos hilos van depositando datos (los escritores) y otros van leyéndolos (los lectores).
- Los escritores son iguales entre sí, pero exclusivos: solo un escritor a la vez puede depositar sus datos en la matriz.
- Los lectores son iguales entre sí, pero exclusivos: solo un lector a la vez puede leer los datos almacenados en la matriz.
- Un lector solo puede leer los datos del array cuando un escritor los ha depositado en él, y un escritor solo puede depositar nuevos datos en el array cuando los que ya están en él han sido leídos por un lector.
Se pueden distinguir dos recursos compartidos:
- la tabla en escritura: solo un escritor a la vez debe tener acceso a ella.
- la tabla en lectura: solo un lector a la vez debe tener acceso a ella.
y un orden de uso de estos recursos:
- un lector siempre debe actuar después de un escritor.
- un escritor siempre debe actuar después de un lector, salvo la primera vez.
Se puede controlar el acceso a estos dos recursos con dos barreras del tipo AutoResetEvent:
- la barrera peutEcrire controlará el acceso de los escritores a la tabla.
- La barrera peutLire controlará el acceso de los lectores al panel.
- La barrera peutEcrire se creará inicialmente abierta, permitiendo así el paso a un primer escritor y bloqueando a todos los demás.
- La barrera peutLire se creará inicialmente cerrada, bloqueando a todos los lectores.
- Cuando un escritor haya terminado su trabajo, abrirá la barrera peutLire para dejar entrar a un lector.
- Cuando un lector haya terminado su trabajo, abrirá la barrera peutEcrire para dejar entrar a un escritor.
El programa que ilustra esta sincronización por eventos es el siguiente:
using System;
using System.Threading;
namespace Chap8 {
class Program {
// uso de subprocesos de lectura y escritura
// ilustra el uso de eventos de sincronización
// variables de clase
static int[] data = new int[3]; // recurso compartido entre subprocesos de lectura y subprocesos de escritura
static Random objRandom = new Random(DateTime.Now.Second); // Un generador de números aleatorios
static AutoResetEvent peutLire; // indica que se puede leer el contenido de «data»
static AutoResetEvent peutEcrire; // indica que se puede escribir el contenido de «data»
//main
public static void Main(string[] args) {
// el número de subprocesos que se van a generar
const int nbThreads = 2;
// inicialización de los indicadores
peutLire = new AutoResetEvent(false); // todavía no se puede leer
peutEcrire = new AutoResetEvent(true); // ya se puede escribir
// creación de los subprocesos de lectura
Thread[] lecteurs = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creación
lecteurs[i] = new Thread(Lire);
lecteurs[i].Name = "L" + i.ToString();
// inicio
lecteurs[i].Start();
}
// creación de subprocesos de escritura
Thread[] écrivains = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creación
écrivains[i] = new Thread(Ecrire);
écrivains[i].Name = "E" + i.ToString();
// inicio
écrivains[i].Start();
}
//fin de la mano
Console.WriteLine("Fin de Main...");
}
// lectura del contenido de la tabla
public static void Lire() {
...
}
// escribir en la tabla
public static void Ecrire() {
....
}
}
}
- línea 11: el array data es el recurso compartido entre los subprocesos de lectura y escritura. Los subprocesos de lectura lo comparten en modo lectura, mientras que los de escritura lo comparten en modo escritura.
- línea 13: el objeto peutLire sirve para avisar a los subprocesos de lectura de que pueden leer el array data. El subproceso de escritura que ha rellenado el array data lo establece en verdadero. Se inicializa en false, en la línea 23. Es necesario que un hilo de escritura llene primero la matriz antes de pasar el evento peutLire a vrai.
- línea 14: el objeto peutEcrire sirve para avisar a los hilos de escritura de que pueden escribir en la matriz data. El hilo de lectura que ha agotado toda la matriz data lo establece en verdadero. Se inicializa en true, línea 24. De hecho, el array data está libre para escritura.
- líneas 27-34: creación y lanzamiento de los subprocesos de lectura
- líneas 37-44: creación y lanzamiento de los subprocesos de escritura
El método Lire ejecutado por los subprocesos de lectura es el siguiente:
public static void Lire() {
// seguimiento
Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// hay que esperar a la autorización de lectura
peutLire.WaitOne();
// lectura de la tabla
for (int i = 0; i < data.Length; i++) {
//espera de 1 s
Thread.Sleep(1000);
// visualización
Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// se puede escribir
peutEcrire.Set();
// seguimiento
Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
- línea 5: se espera a que un hilo de escritura señale que la matriz se ha llenado. Cuando se reciba esta señal, solo uno de los hilos de lectura que estén a la espera de dicha señal podrá pasar.
- líneas 7-12: explotación del array data con un Sleep en medio para forzar al hilo a ceder el procesador.
- línea 14: indica a los subprocesos de escritura que el array se ha leído y que puede volver a rellenarse.
El método Ecrire ejecutado por los hilos de escritura es el siguiente:
public static void Ecrire() {
// seguimiento
Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// hay que esperar a la autorización de escritura
peutEcrire.WaitOne();
// escritura en tabla
for (int i = 0; i < data.Length; i++) {
//espera de 1 s
Thread.Sleep(1000);
// visualización
data[i] = objRandom.Next(0, 1000);
Console.WriteLine("{0:hh:mm:ss} : L'écrivain {1} a écrit le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// se puede leer
peutLire.Set();
// seguimiento
Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
- línea 5: se espera a que un hilo de lectura señale que el array ha sido leído. Cuando se reciba esta señal, solo uno de los hilos de escritura que estén a la espera de dicha señal podrá pasar.
- líneas 7-13: procesamiento del array data con un Sleep en medio para forzar al hilo a ceder el procesador.
- línea 15: indica a los subprocesos de lectura que el array se ha rellenado y que puede volver a leerse.
La ejecución da los siguientes resultados:
Cabe destacar lo siguiente:
- efectivamente, solo hay un lector a la vez, aunque este ceda el procesador en la sección crítica Lire
- efectivamente, solo hay un escritor a la vez, aunque este pierde el procesador en la sección crítica Ecrire
- un lector solo lee cuando hay algo que leer en la matriz
- un escritor solo escribe cuando la tabla se ha leído por completo
10.6.2. La clase Monitor
En el ejemplo anterior:
- hay dos recursos compartidos que gestionar
- Para un recurso determinado, los hilos son iguales.
Cuando los subprocesos de escritura están bloqueados en la instrucción peutEcrire.WaitOne, uno de ellos, cualquiera, queda desbloqueado por la operación peutEcrire.Set. Si la operación anterior debe abrir la barrera a un subproceso de escritura en concreto, las cosas se complican.
Podemos establecer una analogía con un establecimiento que atiende al público en ventanillas, donde cada ventanilla está especializada. Cuando llega el cliente, coge un ticket del dispensador correspondiente a la ventanilla X y luego se sienta a esperar. Cada ticket está numerado y se llama a los clientes por su número a través de un altavoz. Mientras espera, el cliente hace lo que quiere. Puede leer o dormitar. Cada vez le despierta el altavoz que anuncia que se llama al n.º Y en la ventanilla X. Si se trata de él, el cliente se levanta y se dirige a la ventanilla X; si no, sigue con lo que estaba haciendo.
Aquí podemos funcionar de manera análoga. Tomemos el ejemplo de los escritores:
sus hilos están bloqueados | |
El hilo que utilizaba el array en lectura indica a los hilos de escritura que el array está disponible. Este hilo u otro ha bloqueado al hilo de escritura, que debe superar la barrera. | |
Cada hilo comprueba si es el elegido. Si lo es, cruza la barrera. Si no, vuelve a ponerse en espera. |
La clase Monitor permite implementar este escenario.

A continuación describimos una construcción estándar (pattern), propuesta en el capítulo Threading del libro C# 3.0 al que se hace referencia en la introducción de este documento, capaz de resolver los problemas de barrera con condición de entrada.
- En primer lugar, los subprocesos que comparten un recurso (la ventanilla, etc.) acceden a él a través de un objeto que denominaremos «token». Para abrir la barrera que conduce a la ventanilla, es necesario disponer del token, y solo hay un token. Por lo tanto, los subprocesos deben pasarse el token entre sí.
- Para dirigirse a la ventanilla, los hilos solicitan primero el token:
Si la ficha está libre, se le asigna al hilo que ha ejecutado la operación anterior; de lo contrario, el hilo queda en espera de la ficha.
- Si el acceso a la ventanilla se realiza de forma no ordenada, c.a.d. En el caso de que no importe quién entre, la operación anterior es suficiente. El hilo que tiene el token se dirige a la ventanilla. Si el acceso se realiza de forma ordenada, el hilo que tiene el token comprueba que cumple la condición para dirigirse a la ventanilla:
Si el hilo no es el que se espera en la ventanilla, cede su turno devolviendo el token. Pasa a un estado bloqueado. Se reactivará en cuanto el token vuelva a estar disponible para él. Entonces volverá a comprobar si cumple la condición para acudir a la ventanilla. La operación Monitor.Wait(ficha), que libera la ficha, solo puede realizarse si el hilo es el propietario de la ficha. Si no es así, se lanza una excepción.
- El hilo que comprueba la condición para acudir al mostrador lo hace:
- // trabajo en la ventanilla
- ....
Antes de abandonar el canal, el hilo debe devolver su token; de lo contrario, los hilos bloqueados a la espera de este permanecerán así indefinidamente. Hay dos situaciones diferentes:
- la primera situación es aquella en la que el hilo que tiene el token es también el que notifica a los hilos que esperan el token que este está libre. Lo hará de la siguiente manera:
En la línea 6, despierta a los hilos que están a la espera del token. Este despertar significa que pasan a ser elegibles para recibir el token. Esto no significa que lo reciban inmediatamente. En la línea 8, se libera el token. Todos los hilos elegibles recibirán el token por turnos, de forma indeterminista. Esto les dará la oportunidad de volver a comprobar si cumplen la condición de acceso. El hilo que ha liberado el token ha modificado dicha condición en la línea 4 para permitir la entrada de un nuevo hilo. El primero que la cumpla se queda con el token y pasa al mostrador cuando le toque el turno.
- La segunda situación es aquella en la que el hilo que tiene el token no es el que debe notificar a los hilos en espera de que el token está libre. No obstante, debe liberarlo porque el hilo encargado de enviar esta señal debe ser el poseedor del token. Lo hará mediante la operación:
El token ya está disponible, pero los hilos que lo esperan (han realizado una operación Wait(token)) no reciben notificación alguna. Esta tarea se confía a otro hilo que, en un momento dado, ejecutará un código similar al siguiente:
En definitiva, la construcción estándar propuesta en el capítulo Threading del libro C# 3.0 es la siguiente:
- definir el token de acceso al mostrador:
- solicitar el acceso al mostrador:
es equivalente a
Cabe señalar que, en este esquema, el token se libera inmediatamente, en cuanto se supera la barrera. Entonces, otro hilo puede comprobar la condición de acceso. Por lo tanto, la construcción anterior permite la entrada de todos los hilos que comprueban la condición de acceso. Si esto no es lo que se desea, se puede escribir:
lock(jeton){
while (! jeNeSuisPasCeluiQuiEstAttendu)
Monitor.Wait(jeton);
// paso al mostrador
...
}
donde el token solo se libera tras pasar por la ventanilla.
- Modificar la condición de acceso a la ventanilla e informar a los demás subprocesos
lock(jeton){
// modificar la condición de acceso al mostrador
...
// notificar a los subprocesos que esperan el token
Monitor.PulseAll(jeton);
}
En el ejemplo anterior, la condición de acceso solo puede ser modificada por el hilo que tiene el token. También se podría escribir:
// modificar la condición de acceso al canal
...
// notificarlo a los subprocesos que esperan el token
Monitor.PulseAll(jeton);
// liberar el token
Monitor.Exit(jeton);
si el hilo ya tiene el token.
Con esta información, podemos reescribir la aplicación de lectores/escritores estableciendo un orden entre lectores y escritores para el acceso a sus respectivos canales. El código es el siguiente:
using System;
using System.Threading;
namespace Chap8 {
class Program2 {
// Uso de subprocesos de lectura y escritura
// ilustra el uso de eventos de sincronización
// Variables de clase
static int[] data = new int[3]; // Recurso compartido entre subprocesos de lectura y de escritura
static Random objRandom = new Random(DateTime.Now.Second); // Un generador de números aleatorios
static object peutLire = new object(); // indica que se puede leer el contenido de «data»
static object peutEcrire = new object(); // indica que se puede escribir el contenido de «data»
static bool lectureAutorisée = false; // para autorizar la lectura de la matriz
static bool écritureAutorisée = false; // para autorizar la escritura en la matriz
static string[] ordreLecture; // establece el orden de los lectores
static string[] ordreEcriture; // establece el orden de los escritores
static int lecteurSuivant = 0; // indica el número del siguiente lector
static int écrivainSuivant = 0; // indica el número del siguiente escritor
//principal
public static void Main(string[] args) {
// el número de subprocesos que se van a generar
const int nbThreads = 5;
// creación de los subprocesos de lectura
Thread[] lecteurs = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creación
lecteurs[i] = new Thread(Lire);
lecteurs[i].Name = "L" + i.ToString();
// inicio
lecteurs[i].Start();
}
// creación del orden de lectura
ordreLecture = new string[nbThreads];
for (int i = 0; i < nbThreads; i++) {
ordreLecture[i] = lecteurs[nbThreads - i - 1].Name;
Console.WriteLine("Le lecteur {0} est en position {1}", ordreLecture[i], i);
}
// creación de los subprocesos de escritura
Thread[] écrivains = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creación
écrivains[i] = new Thread(Ecrire);
écrivains[i].Name = "E" + i.ToString();
// inicio
écrivains[i].Start();
}
// creación de la orden de escritura
ordreEcriture = new string[nbThreads];
for (int i = 0; i < nbThreads; i++) {
ordreEcriture[i] = écrivains[i].Name;
Console.WriteLine("L'écrivain {0} est en position {1}", ordreEcriture[i], i);
}
// autorización de escritura
lock (peutEcrire) {
écritureAutorisée = true;
Monitor.Pulse(peutEcrire);
}
//fin de la mano
Console.WriteLine("Fin de Main...");
}
// leer el contenido de la tabla
public static void Lire() {
...
}
// escribir en la tabla
public static void Ecrire() {
...
}
}
}
El acceso al canal de lectura está condicionado por los siguientes elementos:
- línea 13: el token peutLire
- línea 15: el valor booleano lectureAutorisée
- línea 17: la tabla ordenada de lectores. Los lectores se dirigen a la ventanilla de lectura siguiendo el orden de esta tabla, que contiene sus nombres.
- línea 19: lecteurSuivant indica el número del siguiente lector autorizado a acudir al mostrador.
El acceso a la ventanilla de escritura está condicionado por los siguientes elementos:
- línea 14: el token peutEcrire
- línea 16: el valor booleano écritureAutorisée
- línea 18: la matriz ordenada de los escritores. Los escritores se dirigen a la ventanilla de escritura siguiendo el orden de esta matriz, que contiene sus nombres.
- línea 20: écrivainSuivant indica el número del siguiente solicitante autorizado a acudir a la ventanilla.
Los demás elementos del código son los siguientes:
- líneas 29-36: creación e inicio de los subprocesos de lectura. Todos ellos quedarán bloqueados, ya que la lectura no está permitida (línea 15).
- líneas 39-43: su orden de paso por la ventanilla se realizará en orden inverso al de su creación.
- líneas 46-53: creación e inicio de los subprocesos de escritura. Todos ellos quedarán bloqueados, ya que no está permitida la escritura (línea 16).
- líneas 56-60: su orden de paso por la ventanilla seguirá el orden de su creación.
- línea 64: se permite la escritura
- línea 65: se avisa a los escritores de que algo ha cambiado.
El método Lire es el siguiente:
public static void Lire() {
// seguimiento
Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// hay que esperar a la autorización de lectura
lock (peutLire) {
while (!lectureAutorisée || ordreLecture[lecteurSuivant] != Thread.CurrentThread.Name) {
Monitor.Wait(peutLire);
}
// lectura de la tabla
for (int i = 0; i < data.Length; i++) {
//espera de 1 s
Thread.Sleep(1000);
// visualización
Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// siguiente lector
lectureAutorisée = false;
lecteurSuivant++;
// se avisa a los escritores de que pueden escribir
lock (peutEcrire) {
écritureAutorisée = true;
Monitor.PulseAll(peutEcrire);
}
// seguimiento
Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
}
- Todo el proceso de acceso a la ventanilla está controlado por el lock de las líneas 5-27. El lector que recoge el token lo conserva durante todo el tiempo que permanece en la ventanilla
- líneas 6-8: un lector que haya adquirido el token de la línea 5 lo suelta si la lectura no está autorizada o si no es su turno para pasar.
- líneas 10-15: paso por la ventanilla (explotación de la tabla)
- líneas 17-18: el hilo cambia las condiciones de acceso al canal de lectura. Cabe señalar que sigue teniendo el token de lectura y que estas modificaciones aún no permiten que un lector pase.
- líneas 20-23: el hilo cambia las condiciones de acceso al mostrador de escritura y avisa a todos los escritores en espera de que algo ha cambiado.
- línea 27: el lock finaliza, se libera el token peutLire. Un hilo de lectura podría entonces adquirirlo en la línea 5, pero no superaría la condición de acceso, ya que el valor booleano lectureAutorisée es falso. Por otra parte, todos los hilos que están a la espera del token peutLire siguen esperándolo, ya que la operación PulseAll(peutLire) aún no se ha producido.
El método Ecrire es el siguiente:
public static void Ecrire() {
// seguimiento
Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// hay que esperar a la autorización para escribir
lock (peutEcrire) {
while (!écritureAutorisée || ordreEcriture[écrivainSuivant] != Thread.CurrentThread.Name) {
Monitor.Wait(peutEcrire);
}
// escritura en la tabla
for (int i = 0; i < data.Length; i++) {
//espera 1 s
Thread.Sleep(1000);
// visualización
data[i] = objRandom.Next(0, 1000);
Console.WriteLine("{0:hh:mm:ss} : L'écrivain {1} a écrit le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// siguiente escritor
écritureAutorisée = false;
écrivainSuivant++;
// se activan los lectores en espera del token peutLire
lock (peutLire) {
lectureAutorisée = true;
Monitor.PulseAll(peutLire);
}
// seguimiento
Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
}
- Todo el acceso a la ventanilla de escritura está controlado por el lock de las líneas 5-27. El escritor que recupera el token lo conserva durante toda su estancia en la ventanilla
- líneas 6-8: un escritor que haya obtenido el token de la línea 5 lo libera si la escritura no está autorizada o si no es su turno.
- líneas 10-16: paso por el mostrador (explotación de la tabla)
- líneas 18-19: el hilo cambia las condiciones de acceso al canal de escritura. Cabe señalar que sigue teniendo el token de escritura y que estas modificaciones aún no permiten que un escritor pase.
- líneas 21-24: el hilo cambia las condiciones de acceso al canal de lectura y avisa a todos los lectores en espera de que algo ha cambiado.
- línea 27: el lock finaliza, se libera el token peutEcrire. Un hilo de escritura podría entonces adquirirlo (línea 5), pero no superaría la condición de acceso, ya que el valor booleano écritureAutorisée es falso. Por otra parte, todos los hilos que están a la espera del token peutEcrire permanecen en espera de una nueva operación PulseAll(peutEcrire).
A continuación se muestra un ejemplo de ejecución:
10.7. Los grupos de subprocesos
Hasta ahora, para gestionar los hilos:
- los creábamos mediante Thread T = new Thread(...)
- y, a continuación, los ejecutábamos mediante T.Start()
En el capítulo «Bases de datos» vimos que, con algunos SGBD, era posible disponer de grupos de conexiones abiertas:
- las conexiones n se abren al iniciar el grupo
- cuando un hilo solicita una conexión, se le asigna una de las conexiones abiertas del grupo
- cuando el hilo cierra la conexión, esta no se cierra, sino que se devuelve al grupo
El uso de un grupo de conexiones es transparente a nivel de código. La ventaja radica en la mejora del rendimiento: abrir una conexión supone un coste elevado. En este caso, 10 conexiones abiertas pueden atender cientos de solicitudes.
Existe un sistema similar para los hilos:
- Se crean min subprocesos al iniciar el grupo. El valor de min se establece mediante el método ThreadPool.SetMinThreads(min1,min2). Un grupo de subprocesos puede utilizarse para ejecutar tareas bloqueantes o no bloqueantes, denominadas asíncronas. El primer parámetro, min1, establece el número de subprocesos bloqueantes, mientras que el segundo, min2, establece el número de subprocesos asíncronos. Los valores actuales de estos dos parámetros se pueden obtener mediante ThreadPool.GetMinThreads(out min1,out min2).
- Si este número no es suficiente, el grupo creará más subprocesos para responder a las solicitudes hasta el límite de subprocesos establecido por max. El valor de max se establece mediante el método ThreadPool.SetMaxThreads(max1, max2). Ambos parámetros tienen el mismo significado que en el método SetMinThreads. Los valores actuales de estos dos parámetros pueden obtenerse mediante ThreadPool.GetMaxThreads(out max1,out max2). Cuando se alcance el límite de subprocesos establecido por max1, las solicitudes de subprocesos para tareas bloqueantes quedarán en espera hasta que haya un subproceso libre en el grupo.
Un grupo de subprocesos ofrece diversas ventajas:
- al igual que con el grupo de conexiones, se ahorra tiempo en la creación de subprocesos: 10 subprocesos pueden atender cientos de solicitudes.
- se garantiza la seguridad de la aplicación: al establecer un número máximo de subprocesos, se evita que la aplicación se sature por un exceso de solicitudes. Estas se pondrán en cola.
Para asignar una tarea a un hilo del grupo, se utiliza uno de estos dos métodos:
- ThreadPool.QueueWorkItem(WaitCallBack)
- ThreadPool.QueueWorkItem(WaitCallBack,object)
donde WaitCallBack es cualquier método con la firma void WaitCallBack(object). El método 1 solicita a un hilo que ejecute el método WaitCallBack sin pasarle ningún parámetro. El método 2 hace lo mismo, pero pasando un parámetro de tipo object al método WaitCallBack.
A continuación se muestra un programa que ilustra estos conceptos:
using System;
using System.Threading;
namespace Chap8 {
class Program {
public static void Main() {
// inicialización del hilo actual
Thread main = Thread.CurrentThread;
// se asigna un nombre al hilo
main.Name = "Main";
// se utiliza un grupo de subprocesos
int min1, min2;
// se establece el número mínimo de hilos bloqueantes
ThreadPool.GetMinThreads(out min1, out min2);
Console.WriteLine("Nombre minimum de tâches bloquantes dans le pool : {0}", min1);
Console.WriteLine("Nombre minimum de tâches asynchrones dans le pool : {0}", min2);
ThreadPool.SetMinThreads(3, min2);
ThreadPool.GetMinThreads(out min1, out min2);
Console.WriteLine("Nombre minimum de tâches bloquantes dans le pool après changement : {0}", min1);
// se establece el número máximo de subprocesos bloqueantes
int max1, max2;
ThreadPool.GetMaxThreads(out max1, out max2);
Console.WriteLine("Nombre maximum de tâches bloquantes dans le pool : {0}", max1);
Console.WriteLine("Nombre maximum de tâches asynchrones dans le pool : {0}", max2);
ThreadPool.SetMaxThreads(5, max2);
ThreadPool.GetMaxThreads(out max1, out max2);
Console.WriteLine("Nombre maximum de tâches bloquantes dans le pool après changement : {0}", max1);
// se ejecutan 7 hilos
for (int i = 0; i < 7; i++) {
// se inicia la ejecución del hilo i en un grupo
ThreadPool.QueueUserWorkItem(Sleep, new Data2 { Numéro = i.ToString(), Début = DateTime.Now, Durée = i + 10 });
}
// fin de la rutina
Console.Write("Tapez [entrée] pour terminer le thread {0} à {1:hh:mm:ss:FF}", main.Name, DateTime.Now);
// espera
Console.ReadLine();
}
public static void Sleep(object infos) {
// se recupera el parámetro
Data2 data = infos as Data2;
Console.WriteLine("A {2:hh:mm:ss:FF}, le thread n° {0} va dormir pendant {1} seconde(s)", data.Numéro, data.Durée,DateTime.Now);
// estado del grupo
int cpt1, cpt2;
ThreadPool.GetAvailableThreads(out cpt1, out cpt2);
Console.WriteLine("Nombre de threads pour tâches bloquantes disponibles dans le pool : {0}", cpt1);
// se pone en espera durante Duración segundos
Thread.Sleep(data.Durée * 1000);
// fin de la ejecución
data.Fin = DateTime.Now;
Console.WriteLine("A {3:hh:mm:ss:FF}, le thread n° {0} se termine. Il était programmé pour durer {1} seconde(s). Il a duré {2} seconde(s)", data.Numéro, data.Durée, data.Fin - data.Début,DateTime.Now);
}
}
internal class Data2 {
// información diversa
public string Numéro { get; set; }
public DateTime Début { get; set; }
public int Durée { get; set; }
public DateTime Fin { get; set; }
}
}
- líneas 15-17: se solicita y se muestra el número mínimo actual de los dos tipos de subprocesos del grupo de subprocesos
- línea 18: se cambia el número mínimo de subprocesos para tareas bloqueantes: 2
- líneas 19-21: se muestran los nuevos mínimos
- líneas 22-28: se hace lo mismo para fijar el número máximo de subprocesos para tareas bloqueantes: 5
- líneas 30-33: se ejecutan 7 tareas en un grupo de 5 subprocesos. 5 tareas deberían obtener 1 subproceso cada una; las 2 primeras rápidamente, ya que siempre hay 2 subprocesos disponibles, y las otras 3 con un tiempo de espera de 0,5 segundos. 2 tareas deberían esperar a que se libere un subproceso.
- línea 32: las tareas ejecutan el método Sleep de las líneas 40-54 pasándole un parámetro de tipo Data2 definido en las líneas 56-62.
- línea 40: el método Sleep ejecutado por las tareas
- línea 42: se recupera el parámetro pasado al método Sleep.
- línea 43: la tarea se identifica en la consola
- líneas 45-47: se muestra el número de subprocesos disponibles actualmente. Queremos ver cómo evoluciona.
- línea 49: la tarea se detiene unos segundos (tarea bloqueante).
- línea 52: cuando se reanuda, se muestra información sobre su cuenta.
Los resultados obtenidos son los siguientes.
Para los números de subprocesos min y max del grupo:
Para la ejecución de los 7 hilos:
- líneas 1-6: las tres primeras tareas se ejecutan una tras otra. Encuentran inmediatamente un hilo disponible (MinThreads=3) y, a continuación, pasan a estado de espera.
- líneas 7-9: en el caso de las tareas 3 y 4, el proceso es un poco más largo. Para cada una de ellas no había ningún hilo libre, por lo que hubo que crear uno. Este mecanismo es posible hasta el hilo número 5 (MaxThreads=5).
- línea 10: ya no hay subprocesos disponibles: las tareas 5 y 6 tendrán que esperar.
- líneas 11-12: la tarea 0 finaliza. La tarea 5 ocupa su hilo.
- líneas 13-14: la tarea 1 finaliza. La tarea 6 ocupa su hilo.
- líneas 17-21: las tareas finalizan una tras otra.
10.8. La clase BackgroundWorker
10.8.1. Ejemplo 1
La clase BackgroundWorker pertenece al espacio de nombres [System.ComponentModel]. Se utiliza como un hilo, pero presenta algunas particularidades que, en ciertos casos, pueden hacerla más interesante que la clase [Thread]:
- emite los siguientes eventos:
- DoWork: un hilo ha solicitado la ejecución de BackgroundWorker
- ProgressChanged: el objeto BackgroundWorker ha ejecutado el método ReportProgress. Este método sirve para indicar un porcentaje de ejecución.
- RunWorkerCompleted: el objeto BackgroundWorker ha finalizado su trabajo. Puede haberlo finalizado normalmente, por cancelación o por una excepción.
Estos eventos hacen que el BackgroundWorker resulte útil en las interfaces gráficas: una tarea larga se asignará a un BackgroundWorker, que podrá informar de su progreso mediante el evento ProgressChanged y de su finalización mediante el evento RunWorkerCompleted. El trabajo que debe realizar el BackgroundWorker se llevará a cabo mediante un método que se habrá asociado al evento DoWork.
- Es posible solicitar su cancelación. De este modo, en una interfaz gráfica, el usuario podrá cancelar una tarea que se prolongue demasiado.
- Los objetos BackgroundWorker pertenecen a un grupo y se reciclan según sea necesario. Una aplicación que necesite un objeto BackgroundWorker lo obtendrá del grupo, que le proporcionará un hilo ya existente pero no utilizado. El hecho de reciclar así los hilos, en lugar de crear uno nuevo cada vez, mejora el rendimiento.
Utilizamos esta herramienta en la aplicación anterior en el caso de que el acceso al mostrador no esté controlado:
using System;
using System.Threading;
using System.ComponentModel;
namespace Chap8 {
class Program2 {
// uso de subprocesos de lectura y escritura
// ilustra el uso simultáneo de recursos compartidos y de sincronización
// variables de clase
const int nbThreads = 2; // Número total de subprocesos
static int nbLecteursTerminés = 0; // Número de subprocesos finalizados
static int[] data = new int[5]; // matriz compartida entre subprocesos de lectura y subprocesos de escritura
static object appli; // sincroniza el acceso al número de subprocesos finalizados
static Random objRandom = new Random(DateTime.Now.Second); // un generador de números aleatorios
static AutoResetEvent peutLire; // indica que se puede leer el contenido de la matriz
static AutoResetEvent peutEcrire; // indica que se puede escribir en la matriz
static AutoResetEvent finLecteurs; // indica el final de los lectores
//main
public static void Main(string[] args) {
// se le da un nombre al hilo
Thread.CurrentThread.Name = "Main";
// inicialización de los indicadores
peutLire = new AutoResetEvent(false); // todavía no se puede leer
peutEcrire = new AutoResetEvent(true); // ya se puede escribir
finLecteurs = new AutoResetEvent(false); // la aplicación no ha finalizado
// Sincroniza el acceso al contador de subprocesos finalizados
appli = new object();
// creación de los hilos de lectura
MyBackgroundWorker[] lecteurs = new MyBackgroundWorker[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creación
lecteurs[i] = new MyBackgroundWorker();
lecteurs[i].Numéro = "L" + i;
lecteurs[i].DoWork += Lire;
lecteurs[i].RunWorkerCompleted += EndLecteur;
// inicio
lecteurs[i].RunWorkerAsync();
}
// creación de subprocesos de escritura
MyBackgroundWorker[] écrivains = new MyBackgroundWorker[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creación
écrivains[i] = new MyBackgroundWorker();
écrivains[i].Numéro = "E" + i;
écrivains[i].DoWork += Ecrire;
// inicio
écrivains[i].RunWorkerAsync();
}
// espera a que finalicen todos los subprocesos
finLecteurs.WaitOne();
//fin de la operación
Console.WriteLine("Fin de Main...");
}
public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
...
}
// lectura del contenido de la matriz
public static void Lire(object sender, DoWorkEventArgs infos) {
...
}
// escribir en la tabla
public static void Ecrire(object sender, DoWorkEventArgs infos) {
...
}
}
// hilo
internal class MyBackgroundWorker : BackgroundWorker {
// información diversa
public string Numéro { get; set; }
}
}
Solo detallamos los cambios:
- la clase Thread se sustituye por la clase MyBackgroundWorker en las líneas 79-82. La clase BackgroundWorker se ha derivado para asignar un número al hilo. Se podría haber procedido de otra forma pasando un objeto al método RunWorkerAsync de las líneas 43 y 54, objeto que contuviera el número del hilo.
- Línea 58: el método Main finaliza una vez que todos los hilos de lectura han completado su trabajo. Para ello, en la línea 12, el contador nbLecteursTerminés cuenta el número de subprocesos de lectura que han finalizado su trabajo. Este contador se incrementa mediante el método EndLecteur de las líneas 63-65, que se ejecuta cada vez que finaliza un subproceso de lectura. Es este procedimiento el que controla el evento AutoResetEvent finLecteurs de la línea 18, con el que se sincroniza, en la línea 59, el método Main.
- línea 16: dado que varios hilos de lectura pueden intentar incrementar al mismo tiempo el contador nbLecteursTerminés, el objeto de sincronización appli garantiza un acceso exclusivo al mismo. Este caso es improbable, pero teóricamente posible.
- líneas 35-44: creación de los subprocesos de lectura
- línea 38: creación del hilo de tipo MyBackgroundWorker
- línea 39: se le asigna un número
- línea 40: se le asigna el método Lire para su ejecución
- línea 41: el método EndLecteur se ejecutará una vez finalizado el hilo
- línea 43: se inicia el hilo
- líneas 47-55: creación de los hilos de escritura
- línea 50: creación del hilo de tipo MyBackgroundWorker
- línea 51: se le asigna un número
- línea 52: se le asigna el método Ecrire para que lo ejecute
- línea 54: se inicia el hilo
Los métodos Lire y Ecrire permanecen sin cambios. El método EndLecteur se ejecuta al final de cada hilo de lectura. Su código es el siguiente:
public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
// incremento del número de lectores finalizados
lock (appli) {
nbLecteursTerminés++;
if (nbLecteursTerminés == nbThreads)
finLecteurs.Set();
}
}
La función del método EndLecteur es notificar al método Main que todos los lectores han completado su trabajo.
- línea 4: se incrementa el contador nbLecteursTerminés.
- líneas 5-6: si todos los lectores han completado su trabajo, entonces el evento finLecteurs se establece en verdadero para notificar al método Main, que está a la espera de este evento.
- Dado que el procedimiento EndLecteur se ejecuta en varios subprocesos, la sección crítica anterior está protegida por la cláusula lock de la línea 3.
La ejecución ofrece resultados similares a los de la versión que utiliza subprocesos.
10.8.2. Ejemplo 2
El siguiente código ilustra otros aspectos de la clase BackgroundWorker:
- la posibilidad de cancelar la tarea
- la propagación de una excepción lanzada en la tarea
- el paso de un parámetro de E/S a la tarea
using System;
using System.Threading;
using System.ComponentModel;
namespace Chap8 {
class Program3 {
// hilos
static BackgroundWorker[] tâches = new BackgroundWorker[5];
public static void Main() {
// inicialización del hilo actual
Thread main = Thread.CurrentThread;
// se asigna un nombre al hilo
main.Name = "Main";
// creación de hilos
for (int i = 0; i < tâches.Length; i++) {
// se crea el hilo n.º i
tâches[i] = new BackgroundWorker();
// se inicializa
tâches[i].DoWork += Sleep;
tâches[i].RunWorkerCompleted += End;
tâches[i].WorkerSupportsCancellation = true;
// se ejecuta
tâches[i].RunWorkerAsync(new Data { Numéro = i, Début = DateTime.Now, Durée = i + 1 });
}
// se cancela el último hilo
tâches[4].CancelAsync();
// fin de la rutina
Console.WriteLine("Fin du thread {0}, tapez [entrée] pour terminer...", main.Name);
Console.ReadLine();
return;
}
public static void Sleep(object sender, DoWorkEventArgs infos) {
...
}
public static void End(object sender, RunWorkerCompletedEventArgs infos) {
...
}
internal class Data {
// información diversa
public int Numéro { get; set; }
public DateTime Début { get; set; }
public int Durée { get; set; }
public DateTime Fin { get; set; }
}
}
}
- línea 9: la matriz de BackgroundWorker
- líneas 18-27: creación de subprocesos
- línea 20: creación del hilo
- línea 22: el hilo ejecutará el método Sleep de las líneas 39-41
- línea 23: el método End de las líneas 43-45 se ejecutará al finalizar el hilo
- línea 24: el hilo podrá cancelarse
- línea 26: el hilo se inicia con un parámetro de tipo [Data], definido en las líneas 49-52. Este objeto tiene los siguientes campos:
- Numéro (entrada): n.º del hilo
- Début (entrada): hora de inicio de la ejecución del hilo
- Durée (entrada): duración de la ejecución de Sleep
- Fin (salida): fin de la ejecución del hilo
- línea 29: se cancela el hilo n.º 4
Todos los hilos ejecutan el siguiente método Sleep:
public static void Sleep(object sender, DoWorkEventArgs infos) {
// se procesa el parámetro «infos»
Data data = (Data)infos.Argument;
// excepción en la tarea n.º 3
if (data.Numéro == 3) {
throw new Exception("test....");
}
// suspensión durante «Duración» segundos con una parada cada «ttes» segundos
for (int i = 1; i <= data.Durée && !tâches[data.Numéro].CancellationPending; i++) {
// espera de 1 segundo
Thread.Sleep(1000);
}
// fin de la ejecución
data.Fin = DateTime.Now;
// se inicializa el resultado
infos.Result = data;
infos.Cancel = tâches[data.Numéro].CancellationPending;
}
- línea 1: el método Sleep tiene la firma estándar de los controladores de eventos. Recibe dos parámetros:
- sender: el emisor del evento, en este caso el BackgroundWorker que ejecuta el método
- infos: de tipo DoWorkEventArgs, que proporciona información sobre el evento DoWork. Este parámetro sirve tanto para transmitir información al hilo como para recuperar sus resultados.
- línea 3: el parámetro pasado al método RunWorkerAsync de la tarea se encuentra en la propiedad infos.Argument.
- líneas 5-7: se lanza una excepción para la tarea n.º 3
- líneas 9-12: el hilo «duerme» Durée segundos en intervalos de un segundo para permitir la prueba de cancelación de la línea 9. Esto simula un trabajo de larga duración durante el cual el hilo comprobaría regularmente si existe una solicitud de cancelación. Para indicar que ha sido cancelado, el hilo debe establecer la propiedad infos.Cancel en verdadero (línea 17).
- línea 16: el hilo puede devolver un resultado al hilo que lo ha iniciado. Coloca este resultado en infos.Result.
Una vez finalizados, los hilos ejecutan el siguiente método End:
public static void End(object sender, RunWorkerCompletedEventArgs infos) {
// se utiliza el parámetro «infos» para mostrar el resultado de la ejecución
// ¿Excepción?
if (infos.Error != null) {
Console.WriteLine("Le thread {1} a rencontré l'erreur suivante : {0}", infos.Error.Message, sender);
} else
if (!infos.Cancelled) {
Data data = (Data)infos.Result;
Console.WriteLine("Thread {0} terminé : début {1:hh:mm:ss}, durée programmée {2} s, fin {3:hh:mm:ss}, durée effective {4}",
data.Numéro, data.Début, data.Durée, data.Fin, (data.Fin - data.Début));
} else {
Console.WriteLine("Thread {0} annulé", sender);
}
}
- línea 1: el método End tiene la firma estándar de los controladores de eventos. Recibe dos parámetros:
- sender: el emisor del evento, en este caso el BackgroundWorker que ejecuta el método
- infos: de tipo RunWorkerCompletedEventArgs, que proporciona información sobre el evento RunWorkerCompleted.
- línea 4: el campo infos.Error, de tipo Exception, solo se rellena si se ha producido una excepción.
- línea 7: el campo infos.Cancelled, de tipo booleano, toma el valor true si se ha cancelado el hilo.
- Línea 8: si no se ha producido ninguna excepción ni cancelación, entonces infos.Result es el resultado del hilo ejecutado. Utilizar este resultado si el hilo se ha cancelado o si ha lanzado una excepción provoca una excepción. Por lo tanto, en las líneas 5 y 13, no podemos mostrar el número del hilo cancelado o que ha lanzado una excepción, ya que dicho número se encuentra en infos.Result. Este problema se puede solucionar derivando la clase BackgroundWorker para incluir en ella la información que se va a intercambiar entre el hilo llamante y el hilo llamado, tal y como se ha hecho en el ejemplo anterior. En ese caso, se utiliza el argumento sender, que representa a BackgroundWorker, en lugar del argumento infos.
Los resultados de la ejecución son los siguientes:
10.9. Datos locales de un hilo
10.9.1. El principio
Consideremos una aplicación de tres capas:
![]() |
Supongamos que la aplicación es multiusuario, por ejemplo, una aplicación web. A cada usuario le atiende un hilo dedicado exclusivamente a él. El ciclo de vida del hilo es el siguiente:
- el hilo se crea o se solicita a un grupo de hilos para atender una solicitud de un usuario
- si dicha solicitud requiere datos, el hilo ejecutará un método de la capa [ui], que a su vez llamará a un método de la capa [metier], el cual, a su vez, llamará a un método de la capa [dao].
- El hilo devuelve la respuesta al usuario. A continuación, desaparece o se recicla en un grupo de hilos.
En la operación 2, puede resultar interesante que el hilo disponga de datos propios, c.a.d, que no se compartan con los demás hilos. Estos datos podrían, por ejemplo, pertenecer al usuario concreto al que atiende el hilo. Dichos datos podrían utilizarse entonces en las diferentes capas [ui, metier, dao].
La clase Thread permite este escenario gracias a una especie de diccionario privado en el que las claves serían de tipo LocalDataStoreSlot:
crea una entrada en el diccionario privado del hilo para la clave name. | |
asocia el valor data a la clave name del diccionario privado del hilo | |
recupera el valor asociado a la clave name del diccionario privado del hilo |
Un ejemplo de uso podría ser el siguiente:
- para crear un par (clé,valeur) asociado al hilo actual:
- Para recuperar el valor asociado a clé:
10.9.2. Aplicación del principio
Consideremos la siguiente aplicación de tres capas:
![]() |
Supongamos que la capa [dao] gestiona una base de datos de artículos y que su interfaz es inicialmente la siguiente:
using System.Collections.Generic;
namespace Chap8 {
public interface IDao {
int InsertArticle(Article article);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- línea 5: para insertar un artículo en la base de datos
- línea 6: para recuperar todos los artículos de la base
- línea 7: para eliminar todos los artículos de la base
Posteriormente, surge la necesidad de disponer de un método para insertar una tabla de artículos mediante una transacción, ya que se desea operar en modo «todo o nada»: o se insertan todos los artículos o ninguno. Por lo tanto, se puede modificar la interfaz para incorporar esta nueva necesidad:
using System.Collections.Generic;
namespace Chap8 {
public interface IDao {
int InsertArticle(Article article);
void insertArticles(Article[] articles);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- línea 6: para añadir una tabla de artículos a la base de datos
Posteriormente, para otra aplicación, surge la necesidad de eliminar una lista de artículos registrada en una lista, siempre mediante una transacción. Se observa que, para dar respuesta a diferentes necesidades de negocio, la capa [dao] se verá obligada a ampliarse. Se puede optar por otra vía:
- incluir en la capa [dao] únicamente las operaciones básicas InsertArticle, DeleteArticle, UpdateArticle, SelectArticle y SelectArticles
- trasladar a la capa [métier] las operaciones de actualización simultánea de varios artículos. Estas utilizarían las operaciones elementales de la capa [dao].
La ventaja de esta solución es que la misma capa [dao] podría utilizarse sin modificaciones con diferentes capas [metier]. Sin embargo, plantea una dificultad en la gestión de la transacción que agrupa las actualizaciones que deben realizarse de forma atómica en la base de datos:
- la transacción debe ser iniciada por la capa [metier] antes de que esta llame a los métodos de la capa [dao]
- los métodos de la capa [dao] deben conocer la existencia de la transacción para poder participar en ella si existe
- la transacción debe ser finalizada por la capa [métier].
Para que los métodos de la capa [dao] detecten la existencia de una posible transacción en curso, se podría añadir la transacción como parámetro de cada método de la capa [dao]. Este parámetro aparecerá entonces en la firma de los métodos de la interfaz, lo que vinculará esta a una fuente de datos concreta: la base de datos. Los datos locales del hilo nos ofrecen una solución más elegante: la capa [métier] colocará la transacción en los datos locales del hilo y será allí donde la capa [dao] la recuperará. Por lo tanto, no es necesario modificar la firma de los métodos de la capa [dao].
Implementamos esta solución con el siguiente proyecto de Visual Studio:
![]() |
![]() |
- en [1]: la solución en su conjunto
- en [2]: las referencias utilizadas. Dado que la base de datos [4] es una base de datos SQL Server Compact, es necesario disponer de la referencia [System.Data.SqlServerCe].
- en [3]: las diferentes capas de la aplicación.
La base de datos [4] es la base de datos SQL Server Compact ya utilizada en el capítulo anterior, concretamente en el apartado 9.3.1.
![]() |
La clase Artículo
Una fila de la tabla [articles] anterior se encapsula en un objeto de tipo Article:
namespace Chap8 {
public class Article {
// propiedades
public int Id { get; set; }
public string Nom { get; set; }
public decimal Prix { get; set; }
public int StockActuel { get; set; }
public int StockMinimum { get; set; }
// constructores
public Article() {
}
public Article(int id, string nom, decimal prix, int stockActuel, int stockMinimum) {
Id = id;
Nom = nom;
Prix = prix;
StockActuel = stockActuel;
StockMinimum = stockMinimum;
}
// identidad
public override string ToString() {
return string.Format("[{0},{1},{2},{3},{4}]", Id, Nom, Prix, StockActuel, StockMinimum);
}
}
}
Interfaz de la capa [dao]
La interfaz IDao de la capa [dao] será la siguiente:
using System.Collections.Generic;
namespace Chap8 {
public interface IDao {
int InsertArticle(Article article);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- línea 5: para insertar un artículo en la tabla [articles]
- línea 6: para incluir todas las líneas de la tabla [articles] en una lista de objetos Article
- línea 7: para eliminar todas las líneas de la tabla [articles]
Interfaz de la capa [metier]
La interfaz IMetier de la capa [metier] será la siguiente:
using System.Collections.Generic;
namespace Chap8 {
interface IMetier {
void InsertArticlesInTransaction(Article[] articles);
void InsertArticlesOutOfTransaction(Article[] articles);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- línea 5: para insertar, dentro de una transacción, un conjunto de artículos
- línea 6: lo mismo, pero sin transacción
- línea 7: para obtener la lista de todos los artículos
- línea 8: para eliminar todos los artículos
Implementación de la capa [metier]
La implementación de negocio de la interfaz IMetier será la siguiente:
using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;
namespace Chap8 {
public class Metier : IMetier {
// capa [dao]
public IDao Dao { get; set; }
// cadena de conexión
public string ConnectionString { get; set; }
// Inserción de una tabla de artículos dentro de una transacción
public void InsertArticlesInTransaction(Article[] articles) {
// se establece la conexión con la base de datos
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// apertura de la conexión
connexion.Open();
// transacción
SqlCeTransaction transaction = null;
try {
// Inicio de la transacción
transaction = connexion.BeginTransaction(IsolationLevel.ReadCommitted);
// se registra la transacción en el hilo
Thread.SetData(Thread.GetNamedDataSlot("transaction"), transaction);
// Inserción de artículos
foreach (Article article in articles) {
Dao.InsertArticle(article);
}
// se confirma la transacción
transaction.Commit();
} catch {
// se revierte la transacción
if (transaction != null)
transaction.Rollback();
}
}
}
// Inserción de una tabla de artículos sin transacción
public void InsertArticlesOutOfTransaction(Article[] articles) {
// Inserción de artículos
foreach (Article article in articles) {
Dao.InsertArticle(article);
}
}
// lista de artículos
public List<Article> GetAllArticles() {
return Dao.GetAllArticles();
}
// Eliminar todos los artículos
public void DeleteAllArticles() {
Dao.DeleteAllArticles();
}
}
}
La clase tiene las siguientes propiedades:
- línea 9: una referencia a la capa [dao]
- línea 11: la cadena de conexión que permite conectarse a la base de datos de artículos
Solo comentaremos el método InsertArticlesInTransaction, que es el único que presenta dificultades:
- línea 16: se establece una conexión con la base de datos
- línea 18: se abre la conexión
- línea 23: se crea una transacción
- línea 25: se registra en los datos locales del hilo, asociada a la clave «transaction»
- líneas 27-29: se invoca el método de inserción unitaria de la capa [dao] para cada artículo que se va a insertar
- líneas 21 y 32: toda la inserción de la tabla se controla mediante un try/catch
- línea 31: si se llega hasta aquí, es que no se ha producido ninguna excepción. A continuación, se valida la transacción.
- líneas 34-35: se ha producido una excepción, se deshace la transacción
- línea 37: se sale de la cláusula using. La conexión abierta en la línea 18 se cierra automáticamente.
Implementación de la capa [dao]
La implementación DAO de la interfaz IDao será la siguiente:
using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;
namespace Chap8 {
public class Dao : IDao {
// cadena de conexión
public string ConnectionString { get; set; }
// consultas
public string InsertText { get; set; }
public string DeleteAllText { get; set; }
public string GetAllText { get; set; }
// Implementación de la interfaz
// inserción de artículo
public int InsertArticle(Article article) {
// ¿Hay alguna transacción en curso?
SqlCeTransaction transaction = Thread.GetData(Thread.GetNamedDataSlot("transaction")) as SqlCeTransaction;
// recuperar la conexión o crearla
SqlCeConnection connexion = null;
if (transaction != null) {
// Recuperar la conexión
connexion = transaction.Connection as SqlCeConnection;
} else {
// crearla
connexion = new SqlCeConnection(ConnectionString);
connexion.Open();
}
try {
// Preparación de la orden de inserción
SqlCeCommand sqlCommand = new SqlCeCommand();
sqlCommand.Transaction = transaction;
sqlCommand.Connection = connexion;
sqlCommand.CommandText = InsertText;
sqlCommand.Parameters.Add("@nom", SqlDbType.NVarChar, 30);
sqlCommand.Parameters.Add("@prix", SqlDbType.Money);
sqlCommand.Parameters.Add("@sa", SqlDbType.Int);
sqlCommand.Parameters.Add("@sm", SqlDbType.Int);
sqlCommand.Parameters["@nom"].Value = article.Nom;
sqlCommand.Parameters["@prix"].Value = article.Prix;
sqlCommand.Parameters["@sa"].Value = article.StockActuel;
sqlCommand.Parameters["@sm"].Value = article.StockMinimum;
// ejecución
return sqlCommand.ExecuteNonQuery();
} finally {
// si no se estaba en una transacción, se cierra la conexión
if (transaction == null) {
connexion.Close();
}
}
}
// lista de artículos
public List<Article> GetAllArticles() {
...
}
// eliminación de artículos
public void DeleteAllArticles() {
...
}
}
}
La clase tiene las siguientes propiedades:
- línea 9: la cadena de conexión que permite conectarse a la base de datos de artículos
- línea 11: la orden SQL para insertar un artículo
- línea 12: la orden SQL para eliminar todos los artículos
- línea 13: la orden SQL para obtener todos los artículos
Estas propiedades se inicializarán a partir del siguiente archivo de configuración [App.config]:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<connectionStrings>
<add name="dbArticlesSqlServerCe" connectionString="Data Source=|DataDirectory|\dbarticles.sdf;Password=dbarticles;" />
</connectionStrings>
<appSettings>
<add key="insertText" value="insert into articles(nom,prix,stockactuel,stockminimum) values(@nom,@prix,@sa,@sm)"/>
<add key="getAllText" value="select id,nom,prix,stockactuel,stockminimum from articles"/>
<add key="deleteAllText" value="delete from articles"/>
</appSettings>
</configuration>
Comentamos el método InsertArticle:
- línea 20: se recupera la posible transacción que la capa [metier] haya podido iniciar en el hilo
- líneas 23-25: si la transacción está presente, se recupera la conexión a la que estaba vinculada.
- líneas 26-30: en caso contrario, se crea y se abre una nueva conexión.
- líneas 33-44: se prepara la orden de inserción. Esta se configura (véase la línea g de App.config).
- línea 33: se crea el objeto Command.
- línea 34: está asociado a la transacción actual. Si esta no existe (transacción = null), equivale a ejecutar la orden SQL sin una transacción explícita. Cabe recordar que, en ese caso, sigue existiendo una transacción implícita. Con SQL Server CE, esta transacción implícita se encuentra, por defecto, en modo autocommit: la orden SQL pasa a ser committé tras su ejecución.
- línea 35: el objeto Command se asocia a la conexión actual
- línea 36: se establece el texto SQl que se va a ejecutar. Se trata de la consulta parametrizada de la línea g de App.config.
- líneas 37-44: se inicializan los cuatro parámetros de la consulta
- línea 46: se ejecuta la consulta.
- líneas 49-51: hay que tener en cuenta que, si no hubiera ninguna transacción, se habría abierto una nueva conexión con la base de datos, líneas 26-30. En ese caso, debe cerrarse. Si hubiera una transacción, la conexión no debe cerrarse, ya que es la capa [metier] la que la gestiona.
Los otros dos métodos retoman lo visto en el capítulo «Bases de datos»:
// lista de artículos
public List<Article> GetAllArticles() {
// lista de artículos: vacía al inicio
List<Article> articles = new List<Article>();
// procesamiento de la conexión
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// apertura de conexión
connexion.Open();
// ejecuta sqlCommand con una consulta SELECT
SqlCeCommand sqlCommand = new SqlCeCommand(GetAllText, connexion);
using (SqlCeDataReader reader = sqlCommand.ExecuteReader()) {
// procesamiento del resultado
while (reader.Read()) {
// procesamiento de la línea actual
articles.Add(new Article(reader.GetInt32(0), reader.GetString(1), reader.GetDecimal(2), reader.GetInt32(3), reader.GetInt32(4)));
}
}
}
// se devuelve el resultado
return articles;
}
// eliminación de artículos
public void DeleteAllArticles() {
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// apertura de sesión
connexion.Open();
// ejecuta sqlCommand con solicitud de actualización
new SqlCeCommand(DeleteAllText, connexion).ExecuteNonQuery();
}
}
La aplicación de prueba [console]
La aplicación de prueba [console] es la siguiente:
using System;
using System.Configuration;
namespace Chap8 {
class Program {
static void Main(string[] args) {
// procesamiento del archivo de configuración
string connectionString = null;
string insertText;
string getAllText;
string deleteAllText;
try {
// cadena de conexión
connectionString = ConfigurationManager.ConnectionStrings["dbArticlesSqlServerCe"].ConnectionString;
// otros parámetros
insertText = ConfigurationManager.AppSettings["insertText"];
getAllText = ConfigurationManager.AppSettings["getAllText"];
deleteAllText = ConfigurationManager.AppSettings["deleteAllText"];
} catch (Exception e) {
Console.WriteLine("Erreur de configuration : {0}", e.Message);
return;
}
// creación de capa [dao]
Dao dao = new Dao();
dao.ConnectionString = connectionString;
dao.DeleteAllText = deleteAllText;
dao.GetAllText = getAllText;
dao.InsertText = insertText;
// creación de capa [métier]
Metier metier = new Metier();
metier.Dao = dao;
metier.ConnectionString = connectionString;
// se crea una tabla de artículos
Article[] articles = new Article[2];
for (int i = 0; i < articles.Length; i++) {
articles[i] = new Article(0, "article", 100, 10, 1);
}
// se eliminan todos los artículos
Console.WriteLine("Suppression de tous les articles...");
metier.DeleteAllArticles();
// se inserta la tabla fuera de la transacción
Console.WriteLine("Insertion des articles hors transaction...");
try {
metier.InsertArticlesOutOfTransaction(articles);
} catch (Exception e){
Console.WriteLine("Exception : {0}", e.Message);
}
// se muestran los artículos
Console.WriteLine("Liste des articles");
AfficheArticles(metier);
// se eliminan todos los artículos
Console.WriteLine("Suppression de tous les articles...");
metier.DeleteAllArticles();
// se inserta la tabla en una transacción
Console.WriteLine("Insertion des articles dans une transaction...");
metier.InsertArticlesInTransaction(articles);
// se muestran los artículos
Console.WriteLine("Liste des articles");
AfficheArticles(metier);
}
private static void AfficheArticles(IMetier metier) {
// muestra los artículos
foreach(Article article in metier.GetAllArticles()){
Console.WriteLine(article);
}
}
}
}
- líneas 12-22: se utiliza el archivo [App.config].
- líneas 24-28: se instancia e inicializa la capa [dao]
- líneas 30-32: se hace lo mismo con la capa [metier]
- líneas 34-37: se crea una tabla con dos entradas con el mismo nombre. La tabla [articles] de la base de datos SQL del servidor [dbarticles.sdf] tiene una restricción de unicidad en el nombre. Por lo tanto, se rechazará la inserción del segundo artículo. Si la inserción de la tabla se realiza fuera de una transacción, primero se insertará el primer artículo y este permanecerá en la tabla. Si la inserción de la tabla se realiza dentro de una transacción, primero se insertará el primer artículo y luego se eliminará, al finalizar la transacción.
- líneas 39-50: inserción fuera de transacción de la tabla de 2 artículos y verificación.
- líneas 52-59: lo mismo, pero dentro de una transacción
Los resultados de la ejecución son los siguientes:
- líneas 5-6: la inserción fuera de la transacción ha dejado el primer artículo en la base de datos
- línea 9: la inserción realizada dentro de una transacción no ha dejado ningún artículo en la base de datos
10.9.3. Conclusión
El ejemplo anterior ha mostrado la utilidad de los datos locales de un hilo para la gestión de transacciones. No debe reproducirse tal cual. Frameworks como Spring, NHibernate, etc., utilizan esta técnica, pero la hacen aún más transparente: la capa [metier] puede utilizar transacciones sin que la capa [dao] tenga por qué saberlo. Por lo tanto, no hay ningún objeto Transaction en el código de la capa [dao]. Esto se consigue mediante una técnica de proxy denominada AOP (programación orientada a aspectos). Una vez más, no podemos sino animar al lector a utilizar estos marcos de trabajo.
10.10. Para profundizar...
Para profundizar en el complejo ámbito de la sincronización de subprocesos, se puede consultar el capítulo Threading del libro C# 3.0 al que se hace referencia en la introducción de este documento. En él se presentan numerosas técnicas de sincronización para diferentes tipos de situaciones.







