Writing Rust NIFs for Elixir with Rustler—Ⅱ

February 3 2021



A few months ago, I spent some time exploring Rust and Elixir interoperability by toying with the Rustler library. Not being very familiar with Rust at the time, I fumbled my way through the basics. Some of the lessons learned were featured in my last blog post, a suitable introduction, I hope, to the content I’d like to present below.

I will assume some familiarity with Genservers and Rust syntax. I will also attempt to draw parallels with the Erlang C NIF library, though these are mostly remarks intended as complementary information. I will not provide a complete example in this writing, though I will point you to an exploratory project where these concepts have been put together and applied.

With this in mind, I will be discussing the translation of complex types and techniques to persist them over NIF calls. I will also bring forth the subject of calling Elixir functions from Rust, the inverse of the trivial case.

Leveraging structs and resources

One of the first hurdles in building an Elixir & Rust application is the translation of data. Thankfully, Rustler provides powerful conveniences for encoding and decoding structured data, in the form of macros. Non-exhaustively:

As an example, let’s create a struct we’d like to access both in Elixir and Rust code. The Elixir definition is straight forwards: name it, define the keys and optionally enforce them with @enfore_keys.

defmodule MyApp.SharedStruct do
  @enforce_keys [:ref, :is_cool]
  defstruct [:ref, :is_cool]
end

Although not strictly necessary for the translation from Rust to be successful, working with a nicely defined struct rather than a bare map, albeit with a defined __struct__ key, is likely preferable and allows for the specificity of enforced keys.

Defining the struct in Rust leverages the macros mentioned above, in this case we derive NifStruct to preserve the structure during translation. It’s worth mentioning, should the names of the Elixir and Rust structs differ, the #[module = "MyApp.OtherStructName"] annotation will help in creating the desired correspondence.

#[derive(NifStruct)]
pub struct SharedStruct {
	ref: ResourceArc<Mutex<Private>>,
	is_cool: bool
}

I’ve defined two fields in both struct definitions, ref and is_cool. The latter is simply a boolean. The former makes use of the rustler::resource::ResourceArc type, a thread-safe, reference-counted storage for Rust data. It is analogous to ErlNifResourceType in Erlang’s erl_nif C library and allows Rust structs to be persisted across NIF calls as Erlang terms.

Now, you might be thinking: “Cool but why?”. In short, to keep track of structs private to the Rust code. Typically these might be defined in dependencies we can’t annotate with e.g. #[derive(NifStruct)] or simply ones we don’t need access to in Elixir. In practice, this amounts to passing references in and out of the NIFs; and in this case, the in-memory location of the Private struct so that it can be accessed in a subsequent NIF call. Conveniently, Erlang’s Garbage Collection mechanism will automatically drop the struct when there are no more references to the resource. This, once again, is akin to the behaviour of enif_release_resource in C.

Lastly, data stored in a ResourceArc is immutable by default. As with std::sync::Arc, the solution, is to introduce a Mutex or a RwLock (std or parking_lot).

Calling Erlang functions from Rust

Calling Rust functions from Elixir is trivial, calling Elixir functions from Rust requires a few more tools. Rustler provides the handy rustler::env::OwnedEnv::send_and_clear to send a message from a Rust thread to an Erlang process.

pub fn send_and_clear<F>(&mut self, recipient: &LocalPid, closure: F) where
    F: for<'a> FnOnce(Env<'a>) -> Term<'a>,

In other words, we can leverage message passing to trigger an Elixir function call, provided we know the pid of the process we’re passing the message to. Conveniently, the pid can be retrieved from the NIF’s environment with env.pid(), if the caller process is to be the recipient, or simply passed in if it’s another.

In order to construct the message which includes the SharedStruct, we create an OwnedEnv, a process-independent environment used for creating Erlang terms outside the NIF. Once the message is sent, the method frees all the terms in the environment.

let msg_env =  OwnedEnv::new();
msg_env.send_and_clear(&pid, |env| (atoms::hi(), shared_struct).encode(env));

The message could then be, for instance, received on the Elixir end by a Genserver.

def handle_info({:hi, %SharedStruct{} = shared_struct}, state) do
  # Handle message...

  {:noreply, state}
end

As far as I am aware, directly calling Erlang functions from NIFs, be they written in C or Rust, isn’t possible other than indirectly through message passing.

Formatting tip

Formatting Elixir-Rust projects can be done by creating an alias to run both formatters:

# mix.exs

def project do
  [
    # ...
    aliases: aliases()
  ]
end

defp aliases do
  [
    fmt: ["format", "cmd cargo fmt --manifest-path native/tinybeam/Cargo.toml"]
  ]
end

Formatting Elixir-C projects is done similarly (assuming you have a ClangFormat set up):

# mix.exs

defp aliases do
  [
    fmt: ["format", "cmd clang-format -i c_src/*.[ch]"]
  ]
end

In both cases, running mix fmt will leave the codebase nicely formatted.

In summary

  1. Persist data over NIF calls with rustler::resource::ResourceArc. This amounts to passing references to the memory locations of the data in and out of the NIFs.
  2. Data encapsulated with ResourceArc is immutable by default. Leverage Mutex or RwLock if the data needs to be mutated.
  3. When encoding a Rust struct, explicitly define its Elixir counterpart. This allows the use of @enforce_keys.
  4. Elixir functions can only be called from Rust through message passing. Use send_and_clear with the target pid to do so.
  5. Elixir and Rust interoperability is powerful and crucially, it is getting simpler thanks to the continuous improvements to Rustler.
  6. Rust and Elixir on! 🤘