Best practices: how to maintain Send and Sync for interior mutability and trait objects in a library

We are currently developing a library that has per se nothing to do with async or parallelism. But we are exposing some types that we want the user to be able to Send and Sync to other threads if they need to. On the other hand there will be other users that probably don't need Send and Sync at all.

So our problem now is how to handle those two kinds of users without having to duplicate most of our code base.

The problems arise as soon as we need interior mutability in the types exposed to the user or if we create trait objects from types coming from the user.

In the first case the question is how we achieve interior mutability. Do we use RwLock/Mutex? Or simply RefCell? Note that this should be library-internal logic but is still (private) part of the type that gets exposed to the user so affects if the type is Send and Sync. Since our library is performance critical we don't want to create an overhead of a Mutex or a RefCell for users that don't need it.

In the second case the question is if our types contain something like a Box<dyn SomeTrait> we have to decide if we go with Box<dyn SomeTrait> versus Box<dyn SomeTrait + Send + Sync>. Note that we will have a method like the following to fill the box from the user:

pub fn add<T: SomeTrait>(&mut self, t: T) { ... }

And again it depends what the user wants. If the user doesn't need Send and Sync it might be weird to force them to make all types the pass into this Box to be Send and Sync.

Both the cases I mentioned are quite core to our library and a lot is built on top, so if we just want to duplicate this we have to duplicate almost the whole code base.

Another approach would be to have a feature and use conditional compilation but this breaks additivity of features.

So I am wondering if other major libraries have had similar problems and how they solved it and if there is some best practices that we should follow for such use cases?

Since the problem since quite general I feel like this has to affect many libraries.

1 Like

You have some options. You've already listed a few. Here are some others:

First, you can just say your type is not thread-safe "because performance and reasons, trust me" and if users insist on using threads, they can use channels to communicate with the thread that owns your library type.

Another option is to use locks and threads anyway with the realization that locks are not inherently slow, contention is. This gives you an escape hatch in the form of architecting the API and access patterns to avoid contention, while using thread-safe interior mutability where necessary.

A third option is to try to be generic over thread safety. I don't know how well this works in practice. Some example crates to look at are archery, archway, hybrid-rc, atomic-refcell, atomicell, sendable, and many others. (This is not an endorsement for any of these crates.)

Most libraries pick "definitely not thread safe" or "definitely thread safe" or both. For some prior art, tokio's single-threaded vs multi-threaded runtimes and spawn vs spawn_local are obvious examples. As well as the fact that the standard library keeps Rc vs Arc and RefCell vs RwLock as distinct single-threaded and multi-threaded types. The status quo is providing multiple APIs and letting the user decide which one they want.

4 Likes

This may be stating the obvious, but I would say that libraries usually avoid this problem by not using (and not having a need for) trait objects passed in by the caller (or generic values that become trait objects). You only have this problem when you are calling dependent-supplied code on your own schedule (i.e. not within the scope of one generic function call), without also introducing generics to your data types.

I’m not saying your design is wrong — I have a project with exactly the same problem (which I do not currently have a good solution to) — just that there are particular conditions required for this problem to occur. But if you can think of some way to have fewer “callbacks” that last indefinitely, consider doing that.

You could make your code generic over the trait object type (or a marker type that wraps it).

trait OurTypes {
    type BoxSt: Deref<Target: SomeTrait>;
    // ... possibly other associated types for other cases ...
}

struct ThreadCompatible;
struct NotThreadCompatible;
impl OurTypes for ThreadCompatible {
    type BoxSt = Box<dyn SomeTrait + Send + Sync>;
}
impl OurTypes for NotThreadCompatible {
    type BoxSt = Box<dyn SomeTrait>;
}

struct SomeLibraryType<T: OurTypes> {
    things: Vec<T::BoxSt>,
}

This still has the disadvantages of making all your code generic.

3 Likes

A related approach is to expose two top-level modules that are backed by the same code via #[path] annotations:

pub mod sync {
    type DynTrait = dyn SomeTrait + Send + Sync;
    pub use common::*;
    #[path="../common.rs"] mod common;
}

pub mod unsync {
    type DynTrait = dyn SomeTrait;
    pub use common::*;
    #[path="../common.rs"] mod common;
}

///////
// common.rs
///////

use super::DynTrait;

...
2 Likes