IO architecture
There is more to “Hello world”
One of the first things most people getting into a language see is a such:
IO.puts("hello world")
Seems obvious and predictable, doesn’t it? Well, let’s do it more explicitly:
pid = Process.group_leader()
IO.puts(pid, "hello world")
This in turn looks far more interesting! In fact, there is much more to hello world than you might expect, so bear with me.
What is IO anyway?
Regardless the programming language at hand, whenever you run a program it usually has two channels attached - one responsible for reading whatever you type (input) and one responsible for writing text to the screen (output). Generally the program perceives these channels as regular files.
So whenever you see text printed in your terminal, it is a result of the given program
writing to a specific file. In many languages calling IO related functions operates
directly on that file. For example, in C we could achieve this with printf("hello world\n")
and a more explicit counterpart would be write(1, "hello world\n", 12)
,
where 1
represents the output file.
Erlang (thus Elixir) is no exception to that, but there is some clever abstraction
behind the scenes! In the example above we called IO.puts/2
with some process identifier,
but given how ubiquitous processes are in Erlang, it’s no wonder they are involved
when working with IO as well.
The Erlang I/O Protocol
All the common functions in the IO
(or :io
) module operate on
so called devices. Let’s look at the docs:
import IEx.Helpers
h(IO.write())
t(IO.device())
As you can see device is either a PID or an atom representing some process.
This means all those IO
functions actually send messages to the whatever process
we specify! Consequently, such process must expect messages of certain shape and know how
to respond accordingly - sure enough, that’s specified precisely by
The Erlang I/O Protocol.
Let’s have some fun and write our own device!
defmodule IODev do
@moduledoc """
A simple IO device implementing the The Erlang I/O Protocol.
"""
use GenServer
def start_link() do
GenServer.start_link(__MODULE__, [])
end
@doc """
Lists all IO requests sent to this device.
"""
def get_requests(pid) do
GenServer.call(pid, :get_requests)
end
@impl true
def init(_opts) do
{:ok, %{requests: []}}
end
@impl true
def handle_call(:get_requests, _from, state) do
{:reply, Enum.reverse(state.requests), state}
end
# Below we handle the actual IO requests
@impl true
def handle_info({:io_request, from, reply_as, request}, state) do
# Send reply to the process that requested IO
send(from, {:io_reply, reply_as, :ok})
# Store all the incoming requests
state = update_in(state.requests, &[request | &1])
{:noreply, state}
end
end
Our device is ready, let’s take it for a spin!
# Remember that IO device is a regular process,
# it just has to correctly handle certain messages.
{:ok, io_dev} = IODev.start_link()
# Each of these functions should send a request to our device
IO.puts(io_dev, "hello world")
IO.write(io_dev, "you there?")
IO.gets(io_dev, "what's you name?")
As you can see the above code produced no output and that’s because our device does no such thing. It does however store the requests, so let’s see what it got:
IODev.get_requests(io_dev)
Awesome! The requests are very clear and it’s up to the given device what to do with them. The default device writes to the standard output accordingly, while our device only keeps track of the requests.
Group leader
So now you may be wondering what actually happens when you write IO.puts("hello world")
without specifying the device. Under the default configuration this sends
an IO request to the default device, which writes/reads to the input/output files
described earlier.
Which process is this default device though? Here things get even more interesting! Each process has so called group leader, which is precisely a process that serves as the default IO device. Group leader can be set dynamically and we can actually test that too:
# Get the current group leader
original_group_leader = Process.group_leader()
# Set our custom device as the new group leader
Process.group_leader(self(), io_dev)
# Apply an IO action, this should send message to the new group leader
IO.puts("hey group leader, sup?")
# Revert back to the original
Process.group_leader(self(), original_group_leader)
IODev.get_requests(io_dev)
Alright, there are two more pieces to our puzzle. Firstly, it’s important to note that
whenever a process spawns a new process, the group leader is inherited.
Secondly, when the Erlang runtime starts, the initial processes have their group leader
set to :user
, which is nothing more than a device whose job is to read/write
to the output/input files.
This implies that unless configured otherwise, most processes will have the :user
process
set as their group leader, so calls like IO.puts("hello world")
will send IO request to
the :user
process, which in turn writes to the screen! 🕵️
Use cases
The IO device abstraction is amazingly flexible and allows for pretty powerful things. Let’s have a look at some real world examples.
StringIO
Elixir itself comes with the StringIO
device,
which buffers all the outputs and then returns them as a string.
{:ok, pid} = StringIO.open("")
IO.write(pid, "how")
IO.write(pid, " are")
IO.write(pid, " you")
StringIO.flush(pid)
More notably, it’s exatly what ExUnit.CaptureIO
uses under the hood!
ExUnit.CaptureIO.capture_io(fn ->
IO.puts("🚀🚀🚀")
end)
Livebook
Whenever you use the IO
module Livebook immediately shows all output,
even though we are not in a terminal window. A quick guess could be that StringIO
is behind this as well, but that’s not the case! Livebook needs to show the output
during evaluation, as soon as it is produced. Let’s see an example for a better illustration:
for n <- 1..5 do
IO.puts("Step #{n}")
Process.sleep(300)
end
Internally the code is evaluated by a separate process, but before this happens we set its group leader to a dedicated IO device, whose job is to send the outputs directly to the process responsible for notebook state.
Erlang Distribution
Finally, this IO architecture has important implications for distributed setup.
Let’s assume node :a@hostname
evalutes the following code:
Node.spawn(:b@hostname, fn ->
IO.puts("where should I print?")
end)
A process on :a@hostname
spawns another process on :b@hostname
, but the group leader
is inherited as usually. This means that the spawned process points to group leader
somewhere on :a@hostname
and that’s where the result of IO.puts/1
goes.
In other words, the code evaluates on the other node, but any output gets back
to the original node. Feels elegant, doesn’t it?
Final notes
In this notebook we had a quick tour of the IO architecture in Erlang and Elixir and went through several examples of leveraging this architecure to our advantage.
Primary references:
Related readings: