Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

IO architecture

articles/io_architecture.livemd

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: