Ergonomic ref-counting: RFC decision and preview
Metadata | |
---|---|
Point of contact | Niko Matsakis |
Status | Proposed |
Flagship | Higher-level Rust |
Tracking issue | rust-lang/rust-project-goals#107 |
Zulip channel | N/A |
compiler champion | Santiago Pastorino |
lang champion | Niko Matsakis |
Teams | compiler, lang |
Task owners | Niko Matsakis, Santiago Pastorino |
Summary
We propose to write an alternative RFC for ergonomic ref-counting that makes lightweight cloning automatic and hold design meetings so the lang team can compare both approaches. This work builds on RFC #3680, which proposed a new keyword, use
, that could be used with closures (use || ...
) and expressions like x.use
to help address a longstanding problem: working with ref-counted data structures like Arc<T>
is verbose and confusing.
The 2025H1 work delivered .use
syntax that works technically, but community feedback on the RFC pointed out a tension: if we're trying to improve ergonomics, why add more required syntax? We'll write an RFC exploring the automatic approach, complete the implementation work needed to support it, and run design meetings to help the lang team decide. This will tell us about the impact on user experience and performance, how well we can catch problematic cases, and which approach works better for Rust developers working with ref-counted data.
Motivation: Reference counting is a common pain point across many kinds of Rust applications
The status quo
Working with ref-counted data structures creates verbose and confusing patterns that affect most non-trivial Rust applications. The friction appears not just with user-facing Arc<T>
and Rc<T>
, but throughout Rust's ecosystem where ref-counting is pervasive but often hidden.
Consider this common pattern when spawning async tasks:
#![allow(unused)] fn main() { // Today: verbose and repetitive let config = Arc::new(load_config()); let database = Arc::new(connect_db()); let metrics = Arc::new(MetricsCollector::new()); let config_clone = config.clone(); let database_clone = database.clone(); let metrics_clone = metrics.clone(); spawn(async move { process_request(config_clone, database_clone, metrics_clone).await; }); }
This pattern appears throughout Rust codebases, particularly in async and concurrent code. The repetitive cloning obscures the actual logic and creates maintenance burden when adding or removing shared data.
Applies to high- and low-level code
Explicit cloning friction affects all kinds of Rust projects. This is most apparent in higher-level applications, where the need for explicit cloning (particularly in closures) emerges as one of the largest pain points, but it applies to lower-level development too, particularly async network services. Ref-counting is also applicable beyond Rc
or Arc
, as numerous types (e.g., the channel channel handles found in libstd and tokio) are ref-counted under the hood. Improving the ergonomics of working with ref-counted data is one of those rare cases to simultaneously improve the lives of developers using interop crates like PyO3, GUI crates like Sycamore and dioxus, and tokio simultaneously. As the blog post from Dioxus labs put it:
While working at Cloudflare, I had to work with a struct with nearly 30 fields of Arced data. Spawning tokio tasks looked like:
#![allow(unused)] fn main() { // listen for dns connections let _some_a = self.some_a.clone(); let _some_b = self.some_b.clone(); let _some_c = self.some_c.clone(); let _some_d = self.some_d.clone(); let _some_e = self.some_e.clone(); let _some_f = self.some_f.clone(); let _some_g = self.some_g.clone(); let _some_h = self.some_h.clone(); let _some_i = self.some_i.clone(); let _some_j = self.some_j.clone(); tokio::task::spawn(async move { // do something with all the values }); }
Working on this codebase was demoralizing. We could think of no better way to architect things - we needed listeners for basically everything that filtered their updates based on the state of the app. You could say “lol get gud,” but the engineers on this team were the sharpest people I’ve ever worked with. Cloudflare is all-in on Rust. They’re willing to throw money at codebases like this. Nuclear fusion won’t be solved with Rust if this is how sharing state works.
Projects go great lengths to avoid the need for clone
Projects today structure their APIs in part to avoid the need for users to call clone. Dioxus's 0.5.0 release pivoted their framework around implicit runtime arenas; Sycamore did the same in 0.9 and Leptos uses a similar approach. In the compiler, where the use of arenas allows most types to be Copy
, we've encountered resistance to extracting libraries out for wider reuse due to the need to add clone
calls which obscure the logi, and projects like MiniRust and a-mir-formality have built elaborate facades primarily to avoid having to call clone
.
At the same time, there is a time and a place for explicit ref-counting
There are applications where explicit ref-counting is important. Methods like Rc::make_ref
or Arc::make_ref
, for example, have very different cost depending on whether the ref-count is 1 or 2, so knowing when ref-counts are incremented or decremented can be relevant. Naive and widespread use of atomic reference counting can have measurable impact on optimized applications, so avoiding reference-counts when not needed (or preferring non-atomic reference counting) is worthwhile.
The story so far
This work builds on RFC #3680 and the 2025H1 ergonomic ref-counting goal, which delivered a working nightly implementation of .use
syntax and generated extensive community feedback that revealed an important design question.
RFC #3680 and experimental implementation
RFC #3680, titled "Simplify lightweight clones, including into closures and async blocks," proposed a feature to simplify performing lightweight clones (such as of Arc
/Rc
), particularly cloning them into closures or async blocks, while still keeping such cloning visible and explicit.
The RFC identified common friction patterns where developers need to clone Arc<T>
reference-counted objects into async blocks or tasks, showing examples of the verbose workarounds currently required. The proposal aimed to "minimize the syntactic weight of lightweight-cloning objects, particularly cloning objects into a closure or async block, while still keeping an indication of this operation."
The core approach was to introduce a Use
trait that types like Arc<T>
and Rc<T>
could implement to opt into lightweight cloning behavior, along with new syntax (x.use
and use || { ... }
) to make this cloning more ergonomic while keeping it explicit.
Experimental implementation available on Nightly
Nightly Rust now has an experimental implementation of RFC #3680 (modulo the caveats in the FAQ):
#![allow(unused)] fn main() { let config = Arc::new(load_config()); let database = Arc::new(connect_db()); let metrics = Arc::new(MetricsCollector::new()); spawn(use async { // config, database, metrics are automatically cloned into the block process_request(config, database, metrics).await; }); }
RFC feedback: positive on the idea, but concerns on the details
RFC #3680 generated extensive discussion with over 80 comments of feedback (summarized here) that showed both support for addressing the ergonomics problem and concerns about the specific solution. Contributors from projects like PyO3 highlighted the real-world impact of the current friction, particularly in domains that make heavy use of reference counting.
The discussion covered several themes: concerns about defining what constitutes "cheap" cloning, questions about compiler optimizations that could lead to behavioral differences, debates about keyword overloading with use
, and various alternative syntax proposals. However, one critique stood out as particularly compelling and worth deeper exploration. Outside of the RFC thread, concerns were raised about compilation time impact, since, at least if naively implemented, this new approach involves more use of the trait system than is currently the case.
Key design question: will this make Rust easier?
Among the various concerns raised, the most hard-hitting critique questioned whether explicit .use
syntax actually serves the stated ergonomic goals. Diggsey's comment articulated this view:
Having to navigate a codebase filled with this strange .use keyword, which is hard to explain (it copies the value.. except when it doesn't, and it can be used on Use types, but not Clone types, but Use is not Copy, it's different... When is something Use? What makes something lightweight? Uh... read this essay. Why do I need to make this closure use but this one doesn't need to be? Ahahaha, yeah this is going to take a while to explain...) is more of a blocker than clone-into-closure ever was.
This critique suggests that if the goal is ergonomics, requiring more explicit syntax may be counterproductive. We propose to explore an alternative approach that makes lightweight cloning automatic by default, with optional warnings for non-trivial cases. This would allow the lang team to evaluate both the maximally explicit approach (current RFC) and the seamlessly integrated approach before deciding on Rust's direction for ergonomic improvements.
The next 6 months
To explore the automatic alternative, we will:
- Author an alternative RFC that avoids new keywords and includes a lint fo help catch potentially incorrect usage.
- Implement the new design (feature-gated, natch) to support experimentation and measure compilation overhead.
- Estimate the prevalence of code that benefits or is complicated by this change using Crater runs or other experiments.
- Conduct design reviews with the lang team to compare/contrast the two approaches.
The "shiny future" we are working towards
Ergonomic ref-counting represents a friction point in multiple domains. Async Rust programs frequently have context shared across multiple tasks (e.g., server state) that is managed via reference counting. GUI applications also have callbacks and data patterns that do not correlate well to the stack. In these domains, the existence of .clone()
calls (or .use
notation) represents syntactic noise that distracts from the essential data flow patterns.
Removing barriers in existing domains: In network services and async applications where Rust already excels, eliminating explicit cloning friction will make complex architectures more maintainable and readable. Developers won't need to choose between ergonomic APIs and performance, or resort to arena allocation patterns primarily for ergonomic reasons.
Opening new application domains: More significantly, this change opens up domains where Rust would otherwise be an excellent choice but where cloning friction currently tips the cost-benefit analysis against adoption. GUI applications, reactive programming, data processing pipelines, and other high-level domains become viable when the ergonomic barrier is removed.
Enabling natural architectural patterns: Projects like Dioxus won't need to build custom unsafe abstractions to avoid ref-counting friction. Mathematical formalism projects won't need preprocessors to eliminate distracting .clone()
calls. The Rust compiler and other complex applications can use ref-counting patterns more naturally without relying primarily on arenas for ergonomic relief.
This isn't about making Rust easier for beginners (though it will) - it's about removing a pervasive friction point that affects most non-trivial Rust applications and currently blocks adoption in domains where Rust's performance and safety would otherwise be compelling. The lang team's decision between explicit and seamless approaches will establish important precedent for how Rust balances ergonomics with explicitness in future language evolution.
Design axioms
The design axioms for this alternative RFC are as follows:
- Clarity of purpose. Although experienced users have learned to live with it, we believe that the current copy/clone introduces noise that distracts from being able to read the code (and, along the way, blocks new user adoption). Our top goal is to encourage Rust code that is clearer of purpose, no matters its domain.
- Competitive or improved performance. Using this feature should lead to code which is either as efficient or, in some cases, more efficient that you get today.
Ownership and team asks
Task | Owner(s) or team(s) | Notes |
---|---|---|
Author alternative RFC | Niko Matsakis | Seamlessly integrated approach |
Complete seamless implementation | Santiago Pastorino | Make x equivalent to x.use with optional linting |
Standard reviews | ||
Design meeting | Two meetings to evaluate both approaches | |
RFC decision | Choose between maximally additive vs seamlessly integrated |
Definitions
Definitions for terms used above:
- Discussion and moral support is the lowest level offering, basically committing the team to nothing but good vibes and general support for this endeavor.
- Author RFC and Implementation means actually writing the code, document, whatever.
- Design meeting means holding a synchronous meeting to review a proposal and provide feedback (no decision expected).
- RFC decisions means reviewing an RFC and deciding whether to accept.
- Org decisions means reaching a decision on an organizational or policy matter.
- Secondary review of an RFC means that the team is "tangentially" involved in the RFC and should be expected to briefly review.
- Stabilizations means reviewing a stabilization and report and deciding whether to stabilize.
- Standard reviews refers to reviews for PRs against the repository; these PRs are not expected to be unduly large or complicated.
- Prioritized nominations refers to prioritized lang-team response to nominated issues, with the expectation that there will be some response from the next weekly triage meeting.
- Dedicated review means identifying an individual (or group of individuals) who will review the changes, as they're expected to require significant context.
- Other kinds of decisions:
- Lang team experiments are used to add nightly features that do not yet have an RFC. They are limited to trusted contributors and are used to resolve design details such that an RFC can be written.
- Compiler Major Change Proposal (MCP) is used to propose a 'larger than average' change and get feedback from the compiler team.
- Library API Change Proposal (ACP) describes a change to the standard library.
Frequently asked questions
What is the current status of the implementation?
The implementation plan for the feature gate ergonomic_clones
(experimental version of RFC #3680) is as follows. The following steps are considered required for the basic feature functionality. A step is checked if it is present in nightly.
-
Introduce a
UseCloned
type implemented forRc
andArc
and possibly other types- The RFC called this trait
Use
; the names will have to be reconciled. We deviated becausex.use
can be applies to values of any type, including things that do not implement this trait, so the previous name felt confusing. The intention ofUseCloned
is "when a value of this type isuse
d, it will (if necessary) be cloned".
- The RFC called this trait
-
Introduce the
use
keyword as an operator on places, e.g.,some_place.use
-
Introduce
use || /* body */
closures. These are equivalent tomove
closures except that, where each captured placeplace
is stored into a move closure with an initializer likef: place
,use
closures contain fields initialized withf: place.use
. -
In MIR Build,
place.use
is compiled as a call toclone
with acall_source
ofCallSource::Use
:- As a compile-time optimization, the MIR build for
place.use
depends on the traits implemented by the typeT
ofplace
:- If the type
T
is known to implementCopy
(modulo regions), then we compileplace.use
to a copy likeplace
. - If the type
T
is known to implementUseCloned
(modulo regions), then we compile it to a call withcall_source
as described above. - Otherwise,
place.use
is compiled to a move. - This is a compile-time optimization because, if we didn't do this, it would be optimized later by the monorphization-time optimization, but we would spend more effort in the meantime.
- If the type
- If the type of
x
is NOT known to implementUseCloned
, thenx
will be compiled as a move.
- As a compile-time optimization, the MIR build for
-
Integrate
some_place.use
into borrow check (fairly trivial) -
Identity candidates for "last-use" optimization (
some_place.use
is a last use ifsome_place
is never used again) -
At code generation time, the semantics of
some_place.use
depends on the typeT
ofsome_place
:- If
T
implementsCopy
, thensome_place.use
is a copy - If
T
implementsUseCloned
(and is not a last-use), thensome_place.use
is compiled as a call tosome_place.clone()
- Otherwise,
some_place.use
is a move.
- If
The following features are not planned for implementation until the future; they are a "nice to have":
-
Implement
use
-elision and inter-procedural optimization- If
some_place.use
never escapes the current stack frame and no mutation is occurring, we can forego the call toclone()
.
- If
How does this goal relate to stabilization?
This goal is explicitly not about stabilization - it's about getting to a clear decision point. The chosen approach and its implementation will represent a step toward eventual stabilization, but the focus here is on resolving the fundamental design question that emerged from 2025H1.
Once the lang team chooses a direction, subsequent work can focus on refinement, community testing, and the stabilization process without the uncertainty of fundamental design questions.
What is the relationship to the 2025H1 ergonomic ref-counting goal?
This goal builds on the 2025H1 implementation work and the feedback from the RFC. The current implementation already includes most of the infrastructure needed for both approaches - the remaining technical work is primarily about completing the seamless integration option.