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 llamado 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 solicitó 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, pasándole eventualmente información en un tipo object.
- Abort(), Abort(object obj): para forzar el cierre 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 después de un tiempo determinado.
- Sleep(int n): método estático; el hilo que ejecuta el método se suspende durante n milisegundos. De este modo, pierde el control del procesador, que se le asigna a otro hilo.
Veamos una primera aplicación que destaca 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 ocurre 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 manera «simultánea» en diferentes hilos de ejecución. Cuando se dice que los thread se ejecutan de manera simultánea, a menudo se comete un error de lenguaje. Si la máquina solo tiene un procesador, como suele ser el caso, los thread comparten ese procesador: cada uno dispone de él, 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 predeterminado pero que también puede fijarse mediante programación. Cuando un thread tiene acceso al 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 es iniciada por T.Start(): el método Start, pasado al constructor de T, será ejecutado por el hilo T. El programa que ejecuta la instrucción T.Start() no espera a que termine la tarea T: pasa inmediatamente a la siguiente instrucción. Así, tenemos dos tareas que se ejecutan en paralelo. A menudo deben poder comunicarse entre sí para saber en qué punto se encuentra el trabajo conjunto que deben realizar. Este es el problema de la sincronización de los hilos.
- Una vez iniciado, el hilo T se ejecuta de manera autónoma. Se detendrá cuando el método Start que está ejecutando haya terminado su trabajo.
- Se puede forzar que el hilo T termine:
- T.Abort() le pide al hilo T que termine.
- También se puede esperar a que termine su ejecución mediante T.Join(). Aquí tenemos una instrucción bloqueante: el programa que la ejecuta queda bloqueado hasta que la tarea T haya terminado su trabajo. Es una forma 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;
// se le asigna 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);
// se muestra el 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 le asigna un nombre al hilo que ejecuta el método [Main]
- líneas 13-21: se crean 5 hilos y se ejecutan. Las referencias de los hilos se almacenan en un array para poder recuperarlas posteriormente. Cada hilo 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 inició.
- 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. El procesador se le asignará entonces a otro hilo que esté en espera del procesador. Al final del segundo de pausa, el hilo que se detuvo volverá a ser candidato para el procesador. Lo obtendrá cuando llegue su turno. Esto depende de diversos factores, entre ellos la prioridad de los otros hilos que están en espera del 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 hilos 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 hilos y un procesador. Distribuirá el procesador entre estos 6 hilos 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 primero el método Affiche. Se muestra el mensaje de inicio de ejecución y luego ejecuta el método Sleep, que lo suspende durante 1 segundo. Al hacerlo, pierde el procesador, que queda así disponible para otro hilo. El ejemplo muestra que es el hilo 1 el que lo obtendrá. El hilo 1 seguirá el mismo recorrido, al igual que los demás hilos. Cuando termine el segundo de espera del hilo 0, su ejecución podrá reanudarse. El sistema le asigna 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
Console.WriteLine("Fin du thread " + main.Name);
// se detienen todos los hilos
Environment.Exit(0);
Al ejecutar el nuevo programa se obtienen los siguientes resultados:
- líneas 1-5: los hilos 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 desea 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 esperando al hilo n.º 1, luego al hilo n.º 2, etc. Finalmente, cuando sale del bucle de las líneas 2 a 5, es porque los 5 hilos que lanzó han terminado.
De este modo, se obtienen los siguientes resultados:
- línea 11: el hilo [Main] terminó después de los hilos que había iniciado.
10.3. Importancia de los hilos
Ahora que hemos destacado 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 los hilos para nosotros 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 siguen 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 manera 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 específico. 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, el cual ejecutará el método Run que se le ha asociado por defecto, pasándole el parámetro efectivo obj1. A continuación, 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 le 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++) {
// espera a que finalice la ejecución del hilo i
tâches[i].Join();
// se muestra el 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 función
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;
// se pone en espera 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 —establecida 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 iniciador
- 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 hilos
- 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 el array 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 hilos pueden intercambiar información:
- el hilo iniciador puede controlar la ejecución del hilo iniciado proporcionándole información
- el hilo lanzado puede devolver resultados al hilo lanzador.
Para que el hilo lanzado sepa cuándo están disponibles los resultados que espera, debe recibir una notificación de que el hilo lanzador ha finalizado. En este caso, esperó a que terminara utilizando el método Join. Existen otras formas de lograr lo mismo. Las veremos más adelante.
10.5. Accesos concurrentes 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 específicos. Se trataba de un clásico paso de parámetros. Existen otros casos en los que la información es compartida por varios hilos que 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
- un hilo T2, que recupera el procesador, lee entonces 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 difícil de manejar. Veamos el siguiente ejemplo para ilustrar los problemas que pueden surgir:
- una aplicación generará n hilos, siendo n un parámetro
- 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;
// verificación del número de argumentos
if (args.Length != 1) {
// error
Console.WriteLine(syntaxe);
// parada
Environment.Exit(1);
}
// verificació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);
// nombramiento
threads[i].Name = "" + i;
// inicio
threads[i].Start();
}//para
// espera a que terminen los hilos
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 de generación de subprocesos, que ya hemos estudiado. Centrémonos más bien en el método Incrémente, de la línea 59, que cada subproceso utiliza 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 forzar que el hilo pierda el control del procesador. Este se le asignará a otro hilo. En la práctica, no hay garantía de que un hilo no sea 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 ser una sola instrucción, existe el riesgo de perder el control del procesador entre el momento en que se lee el valor del contador y el momento 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, el paso 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 en 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 leer el valor 0.
- líneas 6-10: cuando se activen uno tras otro, incrementarán el valor 0 que leyeron y escribirán el valor 1 en el contador, lo cual confirma el programa principal (Main) en la línea 11.
¿De dónde surge el problema? El segundo hilo leyó un valor incorrecto debido a que el primero había sido interrumpido antes de terminar su tarea, que consistía en actualizar el contador en la ventana. Esto nos lleva al concepto de recurso crítico y sección crítica de un programa:
- un recurso crítico es un recurso que solo puede ser retenido 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 en tener acceso al recurso.
En nuestro ejemplo, la sección crítica es el código que se encuentra entre la lectura del contador y el registro de su nuevo valor:
// lectura del medidor
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 de objeto visible para todos los hilos que ejecutan la sección crítica. La cláusula lock garantiza que solo un hilo a la vez ejecute 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 terminen 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 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 hilos son los siguientes:
- El hilo 0 es el primero en entrar en la sección crítica: líneas 1, 2, 6, 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
- luego pasa el hilo 1: líneas 7, 9, 10
- luego le toca al hilo 2: líneas 11, 12, 13
- línea 14: se notifica al hilo Main, que estaba esperando a que terminara el hilo 2
- línea 15: el hilo Main ahora espera a que termine el hilo 1. Este ya ha terminado. Se le notifica de inmediato al hilo Main, línea 16.
- líneas 17-18: ocurre 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 hilos de una misma aplicación
- la clase Mutex permite sincronizar hilos 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 tiene en su poder el Mutex M (lo cual ocurre al inicio), se le «asigna» al hilo T1 que lo solicitó. Si un poco más tarde, 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 esta manera, varios hilos pueden quedar bloqueados a la espera del Mutex M. | |
El hilo T1, que realiza la operación M.ReleaseMutex(), deja de tener la propiedad del Mutex Mutex. Cuando el hilo T1 pierda el procesador, el sistema podrá asignárselo a uno de los hilos que esperan el mutex M. Solo uno lo obtendrá a su vez; los demás que esperan a M permanecerán bloqueados |
Un Mutex M administra 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 solo 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 ingresar a la sección crítica; de lo contrario, los hilos que esperan el Mutex que nunca se liberó 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 hilos
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 ingresar a 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 maneja 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 que se ha pasado 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 permite el paso de un hilo a la vez, al igual que las dos herramientas anteriores, lock y Mutex. Se crea un objeto AutoResetEvent de la siguiente manera:
El valor booleano é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 había 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é resolviendo. La barrera se abrirá mediante la operación:
Puede suceder que un hilo quiera cerrar una barrera. Podrá hacerlo mediante:
Si en el ejemplo anterior reemplazamos 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. Será abierta por el hilo Main en la línea 16.
- línea 27: el hilo encargado de incrementar el contador de hilos solicita permiso para ingresar a la sección crítica. Los distintos hilos se acumularán frente a la barrera cerrada. Cuando el hilo Main la abra, uno de los hilos en espera podrá pasar.
- línea 33: cuando termina 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 hacer atómico un grupo de operaciones. 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 como objetivo común convertir a atomique en un grupo de operaciones. Este resultado se logra a costa del bloqueo de hilos. La clase Interlocked permite, para operaciones simples pero bastante frecuentes, evitar el bloqueo de hilos. 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 para contar 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 manera atómica.
10.6. Accesos concurrentes a múltiples recursos compartidos
10.6.1. Un ejemplo
En nuestros ejemplos anteriores, un único recurso era compartido por los distintos hilos. 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 conocida como deadlock, es aquella en la que dos hilos se esperan mutuamente. Consideremos las siguientes acciones que se suceden en el tiempo:
- un hilo T1 obtiene la propiedad de un mutex M1 para tener acceso 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 presenta 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, implica 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 algunos hilos depositan datos (los escritores) y otros los leen (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 depositados en el array.
- 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 allí 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 seguir a un escritor.
- un escritor siempre debe ir después de un lector, excepto 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 tablero.
- 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 permitir el ingreso de 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 generarán
const int nbThreads = 2;
// inicialización de los indicadores
peutLire = new AutoResetEvent(false); // aún no se puede leer
peutEcrire = new AutoResetEvent(true); // ya se puede escribir
// creación de los hilos 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 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();
}
//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() {
....
}
}
}
- línea 11: el array data es el recurso compartido entre los hilos de lectura y de escritura. Los hilos de lectura lo comparten en modo lectura, mientras que los hilos de escritura lo comparten en modo escritura.
- línea 13: el objeto peutLire sirve para notificar a los hilos de lectura que pueden leer el array data. El hilo de escritura que ha llenado 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 el array antes de pasar el evento peutLire a vrai.
- línea 14: el objeto peutEcrire sirve para notificar a los hilos de escritura que pueden escribir en el array data. El hilo de lectura lo establece en verdadero después de haber utilizado la totalidad del array data. 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 hilos de lectura
- líneas 37-44: creación y lanzamiento de los hilos de escritura
El método Lire ejecutado por los hilos 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 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 indique que la matriz se ha llenado. Cuando se reciba esta señal, solo uno de los hilos de lectura que estén esperando esta señal podrá pasar.
- líneas 7-12: procesamiento del array data con un Sleep en el medio para forzar que el hilo ceda el procesador.
- línea 14: indica a los hilos de escritura que el array ya se ha leído y que puede volver a llenarse.
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 la autorización para escribir
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 esperando esta señal podrá pasar.
- líneas 7-13: procesamiento del array data con un Sleep en el medio para forzar que el hilo ceda el procesador.
- línea 15: indica a los hilos de lectura que el array se ha llenado y que puede leerse nuevamente.
La ejecución arroja los siguientes resultados:
Se pueden observar los siguientes puntos:
- efectivamente hay solo un lector a la vez, aunque este pierde 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 tabla
- 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 dado, los hilos son iguales.
Cuando los hilos de escritura están bloqueados en la instrucción peutEcrire.WaitOne, uno de ellos, cualquiera, es desbloqueado por la operación peutEcrire.Set. Si la operación anterior debe abrir la barrera a un hilo de escritura en particular, las cosas se complican.
Podemos considerar la analogía con un establecimiento que atiende al público en ventanillas, donde cada ventanilla está especializada. Cuando llega el cliente, toma un boleto en el dispensador de boletos para la ventanilla X y luego se sienta a esperar. Cada boleto 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 lo despierta el altavoz que anuncia que el n.º Y es llamado a la ventanilla X. Si se trata de él, el cliente se levanta y se dirige a la ventanilla X; de lo contrario, sigue con lo que estaba haciendo.
Aquí se puede funcionar de manera análoga. Tomemos el ejemplo de los escritores:
sus hilos están bloqueados | |
El hilo que utilizaba el array en lectura les indica a los hilos de escritura que el array está disponible. Este hilo u otro ha bloqueado al hilo de escritura, el cual debe pasar el bloqueo. | |
Cada hilo verifica si es el elegido. Si lo es, cruza la barrera. Si no, vuelve a ponerse en espera. |
La clase Monitor permite implementar este escenario.

Ahora 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 hilos que comparten un recurso (la ventanilla, etc.) acceden a él a través de un objeto que llamaremos «token». Para abrir la barrera que conduce a la ventanilla, se necesita el token, y solo hay un token. Por lo tanto, los hilos deben pasarse el token entre sí.
- Para llegar a la ventanilla, los hilos primero solicitan el token:
Si la ficha está disponible, se le entrega al hilo que ejecutó la operación anterior; de lo contrario, el hilo queda en espera de la ficha.
- Si el acceso al mostrador se realiza de manera 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 al mostrador. Si el acceso se realiza de manera ordenada, el hilo que tiene el token verifica que cumple con la condición para dirigirse al mostrador:
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á tan pronto como el token vuelva a estar disponible para él. Entonces volverá a verificar si cumple con la condición para dirigirse a la ventanilla. La operación Monitor.Wait(token), que libera el token, solo se puede realizar si el hilo es el propietario del token. Si no es así, se lanza una excepción.
- El hilo que verifica la condición para ir al mostrador se dirige allí:
- // trabajo en el mostrador
- ....
Antes de salir del ventanilla, el hilo debe devolver su token; de lo contrario, los hilos bloqueados que lo esperan permanecerán así indefinidamente. Existen 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 en espera del token. Este despertar significa que se vuelven elegibles para recibir el token. Esto no significa que lo reciban de inmediato. En la línea 8, se libera el token. Todos los hilos elegibles recibirán el token por turnos, de manera indeterminista. Esto les dará la oportunidad de volver a verificar si cumplen con la condición de acceso. El hilo que liberó el token modificó esta condición en la línea 4 para permitir que ingrese un nuevo hilo. El primero que la cumpla se queda con el token y pasa al mostrador en su 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 del token que este está libre. Sin embargo, 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 le asigna a otro hilo que, en algún momento, ejecutará un código similar al siguiente:
Al final, 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 ventanilla:
- solicitar el acceso al ventanilla:
lock(jeton){
while (! jeNeSuisPasCeluiQuiEstAttendu)
Monitor.Wait(jeton);
}
// paso al ventanillo
...
es equivalente a
Cabe señalar que, en este esquema, el token se libera de inmediato, tan pronto como se cruza la barrera. Entonces, otro hilo puede evaluar la condición de acceso. Por lo tanto, la construcción anterior permite el ingreso de todos los hilos que verifiquen la condición de acceso. Si esto no es lo deseado, se puede escribir:
lock(jeton){
while (! jeNeSuisPasCeluiQuiEstAttendu)
Monitor.Wait(jeton);
// paso al ventanillo
...
}
donde el token solo se libera después de pasar por la ventanilla.
- modificar la condición de acceso al mostrador y notificárselo 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
...
// notificar a los hilos 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 los lectores y los 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 tabla
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 generarán
const int nbThreads = 5;
// creación de los hilos 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 la 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 puerto 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 los lectores. Los lectores se dirigen a la ventanilla de lectura en el orden de esta tabla, que contiene sus nombres.
- línea 19: lecteurSuivant indica el número del próximo lector autorizado a dirigirse al mostrador.
El acceso al mostrador de escritura depende de los siguientes elementos:
- línea 14: el token peutEcrire
- línea 16: el valor booleano écritureAutorisée
- línea 18: la tabla ordenada de los escritores. Los escritores se dirigen a la ventanilla de escritura en el orden de esta tabla, que contiene sus nombres.
- línea 20: écrivainSuivant indica el número del próximo escritor autorizado a acudir al mostrador.
Los demás elementos del código son los siguientes:
- líneas 29-36: creación e inicio de los subprocesos de lectura. Todos quedarán bloqueados porque no se permite la lectura (línea 15).
- líneas 39-43: su orden de paso por la ventanilla se realizará en orden inverso a su creación.
- líneas 46-53: creación e inicio de los subprocesos de escritura. Todos quedarán bloqueados porque no se permite la escritura (línea 16).
- líneas 56-60: su orden de paso por la ventanilla será en 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 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 acceso al mostrador está controlado por el lock de las líneas 5 a 27. El lector que obtiene el token lo conserva durante todo su paso por el mostrador
- líneas 6-8: un lector que haya obtenido el token de la línea 5 lo libera si la lectura no está autorizada o si no es su turno para pasar.
- líneas 10-15: paso por la ventanilla (procesamiento de la tabla)
- líneas 17-18: el hilo modifica las condiciones de acceso al mostrador de lectura. Cabe señalar que aún conserva el token de lectura y que estas modificaciones todavía 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 cumpliría la condición de acceso, ya que el valor booleano lectureAutorisée es falso. Por otra parte, todos los hilos que están en espera del token peutLire permanecen en espera, ya que la operación PulseAll(peutLire) aún no se ha llevado a cabo.
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 la autorización para escribir
lock (peutEcrire) {
while (!écritureAutorisée || ordreEcriture[écrivainSuivant] != Thread.CurrentThread.Name) {
Monitor.Wait(peutEcrire);
}
// 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]);
}
// 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 al mostrador de escritura está controlado por el lock de las líneas 5 a 27. El escritor que recupera el token lo conserva durante todo su paso por el mostrador
- líneas 6-8: un escritor que haya obtenido el token en 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 (procesamiento de la tabla)
- líneas 18-19: el hilo modifica las condiciones de acceso al canal de escritura. Cabe señalar que aún conserva el token de escritura y que estas modificaciones todavía no permiten que un escritor pase.
- líneas 21-24: el hilo modifica 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 en la línea 5, pero no cumpliría la condición de acceso, ya que el valor booleano écritureAutorisée es falso. Por otra parte, todos los hilos que están en espera del token peutEcrire permanecen en espera de una nueva operación PulseAll(peutEcrire).
Un ejemplo de ejecución es el siguiente:
10.7. Los grupos de subprocesos
Hasta ahora, para gestionar los hilos:
- los creábamos mediante Thread T = new Thread(...)
- y luego los ejecutábamos mediante T.Start()
En el capítulo «Bases de datos» vimos que con ciertos SGBD era posible tener 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 del código. La ventaja radica en la mejora del rendimiento: abrir una conexión es costoso. En este caso, 10 conexiones abiertas pueden atender cientos de solicitudes.
Existe un sistema similar para los hilos:
- Se crean min hilos al iniciar el grupo. El valor de min se establece con el método ThreadPool.SetMinThreads(min1,min2). Un grupo de subprocesos se puede utilizar para ejecutar tareas bloqueantes o no bloqueantes, también conocidas como asincrónicas. El primer parámetro, min1, establece el número de subprocesos bloqueantes; el segundo, min2, establece el número de subprocesos asincrónicos. 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 atender 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 se pueden obtener 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 mejora la seguridad de la aplicación: al establecer un número máximo de hilos, 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 le pide 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 le 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 hilos 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 función
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 hilos para tareas bloqueantes: 2
- líneas 19-21: se muestran los nuevos mínimos
- líneas 22-28: se hace lo mismo para establecer el número máximo de subprocesos para tareas bloqueantes: 5
- líneas 30-33: se ejecutan 7 tareas en un grupo de 5 hilos. 5 tareas deberían obtener 1 hilo cada una; las primeras 2 rápidamente, ya que siempre hay 2 hilos disponibles, y las otras 3 con un tiempo de espera de 0,5 segundos. 2 tareas deberían esperar a que se libere un hilo.
- 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 por 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 en el grupo:
Para la ejecución de los 7 hilos:
- líneas 1-6: las primeras 3 tareas se ejecutan una tras otra. Encuentran de inmediato 1 hilo disponible (MinThreads=3) y luego se ponen en espera.
- líneas 7-9: para 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. Fue necesario crear uno. Este mecanismo es posible hasta 5 (MaxThreads=5).
- línea 10: ya no hay hilos disponibles: las tareas 5 y 6 tendrán que esperar.
- líneas 11-12: la tarea 0 termina. La tarea 5 toma su hilo.
- líneas 13-14: la tarea 1 termina. La tarea 6 toma su hilo.
- líneas 17-21: las tareas se completan 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 características que, en ciertos casos, pueden hacerla más interesante que la clase [Thread]:
- emite los siguientes eventos:
- DoWork: un hilo solicitó la ejecución de BackgroundWorker
- ProgressChanged: el objeto BackgroundWorker ejecutó el método ReportProgress. Este método sirve para proporcionar un porcentaje de ejecución.
- RunWorkerCompleted: el objeto BackgroundWorker ha finalizado su trabajo. Pudo terminarlo normalmente, o bien por cancelación o por una excepción.
Estos eventos hacen que el BackgroundWorker sea útil en las interfaces gráficas: una tarea de larga duración se le asignará a un BackgroundWorker, el cual podrá informar sobre su avance mediante el evento ProgressChanged y sobre 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 esta manera, en una interfaz gráfica, el usuario podrá cancelar una tarea que tarde mucho en completarse.
- 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, el cual le asignará un hilo ya existente pero no utilizado. El hecho de reciclar los hilos de esta manera, 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 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 tabla
static AutoResetEvent finLecteurs; // indica el fin 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); // aún no se puede leer
peutEcrire = new AutoResetEvent(true); // ya se puede escribir
finLecteurs = new AutoResetEvent(false); // la aplicación no ha terminado
// sincroniza el acceso al contador de hilos 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 los hilos 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 hilos
finLecteurs.WaitOne();
//fin de la operación
Console.WriteLine("Fin de Main...");
}
public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
...
}
// leer el 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 reemplaza por la clase MyBackgroundWorker en las líneas 79-82. La clase BackgroundWorker se ha derivado para asignarle un número al hilo. Se podría haber procedido de otra manera pasando un objeto al método RunWorkerAsync de las líneas 43 y 54, objeto que contiene 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 lectores que han terminado su trabajo. Este contador es incrementado por el método EndLecteur de las líneas 63 a 65, que se ejecuta cada vez que finaliza un subproceso lector. Es este procedimiento el que controla el evento AutoResetEvent finLecteurs de la línea 18, con el cual se sincroniza, en la línea 59, el método Main.
- línea 16: dado que varios hilos de lectura podrían intentar incrementar al mismo tiempo el contador nbLecteursTerminés, el objeto de sincronización appli garantiza un acceso exclusivo a este. Este caso es poco probable, pero teóricamente posible.
- líneas 35-44: creación de los hilos 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 que lo ejecute
- 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 completados
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á esperando este evento.
- Dado que el procedimiento EndLecteur se ejecuta en varios hilos, la sección crítica anterior está protegida por la cláusula lock de la línea 3.
La ejecución arroja 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 le 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 inicia
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 función
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 los hilos
- 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 a 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úmero 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....");
}
// se pone en modo de suspensión durante Duración segundos con una pausa cada 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 verificarí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 lanzó. 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 manejadores 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, se completa solo si se ha producido una excepción.
- línea 7: el campo infos.Cancelled, de tipo booleano, toma el valor true si se canceló el hilo.
- línea 8: si no hubo 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 lanzó una excepción, ya que ese 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 que llama y el hilo llamado, tal como se hizo en el ejemplo anterior. Entonces 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 se le atiende mediante 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 esta solicitud requiere datos, el hilo ejecutará un método de la capa [ui], el cual llamará a un método de la capa [metier], que a su vez llamará a un método de la capa [dao].
- El hilo devuelve la respuesta al usuario. Luego desaparece o se recicla en un grupo de hilos.
En la operación 2, puede ser interesante que el hilo tenga datos propios, c.a.d, que no se compartan con los demás hilos. Estos datos podrían, por ejemplo, pertenecer al usuario específico al que atiende el hilo. Dichos datos podrían entonces utilizarse 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. | |
asigna 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] administra 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
- 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 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. Entonces, 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 agregar 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 responder a diferentes necesidades de negocio, la capa [dao] se verá obligada a crecer. 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, esto presenta una dificultad en la gestión de la transacción que agrupa actualizaciones que deben realizarse de manera atómica en la base:
- 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 si hay alguna transacción en curso, se podría agregar 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 la vinculará a una fuente de datos específica: 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 es ahí 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 completa
- en [2]: las referencias utilizadas. Dado que la base de datos [4] es una base de datos SQL Server Compact, es necesario contar con la referencia [System.Data.SqlServerCe].
- en [3]: las diferentes capas de la aplicación.
La base [4] es la base SQL Server Compact que ya se utilizó en el capítulo anterior, en particular en el párrafo 9.3.1.
![]() |
La clase Artículo
Una fila de la tabla [articles] anterior está encapsulada 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 colocar 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, ya 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 «transacción»
- 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 porque no se ha producido ninguna excepción. Entonces se valida la transacción.
- líneas 34-35: se ha producido una excepción, se revierte 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 estábamos en una transacción, cerramos 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: recuperamos 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 se vinculó.
- líneas 26-30: de lo 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=nula), 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 Servidor CE, esta transacción implícita está, por defecto, en modo autocommit: la orden SQL se convierte en 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 ejecutará. Se trata de la consulta parametrizada de la línea g de App.config.
- líneas 37-44: se inicializan los 4 parámetros de la consulta
- línea 46: se ejecuta la consulta.
- líneas 49-51: hay que recordar que, si no había una transacción, se abrió una nueva conexión con la base de datos en las líneas 26-30. En este caso, debe cerrarse. Si había una transacción, la conexión no debe cerrarse, ya que es la capa [metier] la que la administra.
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 fila 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)) {
// inicio 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 que tienen 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 una 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 dejó el primer artículo en la base de datos
- línea 9: la inserción realizada dentro de una transacción no dejó ningún artículo en la base de datos
10.9.3. Conclusión
El ejemplo anterior ha demostrado 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: es posible que la capa [metier] utilice transacciones sin que la capa [dao] tenga que saberlo. Por lo tanto, no hay ningún objeto Transaction en el código de la capa [dao]. Esto se logra mediante una técnica de proxy llamada AOP (Programación Orientada a Aspectos). Una vez más, no podemos más que recomendar al lector que utilice estos marcos de trabajo.
10.10. Para profundizar...
Para profundizar en el complejo tema de la sincronización de subprocesos, se puede consultar el capítulo Threading del libro C# 3.0 mencionado en la introducción de este documento. En él se presentan numerosas técnicas de sincronización para diferentes tipos de situaciones.







