Актор

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

Операционная система видит запущенную ноду — для неё это процесс. Актор это подпроцесс ноды, и он не воспринимается операционкой как отдельный процесс.

У каждого актора в Эрланге своё состояние, свой собственный сборщик мусора.

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

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

1> spawn(math, pow, [3,3]).
<0.86.0>

В ответ мы получим лишь пид, а сколько будет 3 в степени 3, так и не узнаем. Пид возвращается в самом-самом начале, когда актор создан. Потом актор работает (в данном примере очень недолго), завершает работу, но ничего не возвращает.

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

В Эрланге господствует идеология “Пусть это рухнет”. В сложной Системе не избежать программных ошибок, так пусть же отдельный актор рухнет, но Система должна остаться стоять. Такие ошибки надо выявлять, анализировать и (быстро) устранять. Например, может быть так, что в if ни одна ветка не подошла, а true -> ... отсутствует. Возможно, надо добавить true-ветку, а может быть, причина лежит глубже, раз возникла исключительная ситуация, которую мы не предусмотрели.

Минималистичный пример создания актора

-module(radio).
-export([start/0]).

start() ->
    io:format("Hello! I am radio.\n").

Сохраним этот модуль как файл radio.erl и скомпилируем: или в терминале с помощью “erlc radio.erl”, или непосредственно в оболочке с помощью “c(radio).”. Зайдём в оболочку (если ещё не там) и заспауним актора:

1> spawn(radio, start, []).
Hello! I am radio.
<0.83.0>

Аргументы у спауна такие: название модуля (radio), название функции (start), список аргументов, которые мы передаем функции start. В данном случае их нет, поэтому просто пустые квадратные скобки.

Арность, следует заметить, определяется автоматически, по количеству элементов в списке аргументов. В данном примере в квадратных скобках нет ничего, а значит, арность 0, поэтому спаунится функция radio:start/0. Если бы в квадратных скобках было три элемента, спаунилась бы radio:start/3. Следует не забывать, что в Эрланге функции с разной арностью — независимые друг от друга функции, разные. Иногда бывает так, что они принципиально разные.

Функция start превращается в актор. Актор запускается, превратившись в самостоятельный процесс. Выполняется. Результат этой работы мы можем видеть по появившейся строчке: “Hello! I am radio.” После этого актор завершает свою работу. Мы не позаботились о том, чтобы он выполнялся долго, поэтому он завершается.

Пример актора, который ждёт сообщение, а потом завершается

-module(radio).
-export([loop/0]).

loop() ->
    io:format("Hello! I am radio.\n"),
    receive
        Mess -> io:format("NEWS: ~p.\n", [Mess])
    end.

Компилируем. Обратите внимание, что в этот раз функцию, которая станет актором, мы назвали иначе: loop() вместо start(). Можно было бы, конечно, оставить прежнее название, но так как-то комфортнее: start это то, что стартует, а loop это то, что зацикливается.

Спауним чуть иначе. В этот раз результат спауна нам надо привязать к переменной. Потом с помощью этой переменной мы пошлём актору сообщение.

1> Radio = spawn(radio, loop, []).
Hello! I am radio.
<0.83.0>
2> Radio ! "Erlang/OTP 26.0 Release".
NEWS: "Erlang/OTP 26.0 Release".
"Erlang/OTP 26.0 Release"

Мы спауним актора. Его пид (идентификатор процесса) привязывается к переменной Radio. Оболочка, которая всегда эхом отдаёт результат выражения, демонстрирует нам этот пид. В данном случае он выглядит как <0.83.0>. Перед этим, однако, актор успевает поздороваться с нами.

В принципе, если мы работаем в оболочке, то пид можно и не привязывать к переменной. Мы могли бы передать сообщение актору так:

2> <0.83.0> ! "Hi!".

Но в основном мы будем спаунить акторов внутри программы, поэтому лучше сразу привыкать привязывать пид к переменной. Как мы видим, отправка сообщения актору выглядит предельно просто. Она состоит из трёх частей: переменная с пидом актора, восклицательный знак, сообщение.

Сообщение, конечно, может быть любым термом Эрланга, а не только строкой. Любое сообщение должно состоять ровно из одного терма. Если надо, можно сгруппировать несколько термов, которые мы желаем передать за раз, с помощью списка или кортежа. Все следующие варианты допустимы:

Radio ! 777.
Radio ! true. 
Radio ! [1, 2, 3, mnogo].
Radio ! {age, 18}.

Однако если мы сейчас снова что-либо пошлём нашему радио, оно уже нам не ответит.

3> Radio ! 777.        
777
4> 

То, что оболочка выдала три семёрки, это особенность самой оболочки — она эхом возвращает результат выражения. Результат выражения Radio ! 777 это 777. Это и выводится. Но само радио молчит, оно не выдаёт строку “NEWS: 777”. Почему? Конечно, потому, что мы ещё ничего не сделали для того, чтобы актор работал долго. Он, конечно, уже сразу не завершается, но ждёт всего лишь до первого нашего сообщения.

Как сделать, чтобы актор работал вечно

Хотя мы и назвали в предыдущем примере нашу функцию loop(), она на самом деле пока ещё не работает циклически. Надо для этого сделать ещё кое-что. Строго говоря, она и после этого не будет работать циклически. Просто она будет бесконечно вызывать саму себя.

Она может вызвать себя хоть триллион раз, но на памяти компьютера это никак не скажется. Поэтому можем себе смело представить, что loop() работает циклически, вертится, так сказать. Хотя, конечно, на самом деле, просто имеет место хвостовая рекурсия.

-module(radio).
-export([loop/0]).

loop() ->
    io:format("Hello! I am radio.\n"),
    receive
        Mess ->
            io:format("NEWS: ~p.\n", [Mess]),
            loop()
    end.

Мы совсем чуть-чуть подправили код. После io:format() поставили запятую, а после запятой — вызов функции loop(). Теперь, получив и обработав сообщение, наш актор не завершится. Функция loop внутри актора вызовет саму себя, и актор продолжит свою работу. И вот теперь мы можем нашему актору посылать сколько угодно сообщений.

1> Radio = spawn(radio, loop, []). 
Hello! I am radio.
<0.83.0>
2> Radio ! 777.
NEWS: 777.
Hello! I am radio.
777
3> Radio ! true. 
NEWS: true.
true
Hello! I am radio.
4> Radio ! {age, 18}. 
NEWS: {age,18}.
{age,18}
Hello! I am radio.

Каждый раз, когда мы видим сообщение “Hello! I am radio.”, это функция loop начинает свою работу с начала. Актор при этом остаётся одним и тем же, что проявляется в частности в том, что его пид остаётся неизменным.

Как сделать, чтобы актор время от времени оживал, отвлекаясь от ожидания входящих сообщений

Каждый раз, когда актор достигает места receive-end, он замирает и ждёт сообщений. Ждёт и ждёт, ждёт и ждёт, ждёт и ждёт. Ждёт, пока не придёт очередное сообщение. За это время может случиться что-нибудь важное для актора, а он это пропустит. Оживить актора, конечно, можно, послав ему какое-нибудь сообщение. А можно просто добавить таймаут.

-module(radio).
-export([loop/0]).

loop() ->
    io:format("Hello! I am radio.\n"),
    receive
        Mess ->
            io:format("NEWS: ~p.\n", [Mess]),
            loop()
    after 5000 ->
            io:format("Timeout.\n"),
            loop()
    end.

Мы добавили три строчки. after надо добавлять в конце блока receive-end, после основных кляуз обработки сообщений. Величина 5000 означает количество миллисекунд, которые можно находиться в блоке receive-end в ожидании новых сообщений. Если сообщения так и не пришли, то выполняется блок after. В данном случае он пишет “Timeout.” и запускает loop() с начала.

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

Hello! I am radio.
Timeout.              
Hello! I am radio.    
2> Radio ! "Good news!".
NEWS: "Good news!".
"Good news!"
Hello! I am radio.
Timeout.
Hello! I am radio.
Timeout.
Hello! I am radio.
Timeout.
Hello! I am radio.
Timeout.
Hello! I am radio.

Как сделать, чтобы один актор спаунил другого

Это сделать проще простого. Где-то в коде актора мы пишем примерно такое:

Radio2 = spawn(radio, loop, []).    % Создаём актор из актора
Radio2 ! hello.                     % и посылаем ему сообщение.

Но посмотрите код нашего модуля radio. Куда там можно вставить этот спаун? Если мы поставим в loop(), тогда при каждой новой итерации будет спауниться ещё один актор. На данный момент наш актор совершает итерацию раз в 5 секунд. Значит, раз в 5 секунд будет порождаться новый актор. Надо ли нам это? Возможно, да. Например, мы спауним этого нового актора, тот выполняет какую-либо работу и завершается. Это нормально.

Но если наше радио будет раз в 5 секунд порождать новое радио (из того же самого материала, то бишь модуля, функции и аргументов), то оно тоже займётся тем, что будет создавать раз в 5 секунд новое радио. И будут наши акторы расти в геометрической прогрессии. Вряд ли мы этого хотим.

Часто бывает такая задача. Нам нужно запустить актор. Этот актор пусть запустит ещё одного или несколько акторов, а потом уже впадает в бесконечный loop(). Поэтому мы вернёмся к нашей первой идее — запускать актор через функцию start(). И уже эта функция будет запускать loop().

-module(radio).
-export([start/0]).

start() ->
    io:format("Hello! I am FM-radio: ~p.\n", [self()]),
    loop().

loop() ->
    receive
        Mess ->
            io:format("NEWS: ~p.\n", [Mess]),
            loop()
    after 5000 ->
        spawn(radio, start, []),
        loop()
    end.

Мы снова экспортируем не loop(), а start(). Это видно в заголовке файла. Когда запускается start(), то выводится приветствие. В этом приветствии подставляется пид текущего актора. Его мы узнаём с помощью бифа self(). Далее start() вызывает loop(), который и зацикливается.

Как и прежде, loop() ждёт входящих сообщений 5 секунд. Потом он спаунит нового актора, а сам “респаунится”. Новый актор проходит тот же самый путь. Количество новых радио, конечно, растёт тоже быстро.

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

1> Radio = spawn(radio, start, []).
Hello! I am FM-radio: <0.83.0>.
<0.83.0>
Hello! I am FM-radio: <0.85.0>.
Hello! I am FM-radio: <0.86.0>.
Hello! I am FM-radio: <0.87.0>.
Hello! I am FM-radio: <0.88.0>.
Hello! I am FM-radio: <0.89.0>.
Hello! I am FM-radio: <0.90.0>.
Hello! I am FM-radio: <0.91.0>.
2> <0.87.0> ! privet. 
NEWS: privet.
privet
3> 
Hello! I am FM-radio: <0.93.0>.
Hello! I am FM-radio: <0.94.0>.
Hello! I am FM-radio: <0.95.0>.
Hello! I am FM-radio: <0.96.0>.
Hello! I am FM-radio: <0.97.0>.
Hello! I am FM-radio: <0.98.0>.
Hello! I am FM-radio: <0.99.0>.
Hello! I am FM-radio: <0.100.0>.
Hello! I am FM-radio: <0.101.0>.
Hello! I am FM-radio: <0.102.0>.
Hello! I am FM-radio: <0.103.0>.
Hello! I am FM-radio: <0.104.0>.
Hello! I am FM-radio: <0.105.0>.
Hello! I am FM-radio: <0.106.0>.
Hello! I am FM-radio: <0.107.0>.
Hello! I am FM-radio: <0.108.0>.
Hello! I am FM-radio: <0.109.0>.
Hello! I am FM-radio: <0.110.0>.
Hello! I am FM-radio: <0.111.0>.
Hello! I am FM-radio: <0.112.0>.
Hello! I am FM-radio: <0.113.0>.
Hello! I am FM-radio: <0.114.0>.
3> q().
ok

Испуганный количеством акторов, в конце я набрал “q().”, чтобы выйти из оболочки. Перед этим она остановила всех созданных акторов.

Сколько акторов можно создать?

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

Для этого я написал такую небольшую программу:

-module(aktor). 
-export([main/0, start/1]). 

main() ->
    loop(0).

loop(X) ->
    spawn(aktor, start, [X]),
    timer:sleep(1),
    loop(X+1).

start(X) ->
    io:format("aktor #~p ~n", [X]),
    receive
        Y -> io:format("got ~p ~n", [Y])
    end.

Сначала я сделал программу без timer:sleep/1, но, порождая несколько тысяч акторов, программа аварийно завершалась. Судя по всему, какая-то внутренняя логика BEAM не успевала регистрировать всё новые и новые процессы. Поэтому я добавил задержку в 1 мс между спауном акторов.

Программу я запустил с помощью команды erl +P 5000000 -noshell -s aktor main -s init stop. Параметр +P 5000000 повышает лимит возможных акторов до пяти миллионов.

Этого хватило. На машине с 8 ГБ оперативной памяти у меня получилось создать 2,4 миллиона параллельно действующих процессов-акторов, что весьма неплохо. В других языках программирования (не функциональных и без виртуальной машины) пробуют создать аналоги акторов Эрланга. Например, вводят так называемые изоляты. Они, во-первых, менее функциональны (например, один изолят не может породить другой). Во-вторых, создание параллельных процессов выглядит совсем не так изящно, как в Эрланге. В-третьих, вес одного такого процесса раз в сто больше, чем у актора Эрланга. В результате память тратится, можно сказать, впустую, а вызов такого процесса занимает неприлично много времени. Хотя в математических расчётах Эрланг очень сильно уступает другим языкам, но в тех случаях, когда надо активно порождать и организовывать взаимодействие между параллельными процессами, это отставание с лихвой компенсируется.


© Алексей Карманов, 2024.