Sincronización de programas mediante variables globales
Dado que las variables globales existen fuera de los programas MQL, son útiles para organizar las banderas externas que controlan varias copias del mismo programa o pasan señales entre diferentes programas. El ejemplo más sencillo es limitar el número de copias de un programa que se pueden ejecutar. Esto puede ser necesario para evitar la duplicación accidental del Asesor Experto en diferentes gráficos (debido a lo cual las órdenes comerciales pueden duplicarse), o para implementar una versión demo.
A primera vista, esta comprobación podría hacerse en el código fuente de la siguiente manera:
void OnStart()
|
Aquí se muestra la versión más sencilla utilizando un script como ejemplo. Para otros tipos de programas MQL, el concepto general de comprobación será el mismo, aunque la ubicación de las instrucciones puede diferir: en lugar de un ciclo de trabajo interminable, los Asesores Expertos y los indicadores utilizan sus característicos manejadores de eventos llamados repetidamente por el terminal. Estudiaremos estos problemas más adelante.
El problema del código presentado es que no tiene en cuenta la ejecución en paralelo de los programas MQL.
Un programa MQL normalmente se ejecuta en su propio hilo. Para tres de los cuatro tipos de programas MQL, es decir, para Asesores Expertos, scripts y servicios, el sistema definitivamente asigna hilos separados. En cuanto a los indicadores, se asigna un hilo común a todas sus instancias, que trabajan en la misma combinación de marco temporal y símbolo de trabajo. Pero los indicadores de las distintas combinaciones siguen perteneciendo a hilos diferentes.
Casi siempre se ejecutan muchos hilos en el terminal, muchos más que el número de núcleos del procesador. Por eso, el sistema suspende cada hilo de vez en cuando para permitir que otros hilos trabajen. Como todos estos cambios entre hilos se producen muy rápidamente, nosotros, como usuarios, no nos damos cuenta de esta «organización interna». Sin embargo, cada suspensión puede afectar a la secuencia en la que los distintos hilos acceden a los recursos compartidos. Las variables globales son recursos de este tipo.
Desde el punto de vista del programa, puede producirse una pausa entre cualquier instrucción adyacente. Si, sabiendo esto, volvemos a fijarnos en nuestro ejemplo, no es difícil ver un lugar donde se puede romper la lógica de trabajar con una variable global.
De hecho, la primera copia (hilo) puede realizar una comprobación y no encontrar ninguna variable, pero suspenderse inmediatamente. Como resultado, antes de tener tiempo de crear la variable con su siguiente instrucción, el contexto de ejecución cambia a la segunda copia. Esa tampoco encontrará la variable y decidirá seguir trabajando, como la primera. Para mayor claridad, el código fuente idéntico de las dos copias se muestra a continuación como dos columnas de instrucciones en el orden de su ejecución intercalada.
Copia 1 |
Copia 2 |
||
|
|
Por supuesto, un esquema de este tipo para cambiar entre hilos tiene bastante de convencional. Pero en este caso, la posibilidad misma de violar la lógica del programa es importante, incluso en una sola cadena. Cuando hay muchos programas (hilos), aumenta la probabilidad de que se produzcan acciones imprevistas con recursos comunes. Esto puede ser suficiente para llevar al EA a una pérdida en el momento más inesperado o para obtener estimaciones de análisis técnico distorsionadas.
Lo más frustrante de este tipo de errores es que son muy difíciles de detectar. El compilador no es capaz de detectarlos, y se manifiestan esporádicamente en tiempo de ejecución. Pero que el error no se revele durante mucho tiempo no significa que no haya error.
Para resolver estos problemas es necesario sincronizar de alguna manera el acceso de todas las copias de los programas a los recursos compartidos (en este caso, a las variables globales).
En informática, existe un concepto especial, mutex (exclusión mutua), que es un objeto para proporcionar acceso exclusivo a un recurso compartido desde programas paralelos. Un mutex evita que los datos se pierdan o se corrompan debido a cambios asíncronos. Normalmente, acceder a un mutex sincroniza diferentes programas debido a que sólo uno de ellos puede editar los datos protegidos capturando el mutex en un momento determinado, y el resto se ve obligado a esperar hasta que el mutex se libere.
No hay mutex ya preparados en MQL5 en su forma pura, pero para las variables globales se puede obtener un efecto similar mediante la función que vamos a analizar a continuación.
bool GlobalVariableSetOnCondition(const string name, double value, double precondition)
La función establece un nuevo value de la variable global existente name siempre que su valor actual sea igual a precondition.
Si tiene éxito, la función devuelve true. En caso contrario, devuelve false, y el código de error estará disponible en _LastError. En concreto, si la variable no existe, la función generará un error ERR_GLOBALVARIABLE_NOT_FOUND (4501).
La función proporciona acceso atómico a una variable global, es decir, realiza dos acciones de forma inseparable: comprueba su valor actual y, si coincide con la condición, asigna a la variable un nuevo value.
El código de función equivalente puede representarse aproximadamente del siguiente modo (más adelante explicaremos por qué es «aproximadamente»):
bool GlobalVariableZetOnCondition(const string name, double value, double precondition)
|
Implementar un código así, que funcione como se pretende, es imposible por dos razones. En primer lugar, no hay nada para implementar bloques que activan y desactivan la protección de interrupción en MQL5 puro (dentro de la función integrada GlobalVariableSetOnCondition esto lo proporciona el propio núcleo). En segundo lugar, la llamada a la función GlobalVariableGet modifica la última vez que se utilizó la variable, mientras que la función GlobalVariableSetOnCondition no la modifica si no se cumplió la condición previa.
Para demostrar cómo utilizar GlobalVariableSetOnCondition, pasaremos a un nuevo tipo de programa MQL: los servicios. Las estudiaremos en detalle en una sección aparte. Por ahora, hay que señalar que su estructura es muy similar a la de los scripts: para ambos, sólo hay una función principal (punto de entrada), OnStart. La única diferencia significativa es que el script se ejecuta en el gráfico, mientras que el servicio se ejecuta por sí mismo (en segundo plano).
La necesidad de sustituir los scripts por servicios se explica por el hecho de que el significado aplicado de la tarea en la que utilizamos GlobalVariableSetOnCondition consiste en contar el número de instancias en ejecución del programa, con la posibilidad de establecer un límite. En este caso, las colisiones con modificación simultánea del contador compartido sólo pueden producirse en el momento de lanzar varios programas. Sin embargo, en el caso de los scripts, es bastante difícil ejecutar varias copias de los mismos en distintos gráficos en un periodo de tiempo relativamente corto. Para los servicios, por el contrario, la interfaz del terminal dispone de un cómodo mecanismo de lanzamiento por lotes (en grupo). Además, todos los servicios activados se iniciarán automáticamente en el siguiente arranque del terminal.
El mecanismo propuesto para contar el número de copias también lo demandarán programas MQL de otros tipos. Dado que los Asesores Expertos y los indicadores permanecen unidos a los gráficos incluso cuando se apaga el terminal, la siguiente vez que se enciende todos los programas leen sus configuraciones y recursos compartidos casi simultáneamente. Por lo tanto, si se incorpora un límite en el número de copias en algunos Asesores Expertos e indicadores, es fundamental sincronizar el recuento basado en variables globales.
En primer lugar, consideremos un servicio que implementa el control de copias de un modo ingenuo, sin utilizar GlobalVariableSetOnCondition, y asegurémonos de que el problema de los fallos del contador es real. Los servicios se encuentran en un subdirectorio dedicado en el directorio general del código fuente, así la ruta ampliada es MQL5/Services/MQL5Book/p4/GlobalsNoCondition.mq5.
Al principio del archivo de servicio debe haber una directiva:
#property service |
En el servicio, proporcionaremos dos variables de entrada para establecer un límite en el número de copias que se permite ejecutar en paralelo y un retraso para emular la interrupción de la ejecución debido a una carga masiva en el disco y la CPU del ordenador, lo que suele ocurrir cuando se lanza el terminal. Esto facilitará la reproducción del problema sin tener que reiniciar el terminal muchas veces esperando que pierda la sincronización. Así pues, vamos a detectar un fallo que sólo puede producirse esporádicamente, pero que al mismo tiempo, si ocurre, está cargado de graves consecuencias.
input int limit = 1; // Limit
|
La emulación del retraso se basa en la función Sleep.
void Delay()
|
En primer lugar, se declara una variable global temporal dentro de la función OnStart. Dado que está diseñada para contar las copias en ejecución del programa, no tiene sentido hacerla constante: cada vez que se inicia el terminal hay que contar de nuevo.
void OnStart()
|
Para evitar el caso de que un usuario cree de antemano una variable con el mismo nombre y le asigne un valor negativo, introducimos una protección.
int count = (int)GlobalVariableGet(__FILE__);
|
A continuación, comienza el fragmento con la funcionalidad principal. Si el contador ya es mayor o igual que la cantidad máxima permitida, interrumpimos el lanzamiento del programa.
if(count >= limit)
|
En caso contrario, incrementamos el contador en 1 y lo escribimos en la variable global. Con antelación, emulamos el retraso para provocar una situación en la que otro programa pudiera intervenir entre la lectura de una variable y su escritura en nuestro programa.
Delay();
|
Si esto ocurre realmente, nuestra copia del programa incrementará y asignará un valor ya obsoleto e incorrecto. Se producirá una situación en la que, en otra copia del programa que se ejecuta en paralelo con la nuestra, el mismo valor count ya se ha procesado o se procesará de nuevo.
El trabajo útil del servicio está representado por el siguiente bucle:
int loop = 0;
|
Después de que el usuario detenga el servicio (para ello, la interfaz dispone de un menú contextual, del que hablaremos más adelante), el ciclo terminará y tendremos que disminuir el contador.
int last = (int)GlobalVariableGet(__FILE__);
|
Los servicios compilados entran en la rama correspondiente del «Navegador».
Servicios en el "Navegador" y menú contextual
Haciendo clic con el botón derecho, abriremos el menú contextual y crearemos dos instancias del servicio GlobalsNoCondition.mq5 llamando dos veces al comando Añadir servicio. En este caso, cada vez se abrirá un cuadro de diálogo con la configuración del servicio, en el que deberá dejar los valores predeterminados para los parámetros.
Es importante señalar que el comando Añadir servicio inicia inmediatamente el servicio creado. Pero no lo necesitamos. Por lo tanto, inmediatamente después de lanzar cada copia, tenemos que llamar de nuevo al menú contextual y ejecutar el comando Detener (si se selecciona una instancia específica), o Detener todo (si se selecciona el programa, es decir, todo el grupo de instancias generadas).
La primera instancia del servicio tendrá por defecto un nombre que coincide completamente con el archivo de servicio («GlobalsNoCondition»), y en todas las instancias posteriores se añadirá automáticamente un número creciente. En concreto, la segunda instancia aparece como «GlobalsNoCondition 1». El terminal le permite renombrar instancias en texto arbitrario usando el comando Renombrar, pero no lo haremos.
Ahora todo está listo para el experimento. Intentemos ejecutar dos instancias al mismo tiempo. Para ello, vamos a ejecutar el comando Ejecutar todo para la rama GlobalsNoCondition correspondiente.
Recordemos que en los parámetros se estableció un límite de 1 instancia. Sin embargo, según los registros, no funcionó.
GlobalsNoCondition GlobalVariableTemp(GlobalsNoCondition.mq5)=true / ok GlobalsNoCondition 1 GlobalVariableTemp(GlobalsNoCondition.mq5)=false / GLOBALVARIABLE_EXISTS(4502) GlobalsNoCondition GlobalVariableSet(GlobalsNoCondition.mq5,count+1)=2021.08.31 17:47:17 / ok GlobalsNoCondition Copy 0 is working [0]... GlobalsNoCondition 1 GlobalVariableSet(GlobalsNoCondition.mq5,count+1)=2021.08.31 17:47:17 / ok GlobalsNoCondition 1 Copy 0 is working [0]... GlobalsNoCondition Copy 0 is working [1]... GlobalsNoCondition 1 Copy 0 is working [1]... GlobalsNoCondition Copy 0 is working [2]... GlobalsNoCondition 1 Copy 0 is working [2]... GlobalsNoCondition Copy 0 is working [3]... GlobalsNoCondition 1 Copy 0 is working [3]... GlobalsNoCondition Copy 0 (out of 1) is stopping GlobalsNoCondition GlobalVariableSet(GlobalsNoCondition.mq5,last-1)=2021.08.31 17:47:26 / ok GlobalsNoCondition 1 Count underflow |
Ambas copias «piensan» que son el número 0 (salida «Copia 0» del bucle de trabajo) y su número total es erróneamente igual a 1 porque ese es el valor que ambas copias tienen almacenado en la variable contador.
Es por esto que cuando se detienen los servicios (el comando Detener todo), recibimos un mensaje sobre un estado incorrecto («Count underflow»): al fin y al cabo, cada una de las copias está tratando de disminuir el contador en 1, y como resultado, la que se ejecutó en segundo lugar recibió un valor negativo.
Para resolver el problema debe utilizar la función GlobalVariableSetOnCondition. A partir del código fuente del servicio anterior se ha preparado una versión mejorada GlobalsWithCondition.mq5. En general, reproduce la lógica de su predecesora, pero hay diferencias significativas.
En lugar de limitarse a llamar a GlobalVariableSet para aumentar el contador, hubo que escribir una estructura más compleja.
const int maxRetries = 5;
|
Dado que la función GlobalVariableSetOnCondition no puede escribir un nuevo valor de contador, si el antiguo ya está obsoleto, volvemos a leer la variable global en el bucle y repetimos los intentos de incrementarla hasta que se supere el valor máximo permitido del contador. La condición de bucle también limita el número de intentos. Si el bucle termina con una violación de una de las condiciones, entonces la actualización del contador ha fallado y el programa no debe continuar ejecutándose.
Estrategias de sincronización
En teoría, existen varias estrategias estándar para implementar la captura de recursos compartidos.
La primera consiste en comprobar suavemente si el recurso está libre y bloquearlo sólo si lo está en ese momento. Si está ocupado, el algoritmo planifica el siguiente intento después de un cierto periodo, y en ese momento se dedica a otras tareas (por eso este enfoque es preferible para programas que tienen varias áreas de actividad o responsabilidad). Un análogo de este esquema de comportamiento en la transcripción para la función GlobalVariableSetOnCondition es una sola llamada, sin bucle, saliendo del bloque actual en caso de fallo. El cambio variable se pospone «en espera de tiempos mejores».
La segunda estrategia es más persistente, y se aplica en nuestro script. Se trata de un bucle que repite la solicitud de un recurso durante un número determinado de veces, o un tiempo predefinido (el periodo de tiempo de espera permitido para el recurso). Si el bucle expira y no se alcanza un resultado positivo (la llamada a la función GlobalVariableSetOnCondition nunca devolvió «true»), el programa también sale del bloque actual y probablemente planea volver a intentarlo más tarde.
Por último, la tercera estrategia, la más dura, consiste en solicitar un recurso «hasta las últimas consecuencias». Se puede ver como un bucle infinito con una llamada a una función. Este enfoque tiene sentido utilizarlo en programas que se centran en una tarea específica y no pueden seguir funcionando sin un recurso incautado. En MQL5, utilice el bucle while(!IsStopped()) para esto y no se olvide de llamar a Sleep dentro.
Es importante señalar aquí el problema potencial de la apropiación «dura» de múltiples recursos. Imagine que un programa MQL modifica varias variables globales (lo cual es, en teoría, una situación habitual). Si una copia captura una variable, y la segunda copia captura otra, y ambas esperan la liberación, se producirá su bloqueo mutuo (deadlock).
Basándose en lo anterior, el uso compartido de variables globales y otros recursos (por ejemplo, archivos) debe diseñarse y analizarse cuidadosamente para detectar bloqueos y las denominadas «condiciones de carrera», cuando la ejecución paralela de programas conduce a un resultado indefinido (dependiendo del orden de su trabajo).
Tras la finalización del ciclo de trabajo en la nueva versión del servicio, el algoritmo de disminución del contador se ha modificado de forma similar.
retry = 0;
|
Como experimento, vamos a crear tres instancias para el nuevo servicio. En la configuración de cada uno de ellos, en el parámetro Limit, especificamos dos instancias (para realizar una prueba en condiciones modificadas). Recordemos que la creación de cada instancia la lanza inmediatamente, lo que no necesitamos, y por lo tanto cada instancia recién creada debe ser detenida.
Las instancias recibirán los nombres predeterminados «GlobalsWithCondition», «GlobalsWithCondition 1» y «GlobalsWithCondition 2».
Cuando todo esté listo, ejecutamos todas las instancias a la vez y obtenemos algo como esto en el registro.
GlobalsWithCondition 2 GlobalVariableTemp(GlobalsWithCondition.mq5)= » GlobalsWithCondition 1 GlobalVariableTemp(GlobalsWithCondition.mq5)= » GlobalsWithCondition GlobalVariableTemp(GlobalsWithCondition.mq5)=true / ok GlobalsWithCondition GlobalVariableSetOnCondition(GlobalsWithCondition.mq5,count+1,count)= » » true / ok GlobalsWithCondition 1 GlobalVariableSetOnCondition(GlobalsWithCondition.mq5,count+1,count)= » » false / GLOBALVARIABLE_NOT_FOUND(4501) GlobalsWithCondition 2 GlobalVariableSetOnCondition(GlobalsWithCondition.mq5,count+1,count)= » » false / GLOBALVARIABLE_NOT_FOUND(4501) GlobalsWithCondition 1 Counter is already altered by other instance: 1 GlobalsWithCondition Copy 0 is working [0]... GlobalsWithCondition 2 Counter is already altered by other instance: 1 GlobalsWithCondition 1 GlobalVariableSetOnCondition(GlobalsWithCondition.mq5,count+1,count)=true / ok GlobalsWithCondition 1 Copy 1 is working [0]... GlobalsWithCondition 2 GlobalVariableSetOnCondition(GlobalsWithCondition.mq5,count+1,count)= » » false / GLOBALVARIABLE_NOT_FOUND(4501) GlobalsWithCondition 2 Counter is already altered by other instance: 2 GlobalsWithCondition 2 Start failed: count: 2, retries: 2 GlobalsWithCondition Copy 0 is working [1]... GlobalsWithCondition 1 Copy 1 is working [1]... GlobalsWithCondition Copy 0 is working [2]... GlobalsWithCondition 1 Copy 1 is working [2]... GlobalsWithCondition Copy 0 is working [3]... GlobalsWithCondition 1 Copy 1 is working [3]... GlobalsWithCondition Copy 0 (out of 2) is stopping GlobalsWithCondition GlobalVariableSetOnCondition(GlobalsWithCondition.mq5,last-1,last)=true / ok GlobalsWithCondition Stopped copy 0: count: 2, retries: 0 GlobalsWithCondition 1 Copy 1 (out of 1) is stopping GlobalsWithCondition 1 GlobalVariableSetOnCondition(GlobalsWithCondition.mq5,last-1,last)=true / ok GlobalsWithCondition 1 Stopped copy 1: count: 1, retries: 0 |
En primer lugar, preste atención a la demostración aleatoria, pero al mismo tiempo visual, del efecto descrito del cambio de contexto para programas que se ejecutan en paralelo. La primera instancia en crear una variable temporal ha sido «GlobalsWithCondition» sin número: esto se puede ver en el resultado de la función GlobalVariableTemp, que es true. Sin embargo, en el registro, esta línea ocupa sólo la tercera posición, y las dos anteriores contienen los resultados de llamar a la misma función en copias bajo los nombres con números 1 y 2; en ellas, la función GlobalVariableTemp devolvía false. Esto significa que estas copias comprobaron la variable más tarde, aunque sus hilos superaron al hilo no numerado «GlobalsWithCondition» y terminaron antes en el registro.
Pero volvamos a nuestro algoritmo principal de recuento de programas. La instancia «GlobalsWithCondition» fue la primera en pasar la comprobación, y empezó a funcionar con el identificador interno «Copy 0» (no podemos averiguar en el código de servicio cómo nombró el usuario a la instancia: no existe tal función en la API de MQL5, al menos de momento).
Gracias a la función GlobalVariableSetOnCondition, en las instancias 1 y 2 («GlobalsWithCondition 1», «GlobalsWithCondition 2»), se detectó el hecho de modificar el contador: era 0 al principio, pero GlobalsWithCondition lo incrementó en 1. Ambas instancias tardías emiten el mensaje «El contador ya ha sido alterado por otra instancia: 1». Una de estas instancias («GlobalsWithCondition 1») por delante del número 2, consiguió obtener un nuevo valor de 1 de la variable y aumentarlo a 2. Esto se indica mediante una llamada exitosa GlobalVariableSetOnCondition (devolvió true). Y había un mensaje de que empezaba a funcionar, «Copia 1 está funcionando».
El hecho de que el valor del contador interno sea el mismo que el número de instancia externo es pura coincidencia. Bien podría ser que «GlobalsWithCondition 2» hubiera empezado antes que «GlobalsWithCondition 1» (o en alguna otra secuencia, dado que hay tres copias). Entonces, la numeración exterior e interior serían diferentes. Puede repetir el experimento arrancando y parando todos los servicios muchas veces, y la secuencia en la que las instancias incrementan la variable contador será muy probablemente diferente. Pero en cualquier caso, el límite del número total cortará una instancia extra.
Cuando la última instancia de «GlobalsWithCondition 2» obtiene acceso a una variable global, el valor 2 ya está almacenado allí. Puesto que éste es el límite que hemos fijado, el programa no se inicia.
GlobalVariableSetOnCondition(GlobalsWithCondition.mq5,count+1,count)= » Counter is already altered by other instance: 2 Start failed: count: 2, retries: 2 |
Más adelante, las copias de «GlobalsWithCondition» y «GlobalsWithCondition 1» «giran» en el ciclo de trabajo hasta que se detienen los servicios.
Puede intentar detener sólo una instancia. Entonces será posible lanzar otra que previamente haya recibido una prohibición de ejecución por superar la cuota.
Por supuesto, la versión propuesta de protección contra la modificación paralela sólo es eficaz para coordinar el comportamiento de sus propios programas, pero no para limitar una copia única de la versión de demostración, ya que el usuario puede simplemente borrar la variable global. Para ello, las variables globales se pueden utilizar de una manera diferente, en relación con el ID del gráfico: un programa MQL funciona sólo mientras su variable global creada contenga su ID artes gráficas. Otras formas de controlar los datos compartidos (contadores y demás información) son las proporcionadas por recursos y base de datos.