陈天 · Rust 编程第一课
陈天
Tubi TV 研发副总裁
23195 人已学习
新⼈⾸单¥68
登录后,你可以任选4讲全文学习
课程目录
已完结/共 65 讲
基础篇 (21讲)
陈天 · Rust 编程第一课
15
15
1.0x
00:00/00:00
登录|注册

09|所有权:一个值可以有多个所有者么?

Box::leak()机制
堆内存生命周期
clone()
内部可变性
Rc(Reference counter)
Rc::clone()方法
std::thread::spawn
Arc 和 Mutex/RwLock
RefCell
有向无环图(DAG)
Rust 借用检查器
静态检查
参考资料
思考题
实现可修改DAG
特殊情况处理
单一所有权规则
所有权和智能指针

该思维导图由 AI 生成,仅供参考

你好,我是陈天。
之前介绍的单一所有权规则,能满足我们大部分场景中分配和使用内存的需求,而且在编译时,通过 Rust 借用检查器就能完成静态检查,不会影响运行时效率。
但是,规则总会有例外,在日常工作中有些特殊情况该怎么处理呢?
一个有向无环图(DAG)中,某个节点可能有两个以上的节点指向它,这个按照所有权模型怎么表述?
多个线程要访问同一块共享内存,怎么办?
我们知道,这些问题在程序运行过程中才会遇到,在编译期,所有权的静态检查无法处理它们,所以为了更好的灵活性,Rust 提供了运行时的动态检查,来满足特殊场景下的需求。
这也是 Rust 处理很多问题的思路:编译时,处理大部分使用场景,保证安全性和效率;运行时,处理无法在编译时处理的场景,会牺牲一部分效率,提高灵活性。后续讲到静态分发和动态分发也会有体现,这个思路很值得我们借鉴。
那具体如何在运行时做动态检查呢?运行时的动态检查又如何与编译时的静态检查自洽呢?
Rust 的答案是使用引用计数的智能指针:Rc(Reference counter) 和 Arc(Atomic reference counter)这里要特别说明一下,Arc 和 ObjC/Swift 里的 ARC(Automatic Reference Counting)不是一个意思,不过它们解决问题的手段类似,都是通过引用计数完成的。
确认放弃笔记?
放弃后所记笔记将不保留。
新功能上线,你的历史笔记已初始化为私密笔记,是否一键批量公开?
批量公开的笔记不会为你同步至部落
公开
同步至部落
取消
完成
0/2000
荧光笔
直线
曲线
笔记
复制
AI
  • 深入了解
  • 翻译
    • 英语
    • 中文简体
    • 中文繁体
    • 法语
    • 德语
    • 日语
    • 韩语
    • 俄语
    • 西班牙语
    • 阿拉伯语
  • 解释
  • 总结

Rust语言的内存管理模型在处理大部分场景中表现出色,通过借用检查器在编译时完成静态检查,保证了安全性和效率。然而,对于特殊情况,如有向无环图(DAG)中节点可能有多个所有者或多个线程要访问同一块共享内存,静态检查无法处理。为了解决这些情况,Rust提供了运行时的动态检查,使用引用计数的智能指针Rc和Arc来满足特殊场景下的需求。Rc允许对某个数据结构创建引用计数,使其有多个所有者,而Arc则是原子引用计数的智能指针。通过Rc的clone()方法,可以增加引用计数而不复制实际数据,实现多个所有者对同一块内存的管理。此外,Rust还提供了Box::leak()机制,允许创建不受栈内存控制的堆内存,以应对引用计数的生命周期需求。文章还介绍了如何使用Rc来实现之前无法实现的DAG,并引入了RefCell概念,允许在运行时对只读数据进行可变借用。总之,Rust通过静态检查和动态检查相结合的方式,保证了代码的安全性和灵活性,为开发者提供了强大的工具来处理各种内存管理需求。文章还介绍了Arc和Mutex/RwLock的用法,以及对Rc的clone()方法的实现进行了探讨。通过本文,读者可以深入了解Rust语言的内存管理特点,以及如何应对特殊场景下的内存管理需求。

仅可试看部分内容,如需阅读全部内容,请付费购买文章所属专栏
《陈天 · Rust 编程第一课》
新⼈⾸单¥68
立即购买
登录 后留言

全部留言(51)

  • 最新
  • 精选
  • 清风徐来
    置顶
    老师问一个额外的问题 以anyhow、clap库为例,通篇看rust技术文档有一种很蒙蔽的感觉,感觉文档只是把技术点拆的很零碎进行说明(如:有哪些struct、trait等),外加一个没有实质性帮助的为例文档代码,感觉和其他语言的技术文档很不一样;一句话来说明:文档描述拆的很零碎,并没有一个清晰地整体的统一使用说明,致使无从下手;这个问题如何解决???
    2022-03-18
    5
    14
  • 有铭
    置顶
    今天这章我看完以后感觉有一股非常遗憾的情绪: 我原本以为rust真的用编译时检查解决了当年C++面临的那些问题。结果最后还是妥协撕开了一道口子开了后门,而且这个后门开的很复杂,我的感觉,这个特性,将来要么就是没什么人用,要么就是被人滥用。这个东西对人的自控要求太高了

    作者回复: 这个世界本就不纯粹,没有绝对的完美。所有的语言都会撕开一道口子,Haskell 也需要 IO Monad 和外界打交道,Erlang VM 也得忍着可能 crash VM 的风险引入 NIF (native function) 等等。不光语言如此,框架也如此,给你解决 80% 问题的利器,也保留让你解决剩下 20% 问题的灵活度。这还只是一个 Box:leak/Box::into_raw/ManuallyDrop,Rust 还有 unsafe 呢...别着急下结论,让子弹飞一会。

    2021-09-11
    6
    66
  • lisiur
    老师您好,在代码3里,把内部作用域去掉会导致运行时错误,但是如果使用普通的借用方式像这样: ```rust fn main() { let mut data = 1; let v = &mut data; *v += 1; println!("data: {:?}", &data); } ``` 却不需要使用多余的作用域。 我的理解是普通的借用方式走的是编译期检查,编译器标记借用的生命期的粒度比作用域要小,比如上述代码的 mut 借用,正常的生命期应该是到main函数结束,但是编译器应该是把它缩小到了 println 代码之前的位置,所以 println 的不可变借用并不和上面的可变借用冲突。但是运行时的"生命期检查"应该就是作用域粒度的,所以需要使用额外的作用域来达到手动 drop 可变借用的效果。 我的想法是,既然编译期能够做到尽可能小的缩小借用的生命周期,那编译器能不能自动对这种特殊的内部可变性的借用在合适的位置插入drop代码,使得不使用额外的作用域也能满足运行时检查呢?

    作者回复: 谢谢你这么有深度的思考! 编译器是一个不断进化的过程,在基本的规则下,它可以尽可能多地把常见的,并且合法的情况处理掉,不给开发者太多的负担。所以可以引用引入了是否活跃的概念,以及 NLL(https://rust-lang.github.io/rfcs/2094-nll.html)。 对于文中的内部可变性的例子,检查完全有赖正在执行的函数来确保。的确,对于这个例子,在编译期完全有可能把原本在作用域结束时 drop 的 v 提前到 v 使用完的地方: ```rust use std::cell::RefCell; fn main() { let data = RefCell::new(1); // 获得 RefCell 内部数据的可变借用 let mut v = data.borrow_mut(); *v += 1; // 提前 drop 可变引用 drop(v); println!("data: {:?}", data.borrow()); // 隐含的 drop // 原本 drop(v) 发生在这里 drop(data); } ``` 这样可以避免额外的作用域。 但是,如果这样做,编译器就是在为某个特定的数据结构 RefCell,而非 Rust 的语法单元 &mut 来做优化了。从系统设计的角度,除非能找到一种很通用的方法(比如设计一个新的像 Send/Sync/Unpin 这样的 auto trait 对所有实现了这个 auto trait 的数据结构进行优化),否则,编译器需要知道哪个数据结构在做哪个操作的时候需要特殊处理。这就会让编译器本身的设计变得复杂。 个人意见,不代表编译器团队的思路 :)

    2021-09-10
    3
    35
  • 千回百转无劫山
    1. 错误为线程借用的arr生命周期可能会长于main函数中的arr,简单处理的话把main中arr的所有权move到线程里即可,编译器对此有详尽的提示: ```rust fn main() { let arr = vec![1]; std::thread::spawn(move || { println!("{:?}", arr); }); } ``` 2. 这个问题其实是第1个问题的延续,如果将main中变量的所有权move到线程中,那么在main中将无法访问,所以使用Arc这个智能指针即可实现共享所有权: ```rust use std::sync::Arc; fn main() { let s = Arc::new("rust rocks!"); let s1 = s.clone(); let handler = std::thread::spawn(move || { println!("thread: {:?}", s1); }); println!("main: {:?}", s); handler.join().unwrap(); } ``` 3. 不太确定,查文档看调用链是inner方法返回了RcBox,RcBox调用的inc_strong是RcInnerPtr这个trait的方法,它会通过调用该trait的strong_ref方法返回cell,而cell是一个可变的共享容器,即最终通过cell共享内存来改变内部数据。 看完这一小节产生一个疑问,本小节介绍的智能指针都是为了突破rust编译期的所有权规则限制。那为什么要先做限制再提供突破限制的方法呢?这样做的意义是否可以理解为就像unsafe或者最小权限原则一样,大部分情况下,遵循所有权规则,仅在必要的时候使用智能指针来突破限制?如果用户滥用了智能指针,那么是否就像滥用了unsafe一样,rust内存安全等特性就无法保证了?

    作者回复: 赞!非常棒的回答。第 3 题其实不用看代码也可以尝试猜一下:当一个只读引用可以修改内部数据时,它一定是用了内部可变性。:) 一个实际使用的系统一定是符合二八定律的:可以用少量规则来满足 80%的应用场景。但总还有例外需要处理。所以编译期尽管能解决绝大多数的应用场景,但是解决不了的时候,还是需要运行期的检查来弥补。 这就涉及到如何权衡了。Rust 的选择是最小权限原则,你只有显式地要求(比如数据结构用 Arc<T> 封装),才会进行额外的处理(维护引用计数)。其实这也是我们撰写软件系统时应该遵循的原则。 谈到滥用,最小权限原则恰恰是为了防止滥用。比如 mut,当你实际不需要的时候,编译器会报警。Rc/Arc 不必要的 clone,clippy(Rust 的 linting 工具) 会提示。 如果「滥用」智能指针,并不会导致内存安全无法保证,比如你在不需要的时候使用 Arc,损失的是 atomic compare_and_swap 时的性能,但不会带来安全问题。

    2021-09-10
    3
    34
  • dotfiles
    1. rust由于所有权以及默认不可变等限制,导致最常见的数据结构---链表的实现变得相当复杂,常见的实现使用了3层结构体. Option<>提供空和非空,相关: Some/None Rc<T>提供引用计数,相关: New/clone; 如果为了避免循环引用,还要考虑downgrade/upgrade; 这块和cpp的shared_ptr/weak_ptr类似. RefCell<T>提供内部可变性,基于unsafe机制,提供运行时检查. 相关: borrow/borrow_mut 2. 内部可变性 Rc的引用计数器和RefCell中的数据可变,都是基于unsafe实现的. 我们以Rc引用计数器的更新为例: ``` impl<T> Cell<T> { pub fn replace(&self, val: T) -> T { mem::replace(unsafe { &mut *self.value.get() }, val) } } ``` 如上可以看到,即使replace的参数self是不可变的,也可以通过unsafe去改变其中的值.也就是说rust通过unsafe具有完全的c/c++类似的能力. 同理,可以看到Refcell获取可变引用,也是通过unsafe将指针直接转成可变引用.可以想象的是,在RefCell中,还需要通过额外的代码来处理可读不可写,可写不可读的类似读写锁的问题. ``` impl<T: ?Sized> RefCell<T> { pub fn try_borrow_mut(&self) -> Result<RefMut<'_, T>, BorrowMutError> { ... Ok(RefMut { value: unsafe { &mut *self.value.get() }, borrow: b }) ... } ``` 在rust的设计中,明显推崇将问题尽量在静态编译期解决.实在搞不定的,就是通过unsafe和额外的处理推迟到运行期解决.

    作者回复: 👍

    2021-10-12
    17
  • noisyes
    fn main() { /*let data = RefCell::new(1); let mut v = data.borrow_mut(); *v += 1; println!("data: {:?}", data.borrow()); */ let mut v = vec![1, 2, 3]; let data1 = &mut v[1]; *data1 = 2; let data2 = &v[1]; println!("{}", data2); } 老师这段代码注释的部分,运行时不能通过,可变借用和不可变借用并没有冲突呀(v并没在borrow之后使用,同一时刻并没有同时有可变借用和不可变借用),我自己写的这部分就是可以编译运行的。

    作者回复: 这就是运行期检查和编译期检查的区别。data.borrow_mut() 产生的 v 会一直活跃到作用域结束,而对于 &mut 编译器可以检查它是否活跃从而让我们撰写代码时不需要额外的作用域。你可以看看我和另一个同学关于这个问题的详细讨论。

    2021-09-10
    10
  • Marvichov
    为啥要week == 0的时候才deallocate所有内存呢? 这样是不是不太高效? ``` fn drop(&mut self) { unsafe { self.inner().dec_strong(); if self.inner().strong() == 0 { // destroy the contained object ptr::drop_in_place(Self::get_mut_unchecked(self)); // remove the implicit "strong weak" pointer now that we've // destroyed the contents. self.inner().dec_weak(); if self.inner().weak() == 0 { Global.deallocate(self.ptr.cast(), Layout::for_value(self.ptr.as_ref())); } } } } ```

    作者回复: 好问题。strong 为 0 时,ptr::drop_in_place 已经把大部分内存释放。但此时可以还有孤悬的 weak reference,它们还是可用的,但无法 upgrade。可以看这个代码: ```rust use std::rc::{Rc, Weak}; #[derive(Debug)] struct Foo { bar: &'static str, } impl Drop for Foo { fn drop(&mut self) { println!("dropped!"); } } fn main() { let foo = Rc::new(Foo { bar: "hello" }); let weak_foo = Rc::downgrade(&foo); let other_weak_foo = Weak::clone(&weak_foo); drop(weak_foo); // 不会 drop drop(foo); // 打印 dropped! // 此时还有 weak ref 存在 println!("other weak foo: {:?}", other_weak_foo); // 无法 upgrade assert!(other_weak_foo.upgrade().is_none()); } ``` playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=ffd75a38877cfd78b6c66d0b13dc1d90

    2021-09-12
    3
    6
  • 枸杞红茶
    1. 线程的运行时间可能会比当前函数还要长,而闭包中又借用了arr,使用move将当前函数的arr所有权转移给线程。 ``` use std::thread; fn main() { let arr = vec![1]; let handler = thread::spawn(move || { println!("{:?}", arr); }); handler.join().unwrap(); } ``` 2. ``` use std::thread; use std::sync::Arc; fn main() { let five = Arc::new("hello Tyr"); { let give_me_five = five.clone(); let handler = thread::spawn(move || { println!("thread greeting: {:?}", give_me_five); }); handler.join().unwrap(); } println!("main greeting: {:?}", five); } ``` 3. Rc源码中有这么一段, ``` #[doc(hidden)] trait RcInnerPtr { fn weak_ref(&self) -> &Cell<usize>; fn strong_ref(&self) -> &Cell<usize>; #[inline] fn strong(&self) -> usize { self.strong_ref().get() } #[inline] fn inc_strong(&self) { let strong = self.strong(); // We want to abort on overflow instead of dropping the value. // The reference count will never be zero when this is called; // nevertheless, we insert an abort here to hint LLVM at // an otherwise missed optimization. if strong == 0 || strong == usize::MAX { abort(); } self.strong_ref().set(strong + 1); } ... } ``` 增加计数器```self.inner().inc_strong();```调用的```inc_strong```函数修改的是```strong_ref()```,属于Cell类型,Shareable mutable containers,可共享可修改的容器。

    作者回复: 👍

    2021-10-10
    5
  • Kerry
    课后思考题: 1. 生命周期问题,主线程可能比派生线程更早结束,导致派生线程引用了过期的值。 你也许会想到加上join强制派生线程先于主线程结束,以此解决编译问题。然而这是行不通的。这里的问题是语法层面的,而不是语义层面的。 解决方法有两种:一是move,二是用ARC。方法一的代码如下: fn main() { let arr = vec![1]; std::thread::spawn(move || { println!("{:?}", arr); }).join().unwrap(); } 方法二参考思考题2即可。 2. 如下所示,用ARC包一下要共享的资源: use std::sync::{Arc, RwLock}; use std::rc::Rc; fn main() { let s = Arc::new(RwLock::new("Hello")); let r = s.clone(); std::thread::spawn(move || { println!("{:?}", r.as_ref().read().unwrap()); }).join().unwrap(); println!("{:?}", s.as_ref().read().unwrap()); } 3. 是时候步入unsafe的世界了~编译器终究只是帮我们干活,规则再严格,那都是死的。为了提供一定灵活性,会像RefCell这样提供一些机制给我们做一些不安全的操作。通过阅读self.inner().inc_strong()的源码,可以知道底层是通过unsafe实现不可变转可变引用的: unsafe { &mut *self.value.get() }

    作者回复: 1、2 正确!3 跟 unsafe 关系不大,跟 strong 是 Cell<usize> 实现了内部可变性有关。(当然,内部可变性底层的实现是 unsafe)

    2021-09-12
    2
    4
  • Arthur
    老师好,关于使用花括号提前结束生命周期这点有一点不明白 ```rust fn main() { let mut v = vec![1, 2, 3]; { v // 编译不通过 v.push(3); //编译通过 }; v.push(4); } ``` 花括号中的两种写法,一种不通过,报错error[E0382]: borrow of moved value: `v`;一种又可以通过,是为什么呢?使用一个花括号增加了一个作用域以后,对于作用域内使用的外部变量的所有权到底产生了怎样的影响呢?

    作者回复: 在你用花括号括起 v 的那句,v 的所有权被转移给了 {} 作用域内部,并且由于没有人使用这个花括号的返回值,所以 v 被 drop 了。之后再用 v 就会报错: ```rust fn main() { let mut v = vec![1, 2, 3]; { v // 编译不通过 }; v.push(4); } ``` 而使用 v.push(),这里使用了 v 的可变引用,没有问题。

    2021-09-11
    4
收起评论
显示
设置
留言
51
收藏
沉浸
阅读
分享
手机端
快捷键
回顶部