Understanding Processes for Elixir Developers

This post is for all developers who want to try Elixir or are trying their first steps in Elixir. This content is aimed at those who already have previous experience with the language. 

This will help to explain one of the most important concepts in the BEAM: processes. Although Why understand processes?

Scalability, fault-tolerance, concurrent design, distributed systems – the list of things that Elixir famously make easy is long and continues to grow. All these features come from the Erlang Virtual Machine, the BEAM. Elixir is a general-purpose programming language. You can learn the basics of this functional programming language without understanding processes, especially if you come from another paradigm. But, understanding processes is an excellent way to grow your understanding of what makes Elixir so powerful and how to harness it for yourself because it represents one of the key concepts from the BEAM world.

Defining a process in the BEAM world

A process is an isolated entity where code execution happens.

Processes are everywhere in an Erlang system, note the iex, the observer and the OTP patterns are examples of how a process looks. They are lightweight, allowing us to run concurrent programs, and build distributed and fault-tolerant designs. There are a wide variety of other processes using the Erlang observer application. Because of how the BEAM runs, you can run a program inside a module without ever knowing the execution is a process. 

Most Elixir users would have at least heard of OTP. OTP allows you to use all the capabilities of the BEAM, and understanding what happens behind the scenes will help you to take full advantage of abstractions when designing processes.

A process is an isolated entity where code execution happens and they are the base to design software systems taking advantage of the BEAM features.   

You can understand what a process is as just a block of memory where you’re going to store data and manipulate it. A process is a built-in memory with the following parts:

  • Stack: To keep local variables.
  • Heap: To keep larger structures.
  • Mailbox: Store messages sent from other processes.
  • Process Control Block: Keeps track of the state of the process.

Process lifecycle

We could define the process lifecycle for now as:

  1. Process creation
  2. Code execution
  3. Process termination 

Process creation

The function spawn helps us to create a new process. We will need to provide a function to be executed inside it, this will return the process identifier PID. Elixir has a module called Process to provide functions to inspect a process. 

Let’s look at the following example using the iex:

  1. Create an anonymous function to print a string. (The function self/0 gives the process identifier).
  2. PROCESS CREATION: Invoke the function spawn with the function created as param. This will create a new process.
  3. CODE EXECUTION: The process created will execute the function provided immediately. You should see the message printed.
  4. TERMINATING: After the code execution, the process will terminate. You can check this using the function Process.alive?/1, if the process is still alive, you should get a true value.
iex(1)> execute_fun = fn -> IO.puts "Hi! ⭐️  I'm the process #{inspect(self())}." end
#Function<45.65746770/0 in :erl_eval.expr/5>

iex(2)> pid = spawn(execute_fun)
Hi! ⭐️  I'm the process #PID<0.234.0>.
#PID<0.234>


iex(2)> Process.alive?(pid) 
false

Receiving messages

A process is an entity that executes code inside but also can receive and process messages from other processes (they can communicate to each other only by messages). Sometimes this might be confusing, but these are different and complementary parts. 

The receive statement helps us to process the messages stored in the mailbox. You can send messages using the send statement, but the process will only store them in the mailbox. To start to process them, you should implement the receive statement, this will put the process into a mode that waits for sent messages to arrive. 

  1. Let’s create a new module with the function waiting_for_receive_messages/0 to implement the receive statement.
  2. PROCESS CREATION: Spawn a new process with the module created. This will execute the function with the receive statement. 
  3. CODE EXECUTION: Since the function provided has the receive statement, this will put the process into a waiting mode, so the process will be alive until processing a message. We can verify this using the Process.alive?/1.
  4. RECEIVE A MESSAGE: We can send a new message to this process using the function send/2. Remember the

TERMINATING: Once the message has been processed, our process will die.


iex(1)> defmodule MyProcess do
...(1)>   def awaiting_for_receive_messages do
...(1)>     IO.puts "Process #{inspect(self())}, waiting to process a message!"
...(1)>     receive do
...(1)>       "Hi" ->
...(1)>         IO.puts "Hi from me"
...(1)>       "Bye" ->
...(1)>         IO.puts "Bye, bye from me"
...(1)>       _ ->
...(1)>         IO.puts "Processing something"
...(1)>     end
...(1)>     IO.puts "Process #{inspect(self())}, message processed. Terminating..."
...(1)>   end
...(1)> end

iex(2)> pid = spawn(MyProcess, :awaiting_for_receive_messages, [])
Process #PID<0.125.0>, waiting to process a message!
#PID<0.125.0>


iex(3)> Process.alive?(pid)
true


iex(4)> send(pid, "Hi")
Hi from me
"Hi"
Process #PID<0.125.0>, message processed. Terminating...


iex(5)> Process.alive?(pid)
false

Keeping the process alive

One option is to enable the process to run and process messages from the mailbox. Remember how the receive/0 statement works: we could just call this statement after processing a message to make a continuous cycle and prevent termination.

  1. Modify our module to call the same function after processing a message.
  2. PROCESS CREATION: Spawn a new process.
  3. RECEIVE A MESSAGE: Send a new message to be executed in the process created. After this, we’ll call the same function that invokes the receive statement to wait to process another message. This will prevent the process termination. 
  4. CODE EXECUTION: Use the Process.alive?/1 and verify that our process is alive.
iex(1)> defmodule MyProcess do
...(1)>   def awaiting_for_receive_messages do
...(1)>     IO.puts "Process #{inspect(self())}, waiting to process a message!"
...(1)>     receive do
...(1)>       "Hi" ->
...(1)>         IO.puts "Hi from me"
...(1)>         awaiting_for_receive_messages()
...(1)>       "Bye" ->
...(1)>         IO.puts "Bye, bye from me"
...(1)>         awaiting_for_receive_messages()
...(1)>       _ ->
...(1)>         IO.puts "Processing something"
...(1)>         awaiting_for_receive_messages()
...(1)>     end
...(1)>   end
...(1)> end

iex(2)>
nil


iex(3)> pid = spawn(MyProcess, :awaiting_for_receive_messages, [])
Process #PID<0.127.0>, waiting to process a message!
#PID<0.127.0>


iex(4)> Process.alive?(pid)
true


iex(5)> send(pid, "Hi")
Hi from me
Process #PID<0.127.0>, waiting to process a message!
"Hi"

iex(6)> Process.alive?(pid)
true

Hold the state

Up to this point we have understood how to create a new process, process messages from the mailbox and how to keep it alive. The mailbox is an important part of the process where you can store messages, but we have other parts of memory that allow us to keep an internal state. Let’s see how to hold an internal state.

  1. Let’s modify our module to receive a list to store all the messages received. We just need to call the same function and send the list updated as param. We’ll print the list of messages as well.
  2. PROCESS CREATION: Spawn a new process. We’ll send an empty list as param to store the messages sent. 
  3. RECEIVE A MESSAGE: Send a new message to be executed in the process created. After receiving the message, we’ll call the same function with the list of messages updated as an argument. This will prevent the process termination, and will update the internal state. 
  4. RECEIVE A MESSAGE: Send another message, and see the output. You should get the list of the messages processed. 

CODE EXECUTION: Use the Process.alive?/1 and verify that our process is alive.

defmodule MyProcess do
  def awaiting_for_receive_messages(messages_received \\ []) do
    receive do
      "Hi" = msg ->
        IO.puts "Hi from me"
        [msg|messages_received]
        |> IO.inspect(label: "MESSAGES RECEIVED: ")
        |> awaiting_for_receive_messages()

      "Bye" = msg ->
        IO.puts "Bye, bye from me"
        [msg|messages_received]
        |> IO.inspect(label: "MESSAGES RECEIVED: ")
        |> awaiting_for_receive_messages()

      msg ->
        IO.puts "Processing something"
        [msg|messages_received]
        |> IO.inspect(label: "MESSAGES RECEIVED: ")
        |> awaiting_for_receive_messages()
    end
  end
end


iex(3)> pid = spawn(MyProcess, :awaiting_for_receive_messages, [])
#PID<0.132.0>

iex(4)> Process.alive?(pid)
true

iex(5)> send(pid, "Hi")
Hi from me
"Hi"
MESSAGES RECEIVED: : ["Hi"]


iex(6)> send(pid, "Bye")
Bye, bye from me
"Bye"
MESSAGES RECEIVED: : ["Bye", "Hi"]


iex(7)> send(pid, "Heeeey!")
Processing something
"Heeeey!"
MESSAGES RECEIVED: : ["Heeeey!", "Bye", "Hi"]

How to integrate Elixir reasoning in your processes

Well done! I hope all of these examples and explanations were enough to illustrate what a process is. It’s important to keep in mind the anatomy and the life cycle,to understand what’s happening behind the scenes. 

You can design with the process, or with OTP abstractions. But the concepts behind this are the same, let’s look at an example with Phoenix Live View:

defmodule DemoWeb.ClockLive do
  use DemoWeb, :live_view

  def render(assigns) do
    ~H"""
    <div>
      <h2>It's <%= NimbleStrftime.format(@date, "%H:%M:%S") %></h2>
      <%= live_render(@socket, DemoWeb.ImageLive, id: "image") %>
    </div>
    """
  end

  def mount(_params, _session, socket) do
    if connected?(socket), do: Process.send_after(self(), :tick, 1000)

    {:ok, put_date(socket)}
  end

  def handle_info(:tick, socket) do
    Process.send_after(self(), :tick, 1000)
    {:noreply, put_date(socket)}
  end

  def handle_event("nav", _path, socket) do
    {:noreply, socket}
  end

  defp put_date(socket) do
    assign(socket, date: NaiveDateTime.local_now())
  end
end

While the functions render/1 and mount/3 allow you to set up the Live View, the functions handle_info/2 and handle_event/3  are updating the socket, which is an internal state. Does this sound familiar to you? This is a process! A live view is an OTP abstraction to create a process behind the scenes, and of course this contains other implementations. For this particular case the essence of the process is present when the Live View reloads the HTML, keeps all the variables inside the state, and handles all the interactions while modifying it. 

Understanding processes gives you the concepts to understand how the BEAM works and to learn how to design better programs. Many of the libraries written in Elixir or the OTP abstractions these concepts as well, so next time you use one of these in your projects, think about these explanations to better understand what’s happening under the hood. 

Thanks for reading this. If you’d like to learn more about Elixir check out our training schedule or join us at ElixirConf EU 2022.

About the author

Carlo Gilmar is a software developer at Erlang Solutions based in Mexico City. He started his journey as a developer at Making Devs, he’s the founder of Visual Partner-Ship, a creative studio to mix technology and visual thinking. 

Keep reading

Advent of Code 2024

Advent of Code 2024

Join Lorena in this years Advent of Code 2024. She'll be solving daily puzzles throughout the month of December.

Optimising for Concurrency: Comparing and contrasting the BEAM and JVM virtual machines

Optimising for Concurrency: Comparing and contrasting the BEAM and JVM virtual machines

Attila Sragli explores the BEAM VM's inner workings, comparing them to the JVM to highlight their importance.

MongooseIM 6.3: Prometheus, CockroachDB and more

MongooseIM 6.3: Prometheus, CockroachDB and more

Pawel Chrząszcz introduces MongooseIM 6.3.0 with Prometheus monitoring and CockroachDB support for greater scalability and flexibility.