Первоначально опубликовано на https://preslav.me 7 марта 2020 г.

Мой набег на Elixir выявил гораздо больше сходств между ним и Go, чем я первоначально ожидал. Один из них - это то, как оба обрабатывают параллелизм. В связи с этим создание в Go акторов с отслеживанием состояния в стиле Elixir на удивление легко. Отвечая на вопрос, нужны ли они кому-то или можно их использовать, я оставил бы пользователю. Если вы подойдете ко мне и скажете, что того же можно добиться, используя канал или карту с наложенным sync.Mutex, вы будете (почти) правы. Тем не менее, стоит изучить разные способы мышления.

Для тех, кто этого не знает, Эликсир - это функциональный язык. Все выполняется внутри неизменяемой области видимости функции, и в таблице не остается никакого состояния. Функция может работать только с тем, чем она была загружена. Функции находятся внутри модулей и выполняются внутри процессов Erlang.

Помимо технических различий, вы можете рассматривать процессы как эквивалент горутин в Go. Вызов цепочки функций выполняется внутри процесса / горутины. Когда внешняя функция возвращается, процесс / горутина завершается. Процесс / горутина может порождать другие процессы / горутины для одновременного выполнения задач. Go синхронизируется по каналам, а Elixir - по почтовому ящику входящих сообщений, который встроен в каждый процесс. Используя встроенное ключевое слово receive, выполнение функции блокируется внутри процесса до тех пор, пока не будет получено сообщение определенного типа.

receive do
    # Don't get too caught up on the Elixir syntax.
    # For now, it is only important to know that :message_a is           
    # equivalent to a string with the value of "message_a"
    # Those are called "atoms" and are quite often used in Ruby-like
    # languages 
    {:message_a, msg} ->
        do_something_with(msg)
end

По сути, это то же самое, что горутина блокирует свое выполнение в ожидании на канале:

type message struct {
    val string
}

msgStream := make(chan message)

go func(out chan message) {
    out <- message{val: "hello world"}
}(msgStream)

msg := <-msgStream
fmt.Printf("%+v", msg)

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

Ясно до сих пор? Хорошо. Давайте двигаться дальше. Я уже упоминал, что Эликсир - это функциональный язык. Все, что передается функции, неизменяемо, и единственный способ изменить это - вернуть новую версию. Это означает, что конструкции цикла невозможны, поскольку это подразумевает изменение и отслеживание переменной счетчика. Функциональные языки достигают эффекта зацикливания посредством рекурсии (или, если быть точнее, хвостовой рекурсии):

def loop(5) do
    # Elixir uses pattern-matching when choosing which function to call.
    # In our case, as soon as its gets a count == 5, it will stop the loop
    5
end

def loop(count) do
    # Just print the count, but use pipes (|>)
    # instead of wrapping in a function call -> IO.puts(count)
    # Pipes totally save the day, when you have multiple call chains
    count
    |> IO.puts()

    loop(count + 1)
end

От рекурсии к актерам

Что, если мы возьмем этот пример рекурсии и подумаем о нем как о бесконечном цикле. Первый вызов функции устанавливает начальное состояние, и функция продолжает вызывать себя до бесконечности.

Вот где ломается чисто функциональная парадигма. Мы уже узнали, что Erlang позволяет другим процессам связываться с нами. Это означает, что если наша бесконечная рекурсивная функция получает сообщение извне, она может использовать свои полезные данные для вызова самой себя с измененной версией своего начального состояния. Имея в виду, что получение сообщения является операцией блокировки, процесс просто задержится, не используя ресурсы ЦП, пока не придет правильное сообщение.

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

defmodule Calculator do
    def start do
    # creates a separate process with its own internal state
    spawn(fn -> loop(0) end)
    end

    defp loop(current_value) do
    new_value =
        receive do
        # with this type of message, we can fetch the state of our calculator
        {:get, caller_pid} ->
            send(caller_pid, {:response, current_value})
            current_value

        # with this type of message, we can modify the state of our calculator
        {:add, value} ->
            current_value + value
        end

    loop(new_value)
    end
end

Давайте протестируем наш калькулятор:

defmodule CalculatorTest do
    def test_calculator do
    calc_pid = Calculator.start()

        # Like `receive`, `send` is built-in and take a PID, as well as a message
        # self() returns the process id (PID) of the current process
        # Like in Go, every piece of Elixir/Erlang code runs in a process
    send(calc_pid, {:get, self()})

        # `receive` will block, until we receive a message,
        # that matches the expected pattern - {:response, value}
    receive do
        {:response, value} ->
        value |> IO.puts()
    end

    send(calc_pid, {:add, 100})

    send(calc_pid, {:get, self()})

    receive do
        {:response, value} ->
        value |> IO.puts()
    end
    end
end

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

От Эликсира к работе

Хорошо, теперь, когда мы знаем, как все работает в мире эликсиров, добиться того же в Go очень просто.

func main() {
    in := make(chan message)
    out := make(chan int)
    go newCalculator(0, in, out)

    in <- message{operation: "get"}
    state := <-out
    log.Printf("Current state: %d", state)

    in <- message{operation: "add", value: 100}
    in <- message{operation: "get"}
    state = <-out
    log.Printf("Current state: %d", state)
}

type message struct {
    operation string
    value     int
}

func newCalculator(initialState int, in chan message, out chan int) {
    state := initialState
    for {
        p := <-in
        switch p.operation {
        case "add":
            log.Printf("Adding %d to the current state", p.value)
            state += p.value

        case "get":
            out <- state
        }
    }
}

Следует отметить, что, поскольку мы можем использовать бесконечный цикл, мы должны просто использовать его, тем более что, исходя из моих знаний, Go не особенно оптимизирован для рекурсии с длинным циклом. Но основная посылка остается прежней. Функция вызывается с некоторым начальным состоянием и возвращает канал. Функция запускает бесконечный цикл, блокируя канал. Если мы отправим значение в этот канал, функция примет его, обновит состояние и снова заблокируется.

Частное государство

Итак, теперь, когда мы раскрыли загадку актеров, самое время обсудить, для чего они могут быть полезны.

Одна вещь, которая сразу приходит в голову, - это достижение глобально доступного, но по-настоящему частного синхронизированного состояния. В настоящее время это достигается с помощью каналов, sync.Mutex или нового sync.Map.

type SynchronizedMap struct {
    sync.RWMutex
    internal map[string]interface{}
}

func (rm *SynchronizedMap) Store(key string, value interface{}) {
    rm.Lock()
    rm.internal[key] = value
    rm.Unlock()
}

Хрупкость этого подхода проистекает из того факта, что в приложениях Go нет реального частного состояния. Карта, которую мы назвали internal в приведенном выше примере, защищена только от внешнего доступа. Любой фрагмент кода внутри того же пакета, что и наш SynchronizedMap, может свободно обращаться к своим внутренним компонентам и изменять их, что приводит к неожиданным последствиям. Хотя в большинстве случаев это не должно вызывать беспокойства, в особых случаях это определенно полезно иметь в виду.

Автономные агенты с сохранением состояния

В чем проявляется модель Actor, так это в оркестровке систем экземпляров Actor - автономных агентов. Каждый экземпляр Actor может изменять свое состояние, реагируя на отправленные ему сообщения. Экземпляры Actor могут легко порождать другие экземпляры Actor, которые контролируются только создателями (супервизорами) (частное состояние, помните). Супервизоры также могут принимать на себя отказы субъектов, за которые они несут ответственность, потенциально убивая некоторых и перезапуская их с чистым состоянием. Доводя этот пример до крайности, поскольку грорутины довольно дешевы, можно легко представить себе рой из тысяч экземпляров Actor в глубоко вложенной иерархии с несколькими уровнями контролирующих акторов, которые берут на себя их «потомство». Это уникальное торговое предложение Erlang, но, как я надеюсь, продемонстрировал, его можно воспроизвести и в Go.

Как обсуждалось в начале, я оставлю обсуждение практичности этого подхода, а также других его приложений читателю. Я хотел бы услышать твои мысли. Не стесняйтесь оставлять мне комментарии или начинать новое обсуждение.

Ресурсы



Одна из лучших книг по изучению Эликсира и, конечно же, та, которая вдохновила меня на написание этого поста. Объяснения Саши Юрич ясны и наглядны, особенно по таким сложным темам, как эта. Если вам нравится этот блог и вы хотели бы поддержать мою страсть к чтению отличных книг, вы можете купить его на Amazon по этой специальной ссылке. Спасибо!