Expand description
Julia memory management.
As you might already know Julia has a garbage collector (GC). Whenever new data, like an
Array
or a String
is created, the GC is responsible for freeing that data when it has
become unreachable. While Julia is aware of references to data existing in Julia code, it is
unaware of references existing outside of Julia code.
To make Julia aware of such foreign references we’ll need to tell it they exist and that the GC needs to leave that data alone. This is called rooting. While a reference is rooted, the GC won’t free its data. Any Julia data referenced by rooted data is also safe from being freed.
Before data can be rooted a scope has to be created. Functions that call into Julia can
only be called from a scope. When the sync runtime is used, a new scope can be created by
calling Julia::scope
. This function takes a closure which contains the code called inside
that scope.
This closure takes a single argument, a GcFrame
, which lets you root data. Methods
in jlrs that return Julia data can be called with a mutable reference to a GcFrame
. The
returned data is guaranteed to be rooted until you leave the scope that provided it:
julia
.scope(|mut frame| {
// This data is guaranteed to live at least until we leave this scope
let i = Value::new(&mut frame, 1u64);
Ok(())
})
.unwrap();
If you tried to return i
from the scope in the example above, the code would fail to
compile. A GcFrame
has a lifetime that outlives the closure but doesn’t outlive the scope.
This lifetime is propageted to types like Value
to prevent the data from being used after it
has become unrooted.
A GcFrame
can also be used to create a temporary subscope:
julia
.scope(|mut frame| {
let i = Value::new(&mut frame, 1u64);
frame
.scope(|mut frame| {
let j = Value::new(&mut frame, 2u64);
// j can't be returned from this scope, but i can.
Ok(i)
})
.unwrap();
Ok(())
})
.unwrap();
As you can see in that example, i
can be returned from the subscope because i
is
guaranteed to outlive it, while j
can’t because it doesn’t. In many cases, though, we want
to create a subscope and return data created in that scope. In that case, we’ll need to
allocate an Output
in the targeted scope:
julia
.scope(|mut frame| {
let output = frame.output();
frame
.scope(|_| {
let j = Value::new(output, 2u64);
Ok(j)
})
.unwrap();
Ok(())
})
.unwrap();
This works because an Output
roots data in the GcFrame
that was used to create it, and
the lifetime of that frame is propageted to the result. We can also see that Value::new
can’t just take a mutable reference to a GcFrame
, but also other types. These types are
called targets.
All targets implement the Target
trait. Each target has a lifetime that enforces the
result can’t outlive the scope that roots it. In addition to rooting targets like
&mut GcFrame
and Output
that we’ve already seen, there also exist non-rooting target.
Non-rooting targets exist because it isn’t always necessary to root data: we might be able to
guarantee it’s already rooted or reachable and avoid creating another root, or never use the
data at all.
Rooting targets can be used as a non-rooting target by using a reference to the target.
Rooting and non-rooting targets return different types when they’re used: Value::new
returns a Value
when a rooting target is used, but a ValueRef
when a non-rooting one
is used instead. Every type that represents managed Julia data has a rooted and an unrooted
variant, which are named similarly to Value
and ValueRef
. There are two type aliases for
each managed type that can be used as the return type of functions that take a target
generically. For Value
they are ValueData
and ValueResult
. ValueData
is a Value
if a rooting target is used and a ValueRef
otherwise. ValueResult
is a Result
that
contains ValueData
in both its Ok
and Err
variants, if an Err
is returned the
operation failed and an exception was caught.
Functions that take a Target
and return Julia data have signatures like this:
fn takes_target<'target, Tgt>(target: Tgt) -> ValueData<'target, 'static, Tgt>
where
Tgt: Target<'target>,
{
}
Because that funtion takes a Target
rather than a GcFrame
it’s not possible to create a
subscope. This prevents us creating and rooting temporary data. There are two ways to deal
with this problem: a target can be extended, or a local scope can be created. A target can
also be extended by calling Target::into_extended_target
. This method bundles the target
with the current frame into an ExtendedTarget
which can be split later:
fn takes_extended_target<'target, Tgt>(
target: ExtendedTarget<'target, '_, '_, Tgt>,
) -> JlrsResult<ValueData<'target, 'static, Tgt>>
where
Tgt: Target<'target>,
{
let (target, frame) = target.split();
frame.scope(|mut frame| {
})
}
Local scopes are similar to the scopes we’ve seen so far. The main difference is that the
closure takes a LocalGcFrame
, which is essentially a statically-sized, stack-allocated
GcFrame
. Unlike a GcFrame
, which can grow to the appropriate size, a LocalGcFrame
can
only store as many roots as its size allows. There are local variants of most targets. It’s
your responsibility that the LocalGcFrame
is created with the correct size, trying to create
a new root when the frame is full will cause a panic
, unused slots occupy stack space and
slow down the GC.
fn creates_local_scope<'target, Tgt>(
target: Tgt,
) -> JlrsResult<ValueData<'target, 'static, Tgt>>
where
Tgt: Target<'target>,
{
target.with_local_scope::<_, _, 2>(|target, mut frame| {
let i = Value::new(&mut frame, 1usize);
let j = Value::new(&mut frame, 2usize);
// this would panic, the frame has capacity for two roots.
// let k = Value::new(&mut frame, 3usize);
let k = Value::new(target, 3usize);
Ok(k)
})
}
In general, it’s highly advised that you only write function that take target. Each use of
&mut frame
in a closure will take one slot, and all you need to do is count how often
&mut frame
is used to find the required size of the LocalGcFrame
.
Modules
- Manage the garbage collector.
- Raw, inactive GC frame.
- Frames, outputs and other targets.