#24: An In-Depth Exploration of Elixir Processes

·

6 min read

In Elixir, a process is a lightweight concurrent computation unit that runs independently of other processes. These processes are not operating system processes but are virtual processes managed by the Erlang Virtual Machine (BEAM).

Elixir Processes are:

  1. Lightweight: Elixir processes are lightweight in terms of memory and CPU usage. It is common to have tens to hundreds of thousands of processes running concurrently in an Elixir application.

  2. Isolated: Each Elixir process has its own memory space and does not share memory with other processes. This isolation ensures that processes do not interfere with each other's data.

  3. Message Passing: Processes communicate with each other by sending and receiving messages. Messages are stored in the process mailbox and are processed in the order they are received. This message-passing mechanism enables coordination and synchronization between processes.

  4. Fault-Tolerant: Elixir processes are designed to be fault-tolerant. If a process crashes due to an error, it does not affect the rest of the system. Supervision strategies can be used to monitor and restart processes in case of failures.

  5. Concurrency: Elixir processes enable concurrent execution of tasks, allowing different parts of the application to run simultaneously. This concurrency model is based on the actor model, where each process behaves like an independent actor processing messages.

  6. Scalability: By leveraging processes for concurrency, Elixir applications can scale across multiple CPU cores without the need for explicit management of threads or locks. The BEAM scheduler efficiently distributes processes across available CPU resources.

Spawning A New Process

In Elixir, you can spawn a new process using the spawn/1 function or the Task module. Here's how you can spawn a process using the first method:

The spawn/1 function takes a function as an argument and executes that function in a new process. Here's an example:

self()
# Output: PID<0.109.0>

# Spawning a process using spawn/1
pid = spawn(fn ->
  IO.puts("Hello from a new process!")
end)

IO.puts("Spawned process PID: #{inspect pid}")
# Output: Spawned process PID: #PID<0.148.0>

In the above example, a new process is created to execute the specified function concurrently with the main process. The spawned process runs independently and can perform its tasks concurrently with other processes in the system.

When you spawn a process, you receive a Process Identifier (PID) that uniquely identifies the new process. This PID can be used to send messages to the process or monitor its status.

Spawning processes in Elixir allow you to achieve concurrency and parallelism in your applications, enabling tasks to run concurrently and take advantage of multi-core processors efficiently.

Message Passing Between Processes

Let's take this one step further to illustrate message passing between two processes. First, we will spawn a process that can receive messages, and then send a message to that process. Finally, we will check the process status:

# Spawning a process to handle messages
pid = spawn(fn ->
  receive do
    :add -> IO.puts("Received :add message")
    {:multiply, a, b} -> IO.puts("Received :multiply message with values #{a} and #{b}")
  end
end)
# Output: #PID<0.150.0>

# Checking if the process is alive
IO.puts("Is process alive? #{inspect Process.alive?(pid)}")
# Output: Is process alive? true

# Sending message to the process
send(pid, :add) # Output: Received :add message

# Checking if the process is still alive after processing messages
IO.puts("Is process alive? #{inspect Process.alive?(pid)}") # Output: Is process alive? false

In the above example:

  • We spawn a new process that waits to receive messages.

  • The process pattern matches on the messages it receives (:add and {:multiply, a, b}).

  • We check if the process is alive before sending messages to it.

  • A message is sent to the process using the send/2 function.

  • The process handles the messages and prints corresponding output based on the message received.

  • We check if the process is alive after sending messages to it.

You can run this code in an Elixir environment (such as IEx) to observe the process of handling messages and their status before and after message processing.

So, now we are going to take it one step further yet again and ensure the newly spawned process lasts forever using an infinite loop. Don't worry. We will make use of recursion to create an infinite loop, but it will be controlled and, thus, manageable as you will see in the following example:

defmodule MyExample.Spawn.ForEver do
  def start do
    spawn(&loop/0)
  end

  defp loop do
    receive do
      {:greet, name} ->
        IO.puts("Hello, #{name}!")
        loop()

      {:calculate, operation, a, b} ->
        result =
          case operation do
            :add -> a + b
            :subtract -> a - b
            :multiply -> a * b
            :divide -> a / b
          end
        IO.puts("Result of #{operation}: #{result}")
        loop()

      :status ->
        {:memory, bytes} = Process.info(self(), :memory)
        IO.puts("Process is using #{bytes} bytes")
        loop()

      :crash ->
        raise "Let it crash!"

      :bye ->
        IO.puts("Goodbye, my friend!")
    end
  end
end

# Start the custom process
pid = MyExample.Spawn.ForEver.start() # Output: #PID<0.157.0>

# Send messages to the process
send(pid, {:greet, "Sara"}) # Output: Hello, Sara!
send(pid, {:calculate, :add, 10, 5}) # Output: Result of add: 15
send(pid, :status) # Output: Process is using 2632 bytes
send(pid, :bye) # Output: Goodbye, my friend!
# OR, alternatively
send(pid, :crash) # Output: [error] Process #PID<0.157.0> raised an exception ** (RuntimeError) Let it crash!

In the above example:

  • We define a module MyExample.Spawn.ForEver with a start function to spawn a process that runs the private loop/0 function.

  • The private loop/0 function uses pattern matching in the receive block to handle different types of messages:

    • {:greet, name}: Greets a person by name.

    • {:calculate, operation, a, b}: Performs arithmetic operations based on the specified operation.

    • :status: Retrieves memory usage information of the process.

    • :crash: Raises an exception to simulate a crash.

    • :bye: Prints a farewell message.

  • Messages are sent to the process to trigger different behaviors, such as greetings, calculations, status checks, crashes, and farewells.

You can run this code in an Elixir environment to observe how the custom process handles various messages and behaviors.

Isolating A Crashed Process

And finally, let's spawn two processes and let one of them crash. What do you think will happen? Will the crashed process take down the other process with it? You can probably guess the answer, but it is still good to see it for yourself.

Here is a new code example that demonstrates spawning multiple processes and simulating a crash in one of them:

defmodule Examples.Spawn.MultipleProcesses do
  def start do
    spawn(&loop/0)
  end

  defp loop do
    receive do
      :crash ->
        raise "You just made me crash!"
    end
  end
end

# Start two processes
p1 = Examples.Spawn.MultipleProcesses.start()
p2 = Examples.Spawn.MultipleProcesses.start()

# Simulate a crash in process p1
send(p1, :crash)

# Check the status of both processes
IO.inspect([Process.alive?(p1), Process.alive?(p2)])
# Output: [false, true]

In this code example:

  • We define a module Examples.Spawn.MultipleProcesses with a start function to spawn processes that run the loop/0 function.

  • The loop/0 function waits to receive a :crash message and raises an exception to simulate a crash.

  • Two processes (p1 and p2) are started using the start function.

  • We send a :crash message to process p1 to simulate a crash in that process.

  • Finally, we check the status of both processes using Process.alive? to see if the process p1 is no longer alive after the crash.

In this straightforward demonstration, we observe that error isolation is effortlessly achieved without any explicit actions on our part. Notably, attempting to crash one process from another is not feasible within the Elixir environment.

Overall, Elixir processes play a key role in achieving concurrency, scalability, fault tolerance, and isolation in Elixir applications. They form the foundation of Elixir's concurrency model and are essential for building responsive and resilient systems.