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:
- interaction with the Erlang Port Mapper Daemon (EPMD) for node registration, lookup and deregistration.
- communication between processes by sending and receiving Erlang-style messages.
- data type encoding/decoding between Erlang and C.
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.
| Feature | NIF (Native Implemented Function) | Port (OS Pipe) | Cnode (Distributed) |
|---|---|---|---|
| Performance | Blazing (No IPC overhead) | Moderate (Standard I/O) | High (Binary protocol) |
| Fault Isolation | None (Crash = VM Down) | High (Separate process) | High (Separate process) |
| Complexity | High (Memory/Scheduler safety) | Low (Stdin/Stdout) | High (Erlang Interface ei) |
| Interaction | Function Call | Byte Stream | Message Passing |
Pros
- Isolation: If your C code has a segmentation fault, your Elixir app can keep running.
- Native Terms: Unlike a standard Port where you have to invent a custom byte-protocol, a Cnode uses the
eilibrary to decode Elixir terms{:ping, "hello"}directly into C structures. - Location Transparency: Your Cnode doesn’t even have to be on the same machine. It can live on a specialized GPU server while your Elixir app lives on a standard web node.
Cons
- Network Configuration: Because Cnodes use distributed Erlang, you must manage ‘cookies’, node naming and ensure your firewall allows EPMD and the ephemeral ports used for node-to-node communication.
- The
eiLearning Curve: Theeilibrary is powerful but “very C”. You’ll be managing buffers, decoding version numbers, and manually initiating the handshake with the EPMD.
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?
- Lifecycle Management: By linking the Cnode Port to a GenServer, the GenServer process can react to messages. If the Cnode dies, the GenServer will get a
{:EXIT, port, reason}message from the Port. If the Cnode disconnects, it will get a{:nodedown, node}message via setting up node monitoring withNode.monitor(c_node_name, true). - Unified Logging: We can capture the C++ standard output and pipe it directly to our
Logger.infofor example. This avoids the headache of separate log files for your native components.
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.