Arc::make_mut 尝试通过首先验证 Arc 是否持有分配的唯一强引用来提供对内部数据的可变访问。它通过对强引用计数进行 Acquire 顺序的原子加载来执行此检查。如果计数恰好为 1,则操作继续返回一个可变引用;否则,它会克隆内部数据并更新 Arc 以指向新的分配。
use std::sync::Arc; let mut data = Arc::new(5); *Arc::make_mut(&mut data) += 1; // 仅在共享时克隆
Acquire/Release 配对是至关重要的,因为当另一个线程释放其 Arc 时,它会对计数执行 Release 减少。make_mut 中的 Acquire 加载确保在减少之前释放线程所做的所有内存写入对于当前线程都是可见的,从而防止对内部数据的数据竞争。
考虑一个高吞吐量的指标聚合服务,其中配置更新通过 Arc<Config> 进行传播。数千个线程持有引用以读取当前设置,但管理员线程需要定期调整阈值而不重新启动服务。
幼稚的方法是将 Config 包装在 RwLock 中,并在每次读取时对其进行锁定,或者在每次小更新时克隆整个结构,而不考虑共享。第一个解决方案遭受缓存行跳动和锁开销的影响,而第二种方法在配置实际上是唯一的情况下浪费了内存和 CPU 周期。
另一种选择是使用带有危险指针的 AtomicPtr 进行无锁更新,但这需要复杂的手动内存管理,并且容易出错。另一种选择是使用 RwLock<Arc<Config>>,允许指针本身的原子交换,但这会增加指针交换的额外间接和锁。
团队选择了 Arc::make_mut,因为它优化了常见情况:如果没有其他线程持有引用(强计数为 1),管理员线程可以在不分配的情况下就地修改数据。如果配置是共享的,它会透明地克隆。这需要严格的 Acquire/Release 语义,以确保当最后一个其他读者释放其 Arc (使用 Release)时,管理员线程的后续检查(使用 Acquire)能够看到对配置的所有先前写入,从而防止撕裂读取。结果是在低争用下配置更新的延迟减少了 40%。
为什么不能在 Arc::make_mut 中对引用计数检查使用 Relaxed 顺序?
Relaxed 操作不提供发生之前的保证。如果 make_mut 使用 Relaxed 来检查强计数是否为 1,它可能会在观察到该线程对内部数据的写入之前观察到计数从另一个线程的减少。这将允许当前线程在另一个线程仍然逻辑上读取数据时对数据进行修改,从而导致数据竞争。Acquire 确保,当我们看到计数达到 1(通过另一个线程释放同步),我们还会看到对数据的所有先前写入。
是什么使 Arc::make_mut 的行为与手动使用 .clone() 克隆 Arc 然后进行修改的行为区别开来?
手动克隆会创建一个指向相同分配的新 Arc,将强计数增加到至少 2。您无法通过这个新的 Arc 获取对内部数据的可变访问,因为 Arc 仅提供不可变共享。Arc::make_mut 是特别的,因为它检查计数是否为 1;如果是,它会将 &mut T 提供给现有的分配。如果不是,它会将 data 克隆到一个强度计数为 1 的新分配中,确保原始共享数据保持不可变,同时让您对新副本拥有唯一的所有权。
弱指针 (Arc::downgrade) 如何影响 Arc::make_mut 的唯一性保证?
弱指针不参与强引用计数。Arc::make_mut 仅检查强计数,忽略弱引用。然而,如果分配仍然存在,弱指针可以升级为强指针。如果 make_mut 在原地进行修改(强计数为 1),而另一个线程随后升级了一个弱指针,则该升级将创建一个指向相同已修改数据的新 Arc。这是安全的,因为升级发生在修改之后,而 Rust 的内存模型保证升级的指针看到完全修改的值。弱计数并不会阻止修改,但它确实保持分配存活,即使所有强引用都暂时被释放。