Как стать автором
Обновить

Гарантийное обслуживание конечных автоматов

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров1.5K

Я много и часто говорю о том, что есть принципиальное различие между конечным автоматом и полем «state» в базе данных. Я даже уже отчасти писал про это, но акценты в том тексте были на другом, поэтому я решил посвятить целые полчаса собственной жизни кристаллизации тезисов о правильных конечных автоматах и их реализации в CS.

Так повелось, что математики ограничились применением конечных автоматов к алфавитам, а прикладники тем временем увидели знакомое слово «состояние» и со свойственным всем нам верхоглядством решили, что набор «состояний» и «переходов» — это и есть конечный автомат. Всем, наверное, доводилось видеть такой код:

void transition(state, event) {
  switch(state) {
    case IDLE:
      this.state = WORKING;
      doWork(event);
      this.state = DONE;
      break;
    case DONE:
      doReset();
      this.state = IDLE;
      break;
    default:
      terminate();
  }
}

Не «Рождение Венеры» Ботичелли, конечно, но функцию свою выполняет. Выполняет ведь? А кто может указать на одну архитектурную и одну критическую ошибку, которая завтра испортит тот объект, который этот автомат обслуживает?

Один переход — одно состояние

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

На самом деле нет. Представьте себе, что рядом с вами работает умный ленивый человек, который решил втайне от вас сохранять состояние в базу. Естественно, он не станет лезть в ваш код и высылать вам на ревью PR с +18000-12000. Он прикрутит аспект на изменение состояния. Который обновит в базе state каждый раз, когда он устанавливается в новое значение в коде.

Если однажды doWork упадёт, то в базе останется значение состояния «WORKING», и код выше для данного объекта теперь всегда будет выходить в terminate. Если вы считаете, что от таких досадных недоразумений должен защищаться программист — мне вас жаль. Переложите такие задачи на компилятор — и у вас высвободится вагон свободного времени, чтобы собственноручно писать код, а не делегировать это развлечение LLM-ке.

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

Гонка

Как бы мы ни стремились замедлить прогресс созданием однопоточного кода, мир отвоёвывает своё. Когда я вкатывался в профессию, понятие thread-safe в прикладном программировании было известно нескольким фрикам, и то — понаслышке. Когда я стал писать более-менее внятный код, все без исключения сторонние библиотеки тщательно оговаривались в README: «вот тут thread-safe, тут — не thread-safe, а тут — рыбу заворачивали». Сегодня писать не thread-safe код — фактически саботаж.

Код выше, будучи вызван в конкурентной среде, может передать управление другому потоку между строками 4 и 5. Вот тут:

      this.state = WORKING;
      // что-то я устал, перекур, давайте пока без меня 
      doWork(event);

А другой поток — может запросто инициировать переход на этом же объекте. Хоба! — и второй поток проник в switch, состояние (установленное первым потоком) — WORKING, — и вот второй поток уже вызывает terminate. Тут город засыпает, и просыпается маф^W первый поток. Хоба! И мы вызываем doWork на терминированном объекте. Буквально на ровном месте, причем.

У этого кода есть и третий, чуть менее явный огрех.

Состояние может изменяться только переходами

Ух, и не сосчитать, сколько раз мне доводилось видеть в чужом коде заплатки (потому что бизнес не ждет, а правильно делать — долго) наподобие внезапной переустановки состояния вопреки любой мыслимой и немыслимой логике, где-нибудь через двести строк от определения функции transition:

void prepareReport() {
    State state = this.state;
    // «буквально на секундочку сделаем эту дорогу двусторонней», —
    //   как говорил мой дядька, сворачивая под кирпич
  
    this.state = WORKING; // чтобы правильно посчитались суммы
    … // do a report
    this.state = state;
}

Хоба! И наша сущность на час (это небыстрый отчет) переходит во временное состояние WORKING.

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

Из последнего анекдота, кстати, родился популярный среди недалеких людей тезис о «комбинаторном взрыве» при проектировании логики на конечных автоматах. Количество возможных переходов невозможно элегантно ограничить, если в качестве состояния использовать просто поле в структуре.

Как всего этого избежать?

Мне неизвестен способ изящнее, чем запускать легковесный процесс (гринтред, корутину, горутину) на каждый экземпляр конечного автомата и обрабатывать входящие запросы — как сообщения в акторной модели. Этого можно добиться и вне акторной модели: тогда на функции/методе transition просто должен висеть мьютекс (наподобие synchronised в джаве). Это, правда, не спасет от непреднамеренного саботажа — от установки поля вручную в обход синхронизированного метода. В акторной модели — вам не придется об этом даже думать. Процесс не может обрабатывать больше одного сообщения одновременно. Там очередь. А внутреннее состояние процесса невозможно изменить извне. Поэтому гарантии консистентности вы получаете просто из коробки.

Вообще говоря, на этом подарки, которые мог бы преподнести разработчику компилятор, — не заканчиваются. Можно было бы проверять такие мелочи, как наличие начального состояния, обязательного для конечного автомата, хотя бы одного конечного, отсутствие сирот… Можно было бы сообщить обо всех циклических «путях», о кратчайших путях достижения конечного состояния, а еще можно было бы нарисовать mermaid-диаграмму и прицепить её к документации. И это всё вполне может сделать компилятор. Я уже рассказывал про свою библиотеку Finitomata, в которой все эти плюшки реализованы. Сейчас я просто на пальцах покажу, как добиться всех тех же гарантий можно на чистых процессах.

Процесс в эрланге — готовый конечный автомат

Директива use GenServer превращает код модуля в процесс, который умеет отвечать на синхронные и асинхронные сообщения — для этого всего лишь надо реализовать соответствующие колбэки. Вот пример (упрощенный) для турникета.

```

defmodule Turnstile do
  use GenServer

  @impl GenServer
  def init(_), do: {:ok, {:closed, 0}}

  @impl GenServer
  def handle_cast(:coin, {_, coins}),
    do: {:noreply, {:open, coins + 1}}

  def handle_cast(:walk, {:open, 1}),
    do: {:noreply, {:closed, 0}}

  def handle_cast(:walk, {:open, coins}),
    do: {:noreply, {:open, coins - 1}}

  def handle_cast(action, {state, coins}) do
    require Logger
    Logger.error("Prohibited action ‹#{action}› in state ‹#{state}›")
    {:noreply, {state, coins}}
  end
end

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

Вот так это выглядит в iex (REPL):

iex|🌢|2 ▶ {:ok, pid} = GenServer.start_link(Turnstile, :ok)
{:ok, #PID<0.115.0>}
iex|🌢|3 ▶ GenServer.cast(pid, :walk)
16:59:10.631 [error] Prohibited action ‹walk› in state ‹closed›
iex|🌢|4 ▶ GenServer.cast(pid, :coin)
iex|🌢|5 ▶ GenServer.cast(pid, :walk)
iex|🌢|6 ▶ GenServer.cast(pid, :coin)
iex|🌢|7 ▶ GenServer.cast(pid, :coin)
iex|🌢|8 ▶ GenServer.cast(pid, :coin)
iex|🌢|9 ▶ :sys.get_state(pid)
{:open, 3}

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

Но что будет, если процесс упадёт? Его перезапустит его супервизор, с начальным состоянием, что нормально для 99% случаев. Но если он упадёт сразу после того, как щедрый Вася отгрузил в монетоприёмник 10 жетонов, Вася может расстроиться. Есть три распространенных варианта решения этой проблемы: ① использовать мою библиотеку peeper, ② хранить состояние в ETS с правильно установленным heir (здесь это немного выходит за рамки темы текста, в библиотеке выше есть реализация), или ③ писать в базу.

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

Удачного конечного автоматизирования!

Теги:
Хабы:
+10
Комментарии50

Публикации

Ближайшие события