Я много и часто говорю о том, что есть принципиальное различие между конечным автоматом и полем «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 (здесь это немного выходит за рамки темы текста, в библиотеке выше есть реализация), или ③ писать в базу.

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

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

Комментарии (45)


  1. dzmitry_sidarau
    20.05.2025 18:56

    И как всегда ни слова о реактивном программировании с вытеснящим полиморфизмом. Аудитория негодует!


  1. vadimr
    20.05.2025 18:56

    Свитч с веткой default – это не конечный автомат вообще.


    1. cupraer Автор
      20.05.2025 18:56

      Да, но мне не хотелось вводить третее состояние, и я решил так. Спасибо, что заметили :)


  1. kmatveev
    20.05.2025 18:56

    Мне кажется, что есть ещё третья ошибка, возникающая при программировании конечных автоматов, от которой спасают акторы. Я называю её "object reentrancy". Суть вот в чём: функция или метод, принимающая событие, должна сделать две вещи: поменять состояние, и выполнить всякие side effect-ы. Представим, что она это делает в таком порядке: сначала выполняет сайд-эффекты, а потом меняет состояние. Если side effect - это синхронный вызов, то в его процессе может породиться новое событие, и в том же потоке выполнения снова вызовется метод, принимающий событие. Но состояние-то осталось старым! Потом эта цепочка начнёт раскручиваться, и состояния начнут обновляться задом наперёд. Mutex-ы не спасут, они обычно позволяют себя повторно захватить потоку, который ими и так уже владеет.

    В ООП есть понятие "class invariant". Это нормально, что внутри метода этот инвариант временно ломается, пока это не наблюдаемо извне. Но если метод временно передаёт управление, например в какой-нибудь callback, пока инвариант сломан, то возможна неожиданная жопа. Я с таким сталкивался, когда у меня несколько стейт-машин общались друг с другом.

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


    1. Dhwtj
      20.05.2025 18:56

      сначала меняем состояние, потом выполняем остальные действия

      3 способа есть

      Просто синхронно вызвать (state → side-effect в том же стеке, lock для атомарности)

      Outbox (state и «что отправить» коммитятся вместе)

      Saga / In-Progress

      state=InProgress; call remote; on OK → state=Done; on Fail → Rollback|Retry.

      Нужна логика повтора/компенсации, зато нет общей БД.

      «Сначала state, потом эффекты» это верно, но способы доставки/исполнения эффектов дают три варианта (стек, outbox, saga).


      1. cupraer Автор
        20.05.2025 18:56

        Сага — по сути та же стейтмашина, это бесконечная рекурсия.


        1. Dhwtj
          20.05.2025 18:56

          Пфф, я же не проснулся ещё. Зачем вы мне мозг ломаете?

          По ощущениям да. Но проверить не могу. И не пойму зачем это мне нужно.


      1. cupraer Автор
        20.05.2025 18:56

        логика повтора/компенсации

        В конечном автомате? Вы там на тяжелых наркотиках, что ли?


        1. Dhwtj
          20.05.2025 18:56

          А... Понял

          В терминах Сага (императивно) это Retry/Rollback

          В терминах конечного автомата (декларативно) это переход в состояние need retry / need rollback


          1. cupraer Автор
            20.05.2025 18:56

            А кто толкнет дальше из need состояния?


            1. Dhwtj
              20.05.2025 18:56

              сервис подписчик


              1. cupraer Автор
                20.05.2025 18:56

                Какой еще сервис? Уже и сервис нарисовался? А хотели ведь мирно без гостей посидеть.


                1. Dhwtj
                  20.05.2025 18:56

                  В акторной можно всё в одном

                  В ФП кто-то должен толкнуть

                  В ООП всё плохо когда его дёргают из нескольких потоков управления. Своего потока управления в ООП нет и начинается хаос. А в акторной модели свой поток управления за счёт очереди и может ещё чего то.


                  1. cupraer Автор
                    20.05.2025 18:56

                    Да, просто за счет очереди и изолированного пространства. Инкапсуляция по-взрослому: не существует способа её нарушить.

                    Это реализуемо, конечно, и в ФП, и в ООП, но за счет дополнительных «сервисов» (что и ладно бы) — но вдобавок оно не страхует от ошибок разработчика, а вот это уже — труба для прикладного языка.


        1. Dhwtj
          20.05.2025 18:56

          Намекаете, что конечный автомат декларативен до мозга костей и императивные действия не царское его дело?


          1. Dhwtj
            20.05.2025 18:56

            Тьфу, черт!

            Это и есть основная мысль статьи, теперь дошло.
            Старею, наверное..


            1. cupraer Автор
              20.05.2025 18:56

              Давайте договоримся, вы перестанете называть простенькие тексты высокопарным «статья» — а я буду прикладывать все усилия к повышению их качества в будущем :)


    1. cupraer Автор
      20.05.2025 18:56

      сначала меняем состояние, потом выполняем остальные действия

      А если «остальное действие» не выполнилось? Просто не смогло по независящим от нас причинам? Выбросило исключение?


    1. Siemargl
      20.05.2025 18:56

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

      Реентрабельный КА не представляю, это ересь)


      1. cupraer Автор
        20.05.2025 18:56

        Что значит реентрабельный?


        1. Siemargl
          20.05.2025 18:56

          Стандартное определение


          1. cupraer Автор
            20.05.2025 18:56

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


  1. Refridgerator
    20.05.2025 18:56

    А вы говорите, зачем нужно ООП. Чтобы State снаружи нельзя было изменять, вот зачем. Я конечные автоматы тоже предпочитаю делать в отдельном потоке, а управление через методы типа bool Stop() {must_stop=true;}, где must_stop - это приватный флаг и будет обработан там, где надо.


    1. Dhwtj
      20.05.2025 18:56

      Варианты:

      • Автомат живёт дольше процесса, влияет на деньги/внешний мир → фиксируем каждый event или хотя бы снапшот в durable storage.

      • Автомат ограничен одним вызовом функции/потоком, можно легко пересчитать → держим state в ОЗУ (переменная, стек, closure). Необходимости /дополнительного удобства в ООП не возникает, но если это ООП язык, то его использование здесь естественно.

      Граница проходит по требованиям к надёжности и времени жизни.

      А... Понял

      польза ООП перевешивает когда:

      • состояние уходит за пределы одной функции (передаём, кешируем, шарим между потоками);

      • нужно запретить нелегальные переходы (метод next() проверяет allowed-таблицу);

      • автомат эволюционирует, его будут трогать другие разработчики.


    1. cupraer Автор
      20.05.2025 18:56

      А если нерадивый коллега вызовет Stop в недозволенном месте?

      Ну и да, чтобы запретить менять что-то снаружи — ООП не требуется.


      1. Refridgerator
        20.05.2025 18:56

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


        1. cupraer Автор
          20.05.2025 18:56

          Если честно, я не очень понимаю, как это должно работать. Можете маленький кусочек кода показать?


          1. Dhwtj
            20.05.2025 18:56

            Он про инкапсуляцию, которая защищает от некорректных изменений мутабельного состояния


            1. cupraer Автор
              20.05.2025 18:56

              Это с каких пор инкапсуляция защищает от некорректных изменений мутабельного состояния?


              1. Dhwtj
                20.05.2025 18:56

                Это одно из главных назначений инкапсуляции: защитить мутабельное состояние объекта от некорректных внешних изменений.
                Может, не всегда получается, но ЭТО ДРУГОЕ, ВЫ НЕ ПОНИМАЕТЕ! (С) лол

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


                1. Dhwtj
                  20.05.2025 18:56

                  а... да... и еще

                  поток (Control Flow) для ООП является внешним, обьект в ООП пассивен и когда к нему обращаются из нескольких потоков управления то поучается хаос

                  актор имеет свой поток управления

                  я всегда воспринимал поток как OS thread а тут вон оно как)


                  1. cupraer Автор
                    20.05.2025 18:56

                    А в каком языке вы чувствуете себя наиболее комфортно? Давайте я найду годную реализацию акторной модели для него, и тогда сможем предметно поговорить.


                    1. Dhwtj
                      20.05.2025 18:56

                      C#

                      но стараюсь быть полиглотом

                      Я в курсе, что есть Akka.net но не пробовал


                      1. cupraer Автор
                        20.05.2025 18:56

                        стараюсь быть полиглотом

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

                        У Akka.NET же крутейшая документация. Примеры, правда, так себе, наподобие «как нарисовать сову» — но вот этот, вроде, не переусложнен нерелевантными деталями.


          1. Refridgerator
            20.05.2025 18:56

            Маленький не получится, для этого придётся какой-то игрушечный пример придумывать.

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

            STOPPED,
            ENUMERATE_RUNTIME_MODULES,
            INIT_DEVICES,
            START_DEVICES,
            WAIT_FOR_BUFFERS,
            STOP_DEVICES,
            FREE_DEVICES

            Запросы на изменение состояния обрабатывается в начале каждого. И если запрос на остановку приходит при переходе с INIT_DEVICES на START_DEVICES - то состояния WAIT_FOR_BUFFERS и STOP_DEVICES можно пропустить и переходить сразу на FREE_DEVICES. А в состояниях STOPPED, STOP_DEVICES и FREE_DEVICES его отслеживать не имеет смысла.

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


            1. vadimr
              20.05.2025 18:56

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

              Это всё, что угодно, но не конечный автомат.


              1. Refridgerator
                20.05.2025 18:56

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


                1. vadimr
                  20.05.2025 18:56

                  Этак вы любую программу назовёте конечным автоматом (кстати, встречал на Хабре и такое мнение).


                  1. Refridgerator
                    20.05.2025 18:56

                    Этак и вы любую программу назовёте не конечным автоматом.


                  1. cupraer Автор
                    20.05.2025 18:56

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


                    1. vadimr
                      20.05.2025 18:56

                      Нет, в силу неразрешимости проблемы останова.


                      1. cupraer Автор
                        20.05.2025 18:56

                        А кто сказал, что переходы конечного автомата должны выполняться за фиксированное время?

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


                      1. vadimr
                        20.05.2025 18:56

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

                        Если только о тотальных функциях говорить, тогда, конечно, формально да.


                      1. cupraer Автор
                        20.05.2025 18:56

                        Не, количество состояний ограничено сверху мощностью множества состояний, выразимых грамматикой (AST), которое счётно :)


                1. cupraer Автор
                  20.05.2025 18:56

                  Это поле «state», про которое я в самом первом предложении написал, что воюю с непониманием, чем оно отличается от конечного автомата.