Immutability and memory management
Navigation
Home Concurrency in elixirProcess internalsImmutability in elixir
In Elixir, variables function as labels that refer to specific values. These values are immutable, meaning they cannot be changed. However, the label or variable can be reassigned to a different value. This provides the flexibility to bind the same value to multiple labels or variables.
In Erlang, it is not possible to reassign or rebind a variable. Attempting to do so will result in an error, as demonstrated in the following code:
X = 5,
X = X * 10. % throws an exception error: no match of right hand side value 50
The error in the Erlang code occurs because the variable X is initially assigned to the value of 5, but then an attempt is made to reassign it to a new value (i.e. X * 10). Since variables in Erlang are immutable, this operation is not allowed and the code will fail to compile.
On the other hand, Elixir allows for rebinding of values, which makes the same code valid in Elixir. This is because in Erlang, the = operator functions as a match operator rather than an assignment operator.
Therefore, in the Erlang code X = X * 10
, the left-hand side of the match (X) is already bound to the value of 5, and trying to match it with the right-hand side (X * 10) which evaluates to 50, results in a mismatch.
In Elixir, the ^ (pin) operator can be used to force a match, while the assignment operation uses the regular = operator. For example:
iex(1)> x = 5
5
iex(2)> x = x * 10 # Assignment
50
iex(3)> ^x = x * 10 # Matching
** (MatchError) no match of right hand side value: 500
(stdlib 4.0.1) erl_eval.erl:496: :erl_eval.expr/6
iex:3: (file)
In Elixir, any input passed into a function to be transformed creates a new value without modifying the original value. This allows for safe concurrent access to the same data by multiple processes. Since there is no shared memory that is getting mutated by multiple processes, concurrency is easier to manage. Any transformation on the original data will result in new data being created. Processes do not share state, they can only communicate asynchronously through message passing. This ensures it is safe to run them at the same time.
Example of immutability & closures in elixir
list = [1, 2, 3, 4]
# Returns a new list
Enum.filter(list, fn num -> num > 2 end)
# [1, 2, 3, 4] Original list remains unchanged
list
x = 1
# An anonymous function
anon = fn ->
# Closure captures the value of x
IO.puts(x)
x = 0
end
# Outputs 1
anon.()
# Outputs 1
IO.puts(x)
x = 5
# Outputs 1
anon.()
Persistent Datastructures
At this point you might be wandering that performing a full copy of the entire data whenever something changes would be an expensive and slow operation and lead to a high performance overhead.
To solve this problem there is a class of datastructures known as persistent data structures.
Persistent data structures are data structures that allow for the efficient storage and retrieval of data even after multiple modifications or updates have been made. These data structures preserve the previous versions of data and allow for efficient access to those versions.
A persistent data structure maintains the previous versions of data by using a technique known as structural sharing. Structural sharing allows the data structure to share the unchanged parts of its structure across multiple versions of the data, rather than copying the entire structure each time a modification is made. This sharing of unchanged structure makes persistent data structures efficient in both time and space.
Persistent data structures are widely used in functional programming languages, where immutability is a core concept.
Under the hood, the BEAM leverages persistent data structures in order to provide immutability as a first-class citizen while not having to copy the entire data structure any time something changes (with the exceptions of when data is passed between processes or when data is extracted from native data stores like ETS).
For example, in Elixir lists are actually linked lists. A linked list is a Tree with one branch.
Elixir: list = [1, 2, 3, 4]
Tree: 1 -> 2 -> 3 -> 4
Every time you prepend an element to a list, it will share its tail:
Elixir: [0 | list]
Tree: 0 -> (1 -> 2 -> 3 -> 4)
High level overview of garbage collection in elixir
Erlang employs a generational copying garbage collection system where each process has its own private heap. This heap is divided into two segments - the young and old generations. Newly allocated data resides in the young generation, while data that has survived multiple garbage collection cycles is stored in the old generation.
The young generation undergoes more frequent garbage collection, whereas the old generation is only garbage collected during a full sweep, which occurs after a certain number of generational GC cycles. It can also be collected if not enough memory is reclaimed or if manually invoked. This process is referred to as soft real-time garbage collection because it halts only the process undergoing GC without affecting other processes. This characteristic is well-suited for soft real-time systems, as the entire runtime doesn’t need to pause.
In addition to this, Erlang implements reference counting garbage collectiong) for the shared heap. Here, objects in the shared heap are assigned reference counters that keep track of the number of references held by other objects. When an object’s reference count drops to zero, it’s considered inaccessible and gets destroyed.
For a deeper dive into garbage collection, you can explore this informative video and this insightful book.