Native Ruby gems with Rust

Montreal.rb 2025-05-21

Please ask questions 🙋

(or share your knowledge)

N'hésitez pas à poser vos questions en français

Bonjour Hi

Ruby: 2009

Rust: 2017

Context

In 2015-2017 a lot of folks from the Ruby community started talking about Rust

What we will cover

  1. How /usr/bin/ruby works
  2. Native extensions and how to build them
  3. Why you might want to consider Rust
  4. Making Ruby talk to Rust
  5. Getting started (with a live demo 🤞)
  6. Additional considerations

Ruby Under a Fisheye Lens

ruby/insns.def

vm_exec.c

include/ruby/internal/value_type.h

string.c

gc.c

What does this get us? ✨

  1. Memory safety (for Ruby code)
  2. Hot code reloading / no compilation step
  3. Runtime metaprogramming
  4. Duck-typing
  5. Portability
  6. "Everything is an Object"

Why native extensions?

Performance

pez.github.io/languages-visualizations/

Other languages benefit from reduced overhead, greater memory efficiency, or parallelism (e.g. polars)

Interoperability

  1. Platform functionality (e.g. OS features, GUI toolkits)
  2. Access to libraries with C/C++ APIs (e.g. openssl, libpq)
  Spawn processes Fiddle/FFI Native extensions
  Chromium, GraphicsMagick LibUI, fluentd Nokogiri, ruby-vips, pg
Pros Strong isolation
Straightforward*
Direct access to C APIs
Dynamic
Deeper integration
Cons Overhead
IPC
Overhead
Trickier with complex APIs
More ceremony

How do native extensions work?

load.c

dln.c (see also: dlopen and dlsym)

include/ruby/internal/method.h

Packaging

Source gem

Ship the source code and build the extension on install

  • Smaller .gem
  • Easier publishing process
  • Requires a toolchain (e.g. GCC/clang) to install

Platform-specific gem

Ship a pre-built binary blob

  • Quicker installs
  • No toolchain needed
  • Binary blobs ☢️

Not mentioned

  • Vendoring vs. using OS-provided libraries (e.g. via apt)
  • Static vs. Dynamic linking

Why Rust? 🦀

  • Advantages of a low-level language (e.g. no GC, no runtime, C interop)
  • The type system, type inference, and the standard library's design can make code feel surprisingly high-level (see Iterator)
  • Memory safety and strong typing push a lot of issues to compile time (e.g. NoMethodError is not a thing)
  • Bundler-like package manager (cargo) and ecosystem (see lib.rs, docs.rs)

Momentum

  • Ruby (YJIT/ZJIT)
  • Linux kernel
  • Windows kernel
  • AWS S3 / Firecracker
  • Language tooling (SWC, Astral)
  • Apps (1Password, Zed, Signal, Matrix)

Ruby 🤝 Rust

oxidize-rb/rb-sys

  • Auto-generated low-level bindings to ruby.h (via bindgen)
  • Ruby gem to make Rust extensions work with Rake and extconf.rb
  • Cross-compilation tooling

matsadler/magnus

  • Friendlier/safer wrapper on top of rb-sys
  • Macros, traits, and other niceties to make interoperability easier (e.g. manipulating Ruby objects from Rust, passing Rust types back to Ruby)

Getting started

bundle gem hello_rust --ext rust

Additional considerations

Mapping to OOP APIs

  • Idiomatic Rust doesn't map cleanly to idiomatic Ruby, particularly when it comes to borrow-checked references
  • This can be worked around through things like Arc or by putting the logic in Ruby
class Document; end

class Element
  def initialize(document)
    @document = document # <= this is fine
  end

  def ancestor
    @document.ancestor(self)
  end
end
#[derive(Clone)]
#[magnus::wrap(class = "Sawzall::Document", free_immediately)]
struct Document(Arc<Mutex<Html>>);

impl Document {
    fn root_element(&self) -> Element {
        self.with_locked_html(|html| Element {
            id: html.root_element().id(),
            document: self.clone(),
        })
    }
}
impl Element {
    fn with_element_ref<U, F>(&self, f: F) -> U
    where
        F: FnOnce(ElementRef) -> U,
    {
        let html = self.document.0.lock().expect("failed to lock mutex");
        let element_ref = html
            .tree
            .get(self.id)
            .and_then(ElementRef::wrap)
            .expect("node with id {self.id} must be an element in the tree");

        f(element_ref)
    }
}

Testing

  • Ruby testing with RSpec/Minitest/etc... works as you would expect with the only caveat being you have to rake compile first.
  • Testing pure Rust code with cargo test also works as you would expected
  • rb-sys provides a rb-sys-test-helpers crate to test Rust code that interacts with the Ruby VM (see oxidize-rb.github.io/rb-sys/testing.html)

Documentation options

  • Write YARD in Rust

    YARD::Rustdoc allows you to write YARD documentation in your Rust code
  • Write YARD in Ruby

    YARD's @!parse and @!method tags let you document classes and methods that aren't defined in the source code (e.g. to support metaprogramming)
  • For extra credit, test your @example tags with YARD::Doctest

GC / memory management considerations

  1. Rust data types that are wrapped and passed to Ruby have their drop implementation called when the GC runs.
  2. If your Rust data structures hold onto Ruby objects, you are responsible for providing the appropriate GC hooks so they aren't removed from under you.

    See:

GVL considerations

  • By default, Rust code is called with the GVL (Global VM Lock) acquired, which can become an issue when performing computationally-expensive work as no other Ruby code can progress in the meantime.
  • In those scenarios it can be manually released

Cross-compilation