Package for Rust-like borrow checker in Julia?

My least favorite bugs are usually caused by:

  1. Thread races
  2. Aliased memory

While Rust doesn’t fill the same niche as Julia with respect to scientific computing, I really like the safety guarantees around ownership and borrow checker. At the expense of flexibility of code, the model provides some formal guarantees about thread races and mutability.

I’m therefore wondering if there is any Julia package that can help provide guarantees for these sorts of issues? The dream is to have some kind of compiler flag to do scope analysis similar to Rust:

fn main() {
    let mut x = 42;

    let ref1 = &x; // Immutable borrow
    let ref2 = &mut x;
    // ERROR: cannot borrow `x` as mutable because
    //        it is also borrowed as immutable

    println!("ref1: {}", ref1);
}

Rust literally refuses to compile this, and the static analysis tool points it out:

I think it would be awesome if Julia itself eventually had an opt-in system that could do borrow checking and ownership like this. But I of course understand such a thing would take a lot of work.

In the meantime I’m wondering if there are any packages available for this sort of thing? I’m looking for something like this –

@borrow_checker function main()
    @safe :mut x = 42
    
    # Borrow x as an immutable reference:
    ref1 = @ref x  # defaults to :immutable
    
    # Attempt to borrow x as a mutable reference:
    ref2 = @ref :mut x
    # ^This should error because ref1 is still active
    
    println(ref1[])
end
  • @borrow_checker
    A macro that does a “function-wide” analysis and unwrapping (either purely syntactic or some combination of syntactic + runtime checks). I think this would basically unwrap x at function call sites so that external functions don’t need to accommodate a Safe{T} type. But it would only unwrap a variable once - so it can’t be owned by multiple scopes.

  • @safe
    Tells the @borrow_checker to track it. Wraps an object (like Ref(1) or an Array) in a “tracked” container that knows how many mutable/immutable borrows currently exist.

  • @ref
    Extracts either an immutable reference or a mutable reference. If a mutable reference is requested (with :mut) but an immutable one is still active, we fail. Similarly, if a mutable reference is active, we can’t even create an immutable reference.

Edit: see updated syntax later in thread

I guess it should really go into the compiler though. It would also permit additional optimizations if the compiler knows a given variable will not be mutated in a given scope – especially things like arrays.

Maybe a JET-style approach could work as well.

3 Likes

What would this even do or expand to in Julia? Julia doesn’t distinguish variables as immutable or mutable, rather the types of the instances they happen to be assigned to at the moment, and variables don’t reference each other. If that gets reconciled with Julia’s variable model somehow, I can see such static analysis being implemented for an unidiomatic subset, but that stops at the vast majority of code that isn’t written with @ref, @mut, or whatever other macros mimicking Rust’s keywords. It won’t know what to do at push!(an_array, value).

While of course you can’t do this at a low level, you can get most of the practical benefit by overloading setproperty! and getproperty to control mutability. So if you make a type

mutable struct Owned{T}
    value::T
    moved::Bool

    function Owned{T}(value::T) where {T}
        new{T}(value, false)
    end
end

For ownership, you can have the accessor check if a variable is still owned or not before returning:

function Base.getindex(x::Owned)
    x.moved && throw(MovedError())
    x.value
end

and the @ref and @ref :mut can wrap values in these other structs:

struct _Ref{T}
    owner::Owned{T}
end

struct _MutRef{T}
    owner::Owned{T}
end

# Forward all property access to the underlying value
function Base.getproperty(r::Union{_Ref, _MutRef}, name::Symbol)
    name === :owner && return getfield(r, :owner)
    getproperty(r.owner[], name)
end
function Base.setproperty!(r::_Ref, name::Symbol, value)
    error("Cannot modify immutable reference")
end
function Base.setproperty!(r::_MutRef, name::Symbol, value)
    name === :owner && error("Cannot modify reference ownership")
    setproperty!(r.owner[], name, value)
end

To be clear I am only talking about “simulating” ownership and borrow checking in user space. Of course you can’t do that with Julia itself yet.

2 Likes

Also, Julia’s immutable don’t have references. So I’m not entirely sure what’s meant here. In Julia, if you want a reference to an immutable you’d wrap it in a Ref, and so all references are mutable, so this error shouldn’t be possible. Reusing the same immutable is an optimization that is possible in some instances by the compiler, but there isn’t a system for immutable references at the user level.

4 Likes

It’s the same way I can define a ReadOnlyArray that simply wraps an Array and denies every call to setindex!. While it is not preventing the user from going under the hood and modifying data._contents[:] = ..., practically speaking, it’s incredibly useful to have loud errors for unintended mutations.

In rust you can turn off these strict rules with unsafe { ... }. But it just forces you to code in a safer way by having these explicit automatic checks.

2 Likes

Interesting approach to make Julia types more like Rust variables, but 2 immediate problems:

  • none of the methods outside this unidiomatic subset will work on these types. I can’t add 2 _Ref{Int} together with +(::Int, ::Int). You could unwrap these before normal Julia function calls, but then the static analysis only covers the scope of the caller function.
  • you’re still dragged down by the mutability/immutability property of Julia types (the T). setproperty! there handles scalars, but what if you have a vector? If you store an immutable vector, then the only thing you can do semantically is replace it with another immutable vector, even if you only need to change 1 element. Sure there’s Accessors.jl, but even if you hack new to skip constructors, there’s no guarantee the compiler can optimize things to essentially mutation (we’ll have to wait for escape analysis to mature). If you instead store a mutable vector (all of Rust’s types are potentially mutable), then you get all the performance drawbacks because Julia variables don’t have the semantics of Rust’s.

At best you may implement a slow Rust on top of Julia that is incapable of using other Julia code, which I don’t think is what you’re hoping to work with. Rust can go at full speed because it doesn’t go through another high level language’s rules and limitations, so if you want Rust’s semantics and zero-cost benefits, use Rust.

2 Likes

I think we might be overindexing on performance concerns here. The primary goal is catching bugs and enforcing safer coding patterns - performance is really a secondary consideration. Ideally, this would be something you could enable during development and testing to catch issues early, then disable in production if needed. Think of the @ref and @move annotations as development-time guardrails that could evaporate when you’re not in test mode.

Regarding compatibility with existing methods, that’s actually aligned with what I’m aiming for here. When unwrapping a mutable value before function calls, it would be marked as moved - preventing any further access to that mutable variable within the function scope - unless you obtain ownership again.

I’m envisioning this initially as an internally-used module. While it’s true that external functions could still have bugs and perform unexpected mutations, having these safety guarantees within your own codebase is still incredibly valuable. It’s similar to how ErrorTypes.jl approaches error handling - while it can’t handle error propagation in external packages, it’s extremely useful for handling errors in your own code.

1 Like

A desirable approach, but this typically doesn’t change the semantics of the code like macros do; after all, you want to check what your production code does as well.

That works if you only use your own code, at which point you might as well reinvent Rust. Once you pass an “immutable reference” to a method that can mutate it in some callee (which can be totally unknown as an argument and can’t be identified with input and output types like Rust’s function pointer types), you lose all those safety guarantees. Unsafe Rust at least still has Rust’s variable semantics and respects the borrow checker, and it’s idiomatically isolated to rare blocks. What you’re proposing is a small island of “safe” Julia that isn’t isolated at all from the vast ecosystem of no-concept-of-safe Julia. That has zero guarantee of stopping the mentioned bugs; it’s why Rust isn’t just a C library.

Yes

I don’t need guarantees for all code executed, I just want to prevent internal race conditions in any library I write. Shouldn’t really be controversial :sweat_smile: Don’t we all want this?

4 Likes

Those guarantees for “all code executed” are what reduces those internal race conditions. There’s no mechanism isolating your code from the unsafe dependencies, so it’s just as unsafe no matter what safety rules you make for yourself. Like sure, you made sure you’re not doing multiple mutable borrows in some sense; Base can still give you race conditions.

Yes, and that desire produced languages like Rust. If it were possible to tack such safety semantics onto a language without it, then all those languages would just be C libraries.

To clarify – this is all I want. I know “eliminates” is impossible without the entire language imposing such a thing. My race conditions have more often been by my own hand than by Base (thankfully).

If someone is interested in taking a crack at a compiler pass that does this, I would recommend starting with the escape analysis compiler machinery (e.g. code_escapes and so on) partially documented here: EscapeAnalysis · The Julia Language

Using code_escapes on a function should theoretically have all the requisite information you’d need to determine if something is borrowable, it’s really just a question of traversing that datastructure and detecting cases where you want to throw a compile-time error.

6 Likes

Julia is a flexible enough language with strong enough analysis tools that compiler passes really can do this sort of thing if you have the time and will to implement them.

And from a certain point of view, Julia is just a C library, it just happens to be a very big, isolated, and heavily abstracted C library.

3 Likes

That’s awesome! Thanks.

Here’s a current attempt at an API from messing around in case people were curious.

First, we look to create an Owned{T} (immutable) and OwnedMut{T} type with the @own and @own_mut macros, respectively:

@own x = 1
@own_mut y = 2

Instead of assigning this variable, you want to always use @move (I’m not sure how to force this though)

@move z = x

This will result in the original x being marked as .moved and thus is unusable:

@take x  # MovedError

You can mutate mutable owned values with @set:

@set x = 5

which will again check for .moved and also whether it is OwnedMut.

The API is written in such a way that if all the macros disappeared, it would just be normal Julia code (so you can turn it on just for testing). This means that you can define functions with signatures like

const ValueOrBorrowed{T} = Union{T,Borrowed{T}}

function foo(bar::ValueOrBorrowed{T}) where {T}
   #= ... =#
end

to permit borrowing a reference to type T or just the actual type T. The Borrowed{T} would be analogous to the rust syntax bar: &T.

You could also declare it

function foo(bar::Union{T,BorrowedMut{T}}) where {T}
   #= ... =#
end

if you want to allow mutable references, analogous to rust syntax bar: &mut T. This forces difference behavior in the caller, because you would need to pass a mutable reference instead.

Now, for references, you can use @ref to borrow x (immutable reference), or @ref_mut to get a mutable reference (only for OwnedMut{T} types). But you can only use these inside a @scoped_refs block to ensure the correct lifetime.

@scoped_refs let
    rx = @ref x
    ry = @ref_mut y

    # modifying y here would be an error, since
    # there is a mutable reference to it! We also
    # can't `@take x` or `@take y` since there are
    # active borrows

    println(rx, ry)
end

# now we can modify y and take from them

The regular Borrowed{T} denies every attempt at a setproperty!, but you can have multiple of them. Meanwhile the BorrowedMut{T} is only allowed to exist one at a time.

At the end of this scope block, the reference counters are reset for x and y and those references are finalized.

I think this @scoped_refs is where you would want to try something like EscapeAnalysis to just handle it automatically!

Actually I like this syntax better because there’s less spooky action at a distance. Explicit lifetime scope blocks!

@own_mut a = [1, 2, 3]
@own b = [4, 5, 6]

# This block lasts for lifetime `lt`, after which
# the borrows are cleared
@lifetime lt let

    # Mutable borrow
    mut_ref = @ref_mut lt a
    # @ref lt a
    ## ^Would throw an error here, since it's already mutably borrowed!
    push!(mut_ref, 4)

    # Immutable borrow
    imm_ref = @ref lt b
    imm_ref2 = @ref lt b # We can do multiple immutable references!
    @test imm_ref == [4, 5, 6]
    @test_throws "Cannot modify immutable reference" push!(imm_ref, 7)

    # c = @move a
    ## ^Error when trying to move a or b while borrowed
end

But yeah it would be really nice to do this escape analysis automatically rather than needing explicit scopes.

1 Like

This I believe, excepting the unpredictable runtime dispatches. AllocCheck.jl analyzes compiled code for possible heap allocations and identifies the problematic source code in a stacktrace, not fooled by runtime branching and doesn’t need to be followed by execution. That doesn’t give Julia allocation-free semantics (or fully user-controlled allocations like Zig), and escape analysis seems intended for optimizing idiomatic Julia, not enforcing special semantics. MilesCranmer is also accepting that the proposed semantics are only enforced for source code inside the @borrow_checker call, as we can’t control how preexisting Julia code is designed or what bugs they enable. I’m not at all confident that would help reduce said bugs; causes of race conditions aren’t evenly distributed across source code, and fixing a personal module may not make an obvious improvement, ie race conditions involving fewer threads rather than reducing race conditions. To definitely oversimplify with an extreme analogy, foo() = [1, 2] + [3, 4] does not directly allocate anything; AllocCheck.check_allocs identifies news and ccalls down several layers of callees from the vect and + I wrote and would be essentially useless if it only errored for my direct calls.

I guess I’ll just add my motivation – I’ve had to debug multiple race conditions in SymbolicRegression.jl which nowadays is quite a complex library with deep call stacks, a lot of memory optimizations, asynchronous stuff, buffers, etc. Every race condition[1] is an absolute pain to debug. In retrospect it feels like each one could have been prevented with a borrow checker.

At the very least it would be nice if there was some sort of pure-Julia MemCheck/Sanitizer that could find such things automatically. (But maybe I’m just not fluent enough in parsing Valgrind analysis into the Julia side)


  1. I mean race conditions produced within SymbolicRegression.jl; I haven’t had one in Base yet, luckily, other than some GC issues I found in 1.11. ↩︎

3 Likes

That seems more reasonable and I think that’s what Mason meant with the escape analysis stuff, that is you wouldn’t need special annotations to catch things like 2 variables being assigned to the same mutable object (a bit stringent) that is consequently mutated in 2 separate threads (danger!), whether it’s in your source code or the code you’re using. Again a silly extreme analogy, AllocCheck doesn’t need us to annotate lines with @malloc to find allocations.

I’m not sure how it could work without some extra metadata to declare stuff as mutable/immutable. There currently isn’t a concept of an immutable “reference” to an object in Julia, which is part of the issue. But the EscapeAnalysis certainly seems like it would make it easier to check rules on top of those references/ownerships.