Бессмысленно внушать представление об аромате дыни человеку, который годами жевал сапожные шнурки.
— Виктор Шкловский, если верить Довлатову
Поскольку среди тех, кому нравится мой стиль изложения, все еще попадаются люди, не имеющие представления о парадигмах внешнего мира, я решил буквально на пальцах показать, что такое акторная модель, и почему познавшие удовольствие работы с ней крайне неохотно отказываются от неё в пользу больших гонораров и душных офисов.
Рассказ рассчитан на тех, кто хотя бы поверхностно знаком с концепциями ООП и (или) ФП. Ниже вы не найдёте всех тех запутывающих псевдонаучных объяснений, которые вам услужливо предоставит Вика или Анжела (или как там вы называете свою любимую 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
будет работоспособным, вне зависимости от того, на какой ноде процесс в результате запущен.
Отправьте ему сообщение — и просто дождитесь результата, если он вам нужен.
Вот и всё на сегодня. Надеюсь, мне удалось сделать вопрос «что такое акторная модель» чуть менее загадочным.
Удачного сообщайзинга!