erlang.tech
Software that keeps running while it is broken
Erlang was built at Ericsson to run telephone exchanges that were never allowed to stop. Every idea here arrives three ways: the Erlang version, the Python version, and the JavaScript version. You already know what a thread, a try block and a global variable do. This guide shows what replaces them when a system has to survive its own failures.
- chapters
- 27
- runnable files
- 65
- dependencies to read it
- 0
Why a language built for telephone exchanges
Erlang answers one question: how do you keep a system running when parts of it are failing. Everything else in the language follows from that answer.
Ericsson built Erlang in the late 1980s for telephone switches. A switch handles hundreds of thousands of calls at once, and it is not allowed to stop, not for a bug, not for a deploy, not for a hardware fault. Those constraints produced a language with an unusual shape, and the shape turns out to suit anything that has to stay up: chat, messaging, payments, queues, real time delivery.
WhatsApp ran two million connections per server on it. Discord passes millions of messages a second through it. RabbitMQ, CouchDB, Riak and the entire Elixir ecosystem sit on the same virtual machine.
%% Three functions, three different stories about failure.
-module(why_specs).
-export([greet/1, find_age/2, start/0]).
%% A spec is a promise to Dialyzer, not to the compiler. It is checked
%% statically by a separate tool, and it is worth writing anyway.
-spec greet(binary()) -> binary().
greet(Name) ->
<<"Hello, ", Name/binary, "!">>.
%% Erlang returns a tagged tuple instead of raising for an expected miss.
%% The caller has to match on it, so the missing case is hard to forget.
-spec find_age(atom(), [{atom(), integer()}]) -> {ok, integer()} | not_found.
find_age(Name, People) ->
case lists:keyfind(Name, 1, People) of
{Name, Age} -> {ok, Age};
false -> not_found
end.
%% This one talks to the world. Nothing in the type system stops it, but the
%% process it runs in is isolated, so when it fails it takes nothing else
%% down with it.
-spec start() -> ok.
start() ->
io:format("~s~n", [greet(<<"world">>)]).
The same three functions in the languages you already use. The annotations describe intent and enforce nothing, and the failure in the middle one is invisible from the outside.
# The same three functions in Python. Type hints document intent and enforce
# nothing: greet() is free to print, open a socket, or raise.
def greet(name: str) -> str:
return f"Hello, {name}!"
def find_age(name: str, people: dict[str, int]) -> int:
# Raises KeyError, and nothing in the signature says so. Callers find out
# in production, and one uncaught raise takes the whole worker down.
return people[name]
def main() -> None:
print(greet("world"))
if __name__ == "__main__":
main()
// The same three functions in JavaScript. JSDoc helps an editor and stops
// nobody: greet can print, fetch and throw without changing shape.
/** @param {string} name */
export function greet(name) {
return `Hello, ${name}!`;
}
/**
* @param {string} name
* @param {Record<string, number>} people
*/
export function findAge(name, people) {
// undefined for a missing key, with no warning if you forget to check.
// An unhandled rejection anywhere can end the whole process.
return people[name];
}
console.log(greet("world"));
Four ideas, and they all connect
isolation
Nothing is shared
Each process has its own heap and its own garbage collector. There is no shared memory to protect, so there are no locks, no mutexes and no data races. Two processes communicate by copying a message, which sounds slow and buys you the ability to reason about each one on its own.
scale
Processes cost almost nothing
An Erlang process is not an operating system thread. It takes about 300 words to create and a few microseconds to start. A million of them on one machine is ordinary, which is why the natural design is one process per connection, per request, per chat room, per anything.
failure
Let it crash
Code handles the cases it understands and dies on the ones it does not. A supervisor notices and starts a clean replacement from a known state. Defensive code disappears, and so does the class of bug where a program limps on in a state its author never imagined.
change
The system stays up while you change it
Modules can be replaced in a running virtual machine. A process picks up the new version on its next fully qualified call, keeping its state. Deploying without dropping connections is a language feature rather than an infrastructure project.
What you give up
| What is missing | What you do instead |
|---|---|
| Static types | Dialyzer, which finds real contradictions without ever crying wolf |
| Mutable variables | State lives in a process and changes by recursion |
| Fast number crunching | NIFs in Rust or C, or a different tool for that part |
| A large standard library for text | Binaries, which are excellent, and a smaller string library |
| Familiar syntax | Two weeks of commas, semicolons and periods feeling wrong |
Every idea here gets an Erlang version with the Python or JavaScript version beside it. Half the chapters build something runnable: a guessing game, a counter, a chat server with supervised rooms, a ring of a hundred thousand processes, a word counter with tests. Read it in order if the language is new to you. If you have written some Erlang, the sidebar is a menu, and OTP, distribution and hot code loading all stand on their own.
Install Erlang and open the shell
Ten minutes to a working toolchain, and then most of your learning happens at a prompt that is also a running virtual machine.
Erlang ships as one package: the compiler, the runtime, the shell and the whole of OTP. There is no separate standard library to install and no framework to choose. The one extra thing worth having is rebar3, the build tool.
# The version manager everyone ends up using. It handles Erlang and Elixir
# side by side and keeps a per project version in a .tool-versions file.
asdf plugin add erlang
asdf install erlang 27.2
asdf global erlang 27.2
# Or through a package manager, if you do not need to switch versions.
# macOS brew install erlang rebar3
# Debian apt install erlang
# Fedora dnf install erlang
# Windows winget install Erlang.ErlangOTP
# rebar3 is the build tool. It is a single self contained escript.
curl -fsSL -o rebar3 https://s3.amazonaws.com/rebar3/rebar3
chmod +x rebar3 && mv rebar3 ~/.local/bin/
erl -version
rebar3 --version
-
Install Erlang/OTP
Any recent version will do. OTP 27 brought documentation attributes and JSON in the standard library; OTP 26 and 25 are still widely deployed. If you expect to work on more than one project, install through asdf so versions can differ per directory.
-
Install rebar3
It is a single self contained script. It fetches dependencies from Hex, compiles, runs tests, runs Dialyzer and builds releases.
-
Set your editor up
erlang_ls or the newer ELP from WhatsApp both speak the language server protocol. VS Code, Neovim and Emacs all have working clients. Hover types and go to definition matter more here than in most languages, because so much of the API lives in modules named after the thing rather than the verb.
Your first module
%% Every file is a module, and the module name must match the file name.
-module(hello).
%% Only exported functions can be called from outside. The number after the
%% slash is the arity: how many arguments the function takes.
-export([start/0]).
start() ->
io:format("Hello from the BEAM.~n"),
io:format("This node is ~p and I am ~p.~n", [node(), self()]).
# Start the shell. This boots a whole virtual machine, so everything you can
# do in production you can do here.
erl
# Compile a file from the command line instead.
erlc hello.erl # produces hello.beam
erl -noshell -s hello start -s init stop
# A standalone script, with no compile step and no project around it.
cat > greet.escript <<'SCRIPT'
#!/usr/bin/env escript
main([Name]) -> io:format("hello ~s~n", [Name]);
main(_) -> io:format("usage: greet NAME~n").
SCRIPT
chmod +x greet.escript && ./greet.escript world
The shell is a running system
erl does not start a read eval print loop that pretends to be a program. It boots the actual virtual machine, with a scheduler per core, a garbage collector, and every OTP application available. Anything you can do in production you can do here, including connecting to a production node and looking around.
1> 2 + 2 * 10.
22
2> X = 42.
42
3> X = 43.
** exception error: no match of right hand side value 43
4> f(X).
ok
5> X = 43.
43
6> Person = #{name => <<"Ada">>, age => 36}.
#{age => 36,name => <<"Ada">>}
7> maps:get(name, Person).
<<"Ada">>
8> c(counter).
{ok,counter}
9> Pid = counter:start().
<0.94.0>
10> counter:add(Pid, 5).
ok
11> counter:value(Pid).
5
12> i().
13> q().
| Command | What it does |
|---|---|
| c(module). | compile and load a module from the current directory |
| f(). | forget every variable binding, since you cannot rebind one |
| f(X). | forget just X |
| b(). | show the current bindings |
| i(). | list every process, with its memory and message queue |
| flush(). | print and discard the shell process's mailbox |
| rr(module). | load a module's record definitions so records print properly |
| observer:start(). | the graphical system monitor, worth opening once early |
| help(). | the full list |
| q(). | stop the node, gracefully |
The syntax table
The translations worth keeping open for the first week. Everything in the Erlang column compiles.
Most of the strangeness comes from three decisions. Variables start with a capital letter and are bound exactly once. Anything starting with a lowercase letter is an atom, a constant whose name is its value. And punctuation carries the structure: a comma means and then, a semicolon means or, a period means the end.
| Idea | Erlang | Python | JavaScript |
|---|---|---|---|
| Variable | Count = 42 | count = 42 | const count = 42 |
| Rebinding it | not allowed | count = 43 | let count = 43 |
| Constant name | ok, error, undefined | ok = "ok" | Symbol("ok") |
| Decimal | Ratio = 3.14 | ratio = 3.14 | const ratio = 3.14 |
| Text, quick | Label = "hi" | label = "hi" | const label = "hi" |
| Text, for real | Text = <<"hi">> | text = b"hi" | Buffer.from("hi") |
| Fixed record | Result = {ok, 200} | result = ("ok", 200) | const result = ["ok", 200] |
| List | Nums = [1, 2, 3] | nums = [1, 2, 3] | const nums = [1, 2, 3] |
| Dictionary | M = #{id => 1} | m = {"id": 1} | const m = {id: 1} |
| Read a key | maps:get(id, M) | m["id"] | m.id |
| Function | add(X, Y) -> X + Y. | def add(x, y): return x + y | const add = (x, y) => x + y |
| Calling it | add(2, 3) | add(2, 3) | add(2, 3) |
| Anonymous function | fun(X) -> X * 2 end | lambda x: x * 2 | (x) => x * 2 |
| Concurrency unit | spawn(fun worker/0) | threading.Thread(...) | new Worker(...) |
| Process handle | Pid = self() | os.getpid() | process.pid |
| Sending a message | Pid ! {add, 1} | queue.put(("add", 1)) | worker.postMessage(...) |
Every line of the Erlang column is taken from this module, which compiles as it stands.
%% Every value in the comparison table, in one module that compiles.
-module(rosetta).
-export([examples/0]).
-record(user, {id, name, admin = false}).
examples() ->
Count = 42,
Ratio = 3.14,
%% An atom is a constant whose name is its value. Lowercase.
Status = ok,
%% A string is a list of integers. Convenient, and slow for big text.
Label = "hi",
%% A binary is a packed byte array. This is what you want for real text.
Text = <<"hi">>,
%% A tuple is a fixed size record of values, usually tagged with an atom.
Result = {ok, 200},
%% A list is a linked list. Any element type, and mixing them is allowed.
Nums = [1, 2, 3],
%% A map is the dictionary. => inserts or updates, := only updates.
Ages = #{ada => 36, grace => 45},
%% A record is a tuple with named fields, resolved at compile time.
User = #user{id = 1, name = <<"Ada">>},
%% A process identifier. There is no equivalent in Python or JavaScript.
Me = self(),
%% A function value. fun with an end, or a reference to a named function.
Double = fun(X) -> X * 2 end,
ToList = fun erlang:integer_to_list/1,
{Count, Ratio, Status, Label, Text, Result, Nums, Ages, User, Me,
Double(21), ToList(7)}.
The punctuation, once and for all
| Mark | Means | Example |
|---|---|---|
| , | and then, between expressions | A = 1, B = 2, A + B |
| ; | or, between clauses | f(0) -> zero; f(N) -> other. |
| . | this form is finished | f(N) -> N * 2. |
| -> | the body of a clause follows | case X of ok -> done end |
| = | match these two, binding what is unbound | {ok, V} = Result |
| ! | send a message | Pid ! hello |
| | | the rest of a list | [Head | Tail] |
| => | put in a map, whether or not the key exists | M#{key => 1} |
| := | update a map, and crash if the key is missing | M#{key := 2} |
Terms: atoms, tuples, maps and binaries
Erlang has a small set of data types and no classes. Learning which one to reach for is most of learning the language.
Everything in Erlang is a term. Terms can be sent between processes, written to disk and copied to another machine without any conversion step, which is why the set is deliberately small.
- atom
- A constant whose name is its value: ok, error, undefined. One word of memory, and comparing two is a pointer comparison. Atoms are never garbage collected, so never turn user input into one with list_to_atom.
- tuple
- A fixed size group, read by position. Tagging the first slot with an atom, as in {ok, Value}, is the convention that the whole ecosystem follows.
- list
- A linked list. Cheap at the front, expensive at the back, and the natural thing to recurse over.
- map
- The dictionary. Any term can be a key. Use it for anything whose shape changes or crosses a boundary.
- binary
- A packed byte array, written <<"like this">>. This is what real text and all network data should be.
- pid
- A process identifier. It can be sent in a message, stored in a map, and used to reach a process on another machine.
- reference
- A value from make_ref/0 that is unique across the cluster. Used to match a reply to the request that asked for it.
- fun
- A function value, which closes over the variables around it.
-module(terms).
-export([atoms/0, numbers/0, strings/0, tuples/0, lists_of/0, maps_of/0,
binaries/0, comparison/0]).
%% Atoms are constants. They cost one word and comparing them is a pointer
%% comparison, which is why Erlang tags everything with them.
atoms() ->
[ok, error, undefined, 'an atom with spaces', true, false].
%% Integers are arbitrary precision. There is no overflow to worry about.
numbers() ->
Big = 1 bsl 200,
[17, -3, 2#1010, 16#ff, $A, 3.5, Big div 3].
%% "abc" is exactly [97, 98, 99]. That is worth knowing before it surprises
%% you in the shell.
strings() ->
Chars = "abc",
Same = [$a, $b, $c],
{Chars =:= Same, length(Chars)}.
%% Tuples are fixed size and fast to read by position. Tagging them with an
%% atom in the first slot is the convention that makes matching readable.
tuples() ->
Point = {point, 3, 4},
{point, X, Y} = Point,
{X, Y, element(2, Point), tuple_size(Point)}.
lists_of() ->
L = [1, 2, 3],
{ [0 | L] %% cons, cheap
, L ++ [4] %% append, walks the left list, so avoid in a loop
, lists:reverse(L)
, lists:sum(L)
, length(L)
}.
%% Maps are the modern dictionary. => works whether or not the key exists,
%% := demands that it does, which turns a typo into an immediate crash.
maps_of() ->
M = #{name => <<"Ada">>, age => 36},
Older = M#{age := 37},
WithEmail = M#{email => <<"ada@example.com">>},
{ maps:get(name, M)
, maps:get(nickname, M, none) %% with a default
, maps:is_key(age, M)
, maps:keys(Older)
, maps:size(WithEmail)
}.
%% Binaries are contiguous memory. Pattern matching on them is how Erlang
%% parses network protocols in a few lines.
binaries() ->
Bin = <<"hello world">>,
<<First:5/binary, " ", Rest/binary>> = Bin,
Packet = <<1:8, 4096:16, 7:8>>,
<<Version:8, Length:16, Type:8>> = Packet,
{First, Rest, byte_size(Bin), Version, Length, Type}.
%% Every term is comparable, and the order between types is fixed:
%% number < atom < reference < fun < port < pid < tuple < map < list < binary
comparison() ->
lists:sort([<<"bin">>, [1, 2], {a, b}, ok, 42, #{}]).
Strings are the one real trap
"abc" is exactly [97, 98, 99]. That is convenient for parsing a few characters and wasteful for anything larger: eight bytes per character on a 64 bit machine, scattered across the heap.
Binaries are contiguous and shareable between processes without copying above 64 bytes. Modern Erlang uses them for text everywhere, and the string module works on both.
| Task | Use |
|---|---|
| A literal | <<"hello">> |
| Join | <<A/binary, B/binary>> or iolist_to_binary([A, B]) |
| Length in bytes | byte_size(Bin) |
| Split | binary:split(Bin, <<",">>, [global]) |
| Words | string:lexemes(Bin, " \n") |
| Case | string:lowercase(Bin), string:uppercase(Bin) |
| To a number | binary_to_integer(Bin) |
| Formatting | io_lib:format("~p", [Term]) |
Building output without joining strings
An iolist is a nested list of binaries, characters and other iolists. Every IO function accepts one, and the runtime walks it while writing rather than flattening it first. Appending to an iolist is putting it in a new list, so building a large response costs nothing until it is sent.
In practice this means never writing A ++ B to join text. Write [A, B] and hand that to file:write/2 or gen_tcp:send/2.
Pattern matching, and why = is not assignment
One operator that binds, tests and destructures at the same time. It is the feature everything else in the language is built on.
= is the match operator. It says the left side and the right side describe the same value, binds any variables that are still unbound, and crashes if that cannot be made true. It is closer to an equation than to an assignment.
That single operator does the work of destructuring, type checking, conditionals and assertions in other languages, and it appears in function heads, case expressions and receive clauses alike.
-module(matching).
-export([bind/0, destructure/1, http_message/1, describe/1, first_error/1,
area/1]).
%% = is not assignment. It asserts that both sides match, and binds any
%% unbound variables on the way. Binding a variable twice to different values
%% is a badmatch error, not an overwrite.
bind() ->
X = 1,
1 = X, %% fine, both sides are 1
{ok, Value} = {ok, 42},
Value.
%% Taking a value apart is the same operation as testing its shape.
destructure(Response) ->
case Response of
{ok, #{body := Body, status := 200}} -> {success, Body};
{ok, #{status := Status}} -> {unexpected, Status};
{error, timeout} -> retry;
{error, Reason} -> {give_up, Reason}
end.
%% Function clauses are pattern matching spread across several definitions.
%% The runtime tries them top to bottom.
http_message(200) -> "OK";
http_message(404) -> "Not Found";
http_message(500) -> "Server Error";
http_message(Code) when is_integer(Code) -> "Status " ++ integer_to_list(Code).
%% Matching on list shapes. [H | T] splits off the first element.
describe([]) -> "nothing";
describe([X]) -> "just " ++ X;
describe([X, Y]) -> X ++ " and " ++ Y;
describe([X | Rest]) -> X ++ " and " ++ integer_to_list(length(Rest)) ++ " more".
%% A variable used twice in one pattern means "these two must be equal".
%% Nothing else in mainstream languages does this.
first_error([{error, Reason}, {error, Reason} | _]) -> {repeated, Reason};
first_error([{error, Reason} | _]) -> {first, Reason};
first_error([_ | Rest]) -> first_error(Rest);
first_error([]) -> none.
%% Guards restrict a clause further. Only a fixed set of side effect free
%% tests is allowed in one, which is why they can never fail halfway.
area({circle, R}) when R > 0 -> math:pi() * R * R;
area({rectangle, W, H}) when W > 0, H > 0 -> W * H;
area({square, S}) when S > 0 -> S * S.
1> {ok, Value} = {ok, 42}.
{ok,42}
2> Value.
42
3> {ok, Other} = {error, timeout}.
** exception error: no match of right hand side value {error,timeout}
4> [Head | Tail] = [1, 2, 3].
[1,2,3]
5> {Head, Tail}.
{1,[2,3]}
6> <<Version:8, Rest/binary>> = <<1, "payload">>.
<<1,112,97,121,108,111,97,100>>
7> {Version, Rest}.
{1,<<"payload">>}
8> #{name := Name} = #{name => <<"Ada">>, age => 36}.
#{age => 36,name => <<"Ada">>}
9> Name.
<<"Ada">>
10> [First, First | _] = [a, a, b].
[a,a,b]
11> [First, First | _] = [a, b, c].
** exception error: no match of right hand side value [a,b,c]
def describe(items: list[str]) -> str:
match items:
case []:
return "nothing"
case [x]:
return f"just {x}"
case [x, y]:
return f"{x} and {y}"
case [x, *rest]:
return f"{x} and {len(rest)} more"
return "unreachable"
def http_message(code: int) -> str:
match code:
case 200:
return "OK"
case 404:
return "Not Found"
case 500:
return "Server Error"
case _:
return f"Status {code}"
def distance(a: tuple[float, float], b: tuple[float, float]) -> float:
(x1, y1), (x2, y2) = a, b
return ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
def grade(score: int) -> str:
if score >= 90:
return "A"
if score >= 80:
return "B"
if score >= 70:
return "C"
return "F"
// There is no pattern matching, so shapes are checked by hand.
export function describe(items) {
if (items.length === 0) return "nothing";
if (items.length === 1) return `just ${items[0]}`;
if (items.length === 2) return `${items[0]} and ${items[1]}`;
const [first, ...rest] = items;
return `${first} and ${rest.length} more`;
}
export function httpMessage(code) {
switch (code) {
case 200:
return "OK";
case 404:
return "Not Found";
case 500:
return "Server Error";
default:
return `Status ${code}`;
}
}
export function distance([x1, y1], [x2, y2]) {
return Math.hypot(x2 - x1, y2 - y1);
}
export function grade(score) {
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
return "F";
}
Python's match statement, added in 3.10, is the closest equivalent. JavaScript has destructuring but no matching, so shapes are checked by hand.
Single assignment is a feature
A variable is bound once and never changes. That sounds restrictive for about a day, and then it becomes the reason you can read a function from top to bottom and know what everything holds. There is no earlier line that might have changed Count, because there cannot be.
The usual objection is loops, and the answer is that a loop with a changing counter becomes a function with a parameter. The recursion chapter is entirely about that translation.
Try it yourself
Write first_error/1, which walks a list of results and returns {first, Reason} for the first error, or {repeated, Reason} if the first two errors share a reason, or none if there are no errors at all.
Hint: Use the same variable name twice in one pattern to say the two reasons must be equal.
Show one solution Hide the solution
-module(matching).
-export([bind/0, destructure/1, http_message/1, describe/1, first_error/1,
area/1]).
%% = is not assignment. It asserts that both sides match, and binds any
%% unbound variables on the way. Binding a variable twice to different values
%% is a badmatch error, not an overwrite.
bind() ->
X = 1,
1 = X, %% fine, both sides are 1
{ok, Value} = {ok, 42},
Value.
%% Taking a value apart is the same operation as testing its shape.
destructure(Response) ->
case Response of
{ok, #{body := Body, status := 200}} -> {success, Body};
{ok, #{status := Status}} -> {unexpected, Status};
{error, timeout} -> retry;
{error, Reason} -> {give_up, Reason}
end.
%% Function clauses are pattern matching spread across several definitions.
%% The runtime tries them top to bottom.
http_message(200) -> "OK";
http_message(404) -> "Not Found";
http_message(500) -> "Server Error";
http_message(Code) when is_integer(Code) -> "Status " ++ integer_to_list(Code).
%% Matching on list shapes. [H | T] splits off the first element.
describe([]) -> "nothing";
describe([X]) -> "just " ++ X;
describe([X, Y]) -> X ++ " and " ++ Y;
describe([X | Rest]) -> X ++ " and " ++ integer_to_list(length(Rest)) ++ " more".
%% A variable used twice in one pattern means "these two must be equal".
%% Nothing else in mainstream languages does this.
first_error([{error, Reason}, {error, Reason} | _]) -> {repeated, Reason};
first_error([{error, Reason} | _]) -> {first, Reason};
first_error([_ | Rest]) -> first_error(Rest);
first_error([]) -> none.
%% Guards restrict a clause further. Only a fixed set of side effect free
%% tests is allowed in one, which is why they can never fail halfway.
area({circle, R}) when R > 0 -> math:pi() * R * R;
area({rectangle, W, H}) when W > 0, H > 0 -> W * H;
area({square, S}) when S > 0 -> S * S.
Modules, clauses and guards
Functions are defined by several clauses tried in order, identified by name and arity, and grouped into modules that export what the world may call.
Every function lives in a module and is known by its name and its arity together. area/2 and area/3 are two unrelated functions, and only what appears in the -export attribute can be called from outside.
-module(functions).
-export([double/1, area/2, classify/1, bmi_tell/2, cylinder_area/2,
add_one_to_all/1, apply_twice/2, make_adder/1, pipeline/1]).
double(X) -> X * 2.
%% Arguments are positional and the arity is part of the name. area/2 and
%% area/3 are two different functions that happen to share a spelling.
area(Width, Height) -> Width * Height.
%% Guards choose between clauses. They run left to right and the first that
%% holds wins. A comma in a guard means and, a semicolon means or.
classify(N) when N < 0 -> negative;
classify(0) -> zero;
classify(N) when N < 10 -> small;
classify(_) -> large.
%% There is no `where`. Intermediate values are just variables, and since
%% each one is bound exactly once, reading top to bottom always tells the
%% truth about what they hold.
bmi_tell(Weight, Height) ->
Bmi = Weight / (Height * Height),
Verdict =
if
Bmi =< 18.5 -> underweight;
Bmi =< 25.0 -> normal;
true -> overweight %% `true` is the else branch
end,
{Verdict, Bmi}.
cylinder_area(Radius, Height) ->
Side = 2 * math:pi() * Radius * Height,
Cap = math:pi() * Radius * Radius,
Side + 2 * Cap.
add_one_to_all(Numbers) ->
lists:map(fun(N) -> N + 1 end, Numbers).
%% Functions are values. A fun can be passed, stored in a map, or sent to
%% another process on another machine.
apply_twice(Fun, Value) -> Fun(Fun(Value)).
%% A fun closes over the variables around it, which is how you get something
%% that behaves like partial application.
make_adder(N) ->
fun(X) -> X + N end.
%% There is no composition operator, so a pipeline is either nested calls or
%% a fold over a list of funs.
pipeline(Value) ->
Steps = [fun(X) -> X * 2 end, fun(X) -> X + 1 end, fun integer_to_list/1],
lists:foldl(fun(Step, Acc) -> Step(Acc) end, Value, Steps).
def double(x: int) -> int:
return x * 2
def area(width: float, height: float) -> float:
return width * height
def classify(n: int) -> str:
if n < 0:
return "negative"
if n == 0:
return "zero"
if n < 10:
return "small"
return "large"
def bmi_tell(weight: float, height: float) -> str:
bmi = weight / height**2
if bmi <= 18.5:
return f"underweight, bmi {bmi}"
if bmi <= 25.0:
return f"normal, bmi {bmi}"
return f"overweight, bmi {bmi}"
def cylinder_area(radius: float, height: float) -> float:
side = 2 * 3.141592653589793 * radius * height
cap = 3.141592653589793 * radius**2
return side + 2 * cap
def add_one_to_all(xs: list[int]) -> list[int]:
return [n + 1 for n in xs]
export const double = (x) => x * 2;
export const area = (width, height) => width * height;
export function classify(n) {
if (n < 0) return "negative";
if (n === 0) return "zero";
if (n < 10) return "small";
return "large";
}
export function bmiTell(weight, height) {
const bmi = weight / height ** 2;
if (bmi <= 18.5) return `underweight, bmi ${bmi}`;
if (bmi <= 25.0) return `normal, bmi ${bmi}`;
return `overweight, bmi ${bmi}`;
}
export function cylinderArea(radius, height) {
const side = 2 * Math.PI * radius * height;
const cap = Math.PI * radius ** 2;
return side + 2 * cap;
}
export const addOneToAll = (xs) => xs.map((n) => n + 1);
Clauses instead of branches
Writing several clauses is the normal way to express a choice. The runtime tries them top to bottom and takes the first whose pattern matches and whose guard holds. If none of them do, the process crashes with function_clause, which is the right outcome: the caller passed something the function was never designed for, and continuing would be a guess.
Guards are a small, safe language
Only a fixed set of expressions is allowed in a guard: comparisons, arithmetic, boolean operators and the type tests. No function you wrote, no message sends, nothing that can have a side effect. That restriction is what lets the runtime evaluate guards while choosing a clause and never leave anything half done.
| Guard | True when |
|---|---|
| is_integer(X), is_float(X), is_number(X) | the type is what it says |
| is_atom(X), is_binary(X), is_list(X), is_map(X) | likewise |
| is_pid(X), is_reference(X), is_function(X, 2) | likewise, with arity |
| X > 0, X =:= ok, X =/= undefined | comparisons, strict and loose |
| length(L) > 2, map_size(M) =:= 0, byte_size(B) < 100 | size tests |
| A > 0, B > 0 | a comma means and |
| A > 0; B > 0 | a semicolon means or |
Lists and comprehensions
Linked lists, the lists module, and comprehensions that read the same way Python's do.
A list is a linked list. [Head | Tail] is both how you build one and how you take it apart, and that symmetry is why so much Erlang code is a recursion over a list.
Adding to the front is constant time. Appending with ++ copies the whole left hand list, so building a result by appending in a loop is quadratic. Build it backwards with [X | Acc] and call lists:reverse/1 once at the end.
-module(lists_demo).
-export([basics/0, comprehensions/0, pythagorean/0, initials/1, folds/1,
keyfind_demo/0, higher_order/0]).
basics() ->
Primes = [2, 3, 5, 7, 11],
{ [1 | Primes] %% cons is O(1)
, lists:nth(2, Primes) %% indexing is O(n), so avoid it in loops
, lists:seq(1, 10)
, lists:seq(10, 1, -1)
, lists:sublist(Primes, 3)
, lists:last(Primes)
}.
%% A comprehension is a generator, some filters and an expression. It reads
%% the same way as the Python version and compiles to a plain recursion.
comprehensions() ->
Squares = [N * N || N <- lists:seq(1, 10)],
Evens = [N || N <- lists:seq(1, 20), N rem 2 =:= 0],
%% A pattern in a generator quietly skips anything that does not match,
%% which is the neatest way to filter and destructure at once.
People = [{ada, 36}, {grace, 45}, unknown, {alan, 41}],
Names = [Name || {Name, _Age} <- People],
{Squares, Evens, Names}.
pythagorean() ->
[{A, B, C}
|| C <- lists:seq(1, 20),
B <- lists:seq(1, C),
A <- lists:seq(1, B),
A * A + B * B =:= C * C].
initials(Sentence) ->
[Char || [Char | _] <- string:tokens(Sentence, " ")].
%% foldl is the workhorse. foldr exists but builds the whole call stack, so
%% for anything long reach for foldl and reverse at the end if order matters.
folds(Numbers) ->
Sum = lists:foldl(fun(X, Acc) -> X + Acc end, 0, Numbers),
Max = lists:foldl(fun erlang:max/2, hd(Numbers), Numbers),
Reversed = lists:foldl(fun(X, Acc) -> [X | Acc] end, [], Numbers),
{Sum, Max, Reversed}.
%% Lists of tagged tuples are everywhere in Erlang, and the keyX functions
%% are how you work with them.
keyfind_demo() ->
People = [{ada, 36}, {grace, 45}],
{ lists:keyfind(ada, 1, People)
, lists:keyfind(alan, 1, People) %% false, not an exception
, lists:keystore(alan, 1, People, {alan, 41})
, lists:keydelete(ada, 1, People)
, proplists:get_value(age, [{age, 36}], 0)
}.
higher_order() ->
Words = ["ada", "grace", "alan", "barbara"],
{ lists:map(fun string:to_upper/1, Words)
, lists:filter(fun(W) -> length(W) =< 4 end, Words)
, lists:any(fun(W) -> length(W) > 6 end, Words)
, lists:all(fun(W) -> length(W) > 2 end, Words)
, lists:sort(fun(A, B) -> length(A) =< length(B) end, Words)
, lists:partition(fun(W) -> length(W) =< 4 end, Words)
, lists:zip([1, 2, 3], [a, b, c])
}.
from itertools import count, islice
primes = [2, 3, 5, 7, 11]
# Python lists are arrays, so prepending copies the whole thing.
with_one = [1, *primes]
countdown = list(range(10, 0, -1))
evens = list(range(0, 21, 2))
letters = [chr(c) for c in range(ord("a"), ord("f"))]
# There is no lazy list literal, so an infinite sequence needs itertools.
squares = [n * n for n in islice(count(1), 10)]
pythagorean = [
(a, b, c)
for c in range(1, 21)
for b in range(1, c + 1)
for a in range(1, b + 1)
if a * a + b * b == c * c
]
def initials(sentence: str) -> str:
return "".join(word[0] for word in sentence.split())
def summary(xs: list[int]) -> tuple[int, int, list[int], list[int]]:
total = sum(xs)
# Slicing and reversed() copy, but sort() and append() do not: some list
# methods return a new list and some change the one you have.
return (len(xs), total, xs[:3], list(reversed(xs)))
export const primes = [2, 3, 5, 7, 11];
export const withOne = [1, ...primes];
export const countdown = Array.from({ length: 10 }, (_, i) => 10 - i);
export const evens = Array.from({ length: 11 }, (_, i) => i * 2);
export const letters = Array.from({ length: 5 }, (_, i) =>
String.fromCharCode(97 + i),
);
// Nothing is lazy, so an endless sequence needs a generator.
function* naturals() {
for (let n = 1; ; n++) yield n;
}
export const squares = [];
for (const n of naturals()) {
if (squares.length === 10) break;
squares.push(n * n);
}
export const pythagorean = [];
for (let c = 1; c <= 20; c++) {
for (let b = 1; b <= c; b++) {
for (let a = 1; a <= b; a++) {
if (a * a + b * b === c * c) pythagorean.push([a, b, c]);
}
}
}
export const initials = (sentence) =>
sentence
.split(/\s+/)
.filter(Boolean)
.map((word) => word[0])
.join("");
// sort() and reverse() change the array in place and also return it, which
// is the source of a great many surprises.
export const summary = (xs) => [xs.length, xs.reduce((a, b) => a + b, 0), xs.slice(0, 3), [...xs].reverse()];
Both need a generator to express an endless sequence. Erlang has no lazy lists either, so infinite sequences are a process that sends the next value when asked.
The lists module, by frequency of use
| Function | What it does |
|---|---|
| lists:map/2, lists:filter/2 | the obvious two |
| lists:foldl/3 | the workhorse. foldr exists and builds a deep stack |
| lists:foreach/2 | when you want the side effects and not the results |
| lists:sort/1, lists:sort/2 | merge sort, stable, with an optional comparator |
| lists:usort/1 | sort and remove duplicates in one pass |
| lists:keyfind/3, lists:keystore/4 | working with lists of tagged tuples |
| lists:member/2 | is it in there. Linear, so a map is often better |
| lists:seq/2, lists:seq/3 | ranges |
| lists:zip/2, lists:unzip/1 | pairing up and splitting apart |
| lists:partition/2, lists:splitwith/2 | one pass, two results |
| lists:flatten/1 | rarely what you want. Prefer an iolist |
| lists:sublist/2, lists:nth/2 | taking. nth is linear, so avoid it in a loop |
Try it yourself
Write initials/1, which turns "ada lovelace king" into "alk", and make it survive extra spaces without crashing.
Hint: A pattern in a comprehension generator skips anything that does not match, including the empty string.
Show one solution Hide the solution
-module(lists_demo).
-export([basics/0, comprehensions/0, pythagorean/0, initials/1, folds/1,
keyfind_demo/0, higher_order/0]).
basics() ->
Primes = [2, 3, 5, 7, 11],
{ [1 | Primes] %% cons is O(1)
, lists:nth(2, Primes) %% indexing is O(n), so avoid it in loops
, lists:seq(1, 10)
, lists:seq(10, 1, -1)
, lists:sublist(Primes, 3)
, lists:last(Primes)
}.
%% A comprehension is a generator, some filters and an expression. It reads
%% the same way as the Python version and compiles to a plain recursion.
comprehensions() ->
Squares = [N * N || N <- lists:seq(1, 10)],
Evens = [N || N <- lists:seq(1, 20), N rem 2 =:= 0],
%% A pattern in a generator quietly skips anything that does not match,
%% which is the neatest way to filter and destructure at once.
People = [{ada, 36}, {grace, 45}, unknown, {alan, 41}],
Names = [Name || {Name, _Age} <- People],
{Squares, Evens, Names}.
pythagorean() ->
[{A, B, C}
|| C <- lists:seq(1, 20),
B <- lists:seq(1, C),
A <- lists:seq(1, B),
A * A + B * B =:= C * C].
initials(Sentence) ->
[Char || [Char | _] <- string:tokens(Sentence, " ")].
%% foldl is the workhorse. foldr exists but builds the whole call stack, so
%% for anything long reach for foldl and reverse at the end if order matters.
folds(Numbers) ->
Sum = lists:foldl(fun(X, Acc) -> X + Acc end, 0, Numbers),
Max = lists:foldl(fun erlang:max/2, hd(Numbers), Numbers),
Reversed = lists:foldl(fun(X, Acc) -> [X | Acc] end, [], Numbers),
{Sum, Max, Reversed}.
%% Lists of tagged tuples are everywhere in Erlang, and the keyX functions
%% are how you work with them.
keyfind_demo() ->
People = [{ada, 36}, {grace, 45}],
{ lists:keyfind(ada, 1, People)
, lists:keyfind(alan, 1, People) %% false, not an exception
, lists:keystore(alan, 1, People, {alan, 41})
, lists:keydelete(ada, 1, People)
, proplists:get_value(age, [{age, 36}], 0)
}.
higher_order() ->
Words = ["ada", "grace", "alan", "barbara"],
{ lists:map(fun string:to_upper/1, Words)
, lists:filter(fun(W) -> length(W) =< 4 end, Words)
, lists:any(fun(W) -> length(W) > 6 end, Words)
, lists:all(fun(W) -> length(W) > 2 end, Words)
, lists:sort(fun(A, B) -> length(A) =< length(B) end, Words)
, lists:partition(fun(W) -> length(W) =< 4 end, Words)
, lists:zip([1, 2, 3], [a, b, c])
}.
Recursion and tail calls
There is no for and no while, because there is nothing to increment. A loop becomes a function whose arguments carry the state.
This is the change that sounds largest and turns out to be smallest. A loop has a starting state, a rule for the next state, and a stopping condition. A recursive function has a base case and a case that makes the problem smaller, which is the same three things.
-module(recursion).
-export([factorial/1, sum/1, sum_tail/1, reverse/1, quicksort/1, fib/1,
collatz/1, count_down/1]).
%% The base case first, then the case that makes the problem smaller.
factorial(0) -> 1;
factorial(N) when N > 0 -> N * factorial(N - 1).
%% Body recursive: the addition happens after the recursive call returns, so
%% the stack grows with the length of the list.
sum([]) -> 0;
sum([Head | Tail]) -> Head + sum(Tail).
%% Tail recursive: the recursive call is the last thing that happens, so the
%% BEAM reuses the same stack frame. This runs in constant space over a list
%% of any length, and it is the shape almost all Erlang loops take.
sum_tail(Numbers) -> sum_tail(Numbers, 0).
sum_tail([], Acc) -> Acc;
sum_tail([Head | Tail], Acc) -> sum_tail(Tail, Head + Acc).
%% The accumulator replaces the variable you would have mutated. Building the
%% result backwards and reversing once at the end is idiomatic and fast.
reverse(List) -> reverse(List, []).
reverse([], Acc) -> Acc;
reverse([Head | Tail], Acc) -> reverse(Tail, [Head | Acc]).
%% Readable rather than fast, exactly like the Haskell one liner. For real
%% work call lists:sort/1, which is a well tuned merge sort.
quicksort([]) -> [];
quicksort([Pivot | Rest]) ->
quicksort([X || X <- Rest, X =< Pivot])
++ [Pivot]
++ quicksort([X || X <- Rest, X > Pivot]).
fib(N) -> fib(N, 0, 1).
fib(0, A, _B) -> A;
fib(N, A, B) when N > 0 -> fib(N - 1, B, A + B).
collatz(1) -> [1];
collatz(N) when N rem 2 =:= 0 -> [N | collatz(N div 2)];
collatz(N) -> [N | collatz(3 * N + 1)].
%% A loop that only does side effects still returns something. ok is the
%% conventional "nothing useful to say" value.
count_down(0) ->
io:format("liftoff~n"),
ok;
count_down(N) when N > 0 ->
io:format("~p...~n", [N]),
count_down(N - 1).
from functools import lru_cache
from itertools import islice
def factorial(n: int) -> int:
return 1 if n == 0 else n * factorial(n - 1)
def my_sum(xs: list[int]) -> int:
# Recursion over a list of more than about a thousand items hits
# RecursionError, so real Python code writes the loop instead.
if not xs:
return 0
return xs[0] + my_sum(xs[1:])
def my_reverse(xs: list[int]) -> list[int]:
result: list[int] = []
for x in xs:
result.insert(0, x)
return result
def quick_sort(xs: list[int]) -> list[int]:
if not xs:
return []
pivot, rest = xs[0], xs[1:]
smaller = [x for x in rest if x <= pivot]
larger = [x for x in rest if x > pivot]
return quick_sort(smaller) + [pivot] + quick_sort(larger)
def fibs():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
first_ten_fibs = list(islice(fibs(), 10))
@lru_cache(maxsize=None)
def collatz_length(n: int) -> int:
if n == 1:
return 1
return 1 + collatz_length(n // 2 if n % 2 == 0 else 3 * n + 1)
export const factorial = (n) => (n === 0 ? 1n : BigInt(n) * factorial(n - 1));
export function mySum(xs) {
// No tail call optimisation in practice, so deep recursion overflows the
// stack and real code writes a loop.
if (xs.length === 0) return 0;
const [first, ...rest] = xs;
return first + mySum(rest);
}
export function myReverse(xs) {
let result = [];
for (const x of xs) result = [x, ...result];
return result;
}
export function quickSort(xs) {
if (xs.length === 0) return [];
const [pivot, ...rest] = xs;
const smaller = rest.filter((x) => x <= pivot);
const larger = rest.filter((x) => x > pivot);
return [...quickSort(smaller), pivot, ...quickSort(larger)];
}
export function* fibs() {
let [a, b] = [0, 1];
for (;;) {
yield a;
[a, b] = [b, a + b];
}
}
export function collatz(n) {
const path = [n];
while (n !== 1) {
n = n % 2 === 0 ? n / 2 : 3 * n + 1;
path.push(n);
}
return path;
}
Both hit a stack limit long before the data does, which is why the idiomatic versions use loops. The BEAM turns a tail call into a jump, so the Erlang version has no such ceiling.
Tail calls are the whole trick
When the recursive call is the last thing a clause does, the runtime reuses the current stack frame instead of pushing a new one. The function runs in constant space no matter how long the list is, and it compiles to something very close to a loop.
The difference is visible in the two sums above. sum/1 has to remember to add Head after the recursive call returns, so it builds a frame per element. sum_tail/2 does the addition on the way in and carries the answer along, so it builds none.
This matters more than usual in Erlang, because a process loop is an infinite tail call. A server that has handled ten million messages is still on its first stack frame.
Records and maps
Two ways to hold structured data, with different tradeoffs. Records are a compile time convenience over tuples; maps are real runtime dictionaries.
A record is a tuple with named fields. The names exist only at compile time: #user{id = 1} becomes {user, 1, undefined, false} in the compiled code, and reading a field is element/2. That makes them fast and rigid.
A map is a real dictionary, checked at runtime, able to gain and lose keys. It is what you want for anything whose shape varies or that crosses a boundary into JSON, another node, or a database.
-module(shapes).
-export([new_user/2, promote/1, describe/1, update_settings/2, totals/1,
to_map/1, from_map/1]).
%% A record is a tuple with named fields. The names are erased at compile
%% time, so access is as fast as element/2 and costs nothing at runtime.
-record(user, {id, name, email, admin = false}).
%% A map is checked at runtime and can grow new keys. Records are for fixed
%% internal structures, maps for anything that crosses a boundary.
new_user(Id, Name) ->
#user{id = Id, name = Name, email = undefined}.
%% Updating a record copies it with some fields changed. The original is
%% still there, unchanged, for anyone else holding it.
promote(User) ->
User#user{admin = true}.
describe(#user{name = Name, admin = true}) ->
io_lib:format("~s (admin)", [Name]);
describe(#user{name = Name}) ->
io_lib:format("~s", [Name]).
%% := requires the key to exist already, so a typo crashes here instead of
%% quietly adding a field nobody reads.
update_settings(Settings, Changes) ->
maps:fold(fun(Key, Value, Acc) -> Acc#{Key := Value} end, Settings, Changes).
totals(Orders) ->
lists:foldl(
fun(#{item := Item, cost := Cost}, Acc) ->
maps:update_with(Item, fun(Old) -> Old + Cost end, Cost, Acc)
end,
#{},
Orders
).
%% Converting between the two is a couple of lines, and worth writing at the
%% edges of a system so records stay internal.
to_map(#user{id = Id, name = Name, email = Email, admin = Admin}) ->
#{id => Id, name => Name, email => Email, admin => Admin}.
from_map(#{id := Id, name := Name} = Map) ->
#user{
id = Id,
name = Name,
email = maps:get(email, Map, undefined),
admin = maps:get(admin, Map, false)
}.
from dataclasses import dataclass, replace
from enum import Enum, auto
from math import pi, sqrt
class Direction(Enum):
NORTH = auto()
SOUTH = auto()
EAST = auto()
WEST = auto()
@dataclass(frozen=True)
class Circle:
radius: float
@dataclass(frozen=True)
class Rectangle:
width: float
height: float
@dataclass(frozen=True)
class Triangle:
a: float
b: float
c: float
Shape = Circle | Rectangle | Triangle
def area(shape: Shape) -> float:
# A missing branch is a runtime surprise unless a type checker is run
# separately. The Erlang compiler warns, and a missing clause crashes the process rather than the system.
match shape:
case Circle(radius):
return pi * radius * radius
case Rectangle(width, height):
return width * height
case Triangle(a, b, c):
s = (a + b + c) / 2
return sqrt(s * (s - a) * (s - b) * (s - c))
raise ValueError(f"unknown shape: {shape}")
@dataclass(frozen=True)
class Employee:
name: str
email: str
salary: int
def raise_salary(amount: int, employee: Employee) -> Employee:
# frozen=True plus replace() is the closest Python gets to Erlang record update.
return replace(employee, salary=employee.salary + amount)
export const Direction = Object.freeze({
North: "North",
South: "South",
East: "East",
West: "West",
});
// A tag field stands in for a real sum type.
export const circle = (radius) => ({ kind: "circle", radius });
export const rectangle = (width, height) => ({ kind: "rectangle", width, height });
export const triangle = (a, b, c) => ({ kind: "triangle", a, b, c });
export function area(shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle": {
const s = (shape.a + shape.b + shape.c) / 2;
return Math.sqrt(s * (s - shape.a) * (s - shape.b) * (s - shape.c));
}
// Forget a case and the answer is quietly undefined.
default:
throw new Error(`unknown shape: ${shape.kind}`);
}
}
// Spread copies one level. Anything nested is still shared with the
// original, which is where "why did that change" bugs come from.
export const raiseSalary = (amount, employee) => ({
...employee,
salary: employee.salary + amount,
});
A frozen dataclass is the closest Python equivalent to a record, and replace() is the closest thing to record update syntax.
| Record | Map | |
|---|---|---|
| Defined | at compile time, in a .hrl or module | never, it is just data |
| Field access | User#user.name, constant time | maps:get(name, M), near constant |
| Unknown field | compile error | runtime error, or a default |
| New field later | recompile every module that uses it | just put it in |
| Pattern matching | #user{name = N} | #{name := N} |
| Prints as | a tuple, unless the shell knows the record | readably |
| Good for | hot internal state, fixed shapes | config, JSON, messages, anything shared |
Errors, exits and let it crash
Erlang has exceptions and mostly does not use them. Expected failures are return values, unexpected ones kill the process, and something else is responsible for what happens next.
The phrase let it crash gets quoted so often that it starts to sound like carelessness. It is the opposite. It means: handle the cases you genuinely understand, and for everything else, stop immediately rather than carry on in a state you did not design for.
That is only reasonable because a crash in Erlang is small. One process dies. It has its own heap, so nothing it was holding is left half updated, and something above it is already responsible for starting a clean replacement.
-module(errors).
-export([tagged/1, raising/1, catching/1, cleanup/1, custom/1, crash_it/0]).
%% Style one, and the one to reach for first: return a tagged tuple. An
%% expected failure is a value, so the caller has to match on it.
tagged(Input) ->
case string:to_integer(Input) of
{error, Reason} -> {error, Reason};
{Number, _Rest} when Number < 0 -> {error, negative};
{Number, _Rest} -> {ok, Number}
end.
%% Style two: raise. There are three classes, and they mean different things.
%% error : a bug. Something the code did not expect.
%% exit : this process is finished, deliberately.
%% throw : a non local return, for control flow inside one module.
raising(bug) -> error(badarg);
raising(done) -> exit(normal);
raising(escape) -> throw({not_found, here});
raising(fine) -> ok.
%% try catches all three, and the pattern is Class:Reason:Stacktrace.
catching(Input) ->
try raising(Input) of
Result -> {returned, Result}
catch
error:Reason:Stack ->
{caught_error, Reason, length(Stack)};
exit:Reason ->
{caught_exit, Reason};
throw:Thrown ->
{caught_throw, Thrown}
end.
%% after always runs, whether or not something was raised. Use it to close
%% what you opened.
cleanup(Path) ->
{ok, Handle} = file:open(Path, [read]),
try
file:read_line(Handle)
after
file:close(Handle)
end.
%% Attaching context to a raise. The stacktrace keeps the original site.
custom(Value) ->
try
check(Value)
catch
error:Reason:Stack ->
erlang:raise(error, {validation_failed, Value, Reason}, Stack)
end.
check(Value) when is_integer(Value), Value > 0 -> ok;
check(_) -> error(not_a_positive_integer).
%% The fourth option, and often the right one: do not handle it. Let the
%% process die and let its supervisor start a clean one. Code that only
%% handles the cases it understands stays much shorter.
crash_it() ->
{ok, Value} = {error, whatever},
Value.
class ValidationError(ValueError):
"""Raised for input that cannot be used."""
def parse_age(raw: str) -> int:
# The failure is invisible from the outside. Nothing in the signature
# says this can raise, and nothing makes the caller catch it.
try:
n = int(raw)
except ValueError as problem:
raise ValidationError(f"not a number: {raw}") from problem
if n < 0:
raise ValidationError(f"negative: {n}")
return n
def process(rows: list[str]) -> list[int]:
results = []
for row in rows:
try:
results.append(parse_age(row))
except ValidationError as problem:
# The choice is between swallowing it and losing information, or
# letting it propagate and taking down whatever was above.
print(f"skipping: {problem}")
return results
def worker() -> None:
# A thread that raises dies quietly. Nothing restarts it, nothing tells
# anyone, and the pool is one worker smaller until someone notices.
raise RuntimeError("connection lost")
export class ValidationError extends Error {}
export function parseAge(raw) {
// Nothing in the signature says this throws, and nothing forces a catch.
const n = Number(raw);
if (!Number.isInteger(n)) throw new ValidationError(`not a number: ${raw}`);
if (n < 0) throw new ValidationError(`negative: ${n}`);
return n;
}
export function process(rows) {
const results = [];
for (const row of rows) {
try {
results.push(parseAge(row));
} catch (problem) {
console.warn(`skipping: ${problem.message}`);
}
}
return results;
}
// An unhandled rejection ends the process by default in modern Node, so one
// forgotten await in one request handler takes down every other request in
// flight with it.
export async function worker() {
throw new Error("connection lost");
}
In both, an unhandled failure takes down the worker or the whole process, and nothing built into the language decides what happens next.
Three classes, three meanings
| Class | Raised by | Means |
|---|---|---|
| error | error(Reason), or a bad match, or a bad argument | a bug. The code met something it was not written for |
| exit | exit(Reason) | this process is finished, deliberately |
| throw | throw(Term) | a non local return, for control flow inside one module |
The distinction matters because links and monitors carry the reason, and supervisors treat normal and shutdown differently from everything else. A process that exits with normal has finished its job; one that exits with badarg has failed.
Which style, when
return it
For failures you expect
A missing key, a malformed request, a refused connection. Return {ok, Value} or {error, Reason} and let the caller match on it. This is what almost all of OTP does, which is why the tagged tuple is so recognisable.
crash
For failures you do not
A response that does not have the shape the protocol promised. Write the match you expect, and if it fails, the process dies with a clear badmatch naming exactly what arrived. No error handling code to write, and none to get wrong.
catch
At a boundary
At the edge of a request handler, or around a call into code you do not control, where you want to turn a crash into a 500 rather than lose the connection. Catching in the middle of your own logic is usually a sign the logic should be in its own process.
after
For cleanup
try ... after ... end runs the after block whether or not something was raised. Use it for file handles and sockets. Note that if the process is killed outright, after does not run, which is one more reason resources belong to the process that owns them.
Processes, mailboxes and receive
The actor model, in the language rather than in a library. Spawn is cheap, sending never blocks, and state lives in a loop instead of a variable.
An Erlang process is not a thread. The virtual machine schedules it itself, gives it its own heap and its own garbage collector, and preempts it after a fixed amount of work, so no process can starve another by running a long loop.
Creating one costs a few microseconds and about 300 words. That price is what makes the standard design possible: a process per connection, per chat room, per job, per state machine, rather than a pool of workers shared between them.
-module(processes).
-export([start/0, loop/1, call/2, cast/2, ping_pong/0, pong/0, spawn_many/1]).
%% spawn returns a process identifier immediately. The new process runs
%% ?MODULE:loop(0) and has its own heap, its own stack and its own mailbox.
%% It costs about 300 words to create, so making a million of them is a
%% design decision rather than a stunt.
start() ->
spawn(?MODULE, loop, [0]).
%% This is the entire pattern for stateful code in Erlang: a function that
%% waits for a message, works out the new state, and calls itself with it.
%% The state is an argument, so nothing else can reach in and change it.
loop(Count) ->
receive
{increment, N} ->
loop(Count + N);
{value, From, Ref} ->
From ! {reply, Ref, Count},
loop(Count);
stop ->
ok
after 300000 ->
%% An idle timeout. Without one, a forgotten process waits forever.
io:format("counter idle for five minutes, stopping~n"),
ok
end.
%% Asking for an answer means sending a message and waiting for the reply.
%% The unique reference is what stops an old, late reply from being mistaken
%% for this one, and the timeout is what stops the caller hanging forever.
%% Getting all three details right every time is why gen_server exists.
call(Pid, Request) ->
Ref = make_ref(),
Pid ! {Request, self(), Ref},
receive
{reply, Ref, Reply} -> {ok, Reply}
after 5000 ->
{error, timeout}
end.
%% Fire and forget. The send always succeeds, even if the process is dead.
cast(Pid, Message) ->
Pid ! Message,
ok.
%% receive scans the mailbox for the first message that matches any clause,
%% which is why this works no matter what order the two messages arrive in.
ping_pong() ->
Pong = spawn(?MODULE, pong, []),
Pong ! {ping, self()},
receive
pong -> {ok, Pong}
after 1000 ->
{error, no_reply}
end.
pong() ->
receive
{ping, From} ->
From ! pong,
pong();
stop ->
ok
end.
%% A hundred thousand processes on a laptop, in well under a second.
spawn_many(N) ->
Parent = self(),
Started = erlang:monotonic_time(millisecond),
[spawn(fun() -> Parent ! {done, self()} end) || _ <- lists:seq(1, N)],
collect(N),
erlang:monotonic_time(millisecond) - Started.
collect(0) -> ok;
collect(N) ->
receive
{done, _Pid} -> collect(N - 1)
end.
1> self().
<0.85.0>
2> Pid = spawn(fun() -> receive Msg -> io:format("got ~p~n", [Msg]) end end).
<0.88.0>
3> Pid ! hello.
got hello
hello
4> is_process_alive(Pid).
false
5> Pid ! anything.
anything
6> Counter = counter:start().
<0.92.0>
7> counter:add(Counter, 10).
ok
8> counter:value(Counter).
10
9> length(processes()).
58
10> erlang:process_info(Counter, [message_queue_len, memory]).
[{message_queue_len,0},{memory,2688}]
11> exit(Counter, kill).
true
12> is_process_alive(Counter).
false
import threading
class Counter:
"""Shared mutable state, which is the thing Erlang does not have."""
def __init__(self) -> None:
self.count = 0
# Nothing in the language ties this lock to the field it protects.
# Remembering to take it is a convention enforced by code review.
self._lock = threading.Lock()
def add(self, amount: int) -> None:
with self._lock:
self.count += amount
def value(self) -> int:
with self._lock:
return self.count
def main() -> None:
counter = Counter()
workers = [
threading.Thread(target=lambda: [counter.add(1) for _ in range(100)])
for _ in range(100)
]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
# Threads are OS threads: about 8 MB of stack each by default, so a
# hundred thousand of them is not an option. CPU bound work also shares
# one interpreter lock, so the answer there is multiprocessing.
print(counter.value())
if __name__ == "__main__":
main()
// One thread, so there is no lock to forget. The cost is that nothing runs
// while anything else is running: a slow loop in one request handler blocks
// every other request, every timer, and the health check.
class Counter {
#count = 0;
add(amount) {
this.#count += amount;
}
get value() {
return this.#count;
}
}
const counter = new Counter();
for (let i = 0; i < 10000; i++) counter.add(1);
console.log(counter.value);
// Real parallelism needs worker_threads, and workers cannot share this
// object. State is copied through messages, or squeezed into a
// SharedArrayBuffer with manual Atomics, which is the lock problem again
// with fewer tools.
export { Counter };
Python needs a lock, and nothing in the language ties the lock to the field it protects. Node has one thread, so there is no lock to forget and also no parallelism.
Three rules that explain the rest
| Rule | Consequence |
|---|---|
| Sending never blocks and never fails | Pid ! Message returns immediately, even if the process is dead, overloaded or on another continent. There is no delivery receipt, so if you need one, ask for a reply. |
| Messages are copied | The receiver gets its own copy, so neither side can change what the other holds. Large binaries are the exception and are shared by reference. |
| Order is preserved between two processes | Messages from A to B arrive in the order A sent them. Nothing is promised about messages from different senders, which is the same guarantee TCP gives you and no more. |
Selective receive
receive does not take the first message in the mailbox. It scans for the first message that matches any of its clauses and leaves the rest where they were. That is what makes the request and reply pattern work: waiting for a reply tagged with a specific reference will not accidentally consume some unrelated message that arrived first.
It is also the one performance trap. If a mailbox has ten thousand messages that match nothing, every receive scans all ten thousand before giving up. Always have a clause that matches anything, or an after timeout, so a mailbox cannot fill up with mail nobody will ever read.
Try it yourself
Write call/2: send a request, wait for the reply, and return {error, timeout} if nothing arrives within five seconds. Make sure a late reply to an earlier call cannot be mistaken for this one's.
Hint: make_ref/0 gives you a value that is unique across the whole cluster. Put it in the request and match on it in the reply.
Show one solution Hide the solution
-module(processes).
-export([start/0, loop/1, call/2, cast/2, ping_pong/0, pong/0, spawn_many/1]).
%% spawn returns a process identifier immediately. The new process runs
%% ?MODULE:loop(0) and has its own heap, its own stack and its own mailbox.
%% It costs about 300 words to create, so making a million of them is a
%% design decision rather than a stunt.
start() ->
spawn(?MODULE, loop, [0]).
%% This is the entire pattern for stateful code in Erlang: a function that
%% waits for a message, works out the new state, and calls itself with it.
%% The state is an argument, so nothing else can reach in and change it.
loop(Count) ->
receive
{increment, N} ->
loop(Count + N);
{value, From, Ref} ->
From ! {reply, Ref, Count},
loop(Count);
stop ->
ok
after 300000 ->
%% An idle timeout. Without one, a forgotten process waits forever.
io:format("counter idle for five minutes, stopping~n"),
ok
end.
%% Asking for an answer means sending a message and waiting for the reply.
%% The unique reference is what stops an old, late reply from being mistaken
%% for this one, and the timeout is what stops the caller hanging forever.
%% Getting all three details right every time is why gen_server exists.
call(Pid, Request) ->
Ref = make_ref(),
Pid ! {Request, self(), Ref},
receive
{reply, Ref, Reply} -> {ok, Reply}
after 5000 ->
{error, timeout}
end.
%% Fire and forget. The send always succeeds, even if the process is dead.
cast(Pid, Message) ->
Pid ! Message,
ok.
%% receive scans the mailbox for the first message that matches any clause,
%% which is why this works no matter what order the two messages arrive in.
ping_pong() ->
Pong = spawn(?MODULE, pong, []),
Pong ! {ping, self()},
receive
pong -> {ok, Pong}
after 1000 ->
{error, no_reply}
end.
pong() ->
receive
{ping, From} ->
From ! pong,
pong();
stop ->
ok
end.
%% A hundred thousand processes on a laptop, in well under a second.
spawn_many(N) ->
Parent = self(),
Started = erlang:monotonic_time(millisecond),
[spawn(fun() -> Parent ! {done, self()} end) || _ <- lists:seq(1, N)],
collect(N),
erlang:monotonic_time(millisecond) - Started.
collect(0) -> ok;
collect(N) ->
receive
{done, _Pid} -> collect(N - 1)
end.
Links, monitors and exit signals
How one process finds out that another has died. Everything OTP does about failure is built out of these two primitives.
Processes are isolated, which raises an obvious question: if a process dies, who is told? The answer is nobody, unless somebody asked to be. There are two ways to ask, and the difference between them is worth getting straight early.
-module(links).
-export([watch/1, trapping/0, linked_pair/0, worker/1, unlinked/0]).
%% A monitor is one directional and does not affect the watched process.
%% This is what you want when you care that something died but are not
%% responsible for it.
watch(Pid) ->
Ref = erlang:monitor(process, Pid),
receive
{'DOWN', Ref, process, Pid, Reason} ->
{died, Reason}
after 5000 ->
erlang:demonitor(Ref, [flush]),
still_running
end.
%% A link is two directional. If either end dies abnormally the other is
%% taken down too, which is how a group of related processes fails as a unit
%% instead of leaving half of itself running.
linked_pair() ->
Child = spawn_link(?MODULE, worker, [ok]),
{linked_to, Child}.
worker(ok) ->
receive
stop -> ok
end;
worker(crash) ->
{ok, _} = {error, deliberate}.
%% Trapping exits converts the signal into an ordinary message, so this
%% process can decide what to do about it. This is exactly what a supervisor
%% does, and writing it once by hand is the best way to see that a supervisor
%% is not magic.
trapping() ->
process_flag(trap_exit, true),
Child = spawn_link(?MODULE, worker, [crash]),
receive
{'EXIT', Child, Reason} ->
{child_died, Reason}
after 1000 ->
no_exit_signal
end.
%% Without a link, the parent never hears about the crash at all.
unlinked() ->
spawn(?MODULE, worker, [crash]),
receive
Anything -> {unexpected, Anything}
after 500 ->
heard_nothing
end.
| Link | Monitor | |
|---|---|---|
| Direction | both ways | one way |
| Set up with | link/1, or spawn_link/1,3 | erlang:monitor(process, Pid) |
| When the other dies | you die too, unless you trap exits | you get a {'DOWN', ...} message |
| Can you have several | no, a link is a single connection | yes, each has its own reference |
| Use it for | processes that belong together and should fail together | watching something you do not own |
| Who uses it | supervisors, and workers that share a fate | gen_server calls, chat room members, connection pools |
Trapping exits turns a signal into a message
process_flag(trap_exit, true) says: do not kill me when a linked process dies, put {'EXIT', Pid, Reason} in my mailbox instead. That one line is the whole mechanism behind supervisors. A supervisor is a process that traps exits, links itself to its children, and starts a replacement when one of those messages arrives.
Writing it once by hand is the fastest way to stop thinking of supervision as magic. The version in the file above is about fifteen lines. What OTP adds is everything around it: restart limits, ordered shutdown, timeouts, child specifications, and the twenty years of edge cases nobody wants to rediscover.
-
An error reaches the top of a process
Nothing catches it, so the process terminates with that reason and its heap is released. Nothing else is affected yet.
-
Exit signals go out along every link
Each linked process either dies with the same reason, or, if it traps exits, receives an
{'EXIT', Pid, Reason}message. -
Monitors fire
Everyone monitoring the process receives a
{'DOWN', Ref, process, Pid, Reason}message. Monitors are one shot and clean themselves up. -
The supervisor decides
It restarts the child, or restarts the whole group, or, if this keeps happening, gives up and dies itself, passing the problem up the tree.
gen_server, the behaviour you will use most
A behaviour is an interface plus a battle tested implementation of everything around it. gen_server is the one that covers most stateful processes.
The hand written counter from the last chapter works. What it does not do is time out a caller, survive a code upgrade, respond to system messages, shut down in an orderly way, produce a useful crash report, or let a supervisor manage it. Adding all that is the same work every time, so OTP did it once.
A behaviour splits a process into two halves. The generic half, in OTP, runs the receive loop and handles the protocol. Your half is a set of callbacks that say what this particular server does with a request and how its state changes.
%% The same counter as the hand written process, as a gen_server. The extra
%% lines buy timeouts, synchronous calls, code upgrade, system messages,
%% orderly shutdown and the ability for a supervisor to manage it.
-module(counter_server).
-behaviour(gen_server).
%% The public interface. Callers never send messages by hand, so the
%% protocol stays an implementation detail of this module.
-export([start_link/0, increment/0, increment/1, value/0, reset/0, stop/0]).
%% The callbacks gen_server calls. These are never called by your own code.
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2,
code_change/3]).
-define(SERVER, ?MODULE).
%% ---------------------------------------------------------------- API
start_link() ->
gen_server:start_link({local, ?SERVER}, ?MODULE, 0, []).
increment() -> increment(1).
%% cast is asynchronous. It returns as soon as the message is on its way.
increment(N) when is_integer(N) ->
gen_server:cast(?SERVER, {increment, N}).
%% call is synchronous and waits for a reply, with a five second timeout by
%% default. If the server is dead, the caller crashes too, which is usually
%% the behaviour you want.
value() ->
gen_server:call(?SERVER, value).
reset() ->
gen_server:call(?SERVER, reset).
stop() ->
gen_server:stop(?SERVER).
%% ---------------------------------------------------------- callbacks
init(Initial) ->
%% Whatever this returns becomes the state passed to every callback.
{ok, Initial}.
handle_call(value, _From, Count) ->
{reply, Count, Count};
handle_call(reset, _From, _Count) ->
{reply, ok, 0};
handle_call(Request, _From, Count) ->
%% Answer rather than crash, so one bad caller cannot take the server
%% down and with it everyone else's state.
{reply, {error, {unknown_request, Request}}, Count}.
handle_cast({increment, N}, Count) ->
{noreply, Count + N};
handle_cast(_Ignored, Count) ->
{noreply, Count}.
%% Anything that arrives without going through call or cast lands here:
%% monitor DOWN messages, timeouts, and mail from processes that guessed.
handle_info(Info, Count) ->
logger:info("counter_server ignored ~p", [Info]),
{noreply, Count}.
terminate(_Reason, _Count) ->
ok.
%% Called during a hot upgrade, with the old state. Return the new shape.
code_change(_OldVsn, Count, _Extra) ->
{ok, Count}.
call, cast and info
| gen_server:call/2 | gen_server:cast/2 | a plain message | |
|---|---|---|---|
| Callback | handle_call/3 | handle_cast/2 | handle_info/2 |
| Caller waits | yes, five seconds by default | no | no |
| Gets a result | yes | no | no |
| If the server is dead | the caller crashes too | silently does nothing | silently does nothing |
| Back pressure | yes, the caller is blocked | none, the mailbox grows | none |
| Use for | reads, and writes that must be confirmed | fire and forget writes | monitors, timeouts, surprises |
The instinct from other languages is to make everything asynchronous because it sounds faster. In practice call should be the default. It gives you back pressure for free: if the server cannot keep up, callers wait, rather than filling a mailbox until the machine dies. Reach for cast when the caller genuinely does not need to know.
The callbacks, and what they return
-
init/1
Runs inside the new process, before
start_linkreturns. Return{ok, State}, or{stop, Reason}to refuse to start. Keep it fast: a supervisor starts its children one at a time, so slow initialisation delays the whole boot. If setup takes a while, return quickly and send yourself a message to continue, or use{ok, State, {continue, Term}}andhandle_continue/2. -
handle_call/3
Return
{reply, Reply, NewState}. Or{noreply, NewState}and reply later withgen_server:reply(From, Reply), which is how you answer a caller only once some other work finishes without blocking the server meanwhile. -
handle_cast/2 and handle_info/2
Both return
{noreply, NewState}or{stop, Reason, NewState}. Always write a catch all clause forhandle_info/2: unexpected messages do arrive, and crashing on them is rarely what you want. -
terminate/2 and code_change/3
terminate/2runs on an orderly shutdown, and not if the process is killed, so it is for tidiness rather than for anything that must happen.code_change/3converts old state to new during a hot upgrade.
Supervision trees
A supervisor starts children, watches them, and restarts them from a known good state. Structuring a system as a tree of these is what OTP is really for.
A supervisor is a process that does nothing but start other processes and react when they die. It contains no business logic at all, and that is deliberate: the part of the system responsible for recovery should be too boring to have bugs in it.
%% The supervisor. It knows how to start its children and what to do when
%% one of them dies. It contains no business logic at all, which is the
%% point: the part that restarts things is too important to be interesting.
-module(chat_sup).
-behaviour(supervisor).
-export([start_link/0, start_room/1, stop_room/1]).
-export([init/1]).
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
init([]) ->
SupFlags = #{
%% one_for_one: restart only the child that died.
%% one_for_all: restart every child, for state that must agree.
%% rest_for_one: restart it and everything started after it.
strategy => one_for_one,
%% More than five restarts in ten seconds means restarting is not
%% working, so this supervisor gives up and dies too. The failure
%% then travels up the tree to someone who can do something about it.
intensity => 5,
period => 10
},
{ok, {SupFlags, [room_spec(lobby)]}}.
%% Rooms can also be added while the system runs.
start_room(Name) ->
supervisor:start_child(?MODULE, room_spec(Name)).
stop_room(Name) ->
ok = supervisor:terminate_child(?MODULE, Name),
supervisor:delete_child(?MODULE, Name).
room_spec(Name) ->
#{
id => Name,
start => {chat_room, start_link, [Name]},
%% permanent: always restart. transient: only if it died abnormally.
%% temporary: never restart.
restart => permanent,
shutdown => 5000,
type => worker,
modules => [chat_room]
}.
1> chat_sup:start_link().
{ok,<0.95.0>}
2> whereis(lobby).
<0.96.0>
3> chat_room:join(lobby).
ok
4> chat_room:members(lobby).
[<0.85.0>]
5> exit(whereis(lobby), kill).
true
6> whereis(lobby).
<0.99.0>
7> chat_room:members(lobby).
[]
8> supervisor:count_children(chat_sup).
[{specs,1},{active,1},{supervisors,0},{workers,1}]
9> chat_sup:start_room(erlang_questions).
{ok,<0.102.0>}
10> supervisor:which_children(chat_sup).
[{erlang_questions,<0.102.0>,worker,[chat_room]},
{lobby,<0.99.0>,worker,[chat_room]}]
Restart strategies
one_for_one
Restart just that child
The default, and right when children are independent of each other. One chat room crashing has nothing to do with the others, so only it comes back.
one_for_all
Restart all of them
For children whose state has to agree with each other. If a connection process and the cache built from it get out of step, restarting both together is the only way back to a consistent state.
rest_for_one
Restart it and everything after it
For a chain of dependencies, where later children were started against something the earlier one owns. If the socket dies, whatever was reading from it has to go too.
simple_one_for_one
Many identical children
Now spelled as a simple_one_for_one strategy or, in modern code, a DynamicSupervisor shaped tree: one child specification, started on demand, thousands of times. This is what a connection acceptor uses.
Restart intensity is a circuit breaker
intensity => 5, period => 10 means: if this supervisor has to restart children more than five times in ten seconds, then restarting is not fixing anything, so it gives up and dies too. Its own supervisor then decides what to do, and so on up the tree.
That escalation is the part people miss. A supervision tree is not just a restart loop, it is a way for a local failure to become a larger one only when it needs to. A bad database password does not cause an infinite restart storm in a corner of the system; it takes out the subsystem, then the application, and the node exits with a crash report saying exactly why.
import time
def run_worker() -> None:
raise RuntimeError("connection lost")
def supervise(restarts: int = 5, window: float = 10.0) -> None:
"""A supervisor, by hand, badly."""
attempts: list[float] = []
while True:
try:
run_worker()
except Exception as problem: # noqa: BLE001
now = time.monotonic()
attempts = [t for t in attempts if now - t < window]
attempts.append(now)
# Give up if restarting is clearly not helping. Every process
# supervisor in every language reinvents this loop, usually
# without the backoff, usually without the give up.
if len(attempts) > restarts:
raise RuntimeError("restarting is not working") from problem
print(f"restarting after {problem}")
time.sleep(0.5)
# What this cannot do: restart one worker out of a group, restart the group
# together when their state has to agree, shut children down in the right
# order, or tell the layer above that the whole subsystem is unhealthy.
// The same idea in Node, and the same gaps. Most projects push this out to
// systemd, Docker restart policies or Kubernetes, which restart the entire
// process: every connection dropped, every cache cold, for one bad worker.
export async function supervise(work, { restarts = 5, windowMs = 10_000 } = {}) {
let attempts = [];
for (;;) {
try {
await work();
return;
} catch (problem) {
const now = Date.now();
attempts = attempts.filter((t) => now - t < windowMs);
attempts.push(now);
if (attempts.length > restarts) {
throw new Error("restarting is not working", { cause: problem });
}
console.warn(`restarting after ${problem.message}`);
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
// The granularity is the whole process. Erlang's granularity is one worker
// out of a hundred thousand, and restarting it costs microseconds.
Both reimplement a fragment of this, usually without the escalation, and usually at the granularity of the whole process rather than one worker out of thousands.
Child specifications
| Key | Meaning |
|---|---|
| id | how this supervisor refers to the child. Any term |
| start | {Module, Function, Args}, which must link, so start_link |
| restart | permanent, transient (only on abnormal exit), or temporary (never) |
| shutdown | milliseconds to wait for an orderly stop, or brutal_kill, or infinity for a supervisor |
| type | worker or supervisor |
| modules | the modules holding state, used during hot upgrades |
Applications and releases
An application is the unit OTP starts, stops and upgrades. A release is that plus the runtime, packaged into something a server can boot with no Erlang installed.
An OTP application is a supervision tree plus a description of it. The description lives in a .app.src file and says which other applications must be running first, which module starts the tree, and what configuration it accepts.
%% The application callback module. An application is the unit OTP starts,
%% stops and upgrades, and it is what turns a folder of modules into
%% something a release can boot.
-module(chat_app).
-behaviour(application).
-export([start/2, stop/1]).
start(_StartType, _StartArgs) ->
%% Returning the top supervisor's pid is what links the application to
%% its tree. When the tree dies, the application is reported as stopped.
chat_sup:start_link().
stop(_State) ->
ok.
{application, chat, [
{description, "A chat server with one room per process"},
{vsn, "0.1.0"},
%% The module that starts the supervision tree.
{mod, {chat_app, []}},
%% Applications that must be running before this one starts. OTP starts
%% them in order and refuses to boot if one of them fails.
{applications, [kernel, stdlib, sasl]},
{registered, [chat_sup]},
{env, [{max_rooms, 100}, {idle_timeout_ms, 300000}]},
{modules, []},
{licenses, ["Apache-2.0"]}
]}.
With those two files, application:ensure_all_started(chat) starts every dependency in order and then this tree. Stopping it stops the tree in reverse order. Nothing else in your code has to know about boot order at all.
The standard project layout
| Path | Holds |
|---|---|
| src/ | your modules and the .app.src file |
| include/ | .hrl files, for records shared between modules |
| test/ | EUnit and Common Test suites |
| config/sys.config | runtime configuration per environment |
| config/vm.args | node name, cookie and virtual machine flags |
| rebar.config | dependencies, profiles, release definition |
| _build/ | everything rebar3 produces. Never edit, never commit |
| apps/ | for an umbrella project holding several applications |
{erl_opts, [debug_info, warnings_as_errors]}.
{deps, [
{cowboy, "2.12.0"},
{thoas, "1.2.1"}
]}.
{shell, [{apps, [chat]}]}.
{relx, [
{release, {chat, "0.1.0"}, [chat, sasl]},
{mode, dev},
{sys_config, "./config/sys.config"},
{vm_args, "./config/vm.args"}
]}.
{profiles, [
{prod, [{relx, [{mode, prod}, {include_erts, true}]}]},
{test, [{deps, [{proper, "1.4.0"}]}]}
]}.
{dialyzer, [
{warnings, [unknown, unmatched_returns, error_handling]},
{plt_extra_apps, [cowboy]}
]}.
Releases
-
Describe it
The
relxsection of rebar.config lists the applications to include.saslis worth including: it is what produces readable crash reports and what handles upgrades. -
Build it
rebar3 as prod releaseproduces a directory containing your compiled code, every dependency, and the Erlang runtime itself. Nothing needs to be installed on the target machine. -
Run it
bin/chat foregroundunder a process supervisor, orbin/chat daemon. In a container, foreground, so the container runtime sees the process it thinks it is running. -
Get inside it
bin/chat remote_consoleattaches a shell to the running node. This is the part that surprises people coming from other stacks: you can inspect and fix a production system from the inside, live, with the same tools you used while writing it.
ETS, the shared memory that is allowed
Process state is private, which is usually right and occasionally too slow. ETS is an in memory table any process can read without sending a message.
A gen_server holding a lookup table is a bottleneck: every reader sends a message and waits for a reply, and they all queue behind each other. For data that is read constantly and written rarely, that is the wrong shape.
ETS is a set of tables built into the runtime. Any process can read one directly, in parallel, with no message and no copy on the way in. It is the escape hatch, and it is well behaved as long as you remember what it is not: it has no transactions across tables, and reading and then writing is not atomic.
%% ETS is the shared memory Erlang does allow: a table of tuples that any
%% process can read without sending a message. It is the right tool for data
%% that is read constantly and owned by nobody in particular.
-module(cache).
-export([init/0, store/2, fetch/1, forget/1, count/0, expire_older_than/1]).
-define(TABLE, cache_table).
init() ->
%% named_table lets other modules use the atom instead of the table id.
%% public means any process may write, read_concurrency optimises for
%% many readers. The owning process holds the table: when it dies, the
%% table disappears, so tables belong to long lived processes.
ets:new(?TABLE, [named_table, set, public, {read_concurrency, true}]).
store(Key, Value) ->
true = ets:insert(?TABLE, {Key, Value, seconds_now()}),
ok.
fetch(Key) ->
case ets:lookup(?TABLE, Key) of
[{Key, Value, _Stored}] -> {ok, Value};
[] -> not_found
end.
forget(Key) ->
true = ets:delete(?TABLE, Key),
ok.
count() ->
ets:info(?TABLE, size).
%% A match specification. It looks like line noise the first time, and it
%% runs inside the table without copying anything into your process, which
%% is why it is worth the ugliness for large tables.
expire_older_than(Seconds) ->
Cutoff = seconds_now() - Seconds,
ets:select_delete(?TABLE, [{{'_', '_', '$1'}, [{'<', '$1', Cutoff}], [true]}]).
seconds_now() ->
erlang:system_time(second).
Table types
| Type | Behaviour |
|---|---|
| set | one row per key. The default and usually what you want |
| ordered_set | one row per key, kept sorted, so range queries work |
| bag | several rows per key, no duplicates |
| duplicate_bag | several rows per key, duplicates allowed |
| Option | Effect |
|---|---|
| named_table | reachable by atom instead of by the returned id |
| public | any process may read and write. protected: read by all, written by the owner. private: owner only |
| {read_concurrency, true} | optimise for many parallel readers, few writers |
| {write_concurrency, true} | optimise for many parallel writers to different keys |
| compressed | smaller, slower to read. For large mostly cold tables |
The neighbours
| Tool | For |
|---|---|
persistent_term | values read constantly and written almost never, such as configuration. Reads are free; every write copies the whole table and triggers a global GC, so treat writes as a deployment event |
counters and atomics | fast shared integers, when a counter really is the hot path |
dets | the same idea on disk. Largely superseded |
mnesia | a distributed database built on ETS, with transactions and replication across nodes. Excellent for configuration and small shared state, and rarely the right answer for large or write heavy data |
| the process dictionary | put/2 and get/1 inside one process. It is mutable state hiding inside a pure looking function, so it makes code hard to follow. Real uses exist, and they are rare |
Build: guess the number
A loop with no loop, input that cannot crash on bad text, and a comparison written as a pattern rather than as a condition.
The smallest interesting program that needs randomness, input, a loop and a counter. Copy it into guess.erl, then c(guess). and guess:start(). in the shell.
-module(guess).
-export([start/0]).
start() ->
Secret = rand:uniform(100),
io:format("I picked a number between 1 and 100.~n"),
Attempts = loop(Secret, 1),
io:format("Solved it in ~p guesses.~n", [Attempts]).
%% There is no while loop. The loop is a function that calls itself, and the
%% guess counter is an argument rather than a variable being reassigned.
loop(Secret, Attempt) ->
case io:fread("> ", "~d") of
%% Secret is already bound, so this clause only matches when the
%% number entered is equal to it. Using a bound variable in a pattern
%% is the same as writing an equality test, and it reads better.
{ok, [Secret]} ->
Attempt;
{ok, [Guess]} when Guess < Secret ->
io:format("Higher.~n"),
loop(Secret, Attempt + 1);
{ok, [_Higher]} ->
io:format("Lower.~n"),
loop(Secret, Attempt + 1);
eof ->
io:format("~nBye.~n"),
Attempt;
{error, _Reason} ->
io:format("That is not a number.~n"),
loop(Secret, Attempt)
end.
import random
def main() -> None:
secret = random.randint(1, 100)
print("I picked a number between 1 and 100.")
attempt = 1
while True:
entered = input("> ").strip()
try:
guess = int(entered)
except ValueError:
print("That is not a number.")
continue
if guess < secret:
print("Higher.")
elif guess > secret:
print("Lower.")
else:
print(f"Solved it in {attempt} guesses.")
return
attempt += 1
if __name__ == "__main__":
main()
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
async function main() {
const rl = readline.createInterface({ input, output });
const secret = Math.floor(Math.random() * 100) + 1;
console.log("I picked a number between 1 and 100.");
let attempt = 1;
for (;;) {
const entered = (await rl.question("> ")).trim();
const guess = Number(entered);
if (!Number.isInteger(guess)) {
console.log("That is not a number.");
continue;
}
if (guess < secret) console.log("Higher.");
else if (guess > secret) console.log("Lower.");
else {
console.log(`Solved it in ${attempt} guesses.`);
break;
}
attempt += 1;
}
rl.close();
}
main();
Three things worth noticing
The loop is a function. loop/2 calls itself with Attempt + 1. It is a tail call, so this runs in constant stack space whether the player takes five guesses or five million.
The winning case is a pattern, not a test. {ok, [Secret]} matches only when the number entered equals the already bound Secret. No comparison is written, because matching against a bound variable is a comparison.
Bad input is a value. io:fread/2 returns {error, Reason} rather than raising, so the clause for nonsense sits beside the clause for a real guess instead of in a catch block somewhere else.
Try it yourself
Give the player five guesses and reveal the answer when they run out. Then keep the guesses so far in a list and print them at the end.
Hint: Both changes are extra parameters on loop. Nothing else has to move.
Show one solution Hide the solution
-module(guess).
-export([start/0]).
start() ->
Secret = rand:uniform(100),
io:format("I picked a number between 1 and 100.~n"),
Attempts = loop(Secret, 1),
io:format("Solved it in ~p guesses.~n", [Attempts]).
%% There is no while loop. The loop is a function that calls itself, and the
%% guess counter is an argument rather than a variable being reassigned.
loop(Secret, Attempt) ->
case io:fread("> ", "~d") of
%% Secret is already bound, so this clause only matches when the
%% number entered is equal to it. Using a bound variable in a pattern
%% is the same as writing an equality test, and it reads better.
{ok, [Secret]} ->
Attempt;
{ok, [Guess]} when Guess < Secret ->
io:format("Higher.~n"),
loop(Secret, Attempt + 1);
{ok, [_Higher]} ->
io:format("Lower.~n"),
loop(Secret, Attempt + 1);
eof ->
io:format("~nBye.~n"),
Attempt;
{error, _Reason} ->
io:format("That is not a number.~n"),
loop(Secret, Attempt)
end.
Build: an arithmetic drill
Keeping score without a counter to increment, and what record update actually costs.
The same loop with something extra to carry: a score with two numbers in it. In Python you would reach for a small class and edit its fields. Here the score is a value, and each answer produces a new one.
-module(drill).
-export([start/0]).
%% The running score is a record, so it can be printed, passed around and
%% tested without any chance of another part of the program editing it.
-record(score, {correct = 0, asked = 0}).
start() ->
io:format("Addition drill. Type quit to stop.~n"),
#score{correct = Correct, asked = Asked} = loop(#score{}),
io:format("~p correct out of ~p~n", [Correct, Asked]).
loop(Score) ->
A = rand:uniform(10),
B = rand:uniform(10),
Prompt = io_lib:format("What is ~p + ~p? ", [A, B]),
case io:get_line(Prompt) of
eof -> Score;
Line -> answered(string:trim(Line), A + B, Score)
end.
%% Two clauses instead of an if. The first pattern is a literal string, so
%% the routing decision is made by the same mechanism as everything else.
answered("quit", _Answer, Score) ->
Score;
answered(Input, Answer, Score) ->
IsRight = parse(Input) =:= {ok, Answer},
case IsRight of
true -> io:format("Correct.~n");
false -> io:format("No, it was ~p.~n", [Answer])
end,
loop(tally(IsRight, Score)).
%% A new score is returned. The old one still exists, unchanged.
tally(true, Score = #score{correct = C, asked = A}) ->
Score#score{correct = C + 1, asked = A + 1};
tally(false, Score = #score{asked = A}) ->
Score#score{asked = A + 1}.
parse(Text) ->
case string:to_integer(Text) of
{error, _Reason} -> error;
{Number, _Rest} -> {ok, Number}
end.
import random
from dataclasses import dataclass
@dataclass
class Score:
correct: int = 0
asked: int = 0
def main() -> None:
print("Addition drill. Type quit to stop.")
score = Score()
while True:
a, b = random.randint(1, 10), random.randint(1, 10)
entered = input(f"What is {a} + {b}? ").strip()
if entered == "quit":
break
answer = a + b
is_right = entered.lstrip("-").isdigit() and int(entered) == answer
print("Correct." if is_right else f"No, it was {answer}.")
# The score object is edited in place, so anything holding a
# reference to it sees the change.
score.asked += 1
score.correct += int(is_right)
print(f"{score.correct} correct out of {score.asked}")
if __name__ == "__main__":
main()
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
async function main() {
const rl = readline.createInterface({ input, output });
console.log("Addition drill. Type quit to stop.");
const score = { correct: 0, asked: 0 };
for (;;) {
const a = Math.floor(Math.random() * 10) + 1;
const b = Math.floor(Math.random() * 10) + 1;
const entered = (await rl.question(`What is ${a} + ${b}? `)).trim();
if (entered === "quit") break;
const answer = a + b;
const isRight = Number(entered) === answer;
console.log(isRight ? "Correct." : `No, it was ${answer}.`);
score.asked += 1;
score.correct += isRight ? 1 : 0;
}
console.log(`${score.correct} correct out of ${score.asked}`);
rl.close();
}
main();
Both edit the score in place. Anything else holding a reference to it sees the change, which is convenient until it is not.
Record update is a copy, and it is cheap
Score#score{correct = C + 1} builds a new record with one field different. The original still exists and still holds its old value, which is what makes it safe to hand a score to another function without wondering what it might do to it.
The copy is shallow. A record is a tuple, so this allocates a new tuple of the same size and points at the same field values as before. Nothing nested is copied. For a record with four fields that is four words, which is why nobody in Erlang worries about it.
Build: a counter, twice
The same thing written by hand and then as a gen_server, so the difference between them is a diff rather than a claim.
Python and JavaScript keep a counter in a variable and change it. Erlang has no variable to change, so the counter lives in a process that waits for messages and calls itself with the new value. That sounds like more work and buys something specific: the state is unreachable from outside, so there is nothing to lock and nothing that can be modified behind your back.
%% The counter with no gen_server, so you can see what OTP is doing for you.
-module(counter).
-export([start/0, loop/1, add/2, value/1, stop/1]).
start() ->
spawn(?MODULE, loop, [0]).
%% The state lives in the argument. There is no variable to reassign, and no
%% lock, because nothing outside this process can reach it.
loop(Count) ->
receive
{add, Amount} ->
loop(Count + Amount);
{value, From} ->
From ! {value, Count},
loop(Count);
stop ->
ok
end.
add(Pid, Amount) ->
Pid ! {add, Amount},
ok.
value(Pid) ->
Pid ! {value, self()},
receive
{value, Count} -> Count
after 1000 ->
{error, timeout}
end.
stop(Pid) ->
Pid ! stop,
ok.
import threading
class Counter:
"""Shared mutable state, which is the thing Erlang does not have."""
def __init__(self) -> None:
self.count = 0
# Nothing in the language ties this lock to the field it protects.
# Remembering to take it is a convention enforced by code review.
self._lock = threading.Lock()
def add(self, amount: int) -> None:
with self._lock:
self.count += amount
def value(self) -> int:
with self._lock:
return self.count
def main() -> None:
counter = Counter()
workers = [
threading.Thread(target=lambda: [counter.add(1) for _ in range(100)])
for _ in range(100)
]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
# Threads are OS threads: about 8 MB of stack each by default, so a
# hundred thousand of them is not an option. CPU bound work also shares
# one interpreter lock, so the answer there is multiprocessing.
print(counter.value())
if __name__ == "__main__":
main()
// One thread, so there is no lock to forget. The cost is that nothing runs
// while anything else is running: a slow loop in one request handler blocks
// every other request, every timer, and the health check.
class Counter {
#count = 0;
add(amount) {
this.#count += amount;
}
get value() {
return this.#count;
}
}
const counter = new Counter();
for (let i = 0; i < 10000; i++) counter.add(1);
console.log(counter.value);
// Real parallelism needs worker_threads, and workers cannot share this
// object. State is copied through messages, or squeezed into a
// SharedArrayBuffer with manual Atomics, which is the lock problem again
// with fewer tools.
export { Counter };
Now the OTP version
The hand written version works and is missing everything that makes a process production worthy. Here is the same counter as a gen_server, with the differences visible side by side.
%% The same counter as the hand written process, as a gen_server. The extra
%% lines buy timeouts, synchronous calls, code upgrade, system messages,
%% orderly shutdown and the ability for a supervisor to manage it.
-module(counter_server).
-behaviour(gen_server).
%% The public interface. Callers never send messages by hand, so the
%% protocol stays an implementation detail of this module.
-export([start_link/0, increment/0, increment/1, value/0, reset/0, stop/0]).
%% The callbacks gen_server calls. These are never called by your own code.
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2,
code_change/3]).
-define(SERVER, ?MODULE).
%% ---------------------------------------------------------------- API
start_link() ->
gen_server:start_link({local, ?SERVER}, ?MODULE, 0, []).
increment() -> increment(1).
%% cast is asynchronous. It returns as soon as the message is on its way.
increment(N) when is_integer(N) ->
gen_server:cast(?SERVER, {increment, N}).
%% call is synchronous and waits for a reply, with a five second timeout by
%% default. If the server is dead, the caller crashes too, which is usually
%% the behaviour you want.
value() ->
gen_server:call(?SERVER, value).
reset() ->
gen_server:call(?SERVER, reset).
stop() ->
gen_server:stop(?SERVER).
%% ---------------------------------------------------------- callbacks
init(Initial) ->
%% Whatever this returns becomes the state passed to every callback.
{ok, Initial}.
handle_call(value, _From, Count) ->
{reply, Count, Count};
handle_call(reset, _From, _Count) ->
{reply, ok, 0};
handle_call(Request, _From, Count) ->
%% Answer rather than crash, so one bad caller cannot take the server
%% down and with it everyone else's state.
{reply, {error, {unknown_request, Request}}, Count}.
handle_cast({increment, N}, Count) ->
{noreply, Count + N};
handle_cast(_Ignored, Count) ->
{noreply, Count}.
%% Anything that arrives without going through call or cast lands here:
%% monitor DOWN messages, timeouts, and mail from processes that guessed.
handle_info(Info, Count) ->
logger:info("counter_server ignored ~p", [Info]),
{noreply, Count}.
terminate(_Reason, _Count) ->
ok.
%% Called during a hot upgrade, with the old state. Return the new shape.
code_change(_OldVsn, Count, _Extra) ->
{ok, Count}.
| By hand | gen_server | |
|---|---|---|
| Lines | about 30 | about 60 |
| Timeouts on a call | you write them, every time | built in, five seconds by default |
| Caller crashes if the server is gone | no, it hangs | yes, which is what you want |
| Supervisor can manage it | only if you remember to link | yes, that is what start_link is for |
| Crash report names the module | no | yes, with the state and the last message |
| Hot code upgrade | you handle it yourself | code_change/3 |
| sys:get_state, sys:trace | no | yes, on any gen_server, live |
Build: the ring benchmark
N processes in a circle passing a token M times. The classic demonstration, and the fastest way to feel how cheap a process is.
Spawn a hundred thousand processes, arrange them in a ring, and send a message all the way round a hundred times. That is ten million messages. On a laptop it takes a few seconds and a few hundred megabytes.
The same program in Python asks the operating system for a hundred thousand threads, which it will refuse to give you. In Node it becomes a hundred thousand promises on one thread, which measures how fast the event loop can shuffle callbacks rather than how fast messages move between isolated units of work.
%% The ring benchmark. N processes in a circle, passing a token round M
%% times. In Python or Node this is a thought experiment; here it is a
%% one line call in the shell.
-module(ring).
-export([start/2, node_loop/1]).
start(N, M) when N > 0, M > 0 ->
Started = erlang:monotonic_time(millisecond),
%% Build the ring backwards, so each process is created already knowing
%% who to forward to. The last one points back at us.
Head = build(N, self()),
Head ! {token, M},
ok = wait(Head),
%% Tell everyone to stop, and wait for the message to come back around.
Head ! stop,
receive stop -> ok end,
Elapsed = erlang:monotonic_time(millisecond) - Started,
io:format("~p processes, ~p laps, ~p messages in ~p ms~n",
[N, M, N * M, Elapsed]),
Elapsed.
build(0, Next) ->
Next;
build(N, Next) ->
build(N - 1, spawn(?MODULE, node_loop, [Next])).
%% Every process in the ring does the same thing: take a message, pass it on.
node_loop(Next) ->
receive
stop ->
Next ! stop,
ok;
Message ->
Next ! Message,
node_loop(Next)
end.
wait(Head) ->
receive
{token, 1} ->
ok;
{token, Laps} ->
Head ! {token, Laps - 1},
wait(Head)
end.
import queue
import threading
def ring(size: int, laps: int) -> None:
"""The Erlang ring benchmark, as far as Python can take it."""
queues = [queue.Queue() for _ in range(size)]
def node(index: int) -> None:
inbox = queues[index]
outbox = queues[(index + 1) % size]
while True:
message = inbox.get()
if message is None:
outbox.put(None)
return
outbox.put(message)
threads = [threading.Thread(target=node, args=(i,), daemon=True) for i in range(size)]
for thread in threads:
thread.start()
for _ in range(laps):
queues[0].put("token")
queues[0].put(None)
# size = 100000 asks the operating system for a hundred thousand threads.
# It will refuse, and if it did not, the memory would not be there. This is
# the point at which the two models stop being comparable.
if __name__ == "__main__":
ring(1000, 10)
// The ring, with promises standing in for processes. They are not processes:
// everything below runs on one thread, so this measures how fast the event
// loop can shuffle callbacks rather than how fast messages move.
export async function ring(size, laps) {
const inboxes = Array.from({ length: size }, () => []);
const waiting = Array.from({ length: size }, () => null);
const send = (index, message) => {
const resolve = waiting[index];
if (resolve) {
waiting[index] = null;
resolve(message);
} else {
inboxes[index].push(message);
}
};
const receive = (index) =>
inboxes[index].length > 0
? Promise.resolve(inboxes[index].shift())
: new Promise((resolve) => (waiting[index] = resolve));
const node = async (index) => {
for (;;) {
const message = await receive(index);
if (message === null) return send((index + 1) % size, null);
send((index + 1) % size, message);
}
};
const nodes = Array.from({ length: size }, (_, i) => node(i));
for (let lap = 0; lap < laps; lap++) send(0, "token");
send(0, null);
await Promise.all(nodes);
}
// There is also no isolation here. One of these throwing takes out the
// whole ring, and there is nobody watching to start it again.
ring(1000, 10);
Both are written to be as fair as the platform allows, and both stop scaling three orders of magnitude before Erlang does.
What the numbers look like
| Processes | Laps | Messages | Roughly |
|---|---|---|---|
| 1 000 | 100 | 100 000 | under 100 ms |
| 100 000 | 10 | 1 000 000 | about a second |
| 1 000 000 | 1 | 1 000 000 | a few seconds, mostly spawning |
From a 2023 laptop. Yours will differ, and the shape of the curve will not: it is close to linear in the number of messages, and spawning is close to free.
Build: a chat server with one room per process
The whole of OTP in one small program: a gen_server per room, a supervisor above them, monitors so member lists cannot fill with ghosts.
A chat room holds a list of members and forwards messages to them. In most languages that is a shared dictionary and a lock. Here it is a process, and the design falls out of that: one room per process, so a bug in one room cannot corrupt another, and a crash loses one room's member list rather than everyone's.
%% One chat room, holding its members in its own state. Nothing is shared
%% with any other room, so a bug in one cannot corrupt another.
-module(chat_room).
-behaviour(gen_server).
-export([start_link/1, join/1, leave/1, say/2, members/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
-record(state, {name, members = #{}}).
%% ---------------------------------------------------------------- API
start_link(Name) when is_atom(Name) ->
gen_server:start_link({local, Name}, ?MODULE, Name, []).
join(Room) -> gen_server:call(Room, {join, self()}).
leave(Room) -> gen_server:cast(Room, {leave, self()}).
say(Room, Text) -> gen_server:cast(Room, {say, self(), Text}).
members(Room) -> gen_server:call(Room, members).
%% ---------------------------------------------------------- callbacks
init(Name) ->
{ok, #state{name = Name}}.
handle_call({join, Pid}, _From, State = #state{members = Members}) ->
%% Monitor the joiner. When their process dies for any reason, this room
%% is told, so a member list can never fill up with ghosts.
Ref = erlang:monitor(process, Pid),
{reply, ok, State#state{members = Members#{Pid => Ref}}};
handle_call(members, _From, State) ->
{reply, maps:keys(State#state.members), State};
handle_call(Request, _From, State) ->
{reply, {error, {unknown_request, Request}}, State}.
handle_cast({say, From, Text}, State = #state{name = Name, members = Members}) ->
Message = {chat, Name, From, Text},
lists:foreach(
fun(Pid) when Pid =/= From -> Pid ! Message;
(_Self) -> ok
end,
maps:keys(Members)
),
{noreply, State};
handle_cast({leave, Pid}, State) ->
{noreply, forget(Pid, State)};
handle_cast(_Ignored, State) ->
{noreply, State}.
handle_info({'DOWN', _Ref, process, Pid, _Reason}, State) ->
{noreply, forget(Pid, State)};
handle_info(_Ignored, State) ->
{noreply, State}.
%% ------------------------------------------------------------ internal
forget(Pid, State = #state{members = Members}) ->
case maps:take(Pid, Members) of
{Ref, Rest} ->
erlang:demonitor(Ref, [flush]),
State#state{members = Rest};
error ->
State
end.
Monitors keep the member list honest
When somebody joins, the room monitors their process. If they disconnect, crash, or their machine catches fire, the room receives a DOWN message and removes them. Nothing has to remember to call leave, and there is no periodic sweep looking for stale entries.
This is the pattern that replaces most cleanup code in Erlang. Instead of tracking whether a resource is still needed, monitor the process that needs it and react when it goes away.
%% The supervisor. It knows how to start its children and what to do when
%% one of them dies. It contains no business logic at all, which is the
%% point: the part that restarts things is too important to be interesting.
-module(chat_sup).
-behaviour(supervisor).
-export([start_link/0, start_room/1, stop_room/1]).
-export([init/1]).
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
init([]) ->
SupFlags = #{
%% one_for_one: restart only the child that died.
%% one_for_all: restart every child, for state that must agree.
%% rest_for_one: restart it and everything started after it.
strategy => one_for_one,
%% More than five restarts in ten seconds means restarting is not
%% working, so this supervisor gives up and dies too. The failure
%% then travels up the tree to someone who can do something about it.
intensity => 5,
period => 10
},
{ok, {SupFlags, [room_spec(lobby)]}}.
%% Rooms can also be added while the system runs.
start_room(Name) ->
supervisor:start_child(?MODULE, room_spec(Name)).
stop_room(Name) ->
ok = supervisor:terminate_child(?MODULE, Name),
supervisor:delete_child(?MODULE, Name).
room_spec(Name) ->
#{
id => Name,
start => {chat_room, start_link, [Name]},
%% permanent: always restart. transient: only if it died abnormally.
%% temporary: never restart.
restart => permanent,
shutdown => 5000,
type => worker,
modules => [chat_room]
}.
1> chat_sup:start_link().
{ok,<0.95.0>}
2> whereis(lobby).
<0.96.0>
3> chat_room:join(lobby).
ok
4> chat_room:members(lobby).
[<0.85.0>]
5> exit(whereis(lobby), kill).
true
6> whereis(lobby).
<0.99.0>
7> chat_room:members(lobby).
[]
8> supervisor:count_children(chat_sup).
[{specs,1},{active,1},{supervisors,0},{workers,1}]
9> chat_sup:start_room(erlang_questions).
{ok,<0.102.0>}
10> supervisor:which_children(chat_sup).
[{erlang_questions,<0.102.0>,worker,[chat_room]},
{lobby,<0.99.0>,worker,[chat_room]}]
What the supervisor does and does not save
-
A room crashes
Its heap goes away, including its member list. The other rooms are untouched, because they never shared anything with it.
-
The supervisor restarts it
A new process with the same registered name, starting from
init/1, which means an empty member list. The clients' monitors on the old room fire, so they know to rejoin. -
State is not restored
This is the part worth being honest about. Supervision gives you a clean process, not the state it had. If state must survive a crash it has to live somewhere else: in ETS owned by a longer lived process, in a database, or in the clients, who can be asked to rejoin.
Try it yourself
Add message history: keep the last twenty messages in the room's state and send them to anyone who joins. Then decide what should happen to that history when the room crashes, and implement whichever answer you argued for.
Hint: A bounded list, built with [New | lists:sublist(Old, 19)]. For surviving a crash, an ETS table owned by the supervisor is the smallest change that works.
Show one solution Hide the solution
%% One chat room, holding its members in its own state. Nothing is shared
%% with any other room, so a bug in one cannot corrupt another.
-module(chat_room).
-behaviour(gen_server).
-export([start_link/1, join/1, leave/1, say/2, members/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
-record(state, {name, members = #{}}).
%% ---------------------------------------------------------------- API
start_link(Name) when is_atom(Name) ->
gen_server:start_link({local, Name}, ?MODULE, Name, []).
join(Room) -> gen_server:call(Room, {join, self()}).
leave(Room) -> gen_server:cast(Room, {leave, self()}).
say(Room, Text) -> gen_server:cast(Room, {say, self(), Text}).
members(Room) -> gen_server:call(Room, members).
%% ---------------------------------------------------------- callbacks
init(Name) ->
{ok, #state{name = Name}}.
handle_call({join, Pid}, _From, State = #state{members = Members}) ->
%% Monitor the joiner. When their process dies for any reason, this room
%% is told, so a member list can never fill up with ghosts.
Ref = erlang:monitor(process, Pid),
{reply, ok, State#state{members = Members#{Pid => Ref}}};
handle_call(members, _From, State) ->
{reply, maps:keys(State#state.members), State};
handle_call(Request, _From, State) ->
{reply, {error, {unknown_request, Request}}, State}.
handle_cast({say, From, Text}, State = #state{name = Name, members = Members}) ->
Message = {chat, Name, From, Text},
lists:foreach(
fun(Pid) when Pid =/= From -> Pid ! Message;
(_Self) -> ok
end,
maps:keys(Members)
),
{noreply, State};
handle_cast({leave, Pid}, State) ->
{noreply, forget(Pid, State)};
handle_cast(_Ignored, State) ->
{noreply, State}.
handle_info({'DOWN', _Ref, process, Pid, _Reason}, State) ->
{noreply, forget(Pid, State)};
handle_info(_Ignored, State) ->
{noreply, State}.
%% ------------------------------------------------------------ internal
forget(Pid, State = #state{members = Members}) ->
case maps:take(Pid, Members) of
{Ref, Rest} ->
erlang:demonitor(Ref, [flush]),
State#state{members = Rest};
error ->
State
end.
Build: counting words in a file
Reading a file, counting with a map, sorting by frequency, and keeping every interesting function free of side effects so the tests need no fixtures.
A small program with the shape most Erlang programs have: a thin layer that talks to the world, and pure functions underneath doing the actual work.
-module(word_count).
-export([main/1, top/2, tally/1, tokens/1]).
%% The only function that touches the outside world.
main([Path]) ->
case file:read_file(Path) of
{ok, Contents} ->
lists:foreach(fun report/1, top(Contents, 10));
{error, Reason} ->
io:format("could not read ~s: ~p~n", [Path, Reason])
end;
main(_Args) ->
io:format("usage: word_count FILE~n").
report({Word, Count}) ->
io:format("~-16s~p~n", [Word, Count]).
%% Everything below is pure. It can be tried in the shell on a literal, and
%% the tests need no file, no fixture and no temporary directory.
tokens(Contents) ->
Lowered = string:lowercase(Contents),
string:lexemes(Lowered, " \r\n\t.,;:!?\"'()[]{}<>/-").
%% update_with takes the function to apply when the key is already there and
%% the value to use when it is not, which is counting in one call.
tally(Contents) ->
lists:foldl(
fun(Word, Counts) ->
maps:update_with(Word, fun(N) -> N + 1 end, 1, Counts)
end,
#{},
tokens(Contents)
).
top(Contents, Limit) ->
Ranked = lists:sort(
fun({WordA, CountA}, {WordB, CountB}) ->
{CountB, WordA} =< {CountA, WordB}
end,
maps:to_list(tally(Contents))
),
lists:sublist(Ranked, Limit).
import sys
from collections import Counter
def tokens(text: str) -> list[str]:
return "".join(c.lower() if c.isalpha() else " " for c in text).split()
def tally(text: str) -> Counter[str]:
return Counter(tokens(text))
def main() -> None:
if len(sys.argv) != 2:
print("usage: wordcount FILE")
return
with open(sys.argv[1], encoding="utf-8") as handle:
contents = handle.read()
for word, count in tally(contents).most_common(10):
print(f"{word:<16}{count}")
if __name__ == "__main__":
main()
import { readFile } from "node:fs/promises";
import { argv } from "node:process";
export const tokens = (text) =>
[...text.toLowerCase()]
.map((c) => (/\p{L}/u.test(c) ? c : " "))
.join("")
.split(/\s+/)
.filter(Boolean);
export function tally(text) {
const counts = new Map();
for (const word of tokens(text)) {
counts.set(word, (counts.get(word) ?? 0) + 1);
}
return counts;
}
async function main() {
const path = argv[2];
if (!path) {
console.log("usage: wordcount FILE");
return;
}
const contents = await readFile(path, "utf8");
const ranked = [...tally(contents)].sort((a, b) => b[1] - a[1]).slice(0, 10);
for (const [word, count] of ranked) {
console.log(word.padEnd(16) + count);
}
}
main();
Python's Counter does more of the work. The Erlang version spells the fold out, which is a fair trade for being able to try tally/1 on a literal in the shell.
Binaries all the way through
file:read_file/1 returns a binary, and string:lexemes/2 and string:lowercase/1 both work on binaries and return them. Nothing is converted to a list of characters at any point, which is what keeps this usable on a file of any size.
The map keys are binaries too, and binaries above 64 bytes are shared rather than copied when sent between processes, so handing these counts to another process later stays cheap.
Try it yourself
Add a stop word list so "the" and "and" are left out, and make the number of results a second command line argument. Then count a large file by splitting it into chunks, counting each in its own process, and merging the maps.
Hint: For the parallel version, spawn one process per chunk and have each send its map back. maps:merge_with/3 combines two count maps correctly.
Show one solution Hide the solution
-module(word_count).
-export([main/1, top/2, tally/1, tokens/1]).
%% The only function that touches the outside world.
main([Path]) ->
case file:read_file(Path) of
{ok, Contents} ->
lists:foreach(fun report/1, top(Contents, 10));
{error, Reason} ->
io:format("could not read ~s: ~p~n", [Path, Reason])
end;
main(_Args) ->
io:format("usage: word_count FILE~n").
report({Word, Count}) ->
io:format("~-16s~p~n", [Word, Count]).
%% Everything below is pure. It can be tried in the shell on a literal, and
%% the tests need no file, no fixture and no temporary directory.
tokens(Contents) ->
Lowered = string:lowercase(Contents),
string:lexemes(Lowered, " \r\n\t.,;:!?\"'()[]{}<>/-").
%% update_with takes the function to apply when the key is already there and
%% the value to use when it is not, which is counting in one call.
tally(Contents) ->
lists:foldl(
fun(Word, Counts) ->
maps:update_with(Word, fun(N) -> N + 1 end, 1, Counts)
end,
#{},
tokens(Contents)
).
top(Contents, Limit) ->
Ranked = lists:sort(
fun({WordA, CountA}, {WordB, CountB}) ->
{CountB, WordA} =< {CountA, WordB}
end,
maps:to_list(tally(Contents))
),
lists:sublist(Ranked, Limit).
Distributed Erlang
Sending a message to a process on another machine uses exactly the same syntax as sending to one next door. That is the feature, and it is also the trap.
Give two Erlang nodes the same cookie and connect them, and a pid from one is usable from the other. Pid ! Message works across the network with no client library, no serialisation format to pick and no interface definition to write.
Connections are transitive by default: connect to one node in a cluster and you are connected to all of them, because each node tells the others about the new arrival.
# Two nodes on one machine. -sname is short names on a local network,
# -name is fully qualified names across networks.
erl -sname alice -setcookie chocolate
erl -sname bob -setcookie chocolate
# In alice's shell:
# net_adm:ping('bob@thishost'). %% pong
# nodes(). %% ['bob@thishost']
# rpc:call('bob@thishost', erlang, node, []).
# spawn('bob@thishost', fun() -> io:format("hi from ~p~n", [node()]) end).
# The cookie is the entire authentication mechanism, so a distributed
# cluster belongs on a private network or behind TLS distribution.
erl -name alice@10.0.0.1 -setcookie chocolate \
-proto_dist inet_tls \
-ssl_dist_optfile /etc/erlang/tls.config
%% Distributed Erlang. A cluster is a set of nodes that share a cookie and
%% can send each other messages using exactly the same syntax as a local
%% send. There is no client library and no serialisation code to write.
-module(dist).
-export([connect/1, cluster/0, remote_time/1, spawn_there/1, tell/3,
watch_nodes/0, register_globally/2, find_globally/1]).
%% net_adm:ping is how a node joins a cluster: connect to one member and the
%% rest are discovered automatically, because connections are transitive by
%% default.
connect(Node) ->
case net_adm:ping(Node) of
pong -> {connected, Node};
pang -> {unreachable, Node}
end.
cluster() ->
[node() | nodes()].
%% Calling a function on another machine. The arguments are copied over, the
%% result is copied back, and a dead node gives you {badrpc, Reason} rather
%% than a hang.
remote_time(Node) ->
case rpc:call(Node, erlang, localtime, [], 5000) of
{badrpc, Reason} -> {error, Reason};
Time -> {ok, Time}
end.
%% spawn takes an optional node argument. That is the entire API for running
%% a process on another machine.
spawn_there(Node) ->
spawn(Node, fun() ->
io:format("running on ~p as ~p~n", [node(), self()])
end).
%% Sending to a registered name on a named node. The tuple form works whether
%% the process is local or six thousand kilometres away.
tell(Node, Name, Message) ->
{Name, Node} ! Message,
ok.
%% Being told when a node joins or leaves. A cluster that cannot be told
%% about a partition is a cluster that will silently do the wrong thing.
watch_nodes() ->
ok = net_kernel:monitor_nodes(true),
receive
{nodeup, Node} -> {joined, Node};
{nodedown, Node} -> {left, Node}
after 30000 ->
quiet
end.
%% global registers a name across the whole cluster rather than one node.
%% It costs a round of coordination, so use it for a handful of singletons
%% rather than for every process.
register_globally(Name, Pid) ->
case global:register_name(Name, Pid) of
yes -> ok;
no -> {error, already_registered}
end.
find_globally(Name) ->
case global:whereis_name(Name) of
undefined -> not_found;
Pid -> {ok, Pid}
end.
(alice@laptop)1> node().
alice@laptop
(alice@laptop)2> nodes().
[]
(alice@laptop)3> net_adm:ping('bob@laptop').
pong
(alice@laptop)4> nodes().
[bob@laptop]
(alice@laptop)5> rpc:call('bob@laptop', erlang, node, []).
bob@laptop
(alice@laptop)6> Remote = spawn('bob@laptop', fun() -> receive {ask, From} -> From ! {answer, node()} end end).
<8927.101.0>
(alice@laptop)7> Remote ! {ask, self()}, flush().
Shell got {answer,bob@laptop}
ok
(alice@laptop)8> erlang:disconnect_node('bob@laptop').
true
(alice@laptop)9> nodes().
[]
The pieces
| Thing | What it is |
|---|---|
| node() | this node's name, an atom such as alice@laptop |
| nodes() | everyone we are currently connected to |
| net_adm:ping(Node) | connect, and get pong or pang |
| {Name, Node} ! Message | send to a registered name on another node |
| spawn(Node, Fun) | start a process over there |
| rpc:call(Node, M, F, A) | call a function there and wait for the result |
| erpc:call/4 | the modern replacement, with better error reporting |
| global:register_name/2 | a name unique across the whole cluster |
| net_kernel:monitor_nodes(true) | be told when nodes join and leave |
| erlang:monitor(process, RemotePid) | works across nodes, exactly as it does locally |
What distribution does not give you
-
Netsplits happen and both halves keep running
When the connection between two nodes drops, each side sees the other go down and carries on. If both halves accept writes, you have two divergent versions of the truth. Erlang gives you the tools to notice, in
monitor_nodes, and no opinion at all about what to do next. -
global is not a consensus system
globalregisters names across the cluster and resolves conflicts after a partition heals by picking a winner and killing the loser, by default. That is fine for singletons you can restart and wrong for anything holding unique state. -
Full mesh has a size limit
Every node connects to every other and heartbeats it, so the connection count grows with the square of the cluster. Somewhere between fifty and a couple of hundred nodes this stops being sensible, and larger systems use a partitioned topology such as the one Discord built, or hidden nodes, or several smaller clusters.
-
Big messages block the link
All traffic between two nodes shares one TCP connection by default. A large term sent between them delays everything else, including the heartbeats, which can make a healthy node look dead. Keep messages small, or raise the distribution buffer, or use several connections with
erl_disttuning.
Hot code loading
Replacing a module in a running system, keeping every connection and every process state. It is a language feature, not a deployment strategy bolted on afterwards.
The virtual machine keeps two versions of a module in memory: the current one and the previous one. Processes already running old code keep running it. The moment one makes a fully qualified call, meaning module:function(...) rather than a bare function(...), it jumps to the newest version, mid flight, with its state intact.
%% Hot code loading. The BEAM keeps two versions of a module in memory at
%% once: the current one and the old one. Processes running old code keep
%% running it until they make a fully qualified call, at which point they
%% jump to the new version, mid flight, with their state intact.
-module(hot).
-export([start/0, loop/0, greet/1, version/0]).
start() ->
spawn(?MODULE, loop, []).
loop() ->
receive
{greet, From} ->
From ! {hello, version()},
%% A fully qualified call, module:function, always goes to the
%% newest version of the module. A plain loop() would keep this
%% process on the code it started with forever.
?MODULE:loop();
stop ->
ok
end.
greet(Name) ->
io_lib:format("hello ~s, from version ~p", [Name, version()]).
version() -> 1.
%% Try it in the shell:
%% Pid = hot:start().
%% Pid ! {greet, self()}, flush().
%% %% edit version() to return 2, then:
%% c(hot).
%% Pid ! {greet, self()}, flush().
%%
%% The same process answers with the new version and never restarted.
%% Load a third version and any process still running the first is killed,
%% which is the two version rule.
1> Pid = hot:start().
<0.90.0>
2> Pid ! {greet, self()}, flush().
Shell got {hello,1}
ok
3> % now edit version() to return 2 and recompile, without stopping anything
3> c(hot).
{ok,hot}
4> Pid ! {greet, self()}, flush().
Shell got {hello,2}
ok
5> is_process_alive(Pid).
true
6> code:which(hot).
"/home/you/chat/_build/default/lib/chat/ebin/hot.beam"
7> erlang:check_old_code(hot).
true
8> code:soft_purge(hot).
true
The two version rule
Load a third version and any process still running the first is killed, because there is nowhere left to keep its code. That is why a long running loop should make a fully qualified call every time round: it guarantees the process never falls more than one version behind.
code:purge/1 removes the old version, killing anything still using it. code:soft_purge/1 refuses to do that if anyone is, which is the one to use when you are not certain.
| Call | Does |
|---|---|
| c(Module) | compile and load, from the shell |
| code:load_file(Module) | load an already compiled beam |
| code:which(Module) | where the loaded version came from |
| code:is_loaded(Module) | is it in memory |
| erlang:check_old_code(Module) | is there a previous version still around |
| code:soft_purge(Module) | drop the old version, unless someone is running it |
| code:purge(Module) | drop it, killing anyone who is |
| sys:suspend/resume | pause a gen_server across a state change |
Upgrading a gen_server's state
Changing a module is easy. Changing the shape of the state a process is holding is the interesting part, and it is what code_change/3 is for. OTP suspends the process, calls your function with the old state and the version you are coming from, and resumes it with whatever you return.
If the counter's state changes from a bare integer to a map, the conversion is one clause: code_change(_Old, Count, _Extra) -> {ok, #{count => Count, updated => erlang:system_time()}}. Every message that arrives after that point sees the new shape.
Releases and relups
-
For development, c/1 is the whole story
Recompile and load. This is what makes the shell so productive: the system you are poking at is the system you are changing.
-
For production, a release upgrade is a described process
You bump the application version, write an
.appupfile saying which modules changed and how their state converts, and rebar3 generates arelup. The release handler then applies it to a running node, in order, with a way back. -
Most teams do not do that
Writing and testing appup files for every release is real work, and rolling restarts behind a load balancer are simpler and good enough when a dropped connection is cheap. Hot upgrade earns its keep when a restart is genuinely expensive: long lived stateful connections, telecoms, trading systems, embedded devices you cannot reboot.
-
Even so, the capability pays for itself in an incident
Being able to attach to a live node and load a patched module, or add a bit of tracing, without restarting anything, changes how a bad night goes. That is worth knowing about even if you never write an appup.
Specs, types and Dialyzer
Erlang is dynamically typed, and there is a static analyser that finds real contradictions in it without ever reporting a false one.
Dialyzer works by success typing. Rather than proving a program is correct, it looks for things that cannot possibly succeed: a function that can only be called with an integer being passed an atom, a case clause that can never match, a return value that is impossible.
The consequence is unusual and worth stating plainly. Dialyzer never complains about code that might work. Everything it reports is a real problem. That means you can adopt it on an existing codebase without a migration, and that a clean run means less than it would in a language with a full type checker.
%% Erlang is dynamically typed, and Dialyzer reads specs to find the
%% contradictions anyway. It never reports a false positive: everything it
%% complains about really is impossible.
-module(typed).
-export([average/1, safe_divide/2, lookup/2, new_handle/0, describe/1]).
-export_type([handle/0, user/0]).
%% A type of your own. The trailing ... means the list cannot be empty, so
%% Dialyzer knows average/1 cannot divide by zero.
-type non_empty_numbers() :: [number(), ...].
-record(user, {id :: pos_integer(), name :: binary(), admin = false :: boolean()}).
-type user() :: #user{}.
%% opaque means other modules may hold one but must not look inside it.
-opaque handle() :: {handle, reference()}.
-spec average(non_empty_numbers()) -> float().
average(Numbers) ->
lists:sum(Numbers) / length(Numbers).
%% A union return type. The caller sees both possibilities in the spec, and
%% Dialyzer complains if they only match on one of them.
-spec safe_divide(number(), number()) -> {ok, float()} | {error, division_by_zero}.
safe_divide(_Numerator, 0) ->
{error, division_by_zero};
safe_divide(Numerator, Denominator) ->
{ok, Numerator / Denominator}.
%% A type variable, introduced with when, so the relationship between the
%% argument and the result is written down rather than implied.
-spec lookup(atom(), #{atom() => Value}) -> {ok, Value} | error when Value :: term().
lookup(Key, Map) ->
case maps:find(Key, Map) of
{ok, Value} -> {ok, Value};
error -> error
end.
-spec new_handle() -> handle().
new_handle() ->
{handle, make_ref()}.
-spec describe(user()) -> binary().
describe(#user{name = Name, admin = true}) ->
<<Name/binary, " (admin)">>;
describe(#user{name = Name}) ->
Name.
Writing specs
| Type | Means |
|---|---|
| integer(), float(), number() | the obvious |
| pos_integer(), non_neg_integer() | narrower, and Dialyzer uses the difference |
| atom(), ok | error | any atom, or one of these exactly |
| binary(), iodata() | text, and anything that can be written as text |
| [integer()] | a list of them, possibly empty |
| [integer(), ...] | a list of at least one |
| {ok, term()} | {error, atom()} | a tagged union, the most common Erlang shape |
| #{name := binary(), age => integer()} | a map with a required key and an optional one |
| fun((integer()) -> boolean()) | a function value |
| term(), any() | anything, and a signal that you gave up |
| none() | this never returns |
-type names a type for reuse. -opaque names one that other modules may hold but must not look inside, which is how you make a handle or an identifier that cannot be forged. -export_type makes either visible outside the module.
# Start a project. `app` gives you a library, `release` gives you something
# that can be shipped and booted on a server.
rebar3 new release chat
cd chat
rebar3 compile
rebar3 shell # compiles, then drops you into a shell with the app started
rebar3 eunit # unit tests
rebar3 ct # common_test, for anything that needs a running system
rebar3 dialyzer # static analysis, slow the first time and then cached
rebar3 fmt # the formatter, if you have added rebar3_format
# Build a release: an entire runtime plus your code, with no Erlang install
# needed on the target machine.
rebar3 as prod release
_build/prod/rel/chat/bin/chat console
_build/prod/rel/chat/bin/chat daemon
_build/prod/rel/chat/bin/chat remote_console # attach to the running node
Testing with EUnit and Common Test
EUnit for functions, Common Test for systems, and a language where the interesting logic is easy to test because it never had to touch the world.
The design advice from the earlier chapters pays off here. When the decisions live in functions with no side effects, testing them needs no fixtures, no mocks and no temporary directories. Call it, compare the answer.
%% EUnit finds any function whose name ends in _test or _test_ and runs it.
%% The include is a parse transform, so no exports are needed.
-module(word_count_tests).
-include_lib("eunit/include/eunit.hrl").
tokens_splits_on_punctuation_test() ->
?assertEqual([<<"one">>, <<"two">>], word_count:tokens(<<"One, two!">>)).
tally_counts_repeats_test() ->
?assertEqual(#{<<"a">> => 2, <<"b">> => 1}, word_count:tally(<<"a b a">>)).
tally_of_nothing_is_empty_test() ->
?assertEqual(#{}, word_count:tally(<<>>)).
top_is_ordered_by_count_test() ->
Text = <<"b a a c c c">>,
?assertEqual([{<<"c">>, 3}, {<<"a">>, 2}], word_count:top(Text, 2)).
%% A generator returns a list of tests, which keeps related cases together
%% and gives each one its own line in the report.
punctuation_test_() ->
[?_assertEqual([<<"a">>], word_count:tokens(<<Text/binary>>))
|| Text <- [<<"a">>, <<" a ">>, <<"a.">>, <<"(a)">>, <<"a\n">>]].
EUnit finds any function whose name ends in _test and runs it, and any function ending in _test_ and treats what it returns as a list of tests. The include is a parse transform, so nothing needs exporting.
| Macro | Checks |
|---|---|
| ?assert(Expr) | it is true |
| ?assertEqual(Expected, Expr) | equal, and prints both when not |
| ?assertMatch(Pattern, Expr) | matches a pattern, so you can ignore parts |
| ?assertError(Reason, Expr) | raises that error |
| ?assertExit(Reason, Expr) | exits with that reason |
| ?assertThrow(Term, Expr) | throws that term |
| ?_assertEqual(...) | the same, deferred, for use inside a generator |
| ?debugVal(Expr) | print a value during a test run |
Testing processes
A test that starts a gen_server has to stop it again, or the next test finds a registered name already taken. EUnit's fixtures handle that: {setup, fun start/0, fun stop/1, fun tests/1} starts something, runs the tests, and stops it even if they fail.
The harder problem is timing. Tests that sleep for half a second and hope are the main reason Erlang test suites become slow and flaky. Prefer to make the thing you are waiting for observable: a synchronous call that only returns once the work is done, or a monitor on the process you expect to die.
-
EUnit for anything that fits in one process
Pure functions, a single gen_server, a module's public interface. Fast, and run on every save.
-
Common Test for anything that needs a system
It starts applications, can start several nodes, and gives you suites with setup and teardown per group. Slower, richer, and the right home for a test that starts a supervision tree and kills a child in the middle of it.
-
PropEr or QuickCheck for properties
Generate inputs and assert relationships instead of examples: encoding then decoding is the identity, sorting twice is the same as sorting once, a queue never loses a message. Both shrink a failure to the smallest input that still breaks.
-
Concuerror for concurrency bugs
It runs a test under every possible interleaving of your processes. When something fails once a week in production and never on your machine, this is the tool that finds it.
{erl_opts, [debug_info, warnings_as_errors]}.
{deps, [
{cowboy, "2.12.0"},
{thoas, "1.2.1"}
]}.
{shell, [{apps, [chat]}]}.
{relx, [
{release, {chat, "0.1.0"}, [chat, sasl]},
{mode, dev},
{sys_config, "./config/sys.config"},
{vm_args, "./config/vm.args"}
]}.
{profiles, [
{prod, [{relx, [{mode, prod}, {include_erts, true}]}]},
{test, [{deps, [{proper, "1.4.0"}]}]}
]}.
{dialyzer, [
{warnings, [unknown, unmatched_returns, error_handling]},
{plt_extra_apps, [cowboy]}
]}.
Tooling, the ecosystem and where to go next
The build tool, the libraries worth knowing, the instruments for looking inside a running system, and what to read after this.
Erlang's ecosystem is smaller than Python's or JavaScript's and unusually stable. Libraries here tend to be a decade old, still maintained, and still API compatible, partly because OTP itself covers so much of what other languages need packages for.
# Start a project. `app` gives you a library, `release` gives you something
# that can be shipped and booted on a server.
rebar3 new release chat
cd chat
rebar3 compile
rebar3 shell # compiles, then drops you into a shell with the app started
rebar3 eunit # unit tests
rebar3 ct # common_test, for anything that needs a running system
rebar3 dialyzer # static analysis, slow the first time and then cached
rebar3 fmt # the formatter, if you have added rebar3_format
# Build a release: an entire runtime plus your code, with no Erlang install
# needed on the target machine.
rebar3 as prod release
_build/prod/rel/chat/bin/chat console
_build/prod/rel/chat/bin/chat daemon
_build/prod/rel/chat/bin/chat remote_console # attach to the running node
Libraries worth knowing
| Need | Package | Note |
|---|---|---|
| HTTP server | cowboy | the default. Built on ranch, which is worth knowing separately as a socket acceptor pool |
| HTTP client | hackney, or httpc | httpc ships with OTP and is fine for modest use |
| JSON | thoas, jsx, jsone | OTP 27 added a json module, which will absorb most of this |
| Database | epgsql, mysql-otp | plain drivers. There is no ORM tradition here |
| Connection pools | poolboy | small, old, and does exactly one thing |
| Metrics | telemetry | the events standard shared with the Elixir ecosystem |
| Logging | logger | in OTP since 21. No package needed |
| Property tests | proper | the open source QuickCheck |
| Process registry | gproc | when registered atoms are not enough |
| Distributed database | mnesia | in OTP. Good for small shared state, not for large data |
An HTTP handler with cowboy
A handler is a module with an init/2. Cowboy runs one process per connection, so a slow request affects nothing else, and a crashing request returns a 500 and takes down only its own process.
%% A small HTTP handler, using cowboy from Hex. This one needs dependencies,
%% so it is here to show the shape rather than to be run from the shell.
-module(json_api).
-behaviour(cowboy_handler).
-export([init/2]).
init(Req0, State) ->
Method = cowboy_req:method(Req0),
Req = handle(Method, Req0),
{ok, Req, State}.
handle(<<"GET">>, Req) ->
Body = thoas:encode(#{status => ok, node => atom_to_binary(node())}),
cowboy_req:reply(
200,
#{<<"content-type">> => <<"application/json">>},
Body,
Req
);
handle(_Other, Req) ->
cowboy_req:reply(405, #{<<"allow">> => <<"GET">>}, <<>>, Req).
Looking inside a running system
observer
observer:start()
A window with every process, every ETS table, scheduler load, memory by allocator, and a live supervision tree. It can connect to a remote node. Open it once in your first week and the runtime stops being abstract.
recon
The production toolkit
recon is the library for looking at a busy system without hurting it: the processes using the most memory, the longest message queues, safe tracing with rate limits. Add it to every project.
sys
sys:get_state and sys:trace
Any OTP process will hand you its current state, or print every message it handles, without a code change. This works in production, on a process that has been running for months.
dbg
Tracing
The BEAM can trace any function call in any process, live. It is astonishingly powerful and easy to bring down a node with, which is why recon_trace exists with limits built in.
Habits that make Erlang pleasant
-
Keep the shell open
rebar3 shellstarts your application and leaves you inside it. Change a module, callc(module), try it against the running system. The feedback loop is the shortest of any compiled language, and not using it is the most common mistake newcomers make. -
Let it crash, and mean it
Write the match you expect. Do not add a case clause for something that should be impossible, because if it happens you want to know, with a crash report naming the value, rather than to carry on.
-
One process per concurrent thing
Per connection, per room, per job, per state machine. Not a pool, unless a pool is limiting access to something genuinely scarce like a database connection.
-
Design the supervision tree first
Before writing the workers, decide what must fail together and what must fail alone. The tree is the architecture, and it is much harder to change later.
-
Write specs and run Dialyzer in CI
From the first commit. It costs nothing to keep clean and a lot to retrofit.
-
Prefer call to cast
Synchronous by default gives you back pressure. Asynchronous everywhere gives you a mailbox that grows until the machine falls over.
Where to go after this
| Resource | Good for |
|---|---|
| Learn You Some Erlang for Great Good | the friendliest full length introduction, free online and still accurate |
| Programming Erlang, by Joe Armstrong | from one of the people who designed it, and worth reading for the reasoning as much as the syntax |
| Erlang in Anger | a free book about diagnosing production systems. Read it before you need it |
| The OTP Design Principles | the official guide to behaviours, supervision and applications |
| The module index | the reference you will keep open. Erlang's documentation is dry and complete |
| Erlang Forums | an unusually patient community, including several people who wrote the runtime |