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