Handling state between multiple processes with elixir

Cherry Ramatis - Sep 26 '23 - - Dev Community

Elixir works really well for concurrent code because of it's functional nature and ability to run in multiple processes, but how we handle state when our code is running all over the place? Well, there is some techniques and in this article we'll learn more about it together shall we?

Table of contents

What is a process? How to use it with send and receive

image

Processes are the answer from Elixir to concurrent programming; they're basically a continuous-running node that can send and receive messages. In fact, every function in Elixir runs inside a process. Although this sounds really expensive, it's super lightweight compared to threads in other languages, which empowers us developers to build incredibly scalable software with hundreds of processes running at the same time. Another great advantage of using this specifically with the Elixir language is that this language is built on top of immutability and other functional programming concepts, so we can trust that these functions are running completely isolated and without changing or maintaining global state.

The basic way of seeing a process in action is by using the spawn function, with that we can execute a function in a process and get the pid of it.

iex(3)> pid = spawn(fn -> IO.puts("teste") end)
teste
#PID<0.111.0>
iex(4)> pid
#PID<0.111.0>
iex(5)> Process.alive?(pid)
false
iex(6)>
Enter fullscreen mode Exit fullscreen mode

As you can see from the return of Process.alive?(pid) this process is already dead once it runs correctly, but we can easily add a sleep function to check this mechanism:

iex(2)> pid = spawn(fn -> :timer.sleep(10000); IO.puts("teste") end)
#PID<0.111.0>
iex(3)> Process.alive?(pid)
true
teste
iex(4)> Process.alive?(pid)
false
iex(5)>
Enter fullscreen mode Exit fullscreen mode

Since we're sleeping for 10 seconds, the process is alive until it runs out after the sleep function and dies. Cool right? It's important to know that our main program did not hang, it simply put the function in a process and forgot about it. This allows us to create really modular and performant code that runs on multiple nodes.

Besides spawning functions in a process, we can transition information between processes using the functions send and the receive block, as shown below:

iex(1)> defmodule Listener do
...(1)> def call do
...(1)> receive do
...(1)> {:hello, msg} -> IO.puts("Received: #{msg}")
...(1)> end
...(1)> end
...(1)> end
{:module, Listener,
 <<70, 79, 82, 49, 0, 0, 6, 116, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 240,
   0, 0, 0, 25, 15, 69, 108, 105, 120, 105, 114, 46, 76, 105, 115, 116, 101,
   110, 101, 114, 8, 95, 95, 105, 110, 102, 111, ...>>, {:call, 0}}
iex(2)> pid = spawn(&Listener.call/0)
#PID<0.115.0>
iex(3)> send(pid, {:hello, "Hello World"})
Received: Hello World
{:hello, "Hello World"}
iex(4)>
Enter fullscreen mode Exit fullscreen mode

Observe that we define a function that acts as a general listener using the receive block. This works as a switch case where we can pattern match and do a quick action, in this case, we're simply printing to STDOUT. Once we spawn this listener, it's possible to use the returned pid to send information using the send/2 function that expects a PID and a value as arguments.

That way, it's possible to keep state in an immutable and separate environment such as elixir.

Incrementing our experience with tasks

The Task module offers an abstraction on top of the spawn function while adding support for asynchronous behavior, i.e., creating a function in a separate process and observing its behavior with wait functions. As you delve into Elixir, you'll discover that the Task module allows you to start a new process that executes a function and returns a task structure. With this structure in hand, you can easily get the value from this function using the Task.await(task) clause, as shown below:

iex(1)> task = Task.async(fn ->
...(1)>   IO.puts("Task is running")
...(1)>   42
...(1)> end)
Task is running
%Task{
  mfa: {:erlang, :apply, 2},
  owner: #PID<0.109.0>,
  pid: #PID<0.110.0>,
  ref: #Reference<0.0.13955.659691257.723058689.43945>
}
iex(2)> IO.puts "a code"
a code
:ok
iex(3)> answer_to_everything = Task.await(task)
42
iex(4)> answer_to_everything
42
iex(5)>
Enter fullscreen mode Exit fullscreen mode

First we saw the Task is running message printed out, and then we got the task struct. Further, we could execute any code in between, and when we're ready, it's just a matter of using the Task.await function to retrieve the function return.

The Task module also provides a common interface for the regular spawn function called start, we can even reuse the code shown at the beginning with the new module abstraction:

iex(1)> defmodule Listener do
...(1)> def call do
...(1)> receive do
...(1)> {:print, msg} -> IO.puts("Received message: #{msg}")
...(1)> end
...(1)> end
...(1)> end
{:module, Listener,
 <<70, 79, 82, 49, 0, 0, 6, 244, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 245,
   0, 0, 0, 26, 15, 69, 108, 105, 120, 105, 114, 46, 76, 105, 115, 116, 101,
   110, 101, 114, 8, 95, 95, 105, 110, 102, 111, ...>>, {:call, 0}}
iex(2)> {:ok, pid} = Task.start(&Listener.call/0)
{:ok, #PID<0.115.0>}
iex(3)> send(pid, {:print, "Eat more fruits"})
Received message: Eat more fruits
{:print, "Eat more fruits"}
Enter fullscreen mode Exit fullscreen mode

It's useful to use the Task module because we can get a higher level of abstraction. You must have noticed that the interface for Task.start and Task.async is the same, right? Yeah, we can swap those and get the power of using Task.await and Task.yield on top of it, that's the power of abstracting lower-level concepts!

Designing state with the agent wrapper

image

The Agent module provides another layer of abstraction focused on controlling state between multiple instances of a process, it acts like a data structure for long-running interactions.

We can first start an agent instance with an initial value passed from a function return, as shown below:

iex(1)> {:ok, agent} = Agent.start_link(fn -> [] end)
{:ok, #PID<0.110.0>}
iex(2)> agent
#PID<0.110.0>
iex(3)>
Enter fullscreen mode Exit fullscreen mode

As you can see, we get a PID just like the other abstractions, the difference here can be observed in the usage of other methods.

For example, we can update the original array by appending a value to it:

iex(3)> Agent.update(agent, fn list -> ["elixir" | list] end)
:ok
iex(4)>
Enter fullscreen mode Exit fullscreen mode

That's the whole difference in this abstraction provided by the Agent module, we can continuously update a state by appending immutable functions as callbacks and reusing the same PID.

We can also return a particular value from the data structure by using the following function:

iex(4)> Agent.get(agent, fn list -> list end)
["elixir"]
iex(5)>
Enter fullscreen mode Exit fullscreen mode

See? It's as simple as returning the whole list from the callback function, you can imagine that it's possible to use any method from Elixir to filter down this list if wanted and keep iterating over the data structure.

Conclusion

This is a simple introduction to this concept that is new for me, and I hope it's useful for anyone reading it! And in the next articles, we'll dive deeper into other topics in elixir, such as Gen Servers, Supervisors, etc. May the force be with you! 🍒

. . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player