Immortal Arc API?

Motivation

I was reading this article about Python's new immortal objects, and wondered if it was applicable to Rust. When using Rust, if you clone an Arc, then the data is not cloned but the refcount is updated. What if you knew that your Arc will never be dropped until your program exits, but it is cloned around a lot?

My idea is to introduce immortal Arcs. This would be done by adding create_immortal and drop_immortal methods to Arc. They would set the strong refcount to some sentinel value, which would inhibit normal dropping.

By inhibiting dropping, code does not need to worry about dangling references and many optimizations can be made, such as with Weak::upgrade which will no longer need a fetch_update call.


Safety

Because this proposal inhibits dropping (a core tenant of Rust), there must be a way to still drop it. Like Box::into_raw and Box::from_raw, this provides a safe API to create an immortal object and an unsafe API to drop that object. This is necessary because if there are any outstanding references, multiple calls to the drop_immortal method will cause UB. However, taking Box::into_raw as an example, it is up to the programmer in this case to ensure safety.


I think this would be a positive addition to Rust, as it allows the programmer to optimize the performance of their smart pointers if they know its lifetime. One may observe that this is a more data-centric lifetime approach and therefore fits well with Arc. Immortal Arcs would essentially have a 'static lifetime, but dynamically determined.

Please see an example implementation of the create_immortal and drop_immortal methods with my Trc smart pointer here, with an example of the optimizations I can make to Weak::upgrade here.

I would really appreciate your thoughts on this idea!

Do you have an example of an API where this would be beneficial? Other possible solutions to these kinds of contention problems:

  • use Rc<Arc<T>> for thread-local refcounting of a thread-shared resource (removes atomic op overheads); or
  • update APIs to prefer &T where possible so that refcounting is not even needed except at thread-distributing-work boundaries; or
  • OnceCell<T> sounds like a better representation of this to me.
4 Likes

I think this API would be beneficial for all cases where the data needs multiple ownership, and where references cannot work due to lifetimes. I do not have a specific use case, but it seems reasonable that this would have a positive impact.

To clarify: this would not solve a contention problem, it just extends the functionality of Arc. This is best for cases where one has what is essentially 'static data, but the data's lifetime cannot be validated by the compiler. In this case, it can be helpful to tell Arc to enforce immortality (although this also means that we take the burden of managing memory lifetimes - like Box::from_raw).

As for OnceCell, if you look at its Clone implementation, you can see it clones the data instead of doing what a smart pointer does. In addition, one may want to mutate the Arc via get_mut, and this cannot be done with OnceCell.

As an alternative, perhaps an API to convert the data held by an Arc to a Box would work? This looks like it would accomplish the same goal.

Perhaps it could take a self and ensure there are no other references, before moving the data to a Box, freeing the allocations, and then mem::forget-ing the self?

So why have an Arc in the first place? Just box the thing to begin with and Box::leak it to get a static reference that you can share around

22 Likes

For “immortal” values, we already have Box::leak(). What this proposal adds is being able to produce a pointer of type Arc<T> rather than &'static {mut} T.

As I see it, the important question then is: what use cases would benefit from this type similarity?

I can think of one: arcstr (a library providing an Arc<str>-like type) allows creating literals of the same type, and you can imagine how string code benefits from this because there are often both literal and dynamic strings in the same situations. However, that's not quite the same thing; that's compile time allocation in static memory, not leaked heap memory.

What use cases are there for creating specifically these immortal objects? (I'm not saying I don't think there are any, but rather that demonstrating them is the key to getting this proposal through.)

11 Likes

What about normal Rc/Arc usage though? Most operations would now need to check the sentinel value, adding a needless branch to current code. I feel like this would be a pessimization for anyone not using this feature.

13 Likes

Isn't this basically a ManuallyDrop<Arc<T>> with a few checks removed? I think that you can build by yourself this abstraction in 10 minutes and in like 100 lines of code, so my recommendation is to make a crate of its own first (or get it into a crate like triomphe), and if it takes off, we can think about upstreaming it.

1 Like

True - this would have a large performance penalty. What about a feature flag that enables the checks, and the new methods?

What do you think about the to_box method I outlined above? I implemented it here already.

Technically, that is true. This specific idea is probably redundant.

Thanks for the link to triomphe, I will check it out!

What is the point to convert it to a Box if it actually converts it to a T (data in your implementation) and then just Boxes it at the end? To me looks like you're just reimplementing a worse Arc::try_unwrap.

I actually already implemented try_unwrap for Trc, but I suppose most of this function is redundant. My idea was actually to allow an Arc that does not need recounting, which is a Box. However, thank you for pointing that out.

Hmm. If I felt that OnceCell was dissatisfactory due to data reasons, I would probably try my hand at a OnceArc that implements the idea of "initialize once, deallocate never".

Interesting, I will look into that!

How is having an immortal "Arc" better than just calling Box::leak? By reborrowing to &T, you get to keep your &'static T for the whole duration of the program, and cloning shared borrows is free, no refcount update needed.

9 Likes

That branch might be worth it if it saves a cache miss from updating the reference count.

Yes, that is the whole idea.

It's not "being worth it" in a global sense. Different parties reap different advantages here. Someone who never uses any undying arcs will also pay the cost and see no benefit. Some people already don't like paying the cost and memory footprint of the extra weak counter when they only use strong references.

Perhaps we could stuff the immortal handling into an existing slow path by default and provide a wrapper/alternative methods that make different tradeoffs. But that seems really contorted and having a separate implementation that just makes different tradeoffs might be better.

Arc::clone is no more than

example::clone:
        mov     rax, qword ptr [rdi]
        lock            inc     qword ptr [rax]
        jle     .LBB0_1
        ret
.LBB0_1:
        ud2

and the branch is never taken. Any extra behavior is going to make the common case slower.

3 Likes

I still don't understand the use-case here. Nobody has explained why Box::leak doesn't cover this need.

Why do you need the Arc? Would having something like Arc::leak fulfill your needs?

6 Likes

My understanding is that they have data structures that contain tons of Arcs and want to avoid the atomic refcount increase/decrease cost for the subset that has been declared to live for the whole program lifetime. In those cases it would only be a load-and-branch.

1 Like