There have been a lot of discussion around adding some sort of effect system (A term I don't like) or asking for the features that would be provided by one. See:
- https://github.com/rust-lang/rfcs/issues/2327
- Another effect handler design for Rust
- https://github.com/canndrew/rfcs/blob/effects/text/0000-effects.md
- Async and effect handlers
- https://users.rust-lang.org/t/module-wide-shared-function-parameters-tls-dynamic-scope-emulation/11996
- Managed(?) thread-local storage
- Idea: "Ambient data"/"Current execution context"/ Was: Provide a thread-local scope with hooks for `std։։thread։։spawn`
- https://github.com/mitsuhiko/rust-execution-context
Along with several alternatives:
- https://github.com/rust-lang/rfcs/issues/424
- Generic-type-dependent static data Per-type static variables (take 2)
Most of these proposals because they require substantive changes to Rust and often introducing new keywords. I would like to find a way to solve these problems without language level changes, that feels natural in Rust.
I have been thinking about this issue for a while and I have what I think is a fairly good API, and an unacceptably bad implementation. I am posting this because I hope someone here can figure our how to improve upon it.
The basic idea is to provide a way to supply an instance of a type implementing a trait without having to directly pass it as a parameter to each function between where it is created and where it is used.
As a motivating example, consider the idea of providing additional information on each log line, such as a UserId or a RequestId.
The log crate could define a trait it can make use of. Such as LogContext
. Then it can obtain the supplied instance.
//Log context provides additional data you want to be displayed on each log line.
trait LogContext : Display { }
impl LogContext for String { }
//... etc
//...
fn log(&self, record: &Record) {
//...
let ctx = get_context!(LogContext);
//...
}
Here the get_context!
call is returning a value which implements the provided trait LogContext
.
Further up the stack a function can supply the context.
fn process(request: Request) {
provide_context!(LogContext, &request.id);
//...
do_stuff();
//...
}
Now the logger can include the requestId in the log output when it is invoked inside of do_stuff()
or any method do_stuff
calls, even though it is not passed as an argument.
This has a number of usecases beyond the obvious ones tracing and logging:
fn example() {
//...
let cache: &dyn Cache = get_context!(Cache).or_else(default_cache);
}
fn example() {
if let Some(custom_allocator) = get_context!(Allocator) {
//...
}
//...
}
Because contexts provide references to items on the stack they cannot outlive the object they come from. To support this provide_context
can return a guard which when it is dropped removes the provided item. So in example below log calls inside of function_1
and function_2
would see the requestId but those in function_3
would not.
fn process(request: Request) {
//...
{
provide_context!(LogContext, &request.id);
function_1(&request);
function_2();
}
function_3();
}
As an implementation get_context!(SomeTrait)
could expand to:
{
const key: u64 = TypeId::of::<SomeTrait>().t;
let context_map: HashMap<u64, TraitObject, BuildNoHashHasher<u64>> = get_context_map!();
let value: Option<&dyn SomeTrait> = map.get(key).map(|v|
{
let val: &dyn SomeTrait = unsafe {mem::transmute(*v)};
val
});
value
}
Basically there is a hashmap, where the key is a constant, and there is a no-op hasher so the lookup can done in a handful of CPU cycle. The rest is just type conversion. The get_context_map!()
could just use thread local storage.
The immediate problems with this are:
- While llvm has support for thread local storage it isn't guarenteed to be super fast and may have limitations on some architectures.
- Using a HashMap is going to require an allocator.
The much bigger problem is how to deal with multiple threads. If a thread is spawned it will necessarily have its own independent context and not inherit anything from the parent thread. However with scoped threads, or when using libraries like Rayon, it makes sense to have worker threads use the
context_map from the caller. Otherwise as soon as an intermediate function did a .par_iter()
the feature would stop working. The same is true for
multi-threaded event loops.
One solution would be to make a shallow copy of the context map and assign it as the context for the task. This has a few downsides:
- Even though it's just an array copy, it still adds overhead to having part of a job run on another thread, and this would be incurred even if the feature is not actually used.
- It requires all the implementations passed to
provide_context
to be bothsend
andsync
. This is enforceable, but not great.
Does anyone see a way around these limitations?