Синхронизация программ с помощью глобальных переменных
Поскольку глобальные переменные существуют вне MQL-программ, их удобно применять для организации внешних флагов, управляющих несколькими копиями одной и той же программы или передающих сигналы между разными программами. Наиболее простой пример заключается в ограничении количества запускаемых копий программы. Это может быть необходимо, чтобы предотвратить случайное дублирование эксперта на разных графиках (из-за чего торговые приказы могут задваиваться), или для реализации демонстрационной версии.
На первый взгляд, подобная проверка могла бы быть сделана в исходном коде следующим образом.
void OnStart()
|
Здесь показан простейший вариант на примере скрипта — для других типов MQL-программ общий принцип проверки будет тот же самый, хотя место расположения инструкций может отличаться: вместо бесконечного рабочего цикла в экспертах и индикаторах используются характерные для них обработчики событий, многократно вызываемые терминалом. Эти нюансы мы изучим позднее.
Проблема с представленным кодом заключается в том, что здесь не учтено параллельное выполнение MQL-программ.
MQL-программа, как правило, выполняется в собственном потоке. Для трех из четырех типов MQL-программ, а именно для экспертов, скриптов и сервисов, система совершенно точно выделяет отдельные потоки. В случае индикаторов по одному общему потоку выделяется на все их экземпляры, работающие на одинаковом сочетании рабочего инструмента и таймфрейма. Но индикаторы на разных сочетаниях по-прежнему принадлежат разным потокам.
Практически всегда в терминале выполняется очень много потоков — значительно больше, чем количество ядер процессора. Из-за этого каждый поток периодически ненадолго приостанавливается системой, чтобы дать возможность поработать и другим потокам. Поскольку все такие переключения между потоками происходят очень быстро, мы как пользователи не замечаем этой "внутренней кухни". Однако каждая приостановка может влиять на последовательность, в которой разные потоки получают доступ к разделяемым общим ресурсам. И глобальные переменные как раз являются такими ресурсами.
С точки зрения программы приостановка может случиться между любыми соседними инструкциями. Если, зная это, взглянуть снова на наш пример, нетрудно увидеть место, где логика работы с глобальной переменной может быть нарушена.
Действительно, первая копия (поток) может выполнить проверку и не обнаружить переменную, но быть тут же приостановленной. В результате, она еще не успеет создать переменную своей следующей инструкцией, как контекст выполнения переключится на вторую копию. Та тоже не обнаружит переменную и "решит" продолжить работу, как и первая. Для наглядности одинаковый исходный код двух копий приведен ниже в виде двух столбцов инструкций в порядке их перемежающегося исполнения.
Копия 1 |
Копия 2 |
||
|
|
Разумеется, такая схема переключений между потоками обладает изрядной долей условности. Но здесь важна сама возможность нарушения логики программы, хотя бы и в одной единственной строке. Когда программ (потоков) много, вероятность непредвиденных действий с общими ресурсами возрастает. Этого может оказаться достаточно, чтобы в самый неожиданный момент увести эксперт в убыток или получить искаженные оценки технического анализа.
Самое неприятное в ошибках такого рода в том, что их очень сложно обнаружить. Их не способен обнаружить компилятор, и на стадии выполнения они проявляют себя спорадически. Но если ошибка не дает о себе знать долгое время, это не значит, что её нет.
Для решения подобных проблем необходимо неким образом синхронизировать доступ всех копий программ к разделяемым ресурсам (в данном случае, к глобальным переменным).
В информатике имеется специальное понятие — мьютекс (от английского mutex, mutual exclusion) — объект по обеспечению исключительного доступа к разделяемому ресурсу из параллельно выполняющихся программ. Мьютекс предотвращает потерю или порчу данных из-за асинхронных изменений. Обычно обращение к мьютексу синхронизирует разные программы за счет того, что только одна из них может редактировать защищенные данные, захватив мьютекс в конкретный момент, а остальные вынуждены ждать, пока мьютекс не освободится.
В MQL5 готовых мьютексов в чистом виде не существует. Но для глобальных переменных похожий эффект позволяет получить следующая функция, которую мы рассмотрим.
bool GlobalVariableSetOnCondition(const string name, double value, double precondition)
Функция устанавливает новое значение value существующей глобальной переменной name при условии, что её текущее значение равно precondition.
При успешном выполнении функция возвращает true, иначе — false, и код ошибки будет доступен в _LastError. В частности, если переменной не существует, функция сгенерирует ошибку ERR_GLOBALVARIABLE_NOT_FOUND (4501).
Функция обеспечивает атомарный доступ к глобальной переменной, то есть неразделимым образом проводит два действия: проверяет её текущее значение, и если оно соответствует условию, присваивает новую величину value.
Эквивалентный код функции можно представить примерно следующим образом (почему "примерно" — поясним далее):
bool GlobalVariableZetOnCondition(const string name, double value, double precondition)
|
Реализовать такой код, работающий по задумке, невозможно по двум причинам. Во-первых, блоки с включением и выключением защиты от прерывания нечем реализовать на чистом MQL5 (внутри встроенной функции GlobalVariableSetOnCondition это обеспечивает само ядро). Во-вторых, вызов функции GlobalVariableGet меняет время последнего использования переменной, в то время как функция GlobalVariableSetOnCondition не меняет его, если предусловие не было выполнено.
Для демонстрации работы с GlobalVariableSetOnCondition мы впервые обратимся к новому типу MQL-программ: сервисам. Подробно мы изучим их в отдельном разделе. Пока же достаточно будет отметить, что их структура максимально похожа на скрипты: и там, и там имеется лишь одна главная функция (точка входа) — знакомая нам OnStart. Единственное существенное отличие заключается в том, что скрипт выполняется на графике, а сервис — сам по себе (в фоновом режиме).
Необходимость замены скриптов на сервисы объясняется тем, что прикладной смысл задачи, в которой мы используем GlobalVariableSetOnCondition, заключается в подсчете количества запущенных экземпляров программы, с возможностью установки лимита. При этом коллизии с одновременной модификацией разделяемого счетчика могут возникать только в момент запуска множества программ. А в случае скриптов довольно сложно запустить несколько их копий на разных графиках в относительно короткий промежуток времени. Для сервисов же, наоборот, в интерфейсе терминала имеется удобный механизм для пакетного (группового) запуска. Кроме того, все активированные сервисы автоматически стартуют при очередной загрузке терминала.
Предлагаемый механизм подсчета количества копий будет востребованным и для MQL-программ других типов. Поскольку эксперты и индикаторы остаются прикрепленными на графики даже при выключении терминала, при следующем его включении происходит практически одновременное чтение всеми программами их настроек и общих ресурсов. Поэтому, если в какие-то эксперты и индикаторы встроено ограничение на количество копий, критически важно синхронизировать подсчет на основе глобальных переменных.
Сперва рассмотрим сервис, реализующий контроль копий в наивном режиме, без применения GlobalVariableSetOnCondition, и убедимся, что проблема сбоев счетчика реальная. Сервисы находятся в выделенном подкаталоге в общем каталоге исходных кодов, поэтому приведем расширенный путь — MQL5/Services/MQL5Book/p4/GlobalsNoCondition.mq5.
В начале файла сервиса должна идти директива:
#property service |
В сервисе предусмотрим 2 входных переменных, чтобы задать ограничение на количество разрешенных параллельно выполняющихся копий (limit) и задержку для эмуляции прерывания выполнения из-за массовой нагрузки на диск и CPU компьютера, что часто бывает при запуске терминала. Это облегчит воспроизведение проблемы без необходимости перезагружать терминал много раз в надежде добиться рассинхронизации — ведь мы собираемся отлавливать ошибку, которая может возникать лишь эпизодически, но при этом, уж если случилась, то чревата серьезными последствия.
input int limit = 1; // Limit
|
Эмуляция задержки построена на функции Sleep.
void Delay()
|
Внутри функции OnStart первым делом декларируется временная глобальная переменная. Поскольку она предназначена для подсчета работающих копий программы, не имеет смысла делать её постоянной: при каждом запуске терминала нужно вести подсчет заново.
void OnStart()
|
На тот случай, что хитрый пользователь сделал одноименную переменную заранее и присвоил ей отрицательное значение, делаем защиту.
int count = (int)GlobalVariableGet(__FILE__);
|
Далее начинается фрагмент с основным функционалом. Если счетчик уже больше или равен предельно допустимому количеству, прерываем запуск программы.
if(count >= limit)
|
В противном случае увеличиваем счетчик на 1 и записываем в глобальную переменную. Предварительно эмулируем задержку, чтобы спровоцировать ситуацию, когда между чтением переменной и её записью в нашей программе могла вклиниться другая программа.
Delay();
|
Если это действительно произойдет, наша копия программы будет делать приращение и присваивать уже устаревшую, некорректную величину. Получится, что в другой копии программы, выполняющейся параллельно с нашей, такое же значение count уже было обработано или будет обработано вторично.
Полезную работу сервиса изображает следующий цикл.
int loop = 0;
|
После того как пользователь остановит сервис (для этого в интерфейсе имеется контекстное меню — о нем чуть ниже), цикл завершится, и нам нужно уменьшить счетчик.
int last = (int)GlobalVariableGet(__FILE__);
|
Откомпилированные сервисы попадают в соответствующую ветвь "Навигатора".
Сервисы в "Навигаторе" и их контекстное меню
По щелчку правой кнопки мыши откроем контекстное меню и создадим два экземпляра сервиса GlobalsNoCondition.mq5, вызвав дважды команду Добавить сервис. При этом каждый раз будет открываться диалог с настройками сервиса, где следует оставить параметрам значения по умолчанию.
Важно отметить, что команда Добавить сервис сразу же запускает созданный сервис. Но нам это не нужно. Поэтому следует сразу же после запуска каждой копии снова вызвать контекстное меню и выполнить команду Остановить (если выделен конкретный экземпляр) или Остановить все (если выделена программа, то есть вся группа порожденных экземпляров).
Первый экземпляр сервиса получит по умолчанию название, полностью совпадающее с файлом сервиса ("GlobalsNoCondition"), а во всех последующих будет автоматически добавляться увеличивающийся номер. В частности, второй экземпляр значится как "GlobalsNoCondition 1". Терминал позволяет переименовать экземпляры в произвольный текст с помощью команды Переименовать, но мы этого делать не будем.
Теперь все готово для эксперимента. Попробуем запустить два экземпляра одновременно. Для этого выполним команду Запустить все для соответствующей ветви GlobalsNoCondition.
Напомним, в параметрах было задано ограничение в 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, потому что именно это значение обе копии сохранили в переменную-счетчик.
Именно из-за этого при остановке сервисов (команда Остановить все) мы получили сообщение о некорректном состоянии ("Count underflow"): ведь каждая из копий пытается уменьшить счетчик на 1, и в результате та из них, что выполнилась второй, получила отрицательное значение.
Для решения проблемы необходимо использовать функцию GlobalVariableSetOnCondition. На основе исходного кода предыдущего сервиса была подготовлена усовершенствованная версия GlobalsWithCondition.mq5. В целом она повторяет логику работы предшественника, но есть и существенные отличия.
Вместо простого вызова GlobalVariableSet для увеличения счетчика пришлось написать более сложную конструкцию.
const int maxRetries = 5;
|
Поскольку функция GlobalVariableSetOnCondition может не записать новое значение счетчика, если старое уже устарело, мы в цикле считываем глобальную переменную еще раз и повторяем попытки её инкрементировать, до тех пор, пока не превышено максимально допустимое значение счетчика. Также условием цикла ограничено количество попыток. Если цикл закончится с нарушением одного из условий, значит обновить счетчик не удалось, и программа не должна дальше выполняться.
Стратегии синхронизации
В принципе, для реализации захвата разделяемого ресурса существует несколько стандартных стратегий.
Первая заключается в "мягкой" проверке, свободен ли ресурс, и его последующем блокировании, только если он в тот момент свободен. Если же он занят, алгоритм планирует следующую попытку через некоторый период, а сам в это время занимается другими задачами (именно поэтому данный подход предпочтителен для программ, в которые "вшито" несколько сфер деятельности/ответственности). Аналогом данной схемы поведения в переложении для функции GlobalVariableSetOnCondition является одиночный вызов, без цикла, с выходом из текущего блока в случае неудачи. Изменение переменной откладывается "до лучших времен".
Вторая стратегия более настойчивая, применена в нашем скрипте. Это цикл, который повторяет запрос ресурса заданное количество раз или предопределенное время (допустимый период ожидания ресурса). Если цикл истекает, а положительный результат не достигнут (вызов функции GlobalVariableSetOnCondition так и не вернул true), программа также выходит из текущего блока и, вероятно, планирует повторить попытки через некоторое время.
Наконец, третья стратегия, самая жесткая, предполагает запрос ресурса "до победного конца". Её можно представить как бесконечный цикл с вызовом функции. Такой подход имеет смысл использовать в программах, которые ориентированы на одну конкретную задачу, и не могут продолжать работу без захваченного ресурса. В MQL5 используйте для этого цикл while(!IsStopped()) и не забывайте внутри вызывать Sleep.
Здесь важно отметить потенциальную проблему с "жестким" захватом нескольких ресурсов. Представьте, что MQL-программа модифицирует несколько глобальных переменных (что является, в принципе, обычной ситуацией). Если одна её копия захватит одну переменную, а вторая — другую, и обе будут ждать освобождения, наступит их взаимная блокировка (deadlock).
Исходя из вышеизложенного, совместный доступ к глобальным переменным и другим ресурсам (например, файлам), следует тщательно проектировать и анализировать на предмет блокировок и так называемых "гонок" (race condition), когда параллельное исполнение программ приводит к неопределенному результату (в зависимости от порядка их работы).
После завершения рабочего цикла в новой версии сервиса по аналогичному принципу изменен алгоритм уменьшения счетчика.
retry = 0;
|
Ради интереса создадим для нового сервиса уже три экземпляра. В настройках каждого из них укажем в параметре Limit: 2 экземпляра (чтобы провести тест в измененных условиях). Напомним, что создание каждого экземпляра сразу же его запускает, что нам не нужно, и потому каждую только что созданную копию следует остановить.
Экземпляры получат имена по умолчанию "GlobalsWithCondition", "GlobalsWithCondition 1" и "GlobalsWithCondition 2".
Когда все будет готово, запустим сразу все копии и получим примерно такие записи в журнале.
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 |
Прежде всего, обратите внимание на случайную, но вместе с тем наглядную демонстрацию описываемого эффекта переключения контекста параллельного выполняющихся программ. Первым экземпляром, который создал временную переменную, был "GlobalsWithCondition" без номера: это видно по результату функции GlobalVariableTemp, равному true. Однако в журнале эта строчка занимает лишь третью позицию, а две предыдущих содержат результаты вызова той же функции в копиях под именами с номерами 1 и 2, и в них функция GlobalVariableTemp вернула false. Это означает, что в этих копиях проверка переменной выполнялась позднее, хотя затем их потоки обогнали поток "GlobalsWithCondition" без номера и оказались в журнале раньше.
Но вернемся к нашему основному алгоритму подсчета программ. Экземпляр "GlobalsWithCondition" первым преодолел проверку и запустился в работу под внутренним идентификатором "Copy 0" (из кода сервиса мы не можем узнать, как пользователь назвал экземпляр: такой функции в MQL5 API нет, по крайней мере пока).
В экземплярах под номерами 1 и 2 ("GlobalsWithCondition 1", "GlobalsWithCondition 2") благодаря функции GlobalVariableSetOnCondition был обнаружен факт модификации счетчика: при старте он был равен 0, но "GlobalsWithCondition" увеличил его на 1. Оба припозднившихся экземпляра вывели сообщение "Counter is already altered by other instance: 1". Одной из этих копий ("GlobalsWithCondition 1") удалось раньше номера 2 получить новое значение 1 из переменной и увеличить его до 2. Об этом говорит успешный вызов GlobalVariableSetOnCondition (он вернул true). И далее появилось сообщение о начале работы "Copy 1 is working".
Тот факт, что значение внутреннего счетчика совпало с внешним номером экземпляра, чисто случаен. Вполне могло быть, что "GlobalsWithCondition 2" запустился бы раньше "GlobalsWithCondition 1" (или в какой-то другой последовательности, учитывая, что копий целых три). Тогда внешняя и внутренняя нумерация отличались бы. Вы можете повторять эксперимент с запуском и остановкой всех сервисов много раз, и скорее всего, последовательность, в которой копии увеличивают переменную-счетчик, будет отличаться. Но в любом случае ограничение на общее количество отсечет один лишний экземпляр.
Когда последний экземпляр "GlobalsWithCondition 2" получает право доступа к глобальной переменной, там уже хранится значение 2. Так как это заданный нами предел, программа не запускается.
GlobalVariableSetOnCondition(GlobalsWithCondition.mq5,count+1,count)= » Counter is already altered by other instance: 2 Start failed: count: 2, retries: 2 |
Далее копии "GlobalsWithCondition" и "GlobalsWithCondition 1" "крутятся" в рабочем цикле, пока сервисы не останавливаются.
Вы можете попробовать остановить лишь один экземпляр. Тогда появится возможность запустить другой, получивший ранее запрет на выполнение из-за превышения квоты.
Разумеется, предложенный вариант защиты от параллельной модификации эффективен только при координации поведения собственных программ, но не для ограничения на единственную копию демо-версии, поскольку пользователь может просто удалить глобальную переменную. Для этой цели глобальные переменные можно использовать по-другому — в привязке к идентификатору графика: MQL-программа работает только до тех пор, пока в созданной ею глобальной переменной находится идентификатор её графика. Другие способы контролировать разделяемые данные (счетчики и прочую информацию) предоставляют ресурсы и база данных.