Инженерный подход к разработке ПО. От теории к практике

в 7:21, , рубрики: model-driven engineering, TLA+, Анализ и проектирование систем, Блог компании Яндекс, Проектирование и рефакторинг, управление разработкой

Как проверить идеи, архитектуру и алгоритмы без написания кода? Как сформулировать и проверить их свойства? Что такое model-checkers и model-finders? Что делать, когда возможностей тестов недостаточно?

Привет. Меня зовут Васил Дядов, сейчас я работаю программистом в Яндекс.Почте, до этого работал в Intel, ещё раньше разрабатывал RTL-код (register transfer level) на Verilog/VHDL для ASIC/FPGA. Давно увлекаюсь темой надёжности софта и аппаратуры, математикой, инструментами и методами, применяемыми для разработки ПО и логики с гарантированными, заранее определёнными свойствами.

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

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

В этой статье мы рассмотрим пример реального применения TLA+ для решения практической задачи.

Я всегда открыт для обсуждения вопросов, связанных с разработкой ПО, и буду рад пообщаться с читателями (координаты для связи есть в моём профиле).

Что такое TLA+?

Для начала, хочу сказать пару слов про TLA+ и TLC.

TLA+ (Temporal Logic of Actions + Data) — это формализм, который основан на разновидности временно́й логики. Разработан Лесли Лэмпортом.

В рамках этого формализма можно описывать пространство вариантов поведения системы и свойства этих поведений.

Для простоты можно считать, что поведение системы представлено последовательностью её состояний (как бесконечные бусы, шарики на ниточке), а формула TLA+ задаёт класс таких цепочек, которые описывают все возможные варианты поведения системы (большое количество бус).

TLA+ хорошо подходит, чтобы описывать взаимодействующие недетерминированные конечные автоматы (например, взаимодействие сервисов в системе), хотя его выразительности хватает и для описания многих других вещей (которые можно выразить в логике первого порядка).

А TLC — это explicit-state model-checker: программа, которая по заданному TLA+ описанию системы и формулам свойств перебирает состояния системы и определяет, удовлетворяет ли система заданным свойствам.

Обычно работа с TLA+/TLC строится таким образом: описываем систему в TLA+, формализуем в TLA+ интересные свойства, запускаем TLC для проверки.

Так как в TLA+ непосредственно описывать более-менее сложную систему непросто, был придуман язык более высокого уровня — PlusCal, который транслируется в TLA+. PlusCal существует в двух ипостасях: с Pascal- и с C-подобным синтаксисом. В статье я использовал Pascal-подобный синтаксис, он, мне кажется, лучше читается. PlusCal по отношению к TLA+ — это примерно как C по отношению к ассемблеру.

Здесь мы не будем глубоко уходить в теорию. Литература для погружения в TLA+/PlusCal/TLC приведена в конце статьи.

Моя основная задача — это показать применение TLA+/TLC на простом и понятном примере из реальной жизни.

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

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

Постановка задачи

Сначала немного расскажу о задаче, для которой применялся TLA+.

Задача связана с обработкой потока событий. А именно — создать очередь для хранения событий и рассылки уведомлений об этих событиях.

Хранилище данных физически организовано на базе СУБД PostgreSQL.

Главное, что нужно знать:

  1. Есть источники событий. Для наших целей можно ограничиться тем, что каждое событие характеризуется временем, в котором запланирована его обработка. Эти источники пишут события в БД. Обычно время записи в БД и время планируемой обработки никак не связаны.
  2. Есть процессы-координаторы, которые вычитывают события из БД и отправляют уведомления о ближайших событиях тем компонентам системы, которые должны реагировать на эти уведомления.
  3. Фундаментальное требование: мы не должны терять событий. Уведомление о событии в крайнем случае можно повторить, т. е. должна быть гарантия at least once. В распределённых системах гарантию only once реализовать крайне тяжело (может быть, даже невозможно, но это нужно доказывать) без механизмов консенсуса, а они (по крайней мере, все мне известные) весьма сильно влияют на систему с точки зрения задержки и пропускной способности.

Теперь немного деталей:

  1. Процессов-источников много, они могут генерировать миллионы (в худшем случае) событий, попадающих в узкий интервал времени.
  2. События могут генерироваться и для будущего, и для прошлого времени (например, если процесс-источник из-за чего-то протормозил и записал событие для момента, который уже прошёл).
  3. Приоритет обработки событий — по времени, т. е. сначала мы должны обработать наиболее ранние события.
  4. Для каждого события процесс-источник генерирует случайный номер worker_id, за счёт которого события распределяются по координаторам.
  5. Процессов-координаторов несколько (масштабируется под потребности исходя из загрузки системы).
  6. Каждый процесс-координатор обрабатывает события для своего множества worker_id, т. е. за счёт worker_id мы избегаем конкуренции между координаторами и необходимости блокировок.

Как видно из описания, мы можем рассматривать только один процесс-координатор и не учитывать worker_id в нашей задаче.

То есть для простоты будем считать, что:

  1. Процессов-источников много.
  2. Процесс-координатор один.

Эволюцию идеи решения данной задачи опишу по этапам, чтобы было понятнее, как мысль развивалась от простой реализации до оптимизированной.

Решение в лоб

Заведём для событий табличку, где будем хранить события в виде просто временно́й метки (timestamp) (остальные параметры нас в данной задаче не интересуют). Построим индекс по полю timestamp.

Кажется, вполне нормальное решение.

Только тут есть проблема с масштабируемостью: чем больше событий, тем медленнее операции с БД.

События могут приходить и для прошлого времени, так что координатору придётся постоянно просматривать всю временную шкалу.

Проблему можно решить экстенсивно, разбив БД на шарды по времени и т. д. Но это ресурсоёмкий способ.

В итоге замедлится работа координаторов, так как придётся вычитывать и объединять данные из нескольких баз.

Сложно реализовать кэширование событий в координаторе, чтобы не ходить в базы на обработку каждого события.

Больше баз — больше проблем с отказоустойчивостью.

И так далее.

Подробно на этом лобовом решении останавливаться не будем, так как оно тривиально и неинтересно.

Первая оптимизация

Посмотрим, как улучшить лобовое решение.

Чтобы оптимизировать доступ к БД, можно немного усложнить индекс, добавить к событиям монотонно возрастающий идентификатор, который будет генерироваться при коммите транзакции в БД. То есть событие теперь характеризуется парой {time, id}, где time — это время, в которое событие запланировано, id — монотонно возрастающий счётчик. Есть гарантия уникальности id для каждого события, но нет гарантии, что значения id идут без дырок (т. е. может быть такая последовательность: 1, 2, 7, 15).

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

Но тут сразу всплывает проблема: процессы-источники могут записать событие с временно́й меткой далеко в будущем. Тогда нам придётся постоянно учитывать в процессе-координаторе множество событий с малыми идентификаторами, время обработки которых ещё не наступило.

Можно заметить, что события относительно текущего времени делятся на два класса:

  1. События с временно́й меткой в прошлом, но с большим идентификатором. Они были записаны в БД недавно, уже после того как мы обработали тот временно́й интервал. Это высокоприоритетные события, и их нужно обработать в первую очередь, чтобы уведомление — которое уже опоздало — не опоздало ещё больше.
  2. События, записанные когда-то давно, с временны́ми метками, близкими к текущему моменту. У таких событий будет низкое значение идентификатора.

Соответственно, текущее состояние (state) процесса-координатора характеризует пара {state.time, state.id}.

Получается, что высокоприоритетные события находятся левее и выше этой точки (розовая область), а нормальные события — правее (светло-голубая):

Инженерный подход к разработке ПО. От теории к практике - 1

Блок-схема

Алгоритм работы координатора такой:

Инженерный подход к разработке ПО. От теории к практике - 2

Инженерный подход к разработке ПО. От теории к практике - 3

При изучении алгоритма могут возникнуть вопросы:

  1. Что, если начнётся обработка нормальных событий и в этот момент поступят новые события в прошлом (в розовую область), не потеряются ли они? Ответ: они будут обработаны при следующем цикле обработки высокоприоритетных событий. Потеряться они не могут, так как их id гарантированно будет выше state.id.
  2. Что, если после обработки всех нормальных событий — в момент перехода к обработке высокоприоритетных событий — поступят новые события с временны́ми метками из интервала [state.time, state.time + Delta], не потеряем ли мы их? Ответ: они попадут уже в розовую область, так как у них будет time < state.time и id > state.id: они поступили недавно, а id монотонно возрастающий.

Пример работы алгоритма

Посмотрим на несколько шагов алгоритма:

Инженерный подход к разработке ПО. От теории к практике - 4

Инженерный подход к разработке ПО. От теории к практике - 5

Инженерный подход к разработке ПО. От теории к практике - 6

Инженерный подход к разработке ПО. От теории к практике - 7

Инженерный подход к разработке ПО. От теории к практике - 8

Инженерный подход к разработке ПО. От теории к практике - 9

Инженерный подход к разработке ПО. От теории к практике - 10

Инженерный подход к разработке ПО. От теории к практике - 11

Модель

Убедимся, что алгоритм не пропустит событий и все уведомления будут отправлены: составим простую модель и проверим её.

Для модели используем TLA+, точнее PlusCal, который транслируется в TLA+.

---------------- MODULE events ----------------
EXTENDS Naturals, FiniteSets, Sequences, TLC

(* --algorithm Events 

    * Основные параметры выбраны по принципу
    * так называемой (by Daniel Jackson)
    * small-scope hypothesis, которая говорит
    * о том, что бо́льшая часть багов
    * алгоритма проявляется в относительно
    * небольших моделях

    * Объявляем переменные:
    *   Events - имитация БД - множество, в котором
    *            будем хранить записи [time, id],
    *            при моделировании нужно будет
    *            явно задать ограничение на размер
    *            этого множества
    *   Event_Id - имтируем монотонный глобальный
    *              счётчик для id
    *   MAX_TIME - максимальное время, до которого
    *              ведём моделирование
    *   TIME_DELTA - множество значений Delta,
    *                для которых будем проверять
    *                модель
    variables Events = {},
              Event_Id = 0,
              MAX_TIME = 5,
              TIME_DELTA in 1..3

    define * вспомогательные определения

      * начальное состояние
      ZERO_EVT == [time |-> 0, id |-> 0]

      MAX(S) == CHOOSE x in S: A y in S: y <= x
      MIN(S) == CHOOSE x in S: A y in S: y >= x

      * аналог fold_left/fold_right из функциональных ЯП
      RECURSIVE SetReduce(_, _, _) 
      SetReduce(Op(_, _), S, value) ==
          IF S = {} THEN value
          ELSE LET s == CHOOSE s in S : TRUE
          IN SetReduce(Op, S  {s}, Op(s, value))

      (* преобразование множества в последовательность
         (вводим произвольный порядок) *)
      ToSeq(S) == 
          LET op(e, val) == Append(val, e)
          IN SetReduce(op, S, << >>) 

      (* обратно: последовательность в множество *)
      ToSet(S) ==
          {S[i] : i in DOMAIN(S)}

      (* аналог map в функциональных языках *)
      MapSet(Op(_), S) == {Op(x) : x in S}

      (* вспомогательные функции *)

      * получить множество id событий  
      GetIds(Evts) == MapSet(LAMBDA x: x.id, Evts)

      * получить множество time событий
      GetTimes(Evts) == MapSet(LAMBDA x: x.time, Evts)

      (* Тут имитируем SQL выражения *)

      * сначала определим отношение порядка
      * для имитации ORDER BY
      EventsOrderByTime(e1, e2) == e1.time < e2.time
      EventsOrderById(e1, e2) == e1.id < e2.id
      EventsOrder(e1, e2) == * лексикографически по time, id
          / EventsOrderByTime(e1, e2)
          / / e1.time = e2.time
             / EventsOrderById(e1, e2)

      * SELECT * FROM events
      *   WHERE time <= curr_time AND id >= max_id
      *   ORDER BY time, id
      SELECT_HIGH_PRIO(state) == 
          LET * сначала выбираем множество по условиям 
              *    time <= curr_time
              *    AND id >= maxt_id
              selected == {e in Events :
                              / e.time <= state.time
                              / e.id >= state.id }
          IN selected

      * SELECT * FROM events
      *   WHERE time > current_time AND time - Time <= delta_time
      *   ORDER BY time, id
      SELECT_NORMAL(state, delta_time) ==
          LET selected == 
              {e in Events :
                 / e.time <= state.time + delta_time
                 / e.time > state.time }
          IN selected

    * Safety predicate
    * должен выполняться во всех состояниях модели
    ALL_EVENTS_PROCESSED(state) ==
        A e in Events:
            / e.time >= state.time
            / e.id >= state.id

    end define;

    * процессы - источники событий
    fair process inserter = "Sources"
      variable n, t;
      begin
        forever:
        while TRUE do
          * выбираем произвольное время для события 
          get_time: * метки задают атомарные шаги
            * всё, что находится под одной меткой,
            * будет выполнено атомарно
            with evt_time in 0..MAX_TIME do
                 t := evt_time;
            end with;
          * тут делаем запись в базу;
          * возможны два варианта:
          *  1. Транзакция завершилась нормально.
          *  2. Транзакция откатилась, но так как 
          *     Event_Id - атомарный глобальный счётчик,
          *     то он остался проинкрементированным
          commit:
            * either - недетерминированно выбирает между ветками  
            either 
              Events := Events cup {[time |-> t, id |-> Event_Id]} ||
              Event_Id := Event_Id + 1;
            or 
              Event_Id := Event_Id + 1;
            end either;
        end while;
      end process

    fair process coordinator = "Coordinator"
      variable state = ZERO_EVT,
               events = {};
      begin
        forever:
        while TRUE do
          * сначала выбираем высокоприоритетные
          high_prio:
            events := SELECT_HIGH_PRIO(state);

          * затем обрабатываем
          process_high_prio: 
            * так как цикл не имеет видимости за пределами этого кода,
            * кроме как мутирования Events,
            * то шаг можно сделать полностью атомарным
            state.id :=
                MAX({state.id} union GetIds(events)) ||
                * обработанные убираем в этом же шаге,
                * чтобы трасса выполнения была читаемее
                Events := Events  events ||
                * обнулять events нет необходимости,
                * но так трасса выглядит понятнее
                events := {};

          * теперь - нормальные события
          normal:
            events := SELECT_NORMAL(state, TIME_DELTA);

          process_normal:
            state.time :=
                MAX({state.time} union GetTimes(events)) ||
            Events := Events  events ||
            events := {};
        end while;
      end process

    end algorithm;
*)

* BEGIN TRANSLATION
* Тут TLA+, автоматически сгенерированный из вышеприведённого PlusCal описания
* END TRANSLATION
================================

Как видим, описание получается относительно небольшое, несмотря на довольно объёмную секцию определений (define), которую можно было бы вынести в отдельный модуль, чтобы потом переиспользовать.

В комментариях постарался постарался объяснить, что происходит в модели. Надеюсь, что это
мне удалось и нет необходимости детальнее расписывать модель.

Хотел бы только пояснить один момент относительно атомарности переходов между состояниями и особенностей моделирования.

Моделирование идёт путём выполнения атомарных шагов процессов. За один переход выполняется один атомарный шаг какого-либо процесса, в котором этот шаг можно сделать. Выбор шага и процесса недетерминированный: при моделировании перебираются все возможные цепочки атомарных шагов всех процессов.

Тут может возникнуть вопрос: а что же по поводу моделирования истинной параллельности, когда у нас одновременно выполняется несколько атомарных шагов в разных процессах?

На этот вопрос Лесли Лэмпорт уже давно дал ответ в книге Specifying Systems и других работах.

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

А вот что действительно важно, так это грамотное выделение атомарных шагов. Если их слишком много — то случится комбинаторный взрыв пространства состояний. Если сделать шагов меньше, чем нужно, или неправильно их выделить — то есть вероятность пропуска невалидного состояния/перехода (т. е. мы пропустим ошибки на модели).

Чтобы правильно разбить процессы на атомарные шаги, нужно хорошо представлять работу системы в плане зависимости процессов по данным и по механизмам синхронизации.

Как правило, разбиение процессов на атомарные шаги больших проблем не вызывает. А если вызывает, то это скорее говорит о недостаточном понимании задачи, а не о проблемах с составлением модели и написанием TLA+ спецификации. Это ещё одно весьма полезное свойство формальных спецификаций: они требуют досконально изучить и проанализировать
проблему. Как правило, если задача осмыслена и хорошо понята, никаких проблем с её формализацией нет.

Проверка модели

Для моделирования буду использовать TLA-toolbox. Можно, конечно, и из командной строки всё запускать, но IDE всё-таки удобнее, особенно для начала изучения моделирования с использованием TLA+.

Создание проекта хорошо описано в руководствах, статьях и книгах, ссылки на которые я привёл в конце статьи, поэтому повторяться не буду. Единственное, на что обращу ваше внимание, — это настройки моделирования.

TLC — это model checker с явной проверкой состояний. Понятно, что пространство состояний нужно ограничить разумными пределами. С одной стороны, оно должно быть достаточно большим для возможности проверки интересных нам свойств, с другой — достаточно маленьким, чтобы моделирование выполнилось за приемлемое время с использованием приемлемых ресурсов.

Это довольно тонкий момент, тут нужно хорошо понимать свойства системы и модели. Но это быстро приходит с опытом. Для начала можно просто ставить максимально возможные ограничения, которые ещё приемлемы с точки зрения времени моделирования и потребляемых ресурсов.

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

Возвращаемся к настройкам моделирования.

Для начала определим ограничения на пространство состояний системы. Ограничения задаются в разделе настроек моделирования Advanced Options/State Constraint.

Там я указал TLA+ выражение: Cardinality(Events) <= 5 / Event_Id <= 5,
где Event_Id — ограничение сверху на значение идентификатора событий, Cardinality(Events) — размер множества записей о событиях (ограничили модель базы
данных пятью записями в табличке).

При моделировании TLC будет просматривать только те состояния, в которых эта формула истинна.

Ещё можно допустимые переходы между состояниями (Advanced Options/Action Constraint),
но это нам не понадобится.

Далее укажем TLA+ формулу, которая описывает нашу систему: Model Overview/Temporal Formula = Spec, где Spec — это имя автосгенерированной по PlusCal описанию TLA+ формулы (в приведённом коде модели её нет: для экономии места я не стал приводить результат трансляции PlusCal в TLA+).

Следующая настройка, на которую стоит обратить внимание, — это проверка deadlock
(галочка в Model Overview/Deadlock). При включении этого флага TLC будет проверять модель на "висячие" состояния, т. е. те, из которых нет исходящих переходов. Если в пространстве состояний есть такие состояния, это означает явную ошибку в модели. Или в TLC, который, как и любая другая нетривиальная программа, не застрахован от ошибок :) В своей (не такой уж и большой) практике с дедлоками я ещё не сталкивался.

И наконец, то, ради чего вся эта проверка и затевалась, safety-формула в Model Overview/Invariants = ALL_EVENTS_PROCESSED(state).

TLC будет проверять истинность формулы в каждом состоянии, а если она станет ложной,
выдаст сообщение об ошибке и покажет последовательность состояний, которая привела к ошибке.

После запуска TLC, проработав около 8 минут, сообщил "No errors". Значит, модель проверена и соответствует заданным свойствам.

Ещё TLC выводит много интересной статистики. Например, для данной модели получилось 7 677 824 уникальных состояний, всего TLC просмотрел 27 109 029 состояний, диаметр пространства состояний — 47 (это максимальная длина цепочки состояний до повторения,
максимальная длина цикла из неповторяющихся состояний в графе состояний и переходов).

Если 27 млн состояний разделить на 8 минут, то получим примерно 56 тыс. состояний в секунду, что может показаться не особо быстрым. Но имейте в виду, что я запускал моделирование на ноутбуке, который работал в энергосберегающем режиме (я принудительно установил частоту ядер в 800 МГц, так как ехал в этот момент в электричке), и совсем не оптимизировал модель для скорости моделирования.

Есть много способов ускорить моделирование: от переноса части кода TLA+ модели в Java и подключения к TLC на лету (так полезно ускорять всякие вспомогательные функции) до запуска TLC в облаках и на кластерах (поддержка облаков Amazon и Azure встроена прямо в сам TLC).

Вторая оптимизация

В предыдущем алгоритме всё хорошо, за исключением нескольких проблем:

  1. Пока не обработаем все события из голубой зоны в интервале [state.time, state.time + Delta], мы не можем перейти к высокоприоритетным событиям. То есть опаздывающие события опоздают ещё больше. И обычно задержка непредсказуема. Из-за этого state.time может сильно отстать от текущего времени, и это — причина следующей проблемы.
  2. События, прилетающие в область нормальных событий, могут опаздывать (id > state.id). Они уже высокоприоритетные и должны считаться событиями из розовой области, а мы по-прежнему считаем их нормальными и обрабатываем как нормальные.
  3. Сложно организовать кэширование событий и дозаполнение кэша (дочитывание из БД).

Если первые два пункта очевидны, то третий, наверное, вызовет больше всего вопросов. Остановимся на нём подробнее.

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

После обработки мы хотим блочными запросами помечать события в БД как обработанные, так как если работать не с блоками, а с одиночными событиями, то большого выигрыша от кэширования не будет.

Допустим, мы обработали часть блоков и хотим дополнить кэш. Тогда, если во время обработки прилетят опаздывающие высокоприоритетные события, мы сможем их обработать пораньше.

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

Что в этом случае делать?

Постараться работать с БД небольшими блоками с синей и розовой областью и двигать точку state небольшими шагами.

Таким образом был введён кэш и дочитывание в него из БД порций данных, после каждого дочитывания точка state смещалась, чтобы не перечитывать уже считанные события.

Теперь алгоритм стал чуть сложнее, мы стали читать ограниченными порциями.

Блок-схема

Инженерный подход к разработке ПО. От теории к практике - 12

Инженерный подход к разработке ПО. От теории к практике - 13

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

То есть теперь мы можем как дочитывать события в кэш небольшими блоками, так и управлять максимальной задержкой перехода к обработке высокоприоритетных событий через управление максимальным размером блока для считывания.

Пример работы алгоритма

Посмотрим на алгоритм в работе, по шагам. Для удобства возьмём LIMIT = 2.

Инженерный подход к разработке ПО. От теории к практике - 14

Инженерный подход к разработке ПО. От теории к практике - 15

Инженерный подход к разработке ПО. От теории к практике - 16

Инженерный подход к разработке ПО. От теории к практике - 17

Инженерный подход к разработке ПО. От теории к практике - 18

Инженерный подход к разработке ПО. От теории к практике - 19

Инженерный подход к разработке ПО. От теории к практике - 20

Инженерный подход к разработке ПО. От теории к практике - 21

Получается задача решена? А вот и нет. (Понятно, что если бы задача была полностью решена на этом этапе, то
статьи бы этой не было :) )

Ошибка?

В таком виде алгоритм и работал довольно длительное время. Тесты все проходили нормально. В продакшне тоже проблем не замечалось.

Но разработчик алгоритма и его реализации (мой коллега Пётр Резников) очень опытен, и он интуитивно чувствовал, что здесь что-то не так. Поэтому рядом с основным кодом был сделан чекер, который по таймеру раз в какое-то время проверял, а нет ли пропущенных событий, и
если есть, то обрабатывал их.

В таком виде система успешно работала. Правда, статистику количества событий, подбираемых чекером, никто не вёл. Так что мы, к сожалению, не знаем, сколько было сбоев, связанных с несвоевременной обработкой событий.

Я реализовывал похожую очередь объектов, привязанных ко времени. Обсуждая реализацию и оптимизацию алгоритмов с Петром Резниковым, мы поговорили и об этом алгоритме работы с событиями. Засомневались, что алгоритм корректен. Решили сделать небольшую модель, чтобы либо подтвердить, либо развеять сомнения. В итоге мы обнаружили ошибку.

Модель

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

От предыдущей модели отличия весьма небольшие, появилось только ограничение размера считываемых блоков: добавлен оператор Limit и, соответственно, изменены операторы выборки событий.

Для экономии места я оставил комментарии только к изменившимся частям модели.

---------------- MODULE events ----------------
EXTENDS Naturals, FiniteSets, Sequences, TLC

(* --algorithm Events 

    * Добавили новую переменную LIMIT,
    * значения которой из множества
    * моделируемых лимитов на количество
    * считываемых событий

    variables Events = {},
              Event_Id = 0,
              MAX_TIME = 5,
              LIMIT in 1..3,
              TIME_DELTA in 1..2

    define
      ZERO_EVT == [time |-> 0, id |-> 0]

      MAX(S) == CHOOSE x in S: A y in S: y <= x
      MIN(S) == CHOOSE x in S: A y in S: y >= x

      RECURSIVE SetReduce(_, _, _) 
      SetReduce(Op(_, _), S, value) ==
          IF S = {} THEN value
          ELSE LET s == CHOOSE s in S : TRUE
          IN SetReduce(Op, S  {s}, Op(s, value))

      ToSeq(S) == 
          LET op(e, val) == Append(val, e)
          IN SetReduce(op, S, << >>) 

      ToSet(S) == {S[i] : i in DOMAIN(S)}

      MapSet(Op(_), S) == {Op(x) : x in S}

      GetIds(Evts) == MapSet(LAMBDA x: x.id, Evts)
      GetTimes(Evts) == MapSet(LAMBDA x: x.time, Evts)

      EventsOrderByTime(e1, e2) == e1.time < e2.time
      EventsOrderById(e1, e2) == e1.id < e2.id
      EventsOrder(e1, e2) ==
          / EventsOrderByTime(e1, e2)
          / / e1.time = e2.time
             / EventsOrderById(e1, e2)

      Limit(S, limit) ==
        LET amount == MIN({limit, Len(S)})
            result == IF amount > 0 THEN SubSeq(S, 1, amount)
                      ELSE << >>
        IN result

      * SELECT * FROM events
      *   WHERE time <= curr_time AND id > max_id
      *   ORDER BY id
      *   LIMIT limit
      SELECT_HIGH_PRIO(state, limit) == 
          LET selected == {e in Events :
                              / e.time <= state.time
                              / e.id >= state.id }
              * сортируем по Id
              sorted ==
                  SortSeq(ToSeq(selected), EventsOrderById)
              * выбираем до лимита
              limited == Limit(sorted, limit)
          IN ToSet(limited)

      * SELECT * FROM events
      *   WHERE time > current_time
      *     AND time - Time <= delta_time
      *   ORDER BY time, id
      *   LIMIT limit
      SELECT_NORMAL(state, delta_time, limit) ==
          LET selected == 
              {e in Events:
                 / e.time <= state.time + delta_time
                 / e.time > state.time }
              * сортируем лексикографически
              sorted == SortSeq(ToSeq(selected), EventsOrder)
              * ограничиваем выборку
              limited == Limit(sorted, limit)
          IN ToSet(limited)

    ALL_EVENTS_PROCESSED(state) ==
        A e in Events:
            / e.time >= state.time
            / e.id >= state.id

    end define;

    fair process inserter = "Sources"
      variable t;
      begin
        forever:
        while TRUE do
          get_time:
            with evt_time in 0..MAX_TIME do
                 t := evt_time;
            end with;
          commit:
            either 
              Events := Events union 
                        {[time |-> t, id |-> Event_Id]} ||
              Event_Id := Event_Id + 1;
            or 
              Event_Id := Event_Id + 1;
            end either;
        end while;
      end process

    fair process event_processor = "Event_processor"
      variable state = ZERO_EVT,
               events = {};
      begin
        forever:
        while TRUE do
          select:
            events :=
                LET evts_high_prio == 
                        SELECT_HIGH_PRIO(state, LIMIT)
                    new_limit == 
                        LIMIT - Cardinality(evts_high_prio)
                    evts_normal ==
                        SELECT_NORMAL(state, TIME_DELTA, new_limit)
                IN evts_high_prio union evts_normal;
          proc_evts: 
            Events := Events  events ||
            state := [
                time |-> MAX({state.time} union GetTimes(events)),
                id   |-> MAX({state.id} union GetIds(events))] ||
            events := {};
        end while;
      end process

    end algorithm;
*)

* BEGIN TRANSLATION
* Тут TLA+, автоматически сгенерированный из вышеприведённого PlusCal описания
* END TRANSLATION
===================================================

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

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

Проверка модели

Моделирование запускаем с теми же самыми параметрами, что и в первом варианте.

И получаем нарушение свойства ALL_EVENTS_PROCESSED на 19-м шаге моделирования при поиске в ширину.

При заданных начальных данных (это весьма малое пространство состояний) ошибка на 19-м шаге говорит о том, что ошибка весьма редкая и сложнообнаружимая, так как до этого были просмотрены все цепочки состояний длиной меньше 19.

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

Всю трассу приводить не буду ради экономии места и времени. Вот отрезок из нескольких состояний вместе с ошибочным:

Инженерный подход к разработке ПО. От теории к практике - 22

Инженерный подход к разработке ПО. От теории к практике - 23

Инженерный подход к разработке ПО. От теории к практике - 24

Инженерный подход к разработке ПО. От теории к практике - 25

Инженерный подход к разработке ПО. От теории к практике - 26

Анализ и исправление

Что же произошло?

Как видим, ошибка проявилась в том, что мы пропустили событие {2, 3} из-за того, что лимит кончился на событии {2, 1}, и после этого мы изменили состояние координатора. Это может случиться, только если в одном моменте времени находится несколько событий.

Именно поэтому ошибка была трудноуловима на тестах. Для её проявления нужно, чтобы совпали достаточно редкие вещи:

  1. Несколько событий попали в один и тот же момент времени.
  2. Лимит на выборку событий закончился до момента чтения всех этих событий.

Ошибку можно относительно несложно поправить, если немного расширить состояние координатора: добавить время и идентификатор последнего считанного события из области нормальных событий с максимальным id, если time этого события соответствует следующему значению state.time.

Если же такого события нет, то мы дополнительное состояние (extra_state) устанавливаем в невалидное состояние (UNDEF_EVT) и не учитываем при работе.

Те события из области нормальных, которые не были обработаны на предыдущем шаге координатора, мы будем считать уже высокоприоритетными и соответствующим образом поправим выборку высокоприоритетных и safety-предикат.

Можно было бы ввести ещё одну область, промежуточную между высокоприоритетными и нормальными, и изменить алгоритм. Вначале он будет обрабатывать высокоприоритетные, потом промежуточные, а затем переходить к нормальным с последующим изменением state.

Но такие изменения привели бы к большему объёму рефакторинга при неочевидных преимуществах (алгоритм будет чуть понятнее; других преимуществ с ходу не видно).

Поэтому решили только немного поправить текущее состояние и выборку событий из базы данных.

Скорректированная модель

Вот исправленная модель.

------------------- MODULE events -------------------
EXTENDS Naturals, FiniteSets, Sequences, TLC

* Для удобства вынес значения в настройки моделирования
CONSTANTS MAX_TIME, LIMIT, TIME_DELTA

(* --algorithm Events 

    variables Events = {},
              Limit in LIMIT,
              Delta in TIME_DELTA,
              Event_Id = 0

    define

      * добавлено неопределённое значение
      * для события, используется в extra_state
      UNDEF_EVT == [time |-> MAX_TIME + 1, id |-> 0]

      ZERO_EVT == [time |-> 0, id |-> 0]

      MAX(S) == CHOOSE x in S: A y in S: y <= x
      MIN(S) == CHOOSE x in S: A y in S: y >= x

      RECURSIVE SetReduce(_, _, _) 
      SetReduce(Op(_, _), S, value) ==
          IF S = {} THEN value
          ELSE LET s == CHOOSE s in S : TRUE
          IN SetReduce(Op, S  {s}, Op(s, value))

      ToSeq(S) == 
          LET op(e, val) == Append(val, e)
          IN SetReduce(op, S, << >>) 

      ToSet(S) == {S[i] : i in DOMAIN(S)}

      MapSet(Op(_), S) == {Op(x) : x in S}

      GetIds(Evts) == MapSet(LAMBDA x: x.id, Evts)
      GetTimes(Evts) == MapSet(LAMBDA x: x.time, Evts)

      EventsOrderByTime(e1, e2) == e1.time < e2.time
      EventsOrderById(e1, e2) == e1.id < e2.id
      EventsOrder(e1, e2) ==
          / EventsOrderByTime(e1, e2)
          / / e1.time = e2.time
             / EventsOrderById(e1, e2)

      TakeN(S, limit) ==
        LET amount == MIN({limit, Len(S)})
            result == IF amount > 0 THEN SubSeq(S, 1, amount)
                      ELSE << >>
        IN result

      (* SELECT * FROM events
         WHERE time <= curr_time AND id > max_id
         ORDER BY id
         Limit limit *)
      SELECT_HIGH_PRIO(state, limit, extra_state) == 
          LET * сначала выбираем множество по условиям 
              *    time <= curr_time
              *    AND id > maxt_id
              selected == {e in Events :
                              / / e.time <= state.time
                                 / e.id >= state.id
                              / / e.time = extra_state.time
                                 / e.id > extra_state.id}
              sorted == * упорядочиваем
                  SortSeq(ToSeq(selected), EventsOrderById)
              limited == TakeN(sorted, limit)
          IN ToSet(limited)

      SELECT_NORMAL(state, delta_time, limit) ==
          LET selected == 
              {e in Events :
                 / e.time <= state.time + delta_time
                 / e.time > state.time }
              sorted == SortSeq(ToSeq(selected), EventsOrder)
              limited == TakeN(sorted, limit)
          IN ToSet(limited)

      * Оператор для обновления extra_state
      UpdateExtraState(events, state, extra_state) ==
          LET exact == {evt in events : evt.time = state.time}
          IN  IF exact # {}
              THEN CHOOSE evt in exact :
                      A e in exact: e.id <= evt.id
              ELSE UNDEF_EVT

      * теперь нужно учесть extra_state
      ALL_EVENTS_PROCESSED(state, extra_state) ==
          A e in Events:
              / e.time >= state.time
              / e.id > state.id
              / / e.time = extra_state.time
                 / e.id > extra_state.id

    end define;

    fair process inserter = "Sources"
      variable t;
      begin
        forever:
        while TRUE do
          get_time:
            with evt_time in 0..MAX_TIME do
                 t := evt_time;
            end with;
          commit:
            either 
              Events := Events union 
                        {[time |-> t, id |-> Event_Id]} ||
              Event_Id := Event_Id + 1;
            or 
              Event_Id := Event_Id + 1;
            end either;
        end while;
      end process

    fair process event_processor = "Event_processor"
      variable events = {},
               state = ZERO_EVT,
               extra_state = UNDEF_EVT;
      begin
        forever:
        while TRUE do
          select:
            events :=
                LET evts_high_prio ==
                        SELECT_HIGH_PRIO(state, Limit, extra_state)
                    new_limit ==
                        Limit - Cardinality(evts_high_prio)
                    evts_normal ==
                        SELECT_NORMAL(state, Delta, new_limit)
                IN evts_high_prio union evts_normal;
          proc_evts:
                Events := Events  events ||
                state := [
                    time |-> MAX({state.time} union GetTimes(events)),
                    id   |-> MAX({state.id} union GetIds(events))
                ];
                extra_state :=
                    UpdateExtraState(events, state, extra_state) ||
                events := {};
        end while;
      end process

    end algorithm;
*)
* BEGIN TRANSLATION
* Тут TLA+, автоматически сгенерированный из вышеприведённого PlusCal описания
* END TRANSLATION
===================================================

Как можно увидеть, изменения совсем небольшие:

  1. Добавили дополнительные данные к состоянию extra_state.
  2. Изменили выборку высокоприоритетных событий.
  3. Добавили определение UpdateExtraState для изменения extra_state.
  4. Поправили safety-предикат для учёта изменений состояния.

Моделирование

Теперь моделирование прошло успешно, ошибок не нашли. Значит, эту конкретную проблему удалось исправить (что, конечно же, не говорит о том, что других проблем не осталось).
Но если возникнут подозрения на другие ошибки, то мы уже сможем их встретить достойно, с TLA+/TLC наперевес. :)

Заключение

С практической точки зрения вам, наверное, интересна трудоёмкость этого процесса (разработка модели, моделирование, поиск ошибки, исправление и повторное моделирование).

Учитывая то, что автор модели не был знаком с кодом координатора и нюансами алгоритма, а автор кода координатора впервые познакомился с TLA+/TLC, чистого времени ушло относительно немного. Думаю, суммарно около рабочего дня.

Если бы один человек с опытом работы с TLA+/TLC создавал алгоритм, код и модель, то на разработку модели и моделирование он бы потратил существенно (в разы, а то и на порядок) меньше времени.

В целом, как показывает практика и конкретный, рассмотренный в данной статье случай, знание TLA+/TLC разработчиками и даже его эпизодическое ретроспективное применение может приносить существенную пользу при относительно невысоких затратах времени.

Библиография

Книги

Для погружения в область

Тут собраны книги, которые отличаются практическим подходом, минимумом сложной математики, максимумом объяснений и примеров. Список упорядочен от простого к сложному.

  1. Michael Jackson Problem Frames: Analysing & Structuring Software Development Problems

    Хорошая книга об анализе проблем (не способов решения!), которые встают перед разработчиками ПО. Содержит множество примеров, как изучать проблемы. В первую очередь будет интересна тем, кто хочет грамотно анализировать задачи и составлять требования.

  2. Hillel Wayne Practical TLA+: Planning Driven Development

    Отличная книга об использовании TLA+/PlusCal для моделирования алгоритмов и др. Написана просто, со множеством понятных примеров. Специальной подготовки от читателя не требуется. Могу рекомендовать её всем программистам независимо от области работы: она будет полезна каждому.

  3. Юрий Карпов MODEL CHECKING. Верификация параллельных и распределённых программных систем

    Хорошая обзорная книга о моделировании. Доходчиво написана, рассмотрены все основные темы, много примеров. Серьёзной подготовки от читателя не требуется, знания матлогики и дискретки в объёме стандартной вузовской программы более чем достаточно.

  4. Leslie Lamport Specifying Systems: The TLA+ Language and Tools for Hardware and Software Engineers

    Основная книга о TLA+. Материал хорошо подан, хоть и суховато. Но это отличительный стиль Лесли Лэмпорта: предельно ясное и точное изложение, но немного сухое. Рекомендую всем, кто хочет детально понять TLA+ и идеи, лежащие в его основе.

Примеры из промышленности

  1. Formal Development of a Network-Centric RTOS

    Описывается опыт применения TLA+ для создания спецификаций при разработке сетецентричной ОС реального времени (RTOS) и опыт моделирования спецификаций с помощью TLC.

    Книга интересна в первую очередь тем, что описывает разработку реального коммерческого продукта. А ещё тем, что использование TLA+ и моделирование позволило оптимизировать архитектуру настолько, что в результате объём кода уменьшился на порядок для той же самой функциональности, что и у RTOS предыдущей версии — Virtuoso. Разработчики даже не подозревали о таком эффекте, для них он стал весьма приятным сюрпризом.

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

  2. Ноw Amazon Web Services Uses Formal Methods

    Реальное применение TLA+ при разработке системных сервисов в AWS. Часть материалов можно найти на сайте Лесли Лэмпорта: http://lamport.azurewebsites.net/tla/amazon-excerpt.html

Интернет

Блоги

  1. Hillel Wayne (автор книги "Practical TLA+") ссылка

    Много интересного материала и примеров. Лёгкий стиль, простая подача. Очень рекомендую тем, кто только-только начинает изучать моделирование.

  2. Ron Pressler ссылка

    Очень интересные статьи. Автор объясняет серьёзные вещи весьма просто и понятно. Рекомендую тем, кто хочет поглубже понять теорию TLA+. Есть интересные статьи не только о TLA+, но и о computer science и математике в целом.

  3. Leslie Lamport ссылка

    Просто кладезь интересного материала о TLA+ и computer science. О TLA+ много всего тут.

Презентации и видеокурсы

  1. Ю. Г. Карпов, И. В. Шошмина, А. Б. Беляев. Верификация параллельных и распределённых программных систем

    Интересная презентация, где вкратце объясняется, что такое моделирование и зачем оно нужно.

  2. Лесли Лэмпорт TLA+, видеокурс ссылка

    Крайне рекомендую всем, кто начинает знакомиться с TLA+. Просто, понятно и доходчиво объясняются основные моменты TLA+.

  3. Hillel Wayne

    Могу рекомендовать эти видео выступлений Hillel Wayne на конференциях. Отличаются лёгкостью подачи материала и юмором.

  4. Ron Pressler

    Меньше юмора, чем у Hillel Wayne, зато более глубоко рассматриваются темы. Рекомендую тем, кто хочет серьёзнее разобраться. Ron Pressler хорошо умеет объяснить фундаментальные математические понятия. Бо́льшая часть его материала о математике и теории, а не о практике, но очень интересно и полезно.

Моделирование

TLA toolbox + TLAPS: эти инструменты для работы со спецификациями на TLA+ я
использовал сам для изучения и решения задач в процессе разработки ПО. Они показались мне наиболее полезными на практике наряду с Alloy Analyzer.

Автор: Васил Дядов

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js