Принудительная запись кэша на диск

Запись и чтение файлов в MQL5 кэшируются. Это значит, что для данных поддерживается некий буфер в памяти, за счет которого повышается эффективность работы. Так, данные, переданные с помощью вызовов функций при записи, попадают в буфер вывода, и только по факту его наполнения происходит физическая запись на диск. При чтении, наоборот, с диска в буфер считывается больше данных, чем программа запрашивала с помощью функций (если это не конец файла), и последующие операции чтения (вероятность которых очень высока) выполняются быстрее.

Кэширование — стандартная технология, применяемая в большинстве приложений и на уровне самой операционной системы. Однако, кэширование имеет помимо плюсов и свои минусы.

В частности, если файлы используются как средство обмена данными между программами, отложенная запись может существенно замедлить коммуникацию и сделать её менее предсказуемой, поскольку размер буфера может быть довольно большим, а периодичность его "сброса" на диск — подстраиваться по неким алгоритмам.

Например, в MetaTrader 5 существует целая категория MQL-программ для копирования торговых сигналов из одного экземпляра терминала в другой. Они, как правило, используют файлы для передачи информации, а для них очень важно, чтобы кэширование не замедляло процесс. На этот случай в MQL5 имеется функция FileFlush.

void FileFlush(int handle)

Функция выполняет принудительный сброс на диск всех данных, оставшихся в файловом буфере ввода-вывода для файла с дескриптором handle.

Если не использовать данную функцию, то часть "отправленных" из программы данных может, в худшем случае, попасть на диск только при закрытии файла.

Функция обеспечивает бóльшие гарантии сохранности ценных данных для непредвиденных случаев (таких как зависание операционной системы или программы). Однако с другой стороны, частый вызов FileFlush при массированной записи не рекомендуется, поскольку может негативно сказаться на быстродействии.

Если файл открыт в "смешанном" режиме — одновременно записи и чтения — функцию FileFlush необходимо вызывать между операциями чтения и записи в файл.

В качестве примера рассмотрим скрипт FileFlush.mq5, в котором реализуем два режима, имитирующих работу копировщика сделок. Нам потребуется запустить два экземпляра скрипта на разных графиках, при этом один из них станет отправителем данных, а второй — получателем.

В скрипте предусмотрено два входных параметра: EnableFlashing позволяет сравнить действия программ с использованием функции FileFlush и без неё, а UseCommonFolder предписывает создавать файл, выступающий как средство передачи данных, на выбор — в папке текущего экземпляра терминала или в общей папке (в последнем случае можно тестировать передачу данных между разными терминалами).

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

Напомним, что для того, чтобы при запуске скрипта появился диалог с входными переменными, необходимо дополнительно задать свойство script_show_inputs.

Имя "транзитного" файла указано в переменной dataport. Опция UseCommonFolder управляет флагом FILE_COMMON, добавляемым в набор переключателей режимов открываемых файлов в функции FileOpen.

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

Главная функция OnStart состоит фактически из двух частей: настройки открываемого файла и цикла с периодической отправкой или приемом данных.

Нам потребуется запустить два экземпляра скрипта, и в каждом из них будет свой дескриптор файла, указывающий на один и тот же файл на диске, но открытый в разных режимах.

void OnStart()
{
   bool modeWriter = true// по умолчанию скрипт должен писать данные
   int count = 0;          // количество произведенных записей/чтений
   // создать новый или обнулить старый файл в режиме чтения, в роли "отправителя"
   int handle = PRTF(FileOpen(dataport,
      FILE_BIN | FILE_WRITE | FILE_SHARE_READ | flag));
   // если запись невозможна, скорее всего, другой экземпляр скрипта уже пишет в файл,
   // поэтому пытаемся открыть его на чтение
   if(handle == INVALID_HANDLE)
   {
      // если открыть файл на чтение получится, продолжим работу в роли "получателя"
      handle = PRTF(FileOpen(dataport,
         FILE_BIN | FILE_READ | FILE_SHARE_WRITE | FILE_SHARE_READ | flag));
      if(handle == INVALID_HANDLE)
      {
         Print("Can't open file"); // такого быть не должно, что-то не так
         return;
      }
      modeWriter = false// переключаем режим/роль
   }

В начале мы пытаемся открыть файл в режиме FILE_WRITE, без разрешения совместной записи (FILE_SHARE_WRITE), поэтому первый экземпляр запущенного скрипта "захватит" файл и не даст второму работать также в режиме записи. Второй экземпляр, получив ошибку и INVALID_HANDLE после первого вызова FileOpen, попытается открыть файл в режиме чтения (FILE_READ) с помощью второго вызова FileOpen, на этот раз с флагом, разрешающим параллельную запись FILE_SHARE_WRITE. В идеале это должно успешно произойти, и тогда переменная modeWriter устанавливается в false для индикации актуальной роли скрипта.

Основной рабочий цикл имеет следующую структуру:

   while(!IsStopped())
   {
      if(modeWriter)
      {
         // ...записываем тестовые данные
      }
      else
      {
         // ...считываем тестовые данные
      }
      Sleep(5000);
   }

Цикл выполняется до тех пор, пока пользователь не удалит скрипт с графика вручную: об этом просигнализирует функция IsStopped. Внутри цикла работа активируется с периодичностью один раз в 5 секунд, за счет вызова функции Sleep, которая "замораживает" программу на указанное количество миллисекунд (5000 в данном случае). Это сделано для облегчения анализа происходящих изменений "на лету" и исключения слишком частой записи состояния в журнал. В реальной программе без подробных логов ничто не мешает пересылать данные каждые 100 миллисекунд или чаще.

В качестве пересылаемых данных будет выступать текущее время (одно значение datetime, 8 байтов). В первой ветви инструкции if(modeWriter), где осуществляется запись в файл, вызовем FileWriteLong с последним отсчетом (получается из функции TimeLocal), увеличим на 1 счетчик операций (count++) и выведем текущее состояние в журнал.

         long temp = TimeLocal(); // получаем текущее локальное время datetime
         FileWriteLong(handletemp); // дописываем его в файл (раз в 5 секунд)
         count++;
         if(EnableFlashing)
         {
            FileFlush(handle);
         }
         Print(StringFormat("Written[%d]: %I64d"counttemp));

Особо следует отметить, что вызов функции FileFlush после каждой записи делается только в том случае, если входной параметр EnableFlashing установлен в true.

Во второй ветви оператора if, где происходит чтение, первым делом сбросим внутренний признак ошибок, вызвав ResetLastError. Это нужно, потому что мы собираемся читать из файла данные, что называется "до упора", то есть пока они не закончатся, что сообщается программе специфическим кодом ошибки 5015 (ERR_FILE_READERROR).

Поскольку встроенные таймеры MQL5, включая и функцию Sleep, обладают ограниченной точностью (примерно 10 мс), мы не можем исключить ситуацию, когда между двумя последовательными попытками чтения файла произошло две последовательных записи. Например, одно чтение произошло в 10:00:00'200, а второе — в 10:00:05'210 (в нотации "часов:­минут:­секунд'­миллисекунд"). При этом параллельно происходили две записи: одна в 10:00:00'205, а вторая — в 10:00:05'205, и обе попали в вышеприведенный период. Такая ситуация маловероятна, но возможна: даже при абсолютно точной выдержке временных интервалов система выполнения MQL5 может быть вынуждена выбирать между двумя выполняющимися скриптами, какой "пробудить" раньше, а какой позже, если общее количество программ велико и на них всех не хватает ядер процессора.

В принципе MQL5 предоставляет таймеры повышенной точности (микросекундной), но это не критично для текущей задачи.

Вложенный цикл нужен и еще по одной причине. Сразу после запуска скрипта в роли "получателя" данных он должен обработать все записи из файла, накопившиеся за время, прошедшее с запуска "отправителя" (запустить оба скрипта одновременно вряд ли получится). Вероятно, кто-нибудь предпочел бы другой алгоритм: пропустить все "старые" записи и отслеживать далее только вновь появляющиеся. Это можно сделать, но здесь реализован вариант "без потерь".

         ResetLastError();
         while(true// цикл, пока есть данные и нет проблем
         {
            bool reportedEndBeforeRead = FileIsEnding(handle);
            ulong reportedTellBeforeRead = FileTell(handle);
  
            temp = FileReadLong(handle);
            // если данных больше нет, получим ошибку 5015 (ERR_FILE_READERROR)
            if(_LastErrorbreak// выходим из цикла по любой ошибке
  
            // здесь данные получены без ошибок
            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

"Отправитель" благополучно открыл файл на запись и начал раз в 5 секунд отправлять данные, о чем свидетельствуют строки со словом "Written" и увеличивающимися значениями. Меньше чем через 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 байтов, поэтому внутренний цикл сразу проделал 2 итерации с FileReadLong, а размер файла "видится" равным 16.