Бессмысленно внушать представление об аромате дыни человеку, который годами жевал сапожные шнурки.
— Виктор Шкловский, если верить Довлатову
Поскольку среди тех, кому нравится мой стиль изложения, все еще попадаются люди, не имеющие представления о парадигмах внешнего мира, я решил буквально на пальцах показать, что такое акторная модель, и почему познавшие удовольствие работы с ней крайне неохотно отказываются от неё в пользу больших гонораров и душных офисов.
Рассказ рассчитан на тех, кто хотя бы поверхностно знаком с концепциями ООП и (или) ФП. Ниже вы не найдёте всех тех запутывающих псевдонаучных объяснений, которые вам услужливо предоставит Вика или Анжела (или как там вы называете свою любимую 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
age.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
будет работоспособным, вне зависимости от того, на какой ноде процесс в результате запущен.
Отправьте ему сообщение — и просто дождитесь результата, если он вам нужен.
Вот и всё на сегодня. Надеюсь, мне удалось сделать вопрос «что такое акторная модель» чуть менее загадочным.
Удачного сообщайзинга!
Комментарии (15)
Dhwtj
17.05.2025 08:01Актор это обёртка над изменяемым состоянием, правилами его валидности, умеющая общаться с другими акторами в конкурентной среде, не имеющей гарантий передачи.
Для дошкольников так лучше.
А вот для детского сада:
Представь, что Актор — это такой маленький робот-помощник.
* У каждого робота есть своя коробочка с игрушками (это его "состояние", которое он меняет). Только он сам может играть со своими игрушками и следит, чтобы они были в порядке (это "правила валидности").
* Роботы могут передавать друг другу записочки (это "общение").
* Вокруг много таких роботов, и все они что-то делают одновременно. Робот примет несколько сообщений даже если не успел разобраться с предыдущими. (это "конкурентная среда").
* Когда робот отправляет записочку, он не знает точно, дойдёт ли она и когда (это "нет гарантий передачи").
В ООП методы объектов обычно вызываются напрямую и синхронно (вызвал метод – ждешь выполнения). И не записочками а командами.
Так понятнее? )
SolidSnack
17.05.2025 08:01ООП вообще никак не влияет на синхронность. Вам не кажется странным, впринципе, разделять ООП и ФП? Чем отличается метод от функции? Мне кажется контекстом, отсюда и DDD, бизнес хочет глубже знать разработку, вот пожалуйста готовый подход.
Dhwtj
17.05.2025 08:01ООП вообще никак не влияет на синхронность
Это для примера. Согласен, не точно.
Вам не кажется странным, в_принципе, разделять ООП и ФП?
Мне кажется ещё более странным вводить полиморфизм через ООП как сделано в статье. Сами найдете или вам цитаты искать?
Чем отличается метод от функции?
Метод есть только в ООП и имеет доступ к данным объекта. То есть функция сама по себе, метод только конкретного объекта. И из определения функция (если она не метод) чистая.
Мне кажется контекстом, отсюда и DDD, бизнес хочет глубже знать разработку, вот пожалуйста готовый подход
Не понял
Бизнесу проще говорить в терминах ООП? Спорно. Проще говорить про события, факты и правила, а это всё ФП
SolidSnack
17.05.2025 08:01Объекты легче описать на понятный язык. Представить объект машины в голове и придумать ему свойства и функционал может каждый в бизнесе, а так сказать стать актором, не каждый
GospodinKolhoznik
17.05.2025 08:01Нет, не понятнее.
Актор — это такой маленький робот-помощник
Кому он помогает и в чём? И насколько он маленький? Это важно, чтобы он был маленьким?
У каждого робота есть своя коробочка с игрушками
Вообще непонятно зачем нужна коробочка с игрушками. Она вводится как что-то явно важное, но потом повествование переключается на записочки а про коробочку больше не вспоминают. Зачем она нужна? Но при этом каждый робот за ней постоянно следит, чтобы коробочка была в порядке - а что значит в порядке?
Робот примет несколько сообщений даже если не успел разобраться с предыдущими.
Стоп. А что значит разобраться? До этого было сказано, что роботы могут передавить друг другу записочки и таким образом общаться. А оказывается, что записочки это не общение, а скорее какие то проблемы с которыми надо разбираться! Да ещё и суетиться надо, так как можно не успеть со всем разобраться. Всё запутано настолько, что жесть!
Когда робот отправляет записочку, он не знает точно, дойдёт ли она и когда
Да уже не важно. В любом случае, кто пытается понять это объяснение, к этому моменту уже поймёт, что это какая то дичь, которую невозможно понять. И слова про то, что доставка записочки толи будет, толи нет, толи дождик толи снег, окончательно его убедят в том, что даже не надо пытаться ничего понять.
nv13
17.05.2025 08:01Интересно, а те, кто программируют на эрланге в курсе, что используют акторную модель?) По моим наблюдениям, точно не все, даже из тех кто этот эрланг и otp преподаёт. Вот Go - в любой вики сразу написано, что это имплементация модели последовательных коммуницирующих процессов, или как там их.
Вы сравниваете акторную модель с ООП - насколько это корректно? Акторная - модель, а ООП - методология. Акторная модель вычислений может быть реализована на любом языке программирования и любой его методологией, и не только программно.
Мне кажется было бы корректней рассматривать акторную модель как разновидность потоковых вычислений, когда не поток инструкций определяет порядок выборки и обработки данных, а потоки данных инициируют выполнение тех или иных инструкций. Это способ организации вычислений, или их модель. Ну да, есть языки, предназначенные только для этого, но это не проблема понимания принципов акторной модели, имхо
Dhwtj
17.05.2025 08:01Мне кажется было бы корректней рассматривать акторную модель как разновидность потоковых вычислений, когда не поток инструкций определяет порядок выборки и обработки данных, а потоки данных инициируют выполнение тех или иных инструкций
Много общего. Но у потока нет состояния.
nv13
17.05.2025 08:01Почему нет? Смотря что понимать под потоком. Пришла реализация данных, запустилась какая то функция, по результатам обработки вычислилась какая то переменная, которая используется при следующей инициации. Переменная - состояние актора. Если внутри актора стейт машина - у актора с каждой инициацией обновляется состояние. Поток не вычислительный тред, а сущность, отражающая распространение данных в системе. Акторная модель, описанная тут в контексте ЯП - частный случай потоковой модели организации вычислений.
Dhwtj
17.05.2025 08:01Всегда рассматривал потоковую модель вычислений как stateless. Но если нет, то соглашусь
Grigorii_K
17.05.2025 08:01Пока в существующих языках (эрланг, эликсир, gleam, lfe) — ① решается виртуальной машиной.
С недавних пор есть и в Swift'е как часть языка, на мой взгляд очень элегантная. Если коротко: организована на безстековых корутинах выполняющихся на кооперативном пуле, но есть возможность задать отдельным актора кастомного исполнителя с любой средой обработки сообщений.
Компилятор сам проследит из какого к какому контексту происходит обращение. Вызов одного и того же метода будет либо синхронным, если работа осуществляется внутри актора, либо явно асинхронным через корутину, если компилятор обнаружит что пересекается граница актора.
Dhwtj
ООП вульгарис имеет более низкий порог вхождения чем ФП или акторная модель. Особенно если не погружаться глубоко в эти ужасы.
Вопрос: почему же так важен низкий порог вхождения? Программист что одноразовый? Разве средний стаж программиста меньше года? Особенно сейчас, когда изучить библиотеки и фреймворки проще с LLM чем лет 5-10 назад когда опыт программиста приравнивался к задрачиванию знаний о фреймворках. (По ощущениям)
Бизнесу нужны стандартные и дешёвые юниты, увы!
Но не в интересах программиста такая узкая и дешёвая специализация.
Jijiki
тогда пусть llm сделает обратную(inverse) матрицу 4х4 сделает ну код будет верный?
Dhwtj
Jijiki
случай с обраткой у меня какая-то модель начала глючить, и рядом было открыто полное руководство как это сделать, а модель предлагала мне усовершенствованные решения, когда я его просил не делать так, а всё развернуть, на что модель мне сказала, что да тут вот так и дала мне упакованное в алгоритм решение, в итоге пришлось по наитию погружаться, после чего где-то после еще 0-5 промпта я дал ему понять, что Минор для 4х4 он и детерминант он считает не правильно, он делал вид что понимает меня, но ответ был не верный, и потом он сам догадался что надо сверяться с калькулятором куда потом и посылал меня, НО замечу ошибку в моём решении он так и не указал, мне её пришлось самому искать, другая модель посильнее к примеру дала код на анимацию чтобы палочка 1-штука двигалась вверх вниз - только этот пример скомпилировался, расширенный пример мы так и не смогли запустить с ИИ он в одной ошибке ушел в дебри, может эти задачи генерируют большое количество токенов или чего-то там нужного, в одном случае рутина проверить повторяемые данные, в другом просто задача громоздкая