[ANN] jlrs 0.17: generic targets, foreign type support, Julia 1.9 support, and more!

jlrs is a crate for the Rust programming language that provides access to most of the Julia C API, it can be used to embed Julia in Rust applications and to use functionality from the Julia C API when writing ccallable functions in Rust. Version 0.17 is compatible with Julia 1.8 by default, 1.6 if the lts feature is enabled, and Julia 1.9 if the beta feature is enabled. The minimum supported version of Rust is 1.65.

Memory management

The biggest change in this version is a redesign of how jlrs ensures the Julia GC is aware of data that is currently used in Rust. This is now handled by a custom resizable stack, which has several advantages over the old system. For example, methods can no longer fail due to running out of stack space; methods like Value::new and Call::call that could only fail due to this limitation are now infallible and no longer return a Result. The async runtime can allocate a stack for each task which gets rid of the Mode trait. And, it’s now possible for a function called through ccall to throw an exception with CCall::throw_exception.

The Scope and PartialScope traits, which were previously used to ensure that newly allocated Julia data would remain valid by rooting that data, have been replaced with the Target trait. Unlike the old traits, when a target is used it depends on the type of the target whether or not the returned data is rooted. Thanks to this property, all methods that take a target can return either rooted or unrooted data. As a result, all methods that returned unrooted data, e.g. Call::call_unrooted, have been removed because this can now be achieved by calling Call::call with a non-rooting target. Methods that used to take an argument that implemented Scope now take an ExtendedTarget instead, which isn’t restricted to using an Output for the final result but can use any target.

The Global struct has been renamed to Unrooted because it’s a target that leaves the result unrooted. Any immutable reference to a target is a valid non-rooting target, you should rarely need to use an Unrooted compared to the more prominent role Global used to play. Methods and closures that previously took both a Global and a GcFrame now only take a GcFrame to account for this.

Layout changes

Because all methods that return Julia data can return rooted or unrooted data depending on the used target, I’ve decided to get rid of a major difference between Ref<T: Wrapper> and T: Wrapper: both types now contain a non-null pointer internally. Methods that previously returned a Ref now take a target, if the data might be undefined the return type is wrapped in an Option.

The ValidLayout trait has been split into a ValidLayout and ValidField trait. ValidLayout can be implemented by all types that provide a valid layout for some type of Julia data, while ValidField can only be implemented by types that provide a valid layout when the type is used as a field type.

Due to these changes all bindings previously generated with JlrsReflect.jl are now invalid. A new version of that package has been released, generated bindings will derive ValidField in addition to ValidLayout whenever applicable.

Improved borrow checking

Arrays and Values can be tracked, while they’re tracked it’s safe to access their contents. Tracking data no longer requires a GcFrame, rather it’s tracked using a global ledger inspired by neon. Thanks to this change, multiple arrays can be mutably borrowed at the same time and ccalled functions that need to access array data no longer need to create a scope first.

Foreign types

You can now create custom foreign types by implementing the ForeignType trait. Types that implement this trait can be moved from Rust to Julia, making Julia’s GC responsible for freeing this data. This can be useful if you want to embed a Rust crate into a Julia package, but is also used internally to implement the previously mentioned dynamic stack.

Kind-of multithread async runtime

Blocking tasks can now be posted to Julia rather than being called from the runtime thread, which allows multiple blocking tasks to run in parallel. When a blocking task is posted a new thread is spawned in Julia which calls the blocking task through ccall.

A custom channel is now used to send tasks to the async runtime. The reason for this change is that the async runtime can make use of multiple threads when the beta feature is enabled.

Julia 1.9 – actually multithreaded async runtime

The most exciting feature added to Julia 1.9 IMO is thread adoption, which allows multiple foreign threads to call into Julia. This lets jlrs create additional worker threads for the async runtime. The number of worker threads can be set with the runtime builder, when a task is sent to the runtime it can be handled by either the runtime thread or any of the worker threads.

Julia 1.9 can also make use of two thread pools, :default and :interactive. The number of threads allocated to this pool can be set with the runtime builder. The methods of the CallAsync trait all target one of these thread pools.

No more import libraries

Import libraries don’t have to be generated anymore when using jlrs with the MSVC toolchain. Rust used to require these import libraries, but with the recently stabilized support for linking against a raw dylib this is no longer necessary.


    use jlrs::prelude::*;

    fn main() {
    // Initializing Julia is unsafe because it can race with another crate
    // that does the same.
    let mut julia = unsafe { RuntimeBuilder::new().start().unwrap() };
    let mut frame = StackFrame::new();
    let mut julia = julia.instance(&mut frame);

    // Create a scope to call into Julia
        .scope(|mut frame| {
            // Create a new two-dimensional Julia array from a `Vec`.
            let data = vec![1u64, 2u64, 3u64, 4u64];
            let arr = Array::from_vec(frame.as_extended_target(), data, (2, 2))?

            // Sum all the elements in the array. Calling a Julia function is unsafe
            // because it can do arbitrary things. Summing the elements of this array is
            // is safe because we've just created it.
            let sum_fn = Module::base(&frame).function(&mut frame, "sum")?;
            let result = unsafe { sum_fn.call1(&mut frame, arr.as_value()) }

            assert_eq!(result, 10);