Thread or not to Thread -- what is the mechanism?

I have written a library which makes use of reference counting. Currently, it is built using Rc which is fine, but limits it to single threads. For my original use case this made sense, but we are also building a python interface to the library and that requires Arc.

I have experimented with the core data structure of the library (representing an IRI) generic and have now managed to implement this here. This works but this single change percolates through the entire library. The diff to support shows around 1900 changes and has rather reduced the ergonomics of the library -- some of which I can fix with further changes, but some not.

The alternative is to use a configuration feature as im-rs does. While this has obvious disadvantages, it would be a much (much!) smaller change to my code base and probably fulfil much of the immediate need.

I am wondering if anyone has thoughts about advantages or disadvantages that I am currently missing? I am really not sure which way to go.

From what I can see over in the following diff:

it does look like you have attempted to remain generic over thread-safety; in a context which wasn't previously generic. That indeed yields a ton of changes.

While you may keep the generic in some layer, if you feel like it, it would be nice to expose "a choice" of the generic family within different modules, like ::once_cell does with its sync and unsync modules.

That is, for a downstream user of your lib (such as your own unit tests), to be able to do:

use …flavor::multi_threaded::*;

or

use …flavor::single_threaded::*;

and then have the types scoped within those modules be non-generic. This ought to greatly reduce your diffs.


One idea of an implementation

See Support both Rc and Arc in a library - #6 by Yandros for an illustration.

But the gist is that:
  1. you could imagine having a "generic module":

    mod my_lib<RC>;
    

    wherein you'd have a duck-typed RC path, with which you could clone, etc.

  2. From there, you'd then "instantiate" such module using a specific choice of RC:

    pub mod single_threaded = my_lib<::std::rc::Rc>;
    pub mod multi_threaded = my_lib<::std::sync::Arc>;
    
  3. One way of achieving both things is through macros and include!:

    //! src/…/path/to/my_lib.rs
    
    /* your logic, which assumes `RC` to be in scope */
    
    pub
    mod single_threaded {
        use ::std::rc::Rc as RC;
    
        include!(concat!(env!("CARGO_MANIFEST_DIR"), "/",
            "…/path/to/my_lib.rs",
        ));
    }
    
    pub
    mod single_threaded {
        use ::std::sync::Arc as RC;
    
        include!(concat!(env!("CARGO_MANIFEST_DIR"), "/",
            "…/path/to/my_lib.rs",
        ));
    }
    
3 Likes

So, if I understand correctly, this is rather like the cfg feature option, but using compile time magic to double up all the data structures, one Rc and once Arc. This would mean that a user of the library could make both threaded and unthreaded usage of the library in the same application, but there would be no immediate and obvious way to convert between types.

I think in my case, this would mean I'd need to double the entire library -- all the IO functionality for instance.

But, yes, you are correct, I have altered a library that was previously not generic. Unfortunately, this means that pretty much every single part of the library has been impacted. It also has some negative side effects that I hadn't considered, mostly stemming from the lack of impl specialization.

Out of curiosity, have you thought of using Arc unconditionally? Even if you might not technically need a thread-safe type everywhere.

Generics (including lifetimes) tend to have a viral effect on your codebase unless you constrain them to a certain type/lifetime. Removing that layer of configuration is a good way to avoid generic soup, especially when your end users may not care anyway.

I agree that this is worth considering. Cloning or dropping an Arc is not particularly slow, and that's the only time you would pay for using Arc instead of Rc.

2 Likes

Yes, I thought about that. The main issue with this is that the point of this library is to be fast -- there is already a good library in Java that does everything my library does and some more things. I picked rust in the hope that the library would go quicker and use less memory. As such, I've tried to follow a "pay for what you use" model, rather than a "sensible defaults" which the Java version has.

I hadn't tested it before, which I probably should have done, just by forking the library. Ironically, now, having build a branch with generic support I can actually try Rc directly alongside Arc (and also a String version which uses no reference counting at all. Answer is Rc is about 15% faster than Arc, and about 100% faster than the pure string version.

So, it's better, but only so much better. The deeper question comes when I think about those parts of the library that could usefully be threaded. I haven't investigated that enough to know whether these will need Rc or whether they can be trivially parallelized.

All of which says that generics is the right way to go. I hadn't expected it to require quite so many changes though.

That didn't mean that generics is the right way. I would suggest using an arc feature flag instead. Your API remains as simple as possible, and your implementation is probably way less affected. It's unusual to redefine a data structure based on a feature flag, but as long as it doesn't affect your API apart from adding Send to your types, it should be additive in the way that feature flags must be.

P.S. since you mentioned String as an alternative, and say that performance matters, I'd suggest considering interned strings (e.g. with e.g. the internment crate, which would be helpful if you performs hashing or equality checks frequently, or also Rc<str> (or Arc<str>) which should be a tad faster than Rc<String> which you may be using due to having one less level of indirection.

I would also suggest a string interning solution, perhaps combining with a small string crate, to see if they can work together. Arc of a String is something I would prefer not to host in a library API; perhaps a string like trait can allow multiple implementations.

Lasso

Smol-str

Indeed, you are right. When I said "generics are the right way", I meant as opposed to just using Arc. A feature flag would be massive simpler in comparison. I did think of using an interning crate, and can't hundred percent remember why I chose not to.

I switched from Rc<String> to Rc<str> a while back when it became possible. The interesting thing with my generic solution is that I can just directly use String; no reference counting, but more deep cloning.

I'll point out that it's not obvious a priori that interned strings will be better. It depends on how many times you hash or compare for equality each string. Creating an interned strings is more expensive than creating an Rc<str>.

Just to finalize this thread, I've now updated my library, replacing two uses of Rc with a generic that works over Rc or Arc (or even a direct reference). The differences are significant but not massive (25%). I still haven't decided whether it is worth the change (2000 loc changes) and will try conditional compilation also.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.