Теория ошибок. Нестабильности второго рода.

Про нестабильности второго рода можно говорить долго и упорно, большая часть серьезных багов в приложениях связана именно с ними. Причиной этого является то, что если нестабильности первого и третьего рода зачастую являются легко детерминируемыми программистскими ошибками, то нарушения порядков вызовов намного чаще связываются с активностью пользователя, внешними hardware-events, синхронизацией или проблемами с файлами данных. Но обо всем по порядку.

Рассмотрим простейший пример модуля, который состоит из трех функций:

  • initialize()
  • calculate()
  • shutdown()

Функция initialize() выполняет какие-то предварительные операции по организации таблиц данных (например, заранее рассчитанная таблица простых чисел). Функция shutdown() освобождает оперативную память. Между initialize() и shutdown() может быть вызвано любое количество calculate().

Что произойдет, если calculate() вызвать до initialize() или после shutdown()? Зависит от реализации модуля и особенностей алгоритма. Функция может упать по access violation, может вернуть случайный результат, может зависнуть в бесконечном цикле… Таким образом, мы можем сказать, что у данного модуля есть 2 состояния: Not Initialized и Initialized, и функции работают следующим образом:

state_machine1.jpg

Глядя на схему, возникают вопросы:

  1. Что произойдет, если initialize() будет вызван в состоянии Initialized
  2. Что произойдет, если shutdown() будет вызван в состоянии Not Initialized?
  3. Что произойдет, если calculate() будет вызван в состоянии Not Initialized?

Эти вопросы означают, что мы видим как минимум 3 потенциальных места получить нестабильность второго рода. На самом деле потенциальных мест нестабильности второго рода может быть сильно больше, например, если нам перенестись в плоскость многопоточного выполнения или если структура программы такова, что shutdown() непосредственно после initialize() без единого caluclate() тоже будет ошибочным?. Но я пока что этой стороны касаться не буду.

Как и в случае с нестабильностями первого рода, диагностика состояния может располагаться в начале функции, а если хватает быстродействия, то и диагностика вкупе с обработкой и разумным откликом на нестабильность. Для того чтобы выполнять такую диагностику, и внутри модуля (или объекта), и снаружи, нам потребуются информационные функции. Эти функции позволяют узнать состояние модуля/объекта, причем вызваны они могут быть стабильно в любой возможный момент времени.

Итого, у каждого модуля (“или объекта” – писать уже не буду, ибо надоело) есть 3 типа функций:

  1. Функции, которые переводят модуль из одного состояния в другое (в нашем верхнем примере – это initilaize и shutdown);
  2. Функции, которые требуют определенного состояния модуля для выполнения операций (calculate);
  3. Информационные функции, которые позволяют выяснить состояние модуля (как для прекондишенов на входах, так и для использования внешними программистами в любом месте, где это им потребуется).

Важные замечания о функциях:

Функции, которые переводят модуль из состояния в состояние, должны делать это четко. В том случае, если в процессе перевода произошел какой-то сбой, функция должна корректно вернуть модуль в исходное состояние, не оставив каких-то memory leaks, недопроинициализированных переменных и т.п. Ничего такого, что бы могло помешать дальнейшей работе системы.

Функции, которые требуют определённого состояния, должны проверять это состояние и (желательно) при несовпадении данных, давать разумный отклик.

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

Теперь еще несколько слов про shutdown. Теперь перенесемся от состояния модуля именно в плоскость ООП, к состоянию объекта. В этом случа часто разумно представить, что initialize() – это конструктор, а shutdown() – это деструктор. В схеме состояния модуля крайне полезным может быть отображать и конструктор и деструктор как одно дополнительное состояние (состояние отсутствующего объекта)  плюс 2 линии (если конструкторов больше, то исходящих линий может быть тоже больше):

state_machine2.jpg

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

Идеальной программой с точки зрения устойчивости к нестабильностям второго рода была бы такая архитектура, в которой любые объекты могут находиться только в одном из двух состояний – “Object doesn’t exist” и “Stage1″. На практике такое, разумеется, не получается, и существует огромное количество функций, которые изменяют внутреннее состояние объектов.

Кроме внутреннего состояния объектов есть и большое  количество внешних факторов, которые влияют на состояние объекта или модуля. Например, наличие запросов в глобальный хип или доступ к файлам в предположении о правильной установке current working folder.

Недавно на тестировании одной из игр обнаружилось странное – несколько сейвгеймов записалось не в подкаталог \saves, а в подкаталог \resources. То есть кто-то поменял текущий рабочий каталог, а подсистема сериализации этого “не заметила”. В результате можно сказать, что операция замены cwd приводит к тому, что другие подсистемы, использующие file I/O, переходят в новое виртуальное состояние “дружно срем не туда”. Решений проблемы может быть несколько – например, обязать каждую подсистему запоминать cwd, ставить новый cwd, а потом возвращать обратно старые параметры (плохо работает с многопоточностью). Можно например сделать рестрикшен “всегда использовать полный путь” (такой есть в RedKey). Можно помолиться на фазу луны и оставить использование cwd.

Аналогично, например, любой  std::vector<>.push_back() может изменить состояние хипа, и тем самым сменить внутреннее состояние всех остальных объектов, которые содержат вектора с дефолтным алокатором. Такие взаимные интерференции объектов через глобальные сущности очень быстро приводят к комбинаторному взрыву состояний.

Примеры таких интерференций я приводил в свое время на КРИ. Например, у нас есть 2 вектора: вектор под партикли и вектор под прозрачные объекты. Оба проинициализированы на старте игры какими-то значениями. И пусть у нас есть уровень, в котором имеется: а) гора, с которой видно далеко и много; б) лужа, в которую можно пострелять. Поднявшись на гору, мы получаем переполнение и ресайз буфера под прохрачные объекты. Выстрелив в лужу 10 раз подряд синхронно с NP, мы получаем переполнение и ресайз буфера частиц. Сделав любую из этих операций, мы никаких проблем не увидим. Попробовав сделать 2 операции вместе, мы получим not enough memory на любом втором ресайзе. Имея фиксированные массивы, любая из проблем может быть детерминирована независимо от второй. Как следствие – более легкая диагностика и отладка проблем. В конце концов, небольшой визуальный глюк с далеко расположенным прозрачным объектом лучше чем падение программы по нехватке памяти.

Таким образом, избавиться от этого комбинаторного взрыва можно только путем локализации изменений (например, работа в собственном хипе) и путем введения дополнительных рестрикшенов. В любом случае, разрабатывая очередной класс, программист должен хорошо представлять себе его возможные состояния и переходы между ними. А понимая такие состояния можно будет написать хорошие прекондишены для других программистов и реализовать разумные отклики в запрещенных состояниях. А еще как бонус – понимая состояния объектов, намного легче реализовывать объекты, которые могут вызываться в многопоточном приложении.