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

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

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

Бессмысленно внушать представление об аромате дыни человеку, который годами жевал сапожные шнурки.
Виктор Шкловский, если верить Довлатову

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

Рассказ рассчитан на тех, кто хотя бы поверхностно знаком с концепциями ООП и (или) ФП. Ниже вы не найдёте всех тех запутывающих псевдонаучных объяснений, которые вам услужливо предоставит Вика или Анжела (или как там вы называете свою любимую LLM в приватных чатиках).

Текст написан именно сегодня, когда Алану Каю исполнилось 85! Поздравляем, Алан, ты — гений, спасибо тебе за всё!

Краткий исторический экскурс

Отцом акторной модели считается Карл Хьюитт, степень популярности которого среди моих соотечественников можно описать отсутствием его персональной страницы на русском языке в Вики. Он даже диссер потом про всё это написал. Его идеями вдохновлялся, среди прочих, Алан Кай, при создании Smalltalk, — именно ему мы обязаны термином «ООП» в его первоначальном значении («я уж точно не имел в виду C++»). Вскорости после этого Джо Армстронг сотоварищи создал эрланг — целиком и полностью построенный на акторной модели. Всё это происходило во времена хиппи.

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

Акторная модель, будучи одной из самых математически элегантных концепций в Computer Science (наравне, пожалуй, с теорией категорий и property-based тестированием), пылилась в дальнем углу запертого на потерянный ключ ящика. Потом в каждый утюг стали пихать по шестьдесят четыре процессора с гипертредингом, но привычка — страшная штука, и акторная модель до сих пор остаётся уделом фриков. Даже невзирая на адаптацию в джаве и дотнете.

Так что же это за зверь?

Давайте на секунду вернемся к аланокайному определению ООП. У нас есть объекты с внутренним состоянием. И унифицированный способ доступа к ним (read/write). По сути, всё современное программирование сводится именно к этому, даже если под объектом мы понимает инстанс тайпкласса в хаскеле, или экземпляр объекта User в джаве. Унифицированный способ доступа тоже может быть любым: это могут быть методы, как в шарпе, или полиморфные функции высшего порядка в идрисе, или даже сообщения, как в эрланге. Если вдуматься, разницы никакой нет.

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

И всё. Никакой высшей математики и астрологии. Всё просто, как увесистая репа в сауне.

Конструктор — или инициализация структуры данных — это старт процесса. Деструктор — его останов (поднимите руки, кто при виде последнего слова сразу увидел угловатый шестиугольник на блок-схеме). Метод — отправка сообщения. Для наглядности я приведу два куска кода на псевдоязыках с использованием парадигм ООП и АМ. Детали наподобие типов и валидаций опущены ради внятности.

Классическое джавастайл ООП (выдуманный язык Джарп):

class Developer {
    property name,
    property age,

    constructor(name, age) { this.name = name, this.age = age }

    reader getName() { this.name } // read-only
  
    reader method getAge() { this.age }
    writer setAge(age) { this.age = age }
}

// Пример использования

master = Developer.new("Alan", 84)
//⇒ object

master.setAge(85)
age = master.getAge() // ⇒ 85

master.delete() //⇒ удалить объект

А вот в акторной модели на выдуманном языке Эликанг:

master =
  spawn_process(fn ->
    state = %{name: "Alan", age: 84}

    receive_loop do
      {:set_age, age} -> state.age = age
      {:get_age, pid} -> {:age, state.age} ! pid
      :stop -> break_loop()
    end
  end) #⇒ process identifier

{:set_age, 85} ! master # отправить сообщение процессу
{:get_age, self()} ! master # отправить сообщение процессу

age =
  receive do # дождаться сообщения от процесса
    {:age, age} -> age
  end #⇒ 85

:stop ! master # остановить процесс

Код выше написан на псевдоязыке, но это не имеет значения, он должен быть и так понятен: мы запускаем процесс master, который запускает бесконечный цикл обработки сообщений. Висит там где-то и ждёт (внутри цикла receive_loop), пока ему кто-то это самое сообщение доставит. Потом матчит сообщение, и, в зависимости от него, предпринимает какие-то действия (изменяет состояние, или высылает сообщение обратно, или завершается).

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

Тогда зачем?

Преимущества незаметны на выдуманных простых примерах. Создать объект с двумя «полями» и изменять/читать их значения — та задача, которая легко решается даже на ассемблере. Кроме того, пример на АМ получился даже немного многословнее. Но что будет, если объектов 100?

На Джарпе код изменится примерно так:

- master = Developer.new("Alan", 84)
+ master = Developer.read_from_database("Alan")

На Эликанге:

-     state = %{name: "Alan", age: 84}
+     state = :db.read("Alan")

Не так-то много отличий, да? — Нет. Посмотрите на скоупы: в акторной модели мы сходим в базу один раз, а потом (пока процесс не помрёт) — наш «developer» будет в «локальном кэше» — в состоянии уже запущенного процесса. Мы можем его изменять, получать из него данные — и всё это без походов в базу. Однажды затребованный «developer» — под рукой всегда. В случае Джарпа — каждый раз, когда нам требуется что-то сделать с объектом «developer» — его сначала нужно откуда-то (из базы) достать. Отсюда все эти N+1 проблемы, красные метрики на базе, ошибки Connection Limit Reached — и прочие никому не нужные радости.

Осталось решить несколько вновь появившихся проблем:

① за процессами кто-то должен следить, потому что если крысы перегрызут кабель — мы не должны потерять наши данные
② процессы надо как-то адресовать (по имени, например), чтобы получить к ним доступ откуда угодно
③ кучу бойлерплейта по отправке/приёмке сообщений надо бы причесать и вынести в абстракции языка
④ нужно уметь адекватно реагировать на невозможность доставки сообщения (процесса нет, он в процессе перезапуска)
⑤ хорошо бы (для прозрачного горизонтального масштабирования), чтобы имена процессов не были бы привязаны к физической машине
⑥ гонки данных — с ними надо что-то делать, давать их на откуп разработчикам нельзя ни при каких обстоятельствах: напортачат-с

Я думаю, что опыт разработчиков Го по созданию вытесняющей многозадачности без виртуальной машины — можно будет скоро использовать для новых языков, построенных на акторной модели. Пока в существующих языках (эрланг, эликсир, gleam, lfe) — ① решается виртуальной машиной. ② и ⑤ закрываются глобальным пространством имён процессов. Ниже я вкратце расскажу, как в эликсир решает проблемы ③, ④ и ⑥.

⑥ → Иммутабельность

Для решения проблемы гонок данных можно было бы навертеть черта лысого в ступе. Но есть очень простое и понятное решение: иммутабельность. Полная иммутабельность языка. Написал foo = 42 — и пока идентификатор foo не вышел из скоупа — значение переменной будет 42. Это нечеловечески удобно (причем, не только нам, программистам, — но и сборщику мусора). Медленнее? — На определенном классе задач — да. Этот класс задач уместнее решать на более приспособленных парадигмах с компилятором в нативный код (си, раст, хаскель).

Но в прикладной разработке таких задач исчезающе мало и они все закрыты прозрачными биндингами. Зато «воткнул к двум еще одну ноду и нагрузка снизилась в полтора раза без изменения кода» — бесценно во всякого рода навороченной джейсоноукладке. Один гигантский CSV с валидациями и тяжелой перегруппировкой данных эликсир умеет разбирать не только на всех ядрах, но и на всех нодах в кластере одновременно. Из коробки. Что скажете?

③, ④ →Абстракции для людей

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

В виртуальной машине эрланга (и супертонкой стандартной библиотеке самого языка) — все сообщения по заветам Алана (с днем рождения еще раз!) асинхронные. Отправил — и всё. Никаких гарантий доставки даже.

Но мы легко может эмулировать синхронность добавкой отсылки сообщения «получено» обратно — и обработкой его в исходном процессе. Это всё еще не даёт 100% гарантию (в хорошем сценарии: сообщение → ответ → реакция — даёт), но мы можем не получить положительный ответ. Что ж, вместо того, чтобы добиваться гарантий костылями в этом случае, достаточно просто привыкнуть к их отсутствию. Я аккуратно отрабатываю такие сценарии уже десять лет, хотя еще ни разу не сталкивался с недоставленным подтверждением от вызываемого процесса.

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

defmodule Developer do
  use GenServer # абстракция работы с процессом

  def init(name, age, do: {:ok, %{name: name, age: age}}

  def handle_cast({:set_age, age}, state) do
    {:noreply, %{state | age: age}}
  end

  def handle_cast(:stop, state) do
    {:stop, :normal, state}
  end

  def handle_call(:get_age, _from, state) do
    {:reply, state.age, state}
  end
end

# Пример использования:
{:ok, pid} = GenServer.start_link(Developer, ["Alan", 84])
GenServer.cast(pid, {:set_age, 85})
#⇒ :ok → этот вызов асинхронный
GenServer.call(pid, :get_age)
#⇒ 85
GenServer.cast(pid, :stop)
#⇒ :ok
Process.alive?(pid)
#⇒ false

Обратите внимание на то, как обрабатываются разные сообщения в разных clauses функции (это колбэк, который вызывает абстракция GenServer когда получает асинхронное сообщение) handle_cast/2.

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

Отправьте ему сообщение — и просто дождитесь результата, если он вам нужен.

Вот и всё на сегодня. Надеюсь, мне удалось сделать вопрос «что такое акторная модель» чуть менее загадочным.

Удачного сообщайзинга!

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

Публикации

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