The hidden "C" in BEAM

We often read about the “BEAM magic”—concurrency, fault tolerance, and hot-code reloading. But let’s be real: sometimes the world isn’t written in Elixir. Maybe you need to interface with a high performance processing library or a legacy proprietary C library. When you hit that “native code” wall, you usually see two options: NIFs and Ports. But there is a third one, not often mentioned: the Erlang Cnode.

What is a Cnode?

A Cnode is an external OS process that mimics a distributed Erlang node. Erlang includes the ei C library, that can be used to implement an Erlang node with any language that supports C libraries. ei provides:

To your Erlang/Elixir cluster, it looks just like another node: it has a name (like cnode@hostname) and you can send or receive messages to/from it.

Why Choose a Cnode?

The choice between NIFs, Ports, and Cnodes is a classic trade-off between performance, safety, and complexity. CNodes are great to offload “long” processing tasks without running into the issues of dirty NIFs or implementing a custom byte protocol when using Ports.

FeatureNIF (Native Implemented Function)Port (OS Pipe)Cnode (Distributed)
PerformanceBlazing (No IPC overhead)Moderate (Standard I/O)High (Binary protocol)
Fault IsolationNone (Crash = VM Down)High (Separate process)High (Separate process)
ComplexityHigh (Memory/Scheduler safety)Low (Stdin/Stdout)High (Erlang Interface ei)
InteractionFunction CallByte StreamMessage Passing

Pros

Cons

An Erlang Cnode in Elixir and C++

Ready to try it? Check out the sample elixir_c_node GitHub repository.

Build setup

To compile the C++ code, this project uses elixir_make. This allows us to integrate C++ compilation into the standard mix compile workflow via a Makefile. The build is configured to output the resulting binary into the priv directory of the Elixir application, ensuring it’s bundled correctly with your release.

After fetching the dependencies, to start iex with the app, run:

iex --sname e1 --cookie foo -S mix

Note the sname and cookie arguments. As mentioned earlier, you need to take care to name your Elixir node and set a cookie. This is so that when we start the Cnode, it can connect back securely to our Elixir node using distributed Erlang.

The Cnode binary

The C++ Cnode implementation initializes the ei library, connects back to the elixir node and enters a loop to receive messages. Here is an overview:

// Erlang Interface header
#include <ei.h>

int main(int argc, char** argv) {
    // 1. Parse args
    if (argc < 3) {
        std::cerr << "Usage: " << argv[0] << " <this_node_name> <erlang_node> <cookie>" << std::endl;
        return 1;
    }

    const char* this_node_name = argv[1];
    const char* erlang_node = argv[2];
    const char* cookie = argv[3];

    // 2. Initialize the ei library
    ei_init();

    // 3. Initialize the Cnode structure
    if (ei_connect_init(&ec, this_node_name, cookie, 0) < 0) {
        std::cerr << "Failed to initialize cnode" << std::endl;
        return 1;
    }

    // 4. Connect back to the Erlang/ELixir node
    int fd = ei_connect(&ec, const_cast<char*>(erlang_node));
    if (fd < 0) {
        std::cerr << "Failed to connect to " << erlang_node << std::endl;
        return 1;
    }

    while (true) {
        erlang_msg msg;
        x_in.index = 0;

        // 5. Block and wait for a message
        int res = ei_xreceive_msg(fd, &msg, &x_in);

        // ... process message ...
    }
}

You can manually start the Cnode to connect to the Elixir node started earlier:

./_build/dev/lib/elixir_c_node/priv/host/cnode cnode e1@localhost foo

Managing the Cnode with Elixir Ports

Since the C++ is compiled to a binary it can be started from Elixir using the Port module.

# Start the C++ binary as a Port
port = Port.open({:spawn_executable, cnode_path}, [:binary, args: args])

This allows for easy control over the Cnode using Port module functions and, once the node is connected, easily send messages to the node simply using send

GenServer wrapper

In the shared sample implementation, we used a hybrid approach. we started the Cnode as a Port inside an Elixir GenServer.

defmodule ElixirCNode do

  use GenServer

  def start_link(init_arg) do
    GenServer.start_link(__MODULE__, init_arg)
  end

  def init(_init_arg) do
    cnode =
      Application.get_application(__MODULE__)
      |> :code.priv_dir()
      |> Path.join("host")
      |> Path.join("cnode")

    cnode_name = random_name()
    args = [cnode_name, node() |> Atom.to_string(), Node.get_cookie()]
    port = Port.open({:spawn_executable, cnode}, [:binary, args: args])
    {:ok, %{cnode_name: cnode_name, port: port}}
  end

  #...
end

Using a GenServer wrapper allows to keep track of the port and other Cnode related information, like a random name assigned to it, through the GenServer state.

To start the GenServer:

{_, pid} = ElixirCNode.start_link nil

Why use a Port and a GenServer wrapper to start a Cnode?

The hidden “C”

If you run the Node.list() function in your iex shell after starting the Cnode, you might be surprised to see no nodes appear to be listed. This is because Cnodes appear under the hidden status. Running Node.list(:hidden) will list all hidden nodes.

And Beyond

The Erlang Cnode is for the “Goldilocks” scenario: you need better performance and term-handling than a simple Port, but you can’t risk the VM-crashing instability of a NIF. By wrapping a Cnode in an Elixir GenServer, you can get the best of both worlds: the raw power of C and the legendary resilience of the BEAM. While it requires more setup, the payoff in architectural resilience is massive.


Stay tuned, in the next post we'll go over how to receive and send messages in the Cnode.