强制将高速缓存写入磁盘

MQL5 中的文件读写采用缓存机制。这意味着系统会为数据维护内存缓冲区,从而提升工作效率。因此,在写入期间通过函数调用传输的数据Hi进入输出缓冲区,仅当缓冲区满了之后,才进行对磁盘的物理写入。相反,在读取时,从磁盘读入缓冲区的数据量要超过程序使用函数请求的数据量(如果不是文件末尾),后续读操作(很可能发生)速度更快。

缓存是在大多数应用程序中以及在操作系统本身层面使用的一种标准技术。然而缓存技术并非完美,也有一些缺点。

尤其是在文件被用作程序之间数据交换的手段的情况下,延迟写入可能显著降低通信速度,并使得通信难以预测,由于缓冲区大小可能很大,因此可能会根据某些算法调整转储到磁盘的频率。

例如,在 MetaTrader 5 中,有一整类的 MQL 程序用于将交易信号从终端的一个实例拷贝到另一个实例。它们趋向于使用文件传输信息,对于其信息传输来说,不能让缓存降低速度,这很重要。为此,MQL5 提供了 FileFlush 函数。

void FileFlush(int handle)

该函数将具有 handle 描述符的文件的 I/O 文件缓冲区中剩余的所有数据强制刷写到磁盘。

如果不使用此函数,在最糟糕的情况下,从程序“发送”的数据部分可能只有在文件关闭后才写入磁盘。

这一特性可进一步保障发生非预见性事件(如操作系统或程序死机)时重要数据的安全。然而,另一方面,也不建议在批量记录期间频繁调用 FileFlush,因为可能会对性能造成不利影响。

如果文件以混合模式(读写同时进行)打开,则在向文件进行读和写之间必须调用 FileFlush 函数。

举个例子,考虑 FileFlush.mq5 脚本,其中我们实现模拟交易拷贝器 (deal copier) 的操作的两种模式。我们将需要在不同图表上运行该脚本的两个实例,其中一个成为数据发送者,另一个成为接收者。

该脚本有两个输入参数:EnableFlashing 允许你在使用和不使用 FileFlush 函数的情况下比较程序的操作:UseCommonFolder 表明需要创建一个可供选择的文件,以作为数据传输手段:在终端的当前实例的文件夹中,或者在共享文件夹中(在后者情况下,你可以在不同终端之间测试数据传输)。

#property script_show_inputs
input bool EnableFlashing = false;
input bool UseCommonFolder = false;

别忘了,要在启动脚本时出现一个带有输入变量的对话框,必须还要设置 script_show_inputs 特性。

中转文件 (transit file) 的名称在 dataport 变量中指定。UseCommonFolder 选项控制 FILE_COMMON 标志,该标志已被添加到 File Open 函数中打开文件的模式开关集合中。

const string dataport = "MQL5Book/dataport";
const int flag = UseCommonFolder ? FILE_COMMON : 0;

主函数 OnStart 实际上由两个部分组成:打开文件的设置,定期发送或接收数据的循环。

我们将需要运行该脚本的两个实例,每个实例都具有自己的文件描述符,虽然文件描述符指向的是磁盘上的同一个文件,但打开文件的模式不同。

void OnStart()
{
   bool modeWriter = true// by default the script should write data
   int count = 0;          // number of writes/reads made
   // create a new or reset the old file in read mode, as a "sender"
   int handle = PRTF(FileOpen(dataport
      FILE_BIN | FILE_WRITE | FILE_SHARE_READ | flag));
   // if writing is not possible, most likely another instance of the script is already writing to the file,
   // so we try to open it for reading
   if(handle == INVALID_HANDLE)
   {
      // if it is possible to open the file for reading, we will continue to work as a "receiver"
      handle = PRTF(FileOpen(dataport
         FILE_BIN | FILE_READ | FILE_SHARE_WRITE | FILE_SHARE_READ | flag));
      if(handle == INVALID_HANDLE)
      {
         Print("Can't open file"); // something is wrong
         return;
      }
      modeWriter = false// switch model/role
   }

一开始,我们尝试以 FILE_WRITE 模式在不共享写权限 (FILE_SHARE_WRITE) 的情况下打开该文件,以便运行脚本的第一个实例将捕获该文件并防止第二个实例以写模式处理该文件。第二个实例将在第一次调用 FileOpen 后出现错误和 INVALID_HANDLE,并将使用 FILE_SHARE_WRITE 并行写入标志第二次调用 FileOpen 尝试以读模式 (FILE_READ) 打开该文件。理想情况下,这应该没有问题。然后,modeWriter 变量将被设置为 false 以指示脚本的实际角色。

主要操作循环具有以下结构体:

   while(!IsStopped())
   {
      if(modeWriter)
      {
         // ...write test data
      }
      else
      {
         // ...read test data
      }
      Sleep(5000);
   }

该循环会一直执行,直至用户将脚本从图表手动删除:这种状态由 IsStopped 函数发出信号。在循环内,该操作通过调用 Sleep 函数每 5 秒触发一次,该函数以指定的毫秒数(在本例中为 5000)“冻结”程序。这样做是为了更易于分析持续变化,避免过于频繁的状态日志。在没有详细日志的真实程序中,你可以按照 100 毫秒的频率或更高频率发送数据。

发送的数据将包括当前时间(一个 datetime 值,8 个字节)。在写入文件的 if(modeWriter) 指令的第一个分支中,我们以最后计数调用 FileWriteLong(获取自函数 TimeLocal),将操作计数器加 1 (count++),并将当前状态输出到日志。

         long temp = TimeLocal(); // get the current local time datetime
         FileWriteLong(handletemp); // append it to the file (every 5 seconds)
         count++;
         if(EnableFlashing)
         {
            FileFlush(handle);
         }
         Print(StringFormat("Written[%d]: %I64d"counttemp));

务必要注意,仅当输入参数 EnableFlashing 设置为 true 时,才会在每次输入之后调用 FileFlush 函数。

在进行数据读取的 if 运算符的第二个分支中,我们首先通过调用 ResetLastError 来重置内部错误标志。这是必要的,因为只要文件中有任何数据,我们就会一直读取。在没有更多数据需要读取时,程序将出现特定错误代码 5015 (ERR_FILE_READERROR)。

由于内置 MQL5 计时器(包括 Sleep 函数)的精度有限(约 10 ms),我们不能排除在两次连续读取文件尝试期间发生了两次连续写入的情况。例如,一次读取发生在 10:00:00'200,第二次读取发生在 10:00:05'210(以记数法 "hours:minutes:seconds'milliseconds" 表示)。在此情况下,两个记录并行发生:一个是在 10:00:00'205,第二个是在 10:00:05'205,两个均处于上述期间内。此类情况虽然概率极小,但也有可能发生。即使时间间隔绝对精确,但如果程序总数过多并且没有足够的处理器核心处理它们,则 MQL5 运行时系统也可能被迫在两个运行脚本之间作出选择(哪个优先调用)。

MQL5 提供 高精度计时器 (精确到微秒),但这对于当前任务来说并不是最重要的。

之所以需要嵌套循环,还有一个原因。一旦脚本作为数据“接收者”启动,其必须立即处理自从“发送者”启动以来已经累积的来自文件的所有记录(不太可能两个脚本同时启动)。或许有人会倾向采用不同的算法:跳过所有“旧”记录而仅跟踪新记录。这可以做到,但在这里实施了“无损”选项。

         ResetLastError();
         while(true)// loop as long as there is data and no problems
         {
            bool reportedEndBeforeRead = FileIsEnding(handle);
            ulong reportedTellBeforeRead = FileTell(handle);
  
            temp = FileReadLong(handle);
            // if there is no more data, we will get an error 5015 (ERR_FILE_READERROR)
            if(_LastError)break// exit the loop on any error
  
            // here the data is received without errors
            count++;
            Print(StringFormat("Read[%d]: %I64d\t"
               "(size=%I64d, before=%I64d(%s), after=%I64d)"
               counttemp
               FileSize(handle), reportedTellBeforeRead
               (string)reportedEndBeforeReadFileTell(handle)));
         }

请注意以下这点。有关打开进行读取的文件的元数据(比如 FileSize 函数返回的文件大小,参见 获取文件属性)在文件被打开后不会更改。如果另一个程序之后向我们打开进行读取的文件中添加了内容,即使我们为读描述符调用 FileFlash,该文件的“可检测”长度也将不会更新。可以关闭并重新打开文件(在每次读取之前执行,但效率不高)来显示新描述符对应的文件新长度。但我们将借助另一种技巧避免这个操作:

这个技巧就是持续使用读取函数(即 FileReadLong)读取数据,直至其返回错误。重要的是,不使用操作元数据的其它函数。尤其是,由于只读性的文件尾保持不变,因此使用 FileIsEnding 函数进行检查(参见 文件内的位置控制)将会对旧位置给出 true 结果,尽管另一个进程可能在文件中追加了内容。此外,试图将内部文件指针移动到末尾(FileSeek(handle, 0, SEEK_END,关于 FileSeek 函数,参见同一 章节)将不会跳转到数据的实际末尾,而是跳转到打开文件时末尾位置(实际上已过期)。

该函数告诉我们在文件 FileTell 内的真实位置(参见同一 章节)。随着信息从脚本的另一个实例添加到文件并在该循环中读取,指针将不断向右移动,最终超过 FileSize(尽管这很奇怪)。为了直观说明指针如何移动超出文件大小,我们保存在调用 FileReadLong 之前和之后的值,然后将这些值与大小一起输出到日志。

一旦使用 FileReadLong 读取产生任何错误,则内部循环将中断。正常循环退出意味着错误 5015 (ERR_FILE_READERROR)。尤其是当在文件中当前位置没有数据可供读取时就会发生这一错误。

最后成功读取的数据被输出到日志,可以轻松将其与发送者脚本输出到日志的内容进行比较。

我们运行新脚本两次。为区分其副本,我们将在不同金融工具的图表上运行。

运行两个脚本时,重要的是注意 UseCommonFolder 参数的值相同。在我们的测试中,将其设置为等于 false,因为我们将在一个终端上进行全部操作。对于独立测试,建议为不同终端之间的数据传输将 UseCommonFolder 设置为 true

首先,我们在 EURUSD,H1 图表上运行第一个实例,保留所有默认设置,包括 EnableFlashing=false。然后,我们将在 XAUUSD,H1 图表上运行第二个实例(同样使用默认设置)。日志内容如下(你看到的时间将会不同):

(EURUSD,H1) *

(EURUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(EURUSD,H1) Written[1]: 1629652995

(XAUUSD,H1) *

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=-1 / CANNOT_OPEN_FILE(5004)

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_READ|FILE_SHARE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(EURUSD,H1) Written[2]: 1629653000

(EURUSD,H1) Written[3]: 1629653005

(EURUSD,H1) Written[4]: 1629653010

(EURUSD,H1) Written[5]: 1629653015

根据包含 "Written" 单词的行以及递增的值,发送者成功打开文件进行写入,并开始每 5 秒发送数据。在发送者启动后不到 5 秒内,接收者也启动。接收者给出错误消息,因为它不能打开文件进行写入。但随后接收者成功打开文件进行写入。然而,没有记录表明接收者能够在文件中找到传输的数据。数据仍然“挂”在发送者的缓存中。

我们停止两个脚本,然后以相同顺序再次运行它们:我们首先在 EURUSD 上运行发送者,然后在 XAUUSD 上运行接收者。但这次我们将为发送者指定 EnableFlashing=true

日志中的内容如下:

(EURUSD,H1) *

(EURUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(EURUSD,H1) Written[1]: 1629653638

(XAUUSD,H1) *

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=-1 / CANNOT_OPEN_FILE(5004)

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_READ|FILE_SHARE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(XAUUSD,H1) Read[1]: 1629653638 (size=8, before=0(false), after=8)

(EURUSD,H1) Written[2]: 1629653643

(XAUUSD,H1) Read[2]: 1629653643 (size=8, before=8(true), after=16)

(EURUSD,H1) Written[3]: 1629653648

(XAUUSD,H1) Read[3]: 1629653648 (size=8, before=16(true), after=24)

(EURUSD,H1) Written[4]: 1629653653

(XAUUSD,H1) Read[4]: 1629653653 (size=8, before=24(true), after=32)

(EURUSD,H1) Written[5]: 1629653658

同一文件再次以不同模式在两个脚本中成功打开,但这次写入的值可被接收者正常读取。

需要注意的是,除了首次读取外,每次后续读取数据前,FileIsEnding 函数返回 true(显示在与接收的数据相同的字符串中,在 "before" 字符串之后的圆括号中)。因此,这表明我们已经处于文本末尾,但随后 FileReadLong 成功读取一个应在文件限值范围以外的值,并将位置向右偏移。例如,条目 "size=8, before=8(true), after=16" 表示报告给 MQL 程序的文件大小是 8,而在调用 FileReadLong 之前的当前指针也等于 8,并且启用了文件尾符号。在成功调用 FileReadLong 之后,指针移动到 16。然而,在下一次以及所有其它迭代时,我们再次看到 "size=8",并且指针逐渐移动越来越远,超出文件范围。

由于在发送者的写入以及在接收者的读取都是每 5 秒发生一次,根据它们的循环偏移阶段,我们可以观察到这两个操作之间的不同延迟(最糟糕的情况延迟达将近 5 秒)的影响。然而,这并不意味着缓存刷写也是如此之慢。事实上,这一操作几乎是瞬间完成。为确保更快速的变化检测,可以缩短循环中的睡眠期(请注意,如果延迟过短,本测试将快速填满日志 - 不同于真实程序,在这里新数据的生成始终是基于发送者精确至秒的当前时间)。

顺便说一句,你可以运行多个接收者,而相比之下,发送者必须只有一个。下面的日志显示了一个发送者在 EURUSD 图表上的操作,以及两个接收者在 XAUUSD 和 USDRUB 图表上的操作。

(EURUSD,H1) *

(EURUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(EURUSD,H1) Written[1]: 1629671658

(XAUUSD,H1) *

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=-1 / CANNOT_OPEN_FILE(5004)

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_READ|FILE_SHARE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(XAUUSD,H1) Read[1]: 1629671658 (size=8, before=0(false), after=8)

(EURUSD,H1) Written[2]: 1629671663

(USDRUB,H1) *

(USDRUB,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=-1 / CANNOT_OPEN_FILE(5004)

(USDRUB,H1) FileOpen(dataport,FILE_BIN|FILE_READ|FILE_SHARE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(USDRUB,H1) Read[1]: 1629671658 (size=16, before=0(false), after=8)

(USDRUB,H1) Read[2]: 1629671663 (size=16, before=8(false), after=16)

(XAUUSD,H1) Read[2]: 1629671663 (size=8, before=8(true), after=16)

(EURUSD,H1) Written[3]: 1629671668

(USDRUB,H1) Read[3]: 1629671668 (size=16, before=16(true), after=24)

(XAUUSD,H1) Read[3]: 1629671668 (size=8, before=16(true), after=24)

(EURUSD,H1) Written[4]: 1629671673

(USDRUB,H1) Read[4]: 1629671673 (size=16, before=24(true), after=32)

(XAUUSD,H1) Read[4]: 1629671673 (size=8, before=24(true), after=32)

(EURUSD,H1) Written[5]: 1629671678

当 USDRUB 上的第三个脚本启动时,在文件中已经有 2 条长度 8 字节的记录,因此内部循环立即从 FileReadLong 执行 2 次迭代,文件大小“似乎”等于 16。