Utilizamos cookies propias y de terceros para mejorar nuestros servicios y mostrarle publicidad relacionada con sus preferencias mediante el análisis de sus hábitos de navegación. Si continua navegando, consideramos que acepta su uso. Puede cambiar la configuración u obtener más información aquí

View site in english Ir a la página de inicio Contacta conmigo
domingo, 05 de noviembre de 2017

Multitarea III, acceso concurrente

En las aplicaciones multitarea existe una problemática con el acceso concurrente a los recursos, como archivos o memoria, por varias tareas simultáneamente. Dos tareas no pueden escribir al mismo tiempo en la misma dirección de memoria, es necesario asegurarse de que algunos datos no se modifiquen mientras los estamos leyendo o cosas por el estilo. En este artículo revisaré los mecanismos que proporciona .NET Framework para tratar con estos problemas.

Si quieres empezar por el principio, este es el primer artículo de la serie sobre aplicaciones multitarea.

En este enlace puedes descargar el código fuente de la aplicación MultithreadingDemo con los ejemplos, escrita en csharp con Visual Studio 2015. La aplicación utiliza la versión 4.5 de .NET Framework.

Todas las clases que usaremos se encuentran en el espacio de nombres System.Threading. En la aplicación MultithreadingDemo podemos abrir el formulario con los ejemplos mediante la opción Concurrency del menú Demo.

El formulario concurrency demo
El formulario concurrecy demo

El primer problema que se puede presentar es que varias tareas necesiten modificar el valor de una misma variable. Necesitamos un mecanismo que permita que solo una de ellas lo haga, mientras que las demás que quieran hacerlo al mismo tiempo queden bloqueadas en espera. De lo contrario, se produciría una excepción. Este mecanismo en realidad es una instrucción del propio lenguaje C#, la instrucción lock. Para utilizarlo, necesitamos un objeto que sea común a todas las tareas que compiten por el recurso. En este caso utilizaremos una variable private static del formulario, de tipo string:

private static string _lockStr = "";

Esta instrucción encierra un bloque de código, que constituye lo que se conoce como sección crítica. Cuando una tarea obtiene el acceso a las instrucciones que contiene esta sección crítica, el resto de tareas que intenten acceder quedarán bloqueadas en la instrucción lock, hasta que el bloque sea abandonado por la tarea actual.

Como ejemplo, vamos a calcular una integral usando un conocido método numérico. Calcularemos la probabilidad aproximada de obtener un valor menor que uno determinado en una distribución normal de media y desviación típica dadas. Para ello, integraremos la función de densidad de dicha distribución entre menos infinito y el valor dado, lo cual equivale a realizar una suma de un gran número de valores de los que toma dicha función en ese intervalo (obviamente, no podemos contar desde menos infinito, así que empezaremos a contar desde 6 veces la desviación típica a la izquierda de la media).

Como la suma es una operación conmutativa, podemos dividir el intervalo en varias partes y sumar los valores de cada una de ellas en una tarea separada, pero necesitaremos tener acceso exclusivo a la variable que contiene la suma total cada vez que haya que añadirle una de las sumas parciales, para esto usaremos la sección crítica creada por la instrucción lock.

En el formulario, debajo del botón Lock, escribiremos un valor para la media y la desviación típica al lado de la etiqueta N, y un valor para calcular la probabilidad al lado de la etiqueta P. Este es el código que realiza los cálculos:

double result = 0;
double gamma = 1.0 / (Math.Sqrt(2.0 * Math.PI));
double den = 2.0;
double h = (((p - mean) / sd) + 6) / 1000000;
double h3 = h / 3.0;

Parallel.For(0, 10, (ip) =>
{
double ig = 0;
for (int ix = ip * 100000;
ix < (ip < 9 ? 0 : 1) + ((1 + ip) * 100000); ix += 2)
{
double xn0 = Math.Round(-6 + ix * h, 10);
double xn1 = Math.Round(-6 + (ix + 1) * h, 10);
double xn2 = Math.Round(-6 + (ix + 2) * h, 10);
ig += Math.Round(h3 * ((Math.Exp(-Math.Pow(xn0, 2) / den)) +
(4 * (Math.Exp(-Math.Pow(xn1, 2) / den))) +
(Math.Exp(-Math.Pow(xn2, 2) / den))), 10);
}
lock (_lockStr)
{
result += ig;
}
});
lResult.Text = ") = " + Math.Round(gamma * result, 5).ToString();

Como puede verse, sumaremos 1000000 de valores de la función, usando 10 tareas que calcularán 10000 valores cada una, mediante un bucle Parallel.For. El bloque lock solo contiene una instrucción, pero puede contener cualquier número de ellas. Al terminar, la probabilidad obtenida aparecerá a la derecha de la etiqueta P.

Al ser una instrucción del lenguaje, lock proporciona un mecanismo rápido para obtener una exclusión mutua, pero también existe una clase que permite realizar lo mismo, proporcionando mecanismos de control adicionales, como la posibilidad de intentar obtener el bloqueo y, si no es posible, continuar con otro tipo de trabajo o pasar el control a otra tarea en medio de la sección crítica, sin haberla abandonado, para continuar posteriormente cuando esta haya finalizado. Se trata de la clase Monitor.

Esta clase solo dispone de métodos estáticos y su uso es similar al de la instrucción lock, aunque en este caso deberemos encerrar la sección crítica entre una llamada a la función Enter y otra a la función Exit. La sección crítica se debe incluir en un bloque try después de llamar a la función Enter, y la llamada a la función Exit se debe colocar en un bloque finally para asegurarnos de que será llamada en todos los casos. Ambas funciones reciben como parámetro la misma variable, que debe ser común a todas las tareas que intervienen y, como en el caso de la instrucción lock, de tipo object o de un tipo derivado.

En este caso, vamos a calcular el tamaño medio de los ficheros de los subdirectorios de un directorio dado, y guardar los valores en un mismo fichero de texto. Para ello, usaremos la clase auxiliar calcHelper, definida de la siguiente manera:

private class calcHelper
{
public long _size;
public int _count;
public long Mean
{
get
{
return _size / _count;
}
}
}

Esta clase contiene el número de ficheros y la suma de sus tamaños, además de una función para calcular el tamaño medio. Definiremos una variable global de tipo Dictionary<string, calcHelper>, cuya clave será cada uno de los subdirectorios contenidos directamente en el directorio elegido, dentro de cada uno de los cuales calcularemos el tamaño medio de todos los ficheros contenidos en ese subdirectorio y todos su árbol interno de subdirectorios. Al terminar, escribiremos los resultados en un fichero de texto que abriremos con el bloc de notas.

En el evento Click del botón Monitor se pide seleccionar primero el directorio y, a continuación, un fichero de texto donde escribir los resultados. Después, se enumeran los subdirectorios del directorio elegido y se lanza una tarea para realizar los cálculos con Task.Run.

if (fbDlg.ShowDialog() == DialogResult.OK)
{
if (sfDlg.ShowDialog() == DialogResult.OK)
{
foreach (string d in
Directory.EnumerateDirectories(fbDlg.SelectedPath))
{
string dir = d;
_calculations[dir] = new calcHelper();
tlist.Add(Task.Run(() => { CalculateThread(dir); }));
}
Task.WaitAll(tlist.ToArray());
StreamWriter wr = new StreamWriter(sfDlg.FileName);
foreach (string k in _calculations.Keys)
{
if (_calculations[k]._count > 0)
{
wr.WriteLine(string.Format("{0}: {1}", k,
_calculations[k].Mean));
}
}
wr.Close();
Process p = new Process();
p.StartInfo.FileName = "notepad";
p.StartInfo.Arguments = sfDlg.FileName;
p.Start();
}
}

Cuando todas las tareas han terminado, se escriben los resultados en el fichero y se ejecuta el block de notas para mostrarlo. La clase que utilizamos para ejecutar otra aplicación es Process, definida en el espacio de nombres System.Diagnostics. Su propiedad StartInfo contiene miembros donde debemos definir el nombre del ejecutable, FileName, y los argumentos que queremos pasarle, Arguments, en este caso, el nombre del fichero de texto. Con el método Start se lanzará por fin el ejecutable.

La clase Monitor la utilizaremos dentro del método que implementa las tareas, CalculateThread, para evitar que más de una tarea acceda al diccionario que contiene los cálculos al mismo tiempo. Se debe evitar que dos tareas diferentes modifiquen una colección simultáneamente, o se puede producir una excepción.

private void CalculateThread(string dir)
{
long sz = 0;
int cnt = 0;
foreach (string f in Directory.EnumerateFiles(dir))
{
FileInfo fi = new FileInfo(f);
sz += fi.Length;
cnt++;
}
Monitor.Enter(_lockStr);
try
{
foreach (string basedir in _calculations.Keys)
{
if (dir.StartsWith(basedir))
{
_calculations[basedir]._count += cnt;
_calculations[basedir]._size += sz;
break;
}
}
}
finally
{
Monitor.Exit(_lockStr);
}
foreach (string sdir in Directory.EnumerateDirectories(dir))
{
CalculateThread(sdir);
}
}

Pueden existir casos en los que se produzcan muchas lecturas sobre un mismo recurso, pero pocas operaciones de escritura. En estas ocasiones nos puede interesar permitir que varias tareas puedan leer los datos simultáneamente mientras ninguna los modifique, pero bloquear el acceso a todas las tareas menos a una cuando debe cambiarse algún valor. Para esto, existe la clase ReaderWriterLock. Esta clase se utiliza de manera similar a la clase Monitor, pero permite obtener acceso en modo lectura o en modo escritura. En modo lectura, cualquier número de tareas puede leer los datos. Cuando una de estas tareas necesita realizar un cambio, debe solicitar acceso en modo escritura. Mientras existan tareas que hayan obtenido acceso al recurso para su lectura o escritura, esta tarea quedará bloqueada. Cuando por fin obtenga acceso para escritura, quedarán bloqueadas todas las tareas que intenten obtener acceso de lectura o escritura, y así sucesivamente.

El ejemplo que voy a mostrar para usar este mecanismo consiste en ordenar las casillas desordenadas de un tablero de ajedrez. Para ello, primero generaremos un tablero desordenado de forma aleatoria, mediante el botón R/W Lock:

Demostración de la clase ReaderWriterLock
Demostración de la clase ReaderWriterLock

A continuación, lanzaremos tres tareas mediante Task.Run, una para ordenar las casillas en las filas, otra para ordenar las casillas en las columnas y una tercera para ordenar las casillas a nivel de todo el tablero, para cubrir los casos en los que la ordenación de filas y columnas se quede atascada.

pbDrawing.Image = null;
_rwLock = new ReaderWriterLock();
List<Task> tasks = new List<Task>();
try
{
RandomizeBoard();
pbDrawing.Image = DrawBoard();
Application.DoEvents();
Thread.Sleep(2000);
tasks.Add(Task.Run(() => ProcessRows()));
tasks.Add(Task.Run(() => ProcessColumns()));
tasks.Add(Task.Run(() => ProcessGlobal()));
Task.WaitAll(tasks.ToArray());
pbDrawing.Image = DrawBoard();
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
finally
{
foreach (Task t in tasks)
{
t.Dispose();
}
}

La función Thread.Sleep sirve para esperar durante un intervalo de milisegundos dado. Su única finalidad en este caso es permitir visualizar el tablero desordenado antes de mostrarlo ordenado. Las tres tareas tienen una estructura similar, primero una parte que solo realiza lecturas de datos en la que se buscan dos casillas que no estén en su sitio, y que se encuentra protegida por un bloqueo de lectura con el método AcquireReaderLock, al que se le pasa un intervalo en milisegundos para fijar un tiempo de espera máximo para intentar adquirir el bloqueo. Esta parte del código se debe proteger con bloques try / catch / finally para asegurarnos de que el bloqueo se libera con el método ReleaseReaderLock. Este es el código para el caso de las filas:

_rwLock.AcquireReaderLock(1000);
try
{
for (int row = 0; row < 8; row++)
{
for (int col = 0; col < 8; col++)
{
if (!PositionOK(col, row))
{
for (int col2 = 0; col2 < 8; col2++)
{
if ((col2 != col) &&
(!PositionOK(col2, row)) &&
(_board[col, row] != _board[col2, row]))
{
pr1 = new Point(col, row);
pr2 = new Point(col2, row);
color1 = _board[col, row];
color2 = _board[col2, row];
}
}
}
if (pr1.X >= 0)
{
break;
}
}
if (pr1.X >= 0)
{
break;
}
}
}
finally
{
_rwLock.ReleaseReaderLock();
}

Las estructuras pr1 y pr2, de tipo Point, contendrán las posiciones del tablero a intercambiar, y color1 y color2, de tipo bool, indicarán si son blancas o negras. Una vez encontradas dos casillas que intercambiar, pasaremos a la parte de la tarea que realiza el intercambio, que debe estar protegida con un bloqueo de escritura con AcquireWriterLock:

_rwLock.AcquireWriterLock(1000);
try
{
if ((_board[pr1.X, pr1.Y] == color1) && (_board[pr2.X, pr2.Y] == color2))
{
bool tmp = _board[pr1.X, pr1.Y];
_board[pr1.X, pr1.Y] = _board[pr2.X, pr2.Y];
_board[pr2.X, pr2.Y] = tmp;
}
}
finally
{
_rwLock.ReleaseWriterLock();
}

Puesto que tenemos tres tareas compitiendo por intercambiar las casillas, antes de realizar el intercambio debemos asegurarnos de que las casillas siguen teniendo los colores iniciales, ya que otra tarea podría haber movido una de ellas. La función BoardCompleted indicará cuando está ordenado todo el tablero y pueden terminar las tareas.

Otro mecanismo para operar con variables de forma protegida lo proporciona la clase Interlocked. Con ella podemos realizar operaciones aritméticas simples con enteros o cambiar el valor de una variable por otro de forma que tengamos garantizado el acceso exclusivo a la misma, de forma atómica. Se trata de una clase que solo tiene métodos estáticos, podemos sumar una cantidad a un entero, con la función Add, incrementar o decrementar en una unidad una variable de tipo int o long con Increment y Decrement, cambiar el valor de una variable por otro con Exchange o hacerlo de forma condicional con CompareExcange, obteniendo el antiguo valor, o simplemente leer su valor con Read.

El ejemplo que voy a mostrar calcula la proporción de rojo, verde y azul de una imagen, primero utilizando todos los píxeles de la imagen y después realizando un promedio del cálculo usando un muestreo aleatorio de puntos con varias tareas. El resultado, al pulsar el botón Interlocked, es el siguiente:

Demostración de la clase Interlocked
Demostración de la clase Interlocked

El histograma de la izquierda es el valor exacto de las proporciones, mientras que el de la derecha es el obtenido como promedio de 30 muestreos aleatorios de solo 10000 puntos.

El muestreo completo se realiza de la siguiente manera, usando el array de enteros _bitmap donde se han copiado los píxeles de la imagen para optimizar el rendimiento:

float pr = 0f;
float pg = 0f;
float pb = 0f;

float den = 640f * 420f;

for (int ix = 0; ix < 640 * 240; ix++)
{
pr += (_bitmap[ix] & 0xFF0000) >> 16;
pg += (_bitmap[ix] & 0x00FF00) >> 8;
pb += (_bitmap[ix] & 0x0000FF);
}

pr /= den;
pg /= den;
pb /= den;

den = (pr + pg + pb);

pr /= den;
pg /= den;
pb /= den;

A continuación, con un bucle Parallel.For, se realizan 30 muestreos de 10000 puntos y se realiza la suma de todos ellos para calcular el valor promedio. Esta suma se protege con Interlocked.Add, de manera que podamos garantizar que se suman todos los valores sin que se produzcan excepciones al intentar realizar la suma dos tareas a la vez.

long rr = 0;
long rg = 0;
long rb = 0;

Parallel.For(0, 30, (n) =>
{
Random r = new Random();
long lrr = 0;
long lrg = 0;
long lrb = 0;

int bx = 0;

for (int ir = 0; ir < 10000; ir++)
{
int x = bx + r.Next(15);
lrr += (_bitmap[x] & 0xFF0000) >> 16;
lrg += (_bitmap[x] & 0x00FF00) >> 8;
lrb += (_bitmap[x] & 0x0000FF);
bx = x;
}
Interlocked.Add(ref rr, lrr / 10000);
Interlocked.Add(ref rg, lrg / 10000);
Interlocked.Add(ref rb, lrb / 10000);
});

float frr = rr / 30f;
float frg = rg / 30f;
float frb = rb / 30f;

den = (frr + frg + frb);

frr /= den;
frg /= den;
frb /= den;

Todos estos mecanismos se pueden utilizar dentro de la misma aplicación, pero también existe un mecanismo que permite sincronizar procesos entre aplicaciones distintas, se trata de la clase Mutex o exclusión mutua. Su funcionamiento es similar al de la instrucción lock o al de la clase Monitor si se usa dentro de la misma aplicación, pero el Mutex también puede tener nivel global y ser visible para todas las aplicaciones, si se crea proporcionando un nombre en forma de cadena de texto en el constructor.

Para obtener acceso exclusivo a un recurso, se debe utilizar el método WaitOne del Mutex. Todas las sucesivas llamadas de otras tareas o procesos tendrán como resultado el bloqueo hasta que la primera libere el Mutex con el método ReleaseMutex.

Para mostrar un ejemplo de este mecanismo, he añadido a la solución la aplicación de consola MutexExample. Esta aplicación genera un fichero .bmp en el que dibuja un fractal de tipo plasma que simula nubes.

Con el botón Mutex, creamos el objeto Mutex global y lanzamos la aplicación de consola para que genere la imagen mediante la clase Process. Esperamos un par de segundos con Thread.Sleep para asegurarnos de que la aplicación ha obtenido acceso exclusivo y esperamos a que termine el dibujo con la función WaitOne, para terminar mostrándolo en la ventana:

pbDrawing.Image = null;
try
{
_mutex = new Mutex(false, "MUTEX_EXAMPLE");
File.Delete("plasma.bmp");
Process p = new Process();
p.StartInfo.Arguments = "plasma.bmp";
p.StartInfo.FileName = "MutexExample.exe";
p.Start();
Thread.Sleep(2000);
_mutex.WaitOne();
_mutex.ReleaseMutex();
using (Bitmap bmp = Bitmap.FromFile("plasma.bmp") as Bitmap)
{
Bitmap bcopy = new Bitmap(bmp.Width, bmp.Height, bmp.PixelFormat);
Graphics gr = Graphics.FromImage(bcopy);
gr.DrawImageUnscaled(bmp, 0, 0);
gr.Dispose();
pbDrawing.Image = bcopy;
}
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
finally
{
if (_mutex != null)
{
_mutex.Close();
_mutex = null;
}
}

El resultado será parecido a este, ya que el fractal se genera de forma aleatoria:

Demostración de la clase Mutex
Demostración de la clase Mutex

En la aplicación MutexExample, lo primero que hacemos es obtener el Mutex global con la función estática OpenExisting, obtenemos acceso exclusivo con WaitOne y generamos la imagen. Para terminar, devolvemos el control con ReleaseMutex:

Mutex mx = Mutex.OpenExisting("MUTEX_EXAMPLE");
mx.WaitOne();
_rand = new Random();
BitmapData bf = null;
try
{
_bitmap[0] = 1 + ((_rand.Next(32767) / 256) * 255) >> 7;
_bitmap[639] = 1 + ((_rand.Next(32767) / 256) * 255) >> 7;
_bitmap[639 + 479 * 640] = 1 + ((_rand.Next(32767) / 256) * 255) >> 7;
_bitmap[479 * 640] = 1 + ((_rand.Next(32767) / 256) * 255) >> 7;

Divide(0, 0, 639, 479, 30);
for (int ix = 0; ix < _bitmap.Length; ix++)
{
_bitmap[ix] = Color.FromArgb(255 - _bitmap[ix],
255 - _bitmap[ix], 255).ToArgb();
}

bf = bmp.LockBits(new Rectangle(0, 0, 640, 480),
ImageLockMode.ReadWrite, PixelFormat.Format32bppRgb);
Marshal.Copy(_bitmap, 0, bf.Scan0, 640 * 480);
bmp.UnlockBits(bf);
bf = null;
bmp.Save(bmpfile);
mx.ReleaseMutex();
}
catch (Exception exi)
{
if (bf != null)
{
bmp.UnlockBits(bf);
}
gr.DrawString(exi.Message, f, Brushes.White, 0f, 0f);
bmp.Save(bmpfile);
mx.ReleaseMutex();
}

Eso es todo con respecto a las clases que nos permiten controlar el acceso concurrente. En el próximo artículo veremos cómo interactuar con el interfaz de usuario de Windows desde otras tareas.

Comparte este artículo: Compartir en Twitter Compártelo en Facebook Compartir en Google Plus Compartir en LinkedIn
Comentarios (0):
* (Su comentario será publicado después de la revisión)

E-Mail


Nombre


Web


Mensaje


CAPTCHA
Change the CAPTCHA codeSpeak the CAPTCHA code