使用全局变量同步程序

由于全局变量是在 MQL 程序之外,它们可用于组织控制同一程序的多个副本或在不同程序之间传递信号的外部标志。最简单的例子是限制可以运行的程序的副本数。这一功能很有必要,可以防止在不同图表上意外复制 EA 交易(交易订单可能会因此翻倍),或者为了实施演示版本。

咋一看,此类检查可能可以在源代码中以如下方式完成。

void OnStart()
{
   const string gv = "AlreadyRunning";
   // if the variable exists, then one instance is already running
   if(GlobalVariableCheck(gv)) return;
   // create a variable as a flag signaling the presence of a working copy
   GlobalVariableSet(gv0);
   
   while(!IsStopped())
   {
       // work cycle
   }
   // delete variable before exit
   GlobalVariableDel(gv);
}

这里显示了一个最简单的版本,以脚本作为示例。对于其它类型的 MQL 程序,总体检查概念将是相同的,尽管指令位置可能不同:EA 交易和指标并没有采用无休止的工作循环,而是使用由终端反复调用的特性事件处理程序。我们将稍后探讨这些问题。

呈现的代码存在的问题是没有考虑 MQL 程序的并行执行。

一个 MQL 程序通常在其自己的线程中运行。对于四种 MQL 程序中的三种(EA 交易、脚本和服务),系统肯定分配单独的线程。至于指标,对于处理相同的操作交易品种和时间范围组合的指标,将为其所有实例分配一个公用线程。但是不同组合的指标仍然属于不同线程。

几乎始终是有很多线程在终端中运行 - 大大超过处理器核心数。因此,每个线程不定期被系统中止,以允许其它线程运行。由于在线程之间的所有这种切换非常迅速,作为用户的我们并不会注意到这一“内部组织过程”。然而,每个中止可能影响不同线程访问共享资源的顺序。全局变量就是此类资源。

从程序角度而言,暂停可能在任何相邻指令之间发生。如果知道这一点,我们再回到示例,就不难看到处理全局变量的逻辑可能被破坏的环节。

实际上,第一个副本(线程)可能执行检查并且未找到变量,但立即被中止。因此,在它有时间使用其下一个指令创建变量之前,执行上下文切换到了第二个副本。第二个副本也将没有找到变量,并将决定象第一个副本一样继续工作。为便于清晰理解,两个副本的相同源代码以其交错执行顺序显示为两列指令。

副本 1

副本 2

void OnStart()              
{                                      
   const string gv = "AlreadyRunning"
                                       
   if(GlobalVariableCheck(gv)) return
   // no variable
                                       
   GlobalVariableSet(gv0);           
   // "I am the first and only"
   while(!IsStopped())                 
                                       
   {                                   
      ;                                
                                       
   }                                   
   GlobalVariableDel(gv);              
                                       
}                                      

void OnStart()              
{                                      
                                       
   const string gv = "AlreadyRunning"
                                       
   if(GlobalVariableCheck(gv)) return
   // still no variable
                                       
   GlobalVariableSet(gv0);           
   // "No, I'm the first and only one"
   while(!IsStopped())                 
   {                                   
                                       
      ;                                
   }                                   
                                       
   GlobalVariableDel(gv);              
}                                      

当然,该线程切换方案具有很大的传统性。但在本例中,关键在于很有可能会破坏程序逻辑,即使是在一个字符串中。当有多个程序(线程)时,对于公用资源的不可预见性操作的概率会增加。这可能足以使 EA 在最意想不到的时刻亏损,或者导致获得的技术分析估计失真。

最令人沮丧的是此类错误非常难以检测到。这些错误无法由编译器无法检测到,但在运行时偶尔就会表现出来。但是如果该错误长时间未表现出来,并不意味着没有错误。

为解决此类问题,有必要以某种方式同步所有程序副本对共享资源(在此情况下是全局变量)的访问。

在计算机科学中,有一个特殊概念 - 互斥体,是指一个用于为并行程序中的共享资源提供独占访问权的对象。互斥体可防止数据因异步变更而丢失或损坏。通常情况下,访问互斥体可同步不同的程序,因为这些程序中只有一个能够通过在特定时刻捕获互斥体来编辑受保护的数据,而其余程序被迫等待直至互斥体被释放。

在 MQL5 中没有现成的纯形态的互斥体。但对于全局变量,可通过以下函数获得类似的效果,我们将探讨该函数。

bool GlobalVariableSetOnCondition(const string name, double value, double precondition)

该函数设置现有全局变量 name 的一个新 value,前提是其当前值等于 precondition

如果成功,该函数返回 true。否则返回 false,并且错误代码将在 _LastError中提供。尤其如果该变量不存在,则函数将生成一个 ERR_GLOBALVARIABLE_NOT_FOUND (4501) 错误。

该函数提供对一个全局变量的原子访问,即,该函数分别执行两个操作:检查其当前值,如果与条件匹配,则其向变量赋予一个新 value

等效函数代码可近似表示如下(为什么是“近似”,我们将稍后解释):

bool GlobalVariableZetOnCondition(const string namedouble valuedouble precondition)
{
   bool result = false;
   { /* enable interrupt protection */ }
   if(GlobalVariableCheck(name) && (GlobalVariableGet(name) == precondition))
   {
      GlobalVariableSet(namevalue);
      result = true;
   }
   { /* disable interrupt protection */ }
   return result;
}

无法实现类似这样按预期工作的代码,原因有二。首先,在纯 MQL5 中,没有实现启用和禁用中断保护的块的功能(在内置 GlobalVariableSetOnCondition 函数中,这是由内核本身来提供)。其次,GlobalVariableGet 函数调用会更改变量上次使用时间,而如果前提条件未满足,GlobalVariableSetOnCondition 函数不会更改它。

为了说明如何使用 GlobalVariableSetOnCondition,我们将了解一种新的 MQL 程序类型:服务。我们将在单独的 章节中详细了解它们。目前需要注意的是,它们的结构与脚本十分相似:对于二者,仅有一个主函数(入口点)OnStart。唯一的主要差别是,脚本在图表上运行,而服务独立运行(在后台运行)。

需要将脚本替换为服务的原因是,我们在其中使用了 GlobalVariableSetOnCondition 的任务的实际意义在于统计程序的运行实例数量,并可以设置一个限值。在此情况下,只有启动多个程序时,才会造成与共享计数器同时修改的冲突。然而,对于脚本,难以在相对较短的时间内在不同图表上运行脚本的多个副本。相反,对于服务,终端接口具有用于批量(群组)启动的便利机制。此外,在下次终端启动时,所有激活的服务将自动启动。

我们拟定的用于统计副本数的机制也适用于其它类型的 MQL 程序。由于 EA 交易和指标即使在终端关闭后也仍然连接到图表,下次终端打开时,所有程序几乎同时读取它们的设置和共享资源。因此,如果对于副本数的限值内置于某些 EA 交易和指标中,则基于全局变量同步计数至关重要。

首先,我们了解一种没有使用 GlobalVariableSetOnCondition,以不成熟模式实现拷贝控制的服务,确保计数器失败问题的真实性。该服务位于总源代码目录中的一个专用子目录,其扩展路径为 MQL5/Services/MQL5Book/p4/GlobalsNoCondition.mq5

在服务文件的开头应有一个指令:

#property service

在该服务中,我们将提供 2 个输入变量以设置允许并行运行的副本量上限,以及设置用于模拟因计算机磁盘和 CPU 高负载导致的执行中断(这在终端启动时经常发生)的延迟。这样更容易复现问题,无需为了中断同步而多次重启终端。因此,我们将捕获一个仅会偶然发生但一旦发生却伴随严重后果的漏洞问题。

input int limit = 1;       // Limit
input int startPause = 100;// Delay(ms)

延迟模拟是基于 Sleep 函数。

void Delay()
{
   if(startPause > 0)
   {
      Sleep(startPause);
   }
}

首先,在 OnStart 函数内声明一个临时全局变量。由于它设计用于统计运行的程序副本数量,没有必要使其为持续性的:每次启动终端,均需要重新计数。

void OnStart()
{
   PRTF(GlobalVariableTemp(__FILE__));
   ...

为了避免用户事先创建一个同名变量并向其赋予一个负值的情况,我们引入了保护机制。

   int count = (int)GlobalVariableGet(__FILE__);
   if(count < 0)
   {
      Print("Negative count detected. Not allowed.");
      return;
   }

接下来,主要功能片段开始。如果计数器已经大于或等于最大允许数量,则我们中断程序启动。

   if(count >= limit)
   {
      PrintFormat("Can't start more than %d copy(s)"limit);
      return;
   }

否则,我们将计数器增加 1,并将其写入到全局变量。事先,我们模拟延迟,诱发另一个程序可能对在我们的程序中读取变量和写入变量之间进行干涉的情况。

   Delay();
   PRTF(GlobalVariableSet(__FILE__count + 1));

如果确实发生了这种情况,我们的程序副本将递增并赋予一个已经过时的不正确的值。这将导致这样一种情况:在与我们的程序并行运行的另一个程序副本中,同一 count 值已经被处理过或者将被再次处理。

该服务的有用工作由以下循环表示:

   int loop = 0;
   while(!IsStopped())
   {
      PrintFormat("Copy %d is working [%d]..."countloop++);
      // ...
      Sleep(3000);
   }

用户停止服务后(为此,接口有一个上下文菜单;稍后将进一步详述),循环将结束,我们需要将计数器递减。

   int last = (int)GlobalVariableGet(__FILE__);
   if(last > 0)
   {
      PrintFormat("Copy %d (out of %d) is stopping"countlast);
      Delay();
      PRTF(GlobalVariableSet(__FILE__last - 1));
   }
   else
   {
      Print("Count underflow");
   }
}

编译的服务属于“导航器”的对应分支。

“导航器”中的服务及上下文菜单

“导航器”中的服务及其上下文菜单

单击右键,我们将打开上下文菜单并通过调用 Add service 命令两次来创建 GlobalsNoCondition.mq5 服务的两个实例。在此情况下,每次对话框打开时都会显示服务设置,其中你应保留参数的默认值。

请务必注意,Add service 命令会立即启动创建的服务。但我们不需要这样。因此,启动每个副本之后,我们必须立即再次调用上下文菜单并执行 Stop 命令(如果选择了特定实例),或者执行 Stop everything(如果选择了程序,即整组生成的实例)。

服务的第一个实例的默认名称与服务文件 ("GlobalsNoCondition") 完全一致,并且在所有后续实例中,将自动添加一个增量编号。尤其是第二个实例将被列为 "GlobalsNoCondition 1"。终端允许你使用 Rename 命令将实例重命名为任意文本,但我们将不那样做。

现在一切就绪,可以进行试验。我们尝试同时运行两个实例。为此,我们为对应的 GlobalsNoCondition 分支运行 Run All 命令。

我们别忘了,参数中设置了 1 个实例的限值。然而,根据日志显示,该限制并未生效。

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

两个副本均“认为”它们是 0 号(从工作循环中输出 "Copy 0"),并且它们的总数错误地等于 1,因为这是两个副本存储在计数器变量中的值。

正因如此,当服务停止(Stop everything 命令)时,我们收到一条关于不正确状态的消息 ("Count underflow"):毕竟,每个副本均试图将计数器减少 1,结果,第二个执行的副本收到一个负值。

为解决这一问题,需要使用 GlobalVariableSetOnCondition 函数。基于前一个服务的源代码,准备了改进版本的 GlobalsWithCondition.mq5。总体而言,它复现了其前身的逻辑,但有重大区别。

相比仅调用 GlobalVariableSet 来增加计数器,必须编写一个更复杂的结构体。

   const int maxRetries = 5;
   int retry = 0;
   
   while(count < limit && retry < maxRetries)
   {
      Delay();
      if(PRTF(GlobalVariableSetOnCondition(__FILE__count + 1count))) break;
      // condition is not met (count is obsolete), assignment failed,
      // let's try again with a new condition if the loop does not exceed the limit
      count = (int)GlobalVariableGet(__FILE__);
      PrintFormat("Counter is already altered by other instance: %d"count);
      retry++;
   }
   
   if(count == limit || retry == maxRetries)
   {
      PrintFormat("Start failed: count: %d, retries: %d"countretry);
      return;
   }
   ...

如果旧值已过时,GlobalVariableSetOnCondition 函数可能会因此而无法写入一个新的计数器值,我们再次在循环中读取全局量并重新尝试将其递增,直至超过允许的最大计数器值。循环条件还限制尝试次数。如果循环因违反任一条件而结束,则计数器更新失败,程序不应继续运行。

同步策略
 
理论上,有若干个实施共享资源获取的标准策略。
 
第一个策略是软检查资源是否空闲,然后仅当资源在检查时刻空闲时才将其锁定。如果资源忙碌,则算法计划在一段时间后再次尝试,而此时资源在处理其它任务(这就是为什么具有若干活动/职责领域的程序会首选这种方法)。在 GlobalVariableSetOnCondition 函数的转录中对这一行为方案的模拟是一个单次调用,没有循环,失败则退出当前块。变量更改被延迟“直至更好的时机”。
 
第二个策略更具持续性,并应用在我们的脚本中。这是一个对资源请求重复给定次数或重复预定义时间(资源允许的超时时间)的循环。如果循环到期并且未获得正结果(调用 GlobalVariableSetOnCondition 函数从未返回 true),则同样退出当前块并可能计划稍后再试。
 
最后,第三个策略,是最坚韧不拔的一个,它“坚持不懈”地请求资源。可以将它视为一个具有函数调用的无限循环。该方法适合用于着眼于一个特定任务并且没有取得资源就无法继续工作的程序。在 MQL5 中,将 while(!IsStopped()) 循环用于此目的,并且别忘了在内部调用 Sleep
 
重要的是,需注意潜在的激烈抢夺多个资源的问题。想象一个 MQL 程序修改若干全局变量(理论上这是常见情况)。如果该程序的一个副本捕获一个变量,第二个副本捕获另一个变量,两个副本均等待变量释放,结果将导致它们相互阻塞(思索)。
 
基于前述原因,全局变量及其它资源(例如文件)的共享应针对锁定和所谓的“竞争情况”仔细设计和分析,在这些情况下程序的并行执行会导致未定义的结果(取决于它们的工作顺序)。

当新版本服务中的工作循环完成之后,计数器递减算法已经以类似的方式更改。

   retry = 0;
   int last = (int)GlobalVariableGet(__FILE__);
   while(last > 0 && retry < maxRetries)
   {
      PrintFormat("Copy %d (out of %d) is stopping"countlast);
      Delay();
      if(PRTF(GlobalVariableSetOnCondition(__FILE__last - 1last))) break;
      last = (int)GlobalVariableGet(__FILE__);
      retry++;
   }
   
   if(last <= 0)
   {
      PrintFormat("Unexpected exit: %d"last);
   }
   else
   {
      PrintFormat("Stopped copy %d: count: %d, retries: %d"countlastretry);
   }

作为试验,我们为新服务创建三个实例。在每个实例的设置中,在 "Limit" 参数中,我们指定 2 个实例(在条件变化的情况下下进行测试)。别忘了,创建每个实例会立即启动它,我们并不需要这样,因此,应停止每个新创建的实例。

这些实例将会获得默认名称 "GlobalsWithCondition"、"GlobalsWithCondition 1",以及 "GlobalsWithCondition 2"。

当一切就绪,我们一次性运行所有实例,并在日志中得到类似如下内容。

GlobalsWithCondition 2 GlobalVariableTemp(GlobalsWithCondition.mq5)= »
 » false / GLOBALVARIABLE_EXISTS(4502)

GlobalsWithCondition 1 GlobalVariableTemp(GlobalsWithCondition.mq5)= »
 » false / GLOBALVARIABLE_EXISTS(4502)

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

首先要注意并行运行程序的上下文切换效应:其表现是随机的,但又能直观展示我们描述的现象。创建了一个临时变量的第一个实例是没有编号的 "GlobalsWithCondition":这可以从 GlobalVariableTemp 函数的结果 true 看出。然而,在日志中,该行仅占用第三个位置,而前两个包含调用具有编号 1 和 2 的名称的副本中的同一函数的结果;其中函数 GlobalVariableTemp 返回 false。这表示这些副本后来检查了变量,尽管它们的线程当时超越了无编号的 "GlobalsWithCondition" 线程并更早出现在日志中。

我们回到我们的主程序计数算法。实例 "GlobalsWithCondition" 首先通过了检查,并以内部标识符 "Copy 0" 开始工作(我们不能从服务代码获知用户是如何命名了该实例的:在 MQL5 API 中没有此类函数,至少目前没有)。

利用 GlobalVariableSetOnCondition 函数,在实例 1 和 2 ("GlobalsWithCondition 1", "GlobalsWithCondition 2") 中,修改计数器的事实被检测到:一开始其为 0,但 GlobalsWithCondition 将其增加了 1。两个模板实例均输出消息“计数器已被其它实例更改:1”。在 2 号前面的实例之一 ("GlobalsWithCondition 1") 成功从变量获得新值 1 并将其增加到 2。GlobalVariableSetOnCondition 调用成功表明了这一点(其返回 true)。对此,会显示一条提示其开始工作的消息:“副本 1 正在工作”。

内部计数器的值与外部实例编号相同这一情况完全是巧合。完全有可能 "GlobalsWithCondition 2" 是在 "GlobalsWithCondition 1" 之前启动(或者以其它顺序启动,如果有三个副本的话)。如果是那样的话,则外部和内部编号将有所不同。你可以重复试验启动和停止所有服务很多次,实例增加计数器变量的顺序将很可能是不同的。但在任何情况下,对于总数的限值将截断一个额外实例。

当 "GlobalsWithCondition 2" 的最后一个实例获准访问全局变量时,变量中已经存储了值 2。由于这是我们设置的限值,因此程序未启动。

GlobalVariableSetOnCondition(GlobalsWithCondition.mq5,count+1,count)= »
» false / GLOBALVARIABLE_NOT_FOUND(4501)

Counter is already altered by other instance: 2

Start failed: count: 2, retries: 2

此外,"GlobalsWithCondition" 和 "GlobalsWithCondition 1" 的副本在工作循环中“回旋”,直至服务停止。

你可以尝试仅停止一个实例。这样便能启动另一个之前因超过配额而被禁止执行的实例。

当然,上述防止并行修改的版本保护机制仅对于协调你自己的程序的行为有效,无法限制演示版本的单个副本,因为用户可以直接删除全局变量。为此,全局变量可以以一种不同的方式使用 - 与图表 ID 相关:仅当 MQL 程序创建的全局变量包含其图表 图形对象。要以其他方式控制共享数据(计数器及其它信息),还可以使用 资源数据库