内容简介:My foray intoFor those not aware of it, Elixir is a functional language. Everything runs inside an immutable function scope, and no state remains on the table. A function can only work on what it has been fed with. Functions reside inside modules and are b
My foray into Elixir
brought forward many more similarities between it and Go, than I originally expected. One of them is how both handle concurrency. With respect to that, creating Elixir-style stateful actors in Go is surprisingly easy. Answering the question, whether one needs them, or can make use of them, I’d leave to the user. If you approach me back saying that the same thing can be achieved using a channel or a map with sync.Mutex
slapped on it, you’d be (almost) right. Yet, it’s worth exploring different ways of thinking.
A 10.000-foot view of Elixir
For those not aware of it, Elixir is a functional language. Everything runs inside an immutable function scope, and no state remains on the table. A function can only work on what it has been fed with. Functions reside inside modules and are being executed inside Erlang processes.
Technical differences aside, you can think of processes as the equivalent of Go’s goroutines. A function chain call runs inside a process/goroutine. When the outermost function returns, the process/goroutine ends. A process/goroutine can spawn other processes/goroutines to achieve concurrent execution of tasks. Go synchronizes over channels while Elixir over a message inbox that is built into every process. Using the built-in receive keyword, a function’s execution blocks inside a process, until a certain type of message gets received
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 laguages {:message_a, msg} -> do_something_with(msg) end
Fundamentally, this is the same as having a goroutine block its execution waiting on a channel :
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)
Whether you are waiting on a message to arrive in your inbox, or explicitly set a blocking channel as a communication mechanism, to me, it describes the same paradigm.
Turn goroutines into stateful actors
Clear until now? Alright. Let’s move on. I already mentioned that Elixir is a functional language. Everything that gets passed to a function is immutable, and the only way to change it, is to return a new version of it. This means that looping constructs are not possible, since it would imply modifying and keeping track of a counter variable. The way functional languages achieve the effect of looping is through recursion (or to be more precise tail recursion):
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
From recursion to actors
What if we take this recursion example and think of it as a never-ending loop. The first call to the function sets the initial state, and the function keeps calling itself ad-infinitum.
Now, this is where the purely functional paradigm breaks. We already learned that Erlang allows for other processes to communicate to us. This means that if our never ending recursive function receives a message from the outside, it can use its payload to call itself with a modified version of its initial state. Keeping in mind that receiving a message is a blocking operation, the process will simply linger around, not using any CPU resources, until our the right message arrives.
We can use the same messaging paradigm to poke inside the state of our never-ending function. Since it runs inside a separate process, the only way to do that is to send an appropriate message to it, pass our current process’ ID (PID) and let it send us a message back.
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
Let’s test our calculator process:
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
In essence, our never-ending function becomes what Elixir calls a stateful server process, an implementation of the Actor model . Actors are great for isolating critical state and allowing for concurrent communication to it, ensuring that only one change happens at a time.
From Elixir to Go
Ok now that we know how things work in Elixir land, achieving the same thing on Go is super straightforward.
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 } } }
One thing to note is that since we can use an infinite-loop, we should just use that, especially, since based on my knowledge Go is not particularly optimized for long-cycle recursion. But the main premise remains the same. A function gets called with some initial state and returns a channel. The function starts an endless loop, blocking on the channel. If we push a value to that channel, the function will take it, update the state and block again.
What are actors good for?
Private state
So, now that we unveiled the mystery behind actors, it’s a good point to discuss what they might be useful for.
One thing that immediately comes to mind, is achieving globally accessible, but truly private, synchronized state. This is currently achieved with the use of channels, sync.Mutex
, or the new 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() }
The fragility of this approach comes from the fact that there is no real private state in Go applications. The map we named internal
in the above example is only protected from outside access. Any piece of code inside the same package as our SynchronizedMap
can freely access and modify its internals, leading to unexpected consequences. While this shouldn’t be a concern in most cases, it’s definitely good to keep in mind for special cases.
Stateful autonomous agents
Where the Actor model shines is the orchestration of systems of Actor instances - autonomous agents. Each Actor instance is able to change its state, reacting to the messages sent to it. Actor instances can easily spawn other Actor instances, which only the creating actors (supervisors) have control over (private state, remember). Supervisors can also take over failures of the actors their are responsible for, potentially, killing some off, and restarting them with a clean state. Taking this example to extremes, groroutines being fairly cheap, one can easily imagine a swarm of thousands of Actor instances, in a deeply nested hierarchy, with multiple levels of supervising actors taking over their “progeny”. This is the unique selling proposition of Erlang, but as I hope to have demonstrated, could be replicated in Go too.
As discussed in the beginning, I would leave the discussions about this approach’s practicality, as well as its other applications to the reader. I would love to hear your thoughts. Do not hesitate to drop me a comment, or start a new discussion.
Resources
Elixir in Action
The Elixir language blends the battle-tested power and stability of the Erlang virtual machine with an approachable and expressive syntax...
Created withNoEmbed
One of the best books about learning Elixir, and certainly, the one that inspired me to write this post. Saša Jurić’s explanations are clear and demonstrative, especially on complex topics such as this one. If you like this blog, and would love to support my passion for reading great books, you can buy it on Amazon using this special link . Thanks!
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。