Los Hilos Preemptivos ya están aquí, y son… bastante buenos

A continuación encontrarás traducido al castellano el artículo escrito por Kem Tekinay y publicado originalmente en el Blog Oficial de Xojo.

Finalmente Xojo ha presentado la que puede ser la característica más solicitada de todos los tiempos: hilos preemptivos. Pero, ¿qué son?, ¿cuando y cómo usarlos?, ¿cuáles son sus limitaciones? Veámoslo.

En los comienzos

Los Hilos han sido parte del lenguaje Xojo durante mucho tiempo, y los usuarios más experimentados saben los fundamentos: crea una subclase de Thread, escribe el código a ejecutar en el evento Run, y llama al método Start. El código disponible en Run se ejecutará de forma independiente sobre el resto de tu código, permitiendo que tu app continúe funcionando mientras que el hilo hace su trabajo en segundo plano.

En gran parte.

Verás, hasta ahora un Hilo sólo era “cooperativo” lo que significa que se ejecutaba a intervalos con respecto al resto de tu código principal, siendo el framework el encargado de decidir cuando cambiar entre ellos y cuanto tiempo de ejecución dedicaba a cada caso. Cuando el código principal estaba en ejecución, el código del Hilo se mantenía en pausa; y mientras que el código del Hilo se ejecutaba… era el código del hilo principal el que se mantenía en pausa.

Piense en ello como si estuviera frente a dos dispositivos que realizan funciones diferentes cuando se accionan, pero que solo tienen una manivela disponible. Haces funcionar la primera máquina durante un rato, luego la otra, luego vuelves a la primera, hasta que finalizan. Y lo haces rápido, realmente rápido.

Esto funcionaba bastante bien cuando tu objetivo era, digamos, mantener la interfaz de usuario funcional. Pordías mantener el código que requería un largo periodo de procesamiento en un Hilo y presentar posteriormente los resultados una vez que finalizaba su ejecución.

Pero no era adecuado si tu objetivo era el de acelerar algún proceso intenso, o bien el código de tu Hilo no se encontraba en ubicaciones lógicas donde se pudiese devolver el control a tu código principal. En el primer supuesto probablemente lo mejor era no utilizar un Hilo o bien utilizar un Worker (un intento muy limitado de permitir el procesamiento simultáneo), mientras que en el último probablemente tuvieses que devolver manualmente tiempo de procesamiento, resultando en una operación incluso más lenta.

En la actualidad

Los hilos cooperativos son buenos para muchos tipos de tareas, pero cuando necesitas algo que realmente funcione de forma independiente en tu app, entonces lo que se requiere es de un hilo “preemptivo”.

Un hilo preemptivo se ejecuta sobre otro núcleo del procesador de tu equipo, el cual tenga probablemente un mínimo de cuatro, y se ejecuta de forma totalmente aislada con respecto a otros hilos. Es como ejecutar una mini-app completamente independiente con la que puedes compartir información.

En este caso desaparecen las limitaciones asociadas con los hilos cooperativos. Dado que funciona sobre un núcleo distinto no puede ralentizar o interferir con el código principal, y funcionará tan rápido como lo permita el núcleo del procesador.

Tiempos desafiantes

Suena demasiado bien como para ser cierto, pero no está exento de retos. Mientras que es realmente fácil utilizar los hilos cooperativos, los hilos preemptivos requieren una capa adicional de cuidados para los usuarios Xojo.

Cuando se está ejecutando el código de un hilo cooperativo, sabes que nada más se está ejecutando; por lo que no tienes que preocuparte, digamos, de actualizar un Diccionario o bien a la hora de eliminar un elemento de un array. Pero cuando se trata de hilos preemptivos, su código se estará ejecutando de form simultánea al resto del código, por lo que no puedes simplemente actualizar dicho Diccionario si existe la posibilidad de que el código del hilo principal o bien otro hilo preemptivo también puedan estar haciéndolo.

Pongamos por caso este ejemplo:

// En nuestro thilo
If MyArray.Count = 0 And FinishUp Then
  Exit
Else
  Process MyArray.Pop
End If
 
//////////////////////////////
 
// En el código del hilo principal
FinishUp = True
MyArray.Add 1

Puede que pienses que el hilo procesará el último elemento y luego saldrá del bucle porque FinishUp se ha definido a True, pero con los hilos preemptivos tenemos que tener en consideración las condiciones de carrera donde el órden de las acciones no es predecible. Aquí, el código del hilo principal podría definir FinishUp a True, y el hilo podría ver que MyArray está vacío y salir antes de que el código del hilo principal pueda añadir el último elemento. Las condiciones pueden cambiar en, literalmente, cualquier instante incluso en mitad de la ejecución de una única línea de código.

Por fortuna hay formas en las que podemos protegernos de ello, y debes de utilizarlas para asegurar una operativa adecuada y evitar sorpresas.

Conoce tu Tipo

El primer paso consiste en crear un hilo preemptivo, y la implementación de Xojo es realmente sencilla. En vez de introducir su propia clase, puedes continuar utilizando la actual clase Thread y ajustar su nueva propiedad Type a Thread.Types.Preemptive. Se puede definir Type cuando se crea la instancia del Hilo o bien cuando se esté ejecutando su código. Incluso puedes cambiar entre tipos, si bien una vez que está funcionando sólo se puede cambiar el Tipo desde el propio Hilo.

Ejemplo:

// Subclase de Thread llamado PThread
 
MyThread = New PThread
PThread.Type = Thread.Types.Preemptive
PThread.Start

O bien dentro del evento Run podrías hacer esto:

Me.Type = Thread.Types.Preemptive

También puedes ajustar la propiedad Type en el Inspector tras arrastrar un Thread sobre una ventana.

Protégete

Ahora que tienes un hilo preemptivo listo para funcionar, has de pensar sobre cómo proteger los recursos comunes. Xojo ha hecho un buen trabajo a la hora de hacer que una gran parte del framework sea segura, pero eso sólo significa que tu app no se colgará en el caso de que, pongamos por caso, el Hilo intente manipular una variable al mismo tiempo que lo hace otro código. Depende de ti asegurarte de que un área de la aplicación no obstaculice los cambios realizados por otra.

Consideremos este código que mantiene un Diccionario de arrays de enteros:

// Código del Hilo
Var arr() As Integer
 
If MyDictionary.HasKey(x) Then
  arr = MyDictionary.Value(x)
Else
  MyDictionary.Value(x) = arr
End If
 
arr.Add 1
 
//////////////////////////////
 
// Código en el hilo principal
If Not MyDictionary.HasKey(x) Then
  Var arr() As Integer
  MyDictionary.Value(x) = arr
End If

Ejecuta el anterior ejemplo durante la suficiente cantidad de tiempo y te preguntarás por qué se pierden algunos datos. ¿Por qué? Porque tanto el código del Hilo como el que se ejecuta sobre el hilo principal podrían estar comprobando la clave exactamente en el mismo instante y rellenándola cuando no se encuentre. Si lo hace el Hilo en primer lugar, el código del hilo principal lo sustituirá instantáneamente con un array vacío.

En vez de lo anterior deberías de utilizar un Semaphore (Semáforo) o CriticalSection (Sección Crítica), para asegurarte de que tu código esté protegido.

En primer lugar deberías de instanciar uno de estos en una propiedad común, como pueda ser en una ventana o módulo. (En este caso usaré un Semaphore, pero también funcionará con CriticalSection):

MySemaphore = New Semaphore
MySemaphore.Type = Thread.Types.Preemptive

¿Observas la nueva propiedad Type? Debe corresponderse con la definida en el Hilo, y puede utilizarse entre el código del hilo principal y el Hilo o bien entre hilos del mismo tipo. (Esta es una de las limitaciones en la implementación de Xojo: no puedes compartir un Semaphore o CriticalSection entre hilos preemptivos y cooperativos).

Podemos actualizar el ejemplo de la siguiente forma:

// Código en el Hilo
Var arr() As Integer
 
MySemaphore.Signal
 
If MyDictionary.HasKey(x) Then
  arr = MyDictionary.Value(x)
Else
  MyDictionary.Value(x) = arr
End If
 
MySemaphore.Release
 
arr.Add 1
 
//////////////////////////////
 
// Código en el hilo principal
MySemaphore.Signal
 
If Not MyDictionary.HasKey(x) Then
  Var arr() As Integer
  MyDictionary.Value(x) = arr
End If
 
MySemaphore.Release

Esto forzará que o bien el código del hilo principal o el del hilo esperen a que el otro se complete antes de continuar, asegurando así un orden apropiado para evitar una condición de carrera.

Limitaciones

El uso de hilos preemptivos tiene algunas restricciones. La más importante es la anteriormente indicada incapacidad de compartir un Semaphore o CriticalSection con un hilo cooperativo. La otra es la misma que en un hilo cooperativo: no puedes actualizar directamente un componente de la interfaz de usuario, requiriendo en vez de ello el mecanismo UiserInterfaceUpdate (más sobre esto a continuación).

Si estás configurando múltiples hilos para trabajar sobre diferentes partes de un mismo objeto puede derivar en un cuelgue en función del tipo de objeto del que se trate. Por ejemplo, está bien escribir sobre el mismo archivo o bien diferentes partes de un MemoryBlock, pero actualizar un Picture puede que no lo esté. En estos casos tendrás que ser creativo.

¿Para qué están bien?

La elección de hilos preemptivos sobre los cooperativos significa que o bien tendrás algún código que realmente necesite ejecutarse en segundo plano, y que de otro modo interferiría con la interfaz de usuario, o bien tienes alguna tarea de larga duración que puede procesarse de forma lógica en diferentes partes.

Servicio de Ventana

En el primer caso, la técnica más sencilla sería la de arrastrar un Hilo a tu ventana, configurar su propiedad Type e implementar el evento Run. Puede iniciarse desde la propia ventana o bien mediante un proceso manual, tal y como pulsar sobre un botón, o bien de forma automática cuando se abra la ventana.

Tal y como ocurre con un hilo cooperativo, puedes enviar datos de regreso a la ventana utilizando el método AddUserInterfaceUpdate, e implementar el evento UserInterfaceUpdate.

Divídelo

Si tu tarea implica algo que pueda dividirse en porciones lógicas, mi recomendación es que eches un vistazo al módulo TrheadPool disponible en los Ejemplos de Xojo o bien a través de mi página de GitHub (este tiene un README con más detalles).

El uso ideal para ThreadPool es cuando tienes datos que pueden procesarse en secciones. Por ejemplo, supongamos que quieras contar las vocales encontradas en un archivo de texto de gran tamaño. Podrías crear una subclase de ThreadPool e implementar su evento Process para contar un bloque. Entonces podrías enviar el archivo a tu clase en porciones de, digamos, 256KB cada uno y recolectar los resultados.

La ventaja de ThreadPool, entre otras, es que se encargará de gestionar la coordinación en tu lugar, de modo que se minimizan los riesgos a la hora de utilizar los hilos preemptivos. Aun tendrás que considerar como enviar tus resultados de vuelta y de forma segura al código del hilo principal, quizá utilizando Semaphore/CritalSection o bien mediante el método AddUserInterfaceUpdate; pero cada bloque puede considerarse independiente sobre el resto.

(Un agradecimiento especial al MVP Anthony Cyphers por escribir el ejemplo incluido en Xojo.)

Malabares con motosierras

La potencia en el uso de los hilos preemprivos está clara, pero los potenciales peligros pueden ser serios. A no ser que seas muy cuidadoso, es fácil que puedas crear bugs que no resulten aparentes, y puede que te lleve horas o incluso días dar con ellos (lo digo por experiencia personal). Salvo que tengas motivos poderosos para asumir este riesgo, mejor no hacerlo.

Lo que intento decir es que si un hilo cooperativo hace la tarea de forma adecuada, entonces no hay necesidad de introducir dolores de cabeza asociados con los hilos preemptivos.

Pero si tienes una necesidad legítima, como un proceso de larga duración que ha de ejecutarse con la mayor celeridad sin bloquear tu interfaz de usuario, entonces… ¡adelante!, sólo has de ser cuidadoso.

En cualquier caso, es genial tener disponible finalmente dicha opción.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *