I've been playing around with the standard Rust library's parallelism structures to better understand how to write 100% safe code in the threaded model. I've created several structures of my own for experimentation with, and now wanted to simulate something similar to Haskell's spark.
I currently have a tested and working implementation of a tokio one-shot/pi-calculus style single-use cross-thread channel, which I've named a Ping
, whose usage looks like this:
let mut p = Ping::i32::new();
let mut q = p.clone();
let h = thread::spawn(move || {
let x = q.recv().unwrap();
println!("{}", x); // Some(22)
});
p.send(22);
h.join();
println!("Some(22) will always print before this");
(if you want the gory details of the above structure, including the function below a with //TODO:
about this issue, look here)
Which I thought would be the perfect basis for the following implementation of a Haskell-style spark
pub fn spark<T: 'static, U: 'static>(arg: T, action: Box<dyn FnOnce(T) -> U + Send>) -> Ping<U>
where
T: Send,
U: Send + Clone,
{
let p = Ping::<U>::new();
let mut q = p.clone();
let f = move || {
let x = action(arg);
q.send(x);
};
std::thread::spawn(f);
p
}
The idea being the thread calling spark
would provide a T
and a function from T->U
, and spark
would give back a Ping<U>
, while also firing off another thread to fill the clone of the Ping<U>
with the result of the function applied to T. The calling thread doesn't have to await
, but can continue time consuming work, and then only call .recv()
on the Ping
when it needs the value. The hope is the value is computed in parallel before it is needed. If that isn't the case, then when the Ping
is recv()
ed the calling thread blocks, similar to a promise.
This works, but I don't like the 'static
lifetime params on T
and U
, because if I understand lifetimes correctly (and I very well may not), that 'static
coerces all T
s and U
s into never-freed memory. If I omit 'static
, the compiler states that T
and U
may not live long enough, which is confusing to me, because the move
in f
seems to clearly show what lifetime belongs where. Is there a way I can annotate f
to show the compiler that arg
and action
are consumed by f
(then moved to another thread) and p
is the only result that it needs to track?
Also, more generally, why is this happening? I tried wrapping arg
and action
in Arc::Mutex
and Arc
respectively, cloned them into fresh variables, then moved them into the closure. I tried with and without defining f
separately. The move
keyword and I seem to agree on everything else I'm doing with threads, what does it need that I'm missing here? Is it because there's a U
that exists in both scopes (though a different U
)? If so why is T
even involved, there are no T
s left in the outer scope after the move
?
Any clarity would be greatly appreciated.
For the record, the above works as expected with:
fn test_spark(){
let test_f = |i: i32| i * i;
let mut my_spark = spark(4, Box::new(test_f));
let result = my_spark.recv().unwrap();
match result {
Some(x) => {
assert_eq!(16, x)
}
_ => panic!("No result")
}
}