The book gives an overview of Elixir and the OTP ecosystem in the context of building concurrent and fault-tolerant systems. Besides being aimed at more experienced programmers, the book still gives an overview of the building blocks of Elixir before jumping into its application for building concurrent systems. For this reason the notes here contained begin on chapter 4, since the first three chapters focus on the syntax of the language and a broad overview of its practical applications and runtime characteristics.
Chapter 4: Data abstractions
In Elixir, abstract data types are implemented with stateless modules, as groups of functions data manipulate a specific data structure.
Todo list
The main application example used is a to-do list system with basic CRUD operations. The idea is to define a data structure and then encapsulate all its operations on a module that combines functions to perform these operations. The functions usually receive the data structure as a first argument and return the resulting data structure after the operation, making them usable with the pipe |> operator.
An example usage of the TodoList module:
Importing from CSV
We can also implement an importer module that constructs a to-do list with the entries present in a file. This is an example of how we can extend de functionality of a system by using existing abstractions.
Protocols
The chapter also explores the concept of polymorphism through protocols.
Chapter 5: Concurrency primitives
In order to achieve high availability, the BEAM uses internal light-weight processes that are independent of each other and can run concurrently. The BEAM process is a single OS process that by default has one scheduler per core available on the system. Processes can be created cheaply and are able to keep internal state completely isolated from other processes. Processes can communicate with each other using messages.
Concurrency vs. parallelism: It’s important to realize that concurrency doesn’t necessarily imply parallelism. Two concurrent things have independent execution contexts, but this doesn’t mean they will run in parallel. If you run two CPU-bound concurrent tasks and you only have one CPU core, parallel execution can’t happen. You can achieve parallelism by adding more CPU cores and relying on an efficient concurrent framework. But you should be aware that concurrency itself doesn’t necessarily speed things up.
Server processes
We can create server processes to handle some operations without blocking the caller process. We typically implement server processes using an infinite tail-recursive loop that waits and handles a new message, calling itself with the new state at the end. Processes can also maintain their own state. In this sense we can think of these stateful server processes as pure objects that can be interacted with via a public interface of functions that can query or update its state. In the following example we simulate a database server process that executes a fake query and then return the result to the caller process.
:
Stateful server process
The following example implements a stateful server process for a calculator, showing how we can mutate and query the state of a server process.
:
Todo list server
In the following example we explore the same concepts, but extend them by using the more complex TodoList data structure.
Runtime considerations
It’s important to note a few runtime characteristics of BEAM processes that may affect the way we choose to use them.
Processes are sequential: although multiple processes may run in parallel, a single process is always sequential. A single slow process can bottleneck an entire system if many processes depend of its computations. We should try to optimize such slow processes and in extreme cases we can resort to split the original process between multiple processes.
Unlimited process mailboxes: a process’s mailbox is limited by the available memory. With that in mind, if a process receives more messages than it can handle it will eventually crash the entire system. Because of that it’s very important to always handle all messages, adding a default clause to receive blocks (because messages that do not match any clause are put back into the mailbox).
Shared-nothing concurrency: processes share no memory. Thus, sending a message to another process results in a deep copy of the message contents. Because of that we should be careful when sending big amounts of data between processes, as it will all be deep copied. Some advantages of shared-nothing concurrency is that the system becomes more resilient and that garbage collection can be performed on a process level, without stopping the whole runtime.
Scheduler inner workings: BEAM schedulers are preemptive, each process can run for about 2000 function calls. A process can yield its execution to the scheduler when executing a receive or performing an I/O operation. This makes the I/O code look synchronous, but under the hood it runs asynchronously using BEAM async threads. With that, we as programmers get a simpler model of programming without compromising the responsiveness of the system.
Chapter 6: Generic server processes
Server processes are very frequent in concurrent systems in Elixir and Erlang, so it’s natural that there are already some common abstractions and utilities available that facilitate the implementation of such processes. In Elixir this abstraction is provided by the GenServer module part of the OTP framework.
Elixir and Erlang offer multiple OTP-compliant abstractions, that is: modules that are based processes that adhere to OTP conventions. Those types of processes are easy to use in supervision trees, and have error-handling and logging benefits. Some common OTP-compliant abstractions are: Task, Agent, GenState, Phoenix.Channel etc. Note that many of these abstractions are built on top of GenServer (in fact, all the abstractions cited before, except for Task, are implemented using GenServer), which makes it arguably the most important OTP abstraction.
Building a generic server process
To better understand GenServer, we’ll implement a simplified version of it. The idea is to make a generic module that implements the following:
Spawn a process
Run an infinite loop in the process
Maintain the state
React to messages
Send the responses back to the caller
The generic module implements these operations, but relies on a callback_module provided as a parameter of the start/0 function, that provides the concrete (business logic dependent) implementation, such as the handling of the messages and the updating of the state. The server supports both synchronous and asynchronous requests, via the call and cast operations, respectively.
:
With that basic abstraction, is possible to issue requests to the server and get the results back, all while keeping internal state. Here’s a simple KeyValueStore module that relies on the generic implementation of the ServerProcess.
:
Todo list server using the generic server process
We can refactor the TodoServer module to make use of the new ServerProcess module.
Using GenServer
GenServer is an Elixir/Erlang abstraction to implement server processes. GenServer is a behaviour, that is: a module that implements a set of generic operations and expects a callback module to implement the specific logic desired.
In total, the GenServer behaviour requeres seven callback functions, but we generally don’t need to implement all of them. We can use the default implementations of all the required callback functions if we use the GenServer module.
:
We can now refactor the TodoServer module to make use of the GenServer abstraction.
Chapter 7: Building a concurrent system
Chapter 8: Fault-tolerance basis
In a distribute system we don’t try to make sure that nothing fails, we make sure that if something fails it doesn’t take down the entire system. As such, we need mechanisms of detecting and acting on failures, so our system can try to recover from them.
In the BEAM processes are completely isolated, so when a process crashes it doesn’t propagate the failure to other processes neither corrupts shared state. This means that we’re covered from the error isolation standpoint, but we must develop have mechanisms that allow the system to detect and recover from failures.
In Elixir, a runtime error has a type, which can be :error:exit or :throw and a value, which is an arbitrary term. If a runtime error isn’t handled, the process terminates.
We can link processes together so they are notified of each other’s termination. Linking two processes is always bidirectional. By default, when one of the linked processes is terminated, the other process also terminates. We can override this default behavior by trapping exits on a process, making the process receive a message about the termination of a linked process. It is also possible to make unidirectional links using monitors.