Multitarea IV, interacción con Windows UI
Hasta ahora he mostrado ejemplos de multitarea que dejaban la aplicación bloqueada hasta que terminaban. Esto no resulta muy útil en la práctica. Lo normal es que el usuario pueda seguir interactuando con la aplicación mientras se ejecutan las tareas en el fondo, y que estas puedan interactuar a su vez con el interfaz de usuario.
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.
El interfaz de usuario de Windows se ejecuta en su propia tarea. Si intentamos cambiar alguna propiedad de un control o llamar a determinados métodos desde otra tarea diferente, se producirá una excepción. Pero existen mecanismos que nos permiten realizar esta interacción de manera bastante simple.
Para ver los ejemplos que acompañan este artículo, en la aplicación MultithreadingDemo podéis acceder a la opción de menú Windows UI del menú Demo:
En el primer ejemplo que vamos a ver, volveremos a dibujar el atractor de Lorenz, pero esta vez podremos ver cómo se va formando el dibujo, a la vez que podemos seguir interactuando con el programa. Para ello, hay que pulsar el botón Lorenz, cuyo texto cambiará a Stop cuando el trazado comience. Pulsando de nuevo el botón, la tarea finalizará:
En este caso solo lanzamos una tarea con la clase Task, y utilizamos un objeto CancellationTokenSource para poder detenerla cuando queramos. Con un objeto Barrier esperamos a que la tarea finalice del todo antes de destruir los objetos para que no se produzcan excepciones por estar en uso:
if (bLorenz.Text == "Lorenz")
{
_barrier = new Barrier(1);
_bitmap = new Bitmap(640, 480);
Graphics gr = Graphics.FromImage(_bitmap);
gr.FillRectangle(Brushes.Black, new Rectangle(0, 0, 640, 480));
pbDrawing.Image = _bitmap.Clone() as Bitmap;
pbDrawing.BringToFront();
_cancel = new CancellationTokenSource();
_lTask = Task.Run(() =>
{
Lorenz();
}, _cancel.Token);
}
else
{
try
{
_cancel.Cancel();
_barrier.SignalAndWait();
bLorenz.Text = "Lorenz";
Thread.Sleep(1000);
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
finally
{
_cancel.Dispose();
_lTask.Dispose();
_barrier.Dispose();
}
}
El objeto _bitmap lo utilizaremos para dibujar el atractor, pero como no podemos utilizarlo mientras esté asignado a la propiedad Image de la PictureBox, o se producirá una excepción, habrá que ir haciendo copias con el método Clone y asignándoselas una por una. Este es el código de la tarea:
private void Lorenz()
{
_barrier.AddParticipant();
bLorenz.EndInvoke(bLorenz.BeginInvoke((Action)(() =>
{ bLorenz.Text = "Stop"; })));
int ix = 0;
double x = 1;
double y = 1;
double z = 1;
double dx, dy, dz;
double dt = 0.001;
bool done = true;
try
{
while (true)
{
if (done)
{
done = false;
dx = 10 * (y - x);
dy = x * (17 - z) - y;
dz = x * y - z;
x += dt * dx;
y += dt * dy;
z += dt * dz;
int xx = (int)(x * 16 + 320);
int yy = 480 - (int)(z * 16);
int c = 63 + ix / 100;
Color color = Color.FromArgb(c, c, c);
_bitmap.SetPixel(xx, yy, color);
pbDrawing.BeginInvoke((Action)(() =>
{ pbDrawing.Image = _bitmap.Clone() as Bitmap;
done = true; }));
ix++;
if (ix > 19200)
{
ix = 0;
}
}
_cancel.Token.ThrowIfCancellationRequested();
}
}
catch
{
_barrier.SignalAndWait();
}
}
El objeto Barrier se ha creado con una cuenta de 1 para la tarea principal, lo primero que hacemos al entrar es aumentar la cuenta con el método AddParticpant con lo que ya tenemos la barrera preparada para sincronizar dos tareas.
A continuación, cambiamos el texto del botón a Stop, pero como el botón pertenece a la tarea principal, la tarea del interfaz de usuario de Windows, no podemos hacerlo directamente desde aquí. Para esto, los controles tienen un método BeginInvoke que permite ejecutar un delegate en el contexto de la tarea propietaria del control, y es en este delegate donde realizaremos el cambio. Con el método EndInvoke podemos esperar a la finalización de esta operación antes de continuar.
Dentro del bucle también tendremos que recurrir a BeginInvoke para asignar la copia de la imagen al control PictureBox, pero en este caso nos encontramos con dos problemas. Por un lado, el bucle puede ejecutarse tan rápidamente que cuando lleguemos de nuevo a la llamada a SetPixel del Bitmap, el método Clone todavía no haya terminado en el delegate, con lo que se produciría una excepción. No podemos usar EndInvoke, porque el programa se podría quedar colgado al cancelar la tarea, así que tenemos que implementar algún mecanismo de sincronización.
No es una buena idea utilizar mecanismos de los que ya hemos hablado anteriormente, como lock o Monitor, pero no siempre es necesario utilizar estos mecanismos. En este caso vamos a usar una simple variable booleana local, ya que en el delegate podemos acceder sin problemas a estas variables, que nos indicará cuando ha terminado el método Clone y podemos seguir con el dibujo.
En lugar de comprobar en el bucle la propiedad IsCancellationRequested de CancellationToken, usaremos el método ThrowIfCancellationRequested, que produce una excepción en caso de cancelación. En el bloque catch indicaremos que la tarea ha finalizado con el método SignalAndWait del objeto Barrier.
Ahora vamos a ver un ejemplo similar con más tareas implicadas. En este caso cada tarea añadirá un control Label con un color y tamaño de fuente diferente a un control Panel y la hará girar siguiendo un círculo o una elipse con radio y dirección aleatorios. Para ejecutarlo, hay que pulsar el botón Labels. Este es el código que lanza o cancela las tareas:
if (bLabels.Text == "Labels")
{
pLabels.Controls.Clear();
pLabels.BringToFront();
_cancel = new CancellationTokenSource();
_barrier = new Barrier(1);
_lTasks.Clear();
for (int ix = 1; ix <= 8; ix++)
{
int c = ix;
_lTasks.Add(Task.Run(() =>
{
LabelCircle(100 + NextRandom(50),
320 + (50 - NextRandom(100)),
240 + (50 - NextRandom(100)),
Math.Sign(50 - NextRandom(100)),
"Circle label " + c.ToString());
}, _cancel.Token));
_lTasks.Add(Task.Run(() =>
{
LabelEllipse(100 + NextRandom(100),
100 + NextRandom(100),
320 + (50 - NextRandom(100)),
240 + (50 - NextRandom(100)),
Math.Sign(50 - NextRandom(100)),
"Ellipse label " + c.ToString());
}, _cancel.Token));
}
bLabels.Text = "Wait...";
}
else
{
if (_barrier.ParticipantCount == 17)
{
try
{
_cancel.Cancel();
bLabels.Text = "Labels";
_barrier.SignalAndWait();
Thread.Sleep(1000);
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
finally
{
_cancel.Dispose();
_barrier.Dispose();
foreach (Task t in _lTasks)
{
t.Dispose();
}
}
}
}
En este caso vamos a lanzar 16 tareas, ocho de las cuales trazarán círculos y las otras ocho elipses. Las tareas tardan en empezar, por lo que el texto del botón cambiará a Wait… hasta que todas ellas hayan arrancado. El objeto Barrier esta vez tendrá que llegar hasta una cuenta de 17, y antes no se permitirá cancelar la operación, aunque el interfaz de usuario seguirá respondiendo.
Este es el código de la tarea que mueve una etiqueta en círculos. La elíptica es prácticamente idéntica:
private void LabelCircle(double r, double xc, double yc, int dir, string text)
{
_barrier.AddParticipant();
if (_barrier.ParticipantCount == 17)
{
bLabels.BeginInvoke((Action)(() =>
{ bLabels.Text = "Stop"; }));
}
Label label = new Label();
label.Text = text;
label.AutoSize = true;
label.Font = new Font("Arial", 8f + NextRandom(8));
label.ForeColor = Color.FromArgb(NextRandom(192),
NextRandom(192),
NextRandom(192));
label.BackColor = Color.Transparent;
double angle = 0;
int ixlabel = 0;
lock (_lockObj)
{
ixlabel = pLabels.Controls.Count;
pLabels.EndInvoke(pLabels.BeginInvoke((Action)(() =>
{ pLabels.Controls.Add(label); })));
}
try
{
while (true)
{
pLabels.Controls[ixlabel].BeginInvoke((Action)(() =>
{
pLabels.Controls[ixlabel].Left =
(int)(xc + r * Math.Cos(angle));
pLabels.Controls[ixlabel].Top =
(int)(yc + r * Math.Sin(angle));
}));
Thread.Sleep(100);
angle += 0.1 * dir;
if (angle > 2 * Math.PI)
{
angle = 0;
}
else if (angle < 0)
{
angle = 2 * Math.PI;
}
_cancel.Token.ThrowIfCancellationRequested();
}
}
catch
{
_barrier.SignalAndWait();
}
}
Aquí utilizamos la instrucción lock para asegurarnos de que dos tareas no añaden el control Label al panel al mismo tiempo, para evitar excepciones y a la vez asegurarnos de conocer el índice correcto de la Label en la colección Controls del Panel. Por lo demás, todo es como en el ejemplo anterior. Aquí no tenemos que preocuparnos de ninguna sincronización extra, ya que cada tarea mueve un control Label diferente.
El resultado es algo como esto:
Por último, en el espacio de nombres System.ComponentModel, existe un componente llamado BackgroundWorker que puede ser utilizado en los formularios para añadir una tarea de fondo interactiva de manera sencilla. Lo podéis encontrar en el cuadro de herramientas, en la sección de componentes. Este componente tiene dos propiedades booleanas importantes: WorkerReportProgress, que permite activar o desactivar la funcionalidad de proporcionar información sobre el progreso de la tarea, y WorkerSupportsCancellation, que permite activar o desactivar la posibilidad de cancelar la tarea.
Este componente tiene definidos solamente tres eventos. DoWork es el evento donde se realiza el trabajo de la tarea de fondo, ProgressChanged es un evento que se dispara cuando es necesario informar del progreso de la tarea y RunWorkerCompleted se dispara cuando la tarea ha finalizado.
Vamos a ver el funcionamiento de este componente con un ejemplo que dibuja un fractal utilizando el método de Newton para encontrar raíces de la función compleja F(z) = Zn – 1, el progreso de la tarea se mostrará mediante un control ProgressBar, y se podrá cancelar como en los ejemplos anteriores. Para ejecutarlo basta con pulsar el botón Worker:
if (bWorker.Text == "Worker")
{
_bitmap = new Bitmap(640, 480);
Graphics gr = Graphics.FromImage(_bitmap);
gr.FillRectangle(Brushes.Black, new Rectangle(0, 0, 640, 480));
pbDrawing.Image = _bitmap.Clone() as Bitmap;
pbDrawing.BringToFront();
_iBitmap = new int[640 * 480];
BitmapData bd = _bitmap.LockBits(new Rectangle(0, 0, 640, 480),
ImageLockMode.ReadWrite,
PixelFormat.Format32bppRgb);
Marshal.Copy(bd.Scan0, _iBitmap, 0, _iBitmap.Length);
_bitmap.UnlockBits(bd);
bwNewton.RunWorkerAsync();
bWorker.Text = "Stop";
}
else
{
bwNewton.CancelAsync();
}
En lugar de utilizar directamente un objeto Bitamp para dibujar el resultado, utilizaremos el array de enteros _iBitmap para acelerar el proceso, ya que el método SetPixel es muy lento. Lo único que tenemos que hacer para poner en marcha la tarea de fondo es utilizar el método RunWorkerAsync, que a su vez disparará el evento DoWork del componente BackgroundWorker. Veremos cómo se va formando la imagen a la vez que el control ProgressBar que hay debajo del botón mostrará el avance de la tarea, como en esta imagen:
El evento DoWork ejecuta el siguiente código:
private void bwNewton_DoWork(object sender, DoWorkEventArgs e)
{
Complex c;
double n = 5;
double xmin = -2;
double xmax = 2;
double ymin = -1.5;
double ymax = 1.5;
double xi = (xmax - xmin) / 639;
double yi = (ymax - ymin) / 479;
int cp = 1;
Parallel.For(0, 640, p =>
{
for (int q = 0; q < 480; q++)
{
c = new Complex(xmin + (p * xi), ymin + (q * yi));
Complex x = c;
Complex epsilon;
for (int k = 1; k <= 400; k++)
{
if (bwNewton.CancellationPending)
{
e.Cancel = true;
return;
}
epsilon = -((Complex.Pow(x, n) - 1) /
(n * Complex.Pow(x, n - 1)));
x += epsilon;
if (Math.Pow(epsilon.Magnitude, 2) < 0.00000000001)
{
_iBitmap[p + 640 * (479 - q)] =
_colors[k % _colors.Length].ToArgb();
break;
}
}
cp++;
if (cp >= 3072)
{
bwNewton.ReportProgress(0);
cp = 1;
}
}
lock (_lockObj)
{
BitmapData bd = _bitmap.LockBits(new Rectangle(0, 0, 640, 480),
ImageLockMode.ReadWrite,
PixelFormat.Format32bppRgb);
Marshal.Copy(_iBitmap, 0, bd.Scan0, _iBitmap.Length);
_bitmap.UnlockBits(bd);
Bitmap bmp = _bitmap.Clone() as Bitmap;
pbDrawing.BeginInvoke((Action)(() =>
{ pbDrawing.Image = bmp; }));
}
});
}
Para acelerar los cálculos utilizamos un bucle Parallel.For para procesar cada columna de puntos por separado, ya que en este caso el color de cada píxel es independiente de los demás. Podéis ver que las columnas se dibujan siguiendo un orden aleatorio, como vimos en el primer artículo de la serie. El bucle Parallel.For solo se puede usar cuando no importa el orden en el que se ejecuten las diferentes iteraciones, como en este caso.
Para cancelar el trabajo, se utiliza el método CancelAsync del componente BackgroundWorker. Desde dentro de la tarea hay que comprobar la propiedad booleana CancellationPending. El argumento DoWorkEventArgs del evento tiene tres propiedades, Argument, de tipo object, permite pasarle datos de inicio a la tarea, Cancel permite indicar que la tarea ha sido cancelada al evento RunWorkerCompleted, y Result, de tipo object, permite devolver un resultado a este mismo evento.
Con el método ReportProgress se dispara el evento ProgressChanged, que se ejecuta en el contexto de la tarea del interfaz de usuario, por lo que podemos acceder sin problemas a todos los controles del formulario. Este método tiene un parámetro en el que podemos pasar el porcentaje de trabajo realizado, aunque en este caso no lo utilizo.
El evento PorgressChanged simplemente hace avanzar el valor del ProgressBar:
private void bwNewton_ProgressChanged(object sender,
ProgressChangedEventArgs e)
{
pbWorker.PerformStep();
}
La propiedad ProgressPercentage del parámetro ProgressChangedEventArgs contendría el porcentaje de trabajo realizado.
Al finalizar la tarea, se dispara el evento RunWorkerCompleted:
private void bwNewton_RunWorkerCompleted(object sender,
RunWorkerCompletedEventArgs e)
{
bWorker.Text = "Worker";
pbWorker.Value = 0;
if (e.Error != null)
{
MessageBox.Show(e.Error.Message);
}
else if (e.Cancelled)
{
MessageBox.Show("Worker cancelled");
}
}
La propiedad Error del parámetro RunWorkerCompletedEventArgs contiene un objeto del tipo Exception si se el trabajo ha terminado debido a una excepción, mientras que la propiedad Cancelled indica si se ha cancelado el trabajo, siempre que lo hayamos indicado dentro del código de la tarea. La propiedad Result contendrá el resultado devuelto por la tarea, si lo hay.
Eso es todo por el momento. En el siguiente artículo veremos un método simplificado para organizar varias tareas mediante el uso de async y await.