再谈 Send 与 Sync | Rust学习笔记

算法聆风人
• 阅读 163

Send 与 Sync 可能是Rust多线程以及异步代码种最常见到的约束。在前面一篇讨论多线程的文章中介绍过这两个约束的由来。但是,真正书写比较复杂的代码时,还是会经常遇到编译器的各种不配合。这里借用我的同事遇到的一个问题再次举例谈一谈 Send 与 Sync 的故事。

基本场景

C/C++中不存在Send/Sync的概念,数据对象可以任意在多线程中访问,只不过需要程序员保证线程安全,也就是所谓“加锁”。而在Rust中,由于所有权的设计,不能直接将一个对象分成两份或多份,每个线程都放一份。一般地,如果一份数据仅仅子线程使用,我们会将数据的值转移至线程中,这也是Send的基础含义。因此,Rust代码经常会看到将数据clone(),然后move到线程中:

let b = aa.clone();

thread::spawn(move || {

b...

})

假如,数据需要在多线程共享,情况会复杂一些。我们一般不会在线程中直接使用外部环境变量引用。原因很简单,生命周期的问题。线程的闭包要求‘static,这会与被借用的外部环境变量的生命周期冲突,错误代码如下:

let bb = AA::new(8);

thread::spawn( || {

let cc = &bb; //closure may outlive the current function, but it borrows bb, which is owned by the current function

});

包裹一个Arc可以解决这个问题,Arc恰好就是用来管理生命周期的,改进后的代码如下:

let b = Arc::new(aa);

let b1 = b.clone();

thread::spawn(move || {

b1...

})

Arc提供了共享不可变引用的功能,也就是说,数据是只读的。如果我们需要访问多线程访问共享数据的可变引用,即读写数据,那么还需要在原始数据上先包裹Mutex,类似于RefCell,提供内部可变性,因此我们可以获取内部数据的&mut,修改数据。当然,这需要通过Mutex::lock() 来操作。

let b = Arc::new(Mutex::new(aa));

let b1 = b.clone();

thread::spawn(move || {

let b = b1.lock();

...

})

为什么不能直接使用RefCell完成这个功能?这是因为RefCell不支持 Sync,没办法装入Arc。注意Arc的约束:

unsafe impl<T: ?Sized + Sync + Send> Send for Arc {}

若 Arc是Send,条件是 T:Send+Sync。RefCell不满足 Sync,因此 Arc<RefCell<>> 不满足Send,无法转移至线程中。错误代码如下:

let b = Arc::new(RefCell::new(aa));

let b1 = b.clone();

thread::spawn(move || {

^^^^^^^^^^^^^ std::cell::RefCell<AA<T>> cannot be shared between threads safely

let x = b1.borrow_mut();

})

异步代码:跨越 await 问题

如上所述,一般地,我们会将数据的值转移入线程,这样只需要做正确的 Send和Sync 标记即可,很直观,容易理解。典型的代码如下:

fn test1<T: Send + Sync + 'static>(t: T) {

let b = Arc::new(t);

let bb = b.clone();

thread::spawn( move|| {

let cc = &bb;

});

}

根据上面的分析,不难推导出条件 T: Send + Sync + 'static 的来龙去脉:Closure: Send + 'static ⇒ Arc: Send + ’static ⇒ T: Send + Sync + 'static。

然而,在异步协程代码中有一种常见情况,推导过程则显得比较隐蔽,值得说道说道。考察以下代码:

struct AA(T);

impl AA {

async fn run_self(self) {}

async fn run(&self) {}

async fn run_mut(&mut self) {}

}

fn test2<T: Send + 'static>(mut aa: AA) {

let ha = async_std::task::spawn(async move {

aa.run_self().await;

});

}

test2 中,限定 T: Send + ‘static,合情合理。async fn 生成的 GenFuture 要求 Send + ‘static,因此被捕获置入 GenFuture 匿名结构中的 AA 也必须满足 Send + ‘static,进而要求AA 泛型参数也满足Send + ‘static。

然而,类似的方式调用 AA::run() 方法,编译失败,编译器提示 GenFuture 不满足 Send。代码如下:

fn test2<T: Send + 'static>(mut aa: AA) {

let ha = async_std::task::spawn(async move {

^^^^^^^^^^^^^^^^^^^^^^ future returned by test2 is not Send

aa.run().await;

});

}

原因在于,AA::run()方法的签名是 &self,所以run()是通过 aa 的不可变借用 &AA 来调用。而run()又是一个异步方法,执行了await,也就是所谓的&aa 跨越了 await,故而要求GenFuture匿名结构除了生成aa之外,还需要生成 &aa,示意代码如下:

struct {

aa: AA

aa_ref: &AA

}

正如之前探讨过,生成的 GenFuture需要满足 Send,因此 AA 以及 &AA 都需要满足 Send。而&AA满足 Send,则意味着 AA 满足 Sync。这也就是各种 Rust教程中都会提到的那句话的真正含义:

对于任意类型 T,如果 &T是 Send ,T 就是 Sync 的

之前出错的代码修改为如下形式,增加 Sync标记,编译通过。

fn test2<T: Send + Sync + 'static>(mut aa: AA) {

let ha = async_std::task::spawn(async move {

aa.run().await;

});

}

另外,值得指出的是上述代码中调用 AA::run_mut(&mut self) 不需要 Sync 标记:

fn test2<T: Send + 'static>(mut aa: AA) {

let ha = async_std::task::spawn(async move {

aa.run_mut().await;

});

}

这是因为 &mut self 并不要求 T: Sync。参见以下标准库中关于Sync定义代码就明白了:

mod impls {

[stable(feature = "rust1", since = "1.0.0")]

unsafe impl<T: Sync + ?Sized> Send for &T {}

[stable(feature = "rust1", since = "1.0.0")]

unsafe impl<T: Send + ?Sized> Send for &mut T {}

}

可以看到,&T: Send 要求 T: Sync,而 &mut T 则 T: Send 即可。

总结

总而言之,Send约束在根源上是由 thread::spawn() 或是 task::spawn() 引入的,因为两个方法的闭包参数必须满足 Send。此外,在需要共享数据时使用Arc会要求 T: Send + Sync。而共享可写数据,需要Arc<Mutex>,此时 T: Send 即可,不再要求Sync。

异步代码中关于 Send/Sync 与同步多线程代码没有不同。只是因为GenFuture 的特别之处使得跨越 await 的变量必须是 T: Send,此时需要注意通过 T 调用异步方法的签名,如果为 &self,则必须满足 T:Send + Sync。

最后,一点经验分享:关于 Send/Sync 的道理并不复杂,更多时候是因为代码中层次比较深,调用关系复杂,导致编译器的错误提示很难看懂,某些特定场合编译器可能还会给出完全错误的修正建议,这时候需要仔细斟酌,追根溯源,找到问题的本质,不能完全依靠编译器提示。

点赞
收藏
评论区
推荐文章
Wesley13 Wesley13
3年前
java多线程和异步回调
   在实际开发过程中遇到的多线程情况不多,但是在生产环境中多线程是最基本的情况,java面试时也会考到,所以看看多线程的知识还是很有必要的。 Thread,Runnable,Callable,Future,FutureTask,Executors这是java常见的接口和类。  thread.run():线程具体要执行的代码,thread.jo
Stella981 Stella981
3年前
Golang WaitGroup源码分析
针对Golang1.9的sync.WaitGroup进行分析,与Golang1.10基本一样除了将panic改为了throw之外其他的都一样。源代码位置:sync\waitgroup.go。结构体typeWaitGroupstruct{noCopynoCopy//noCopy可以嵌入到结构中
Stella981 Stella981
3年前
Rust学习笔记#6:所有权系统
!(https://oscimg.oschina.net/oscnet/up0b8d4b9e5e3854503a73fd494cd4b53d984.JPEG)引子:段错误与内存安全在刚开始接触Rust的时候,我们就提过Rust语言的定位:Rustisasystem'sprogramminglanguagethatr
Stella981 Stella981
3年前
Rust开发环境搭建
1.Rust概述按照百度百科的说法,Rust是一门系统编程语言,专注于安全,尤其是并发安全,支持函数式和命令式以及泛型等编程范式的多范式语言。Rust在语法上和C类似,但是设计者想要在保证性能的同时提供更好的内存安全。Rust最初是由Mozilla研究院的GraydonHoare设计创造,然后在DaveHerman,Brend
Stella981 Stella981
3年前
Rust语言宏原理及开发教程
Rust语言最强大的一个特点就是可以创建和利用宏/Macro。不过创建Rust宏看起来挺复杂,常常令刚接触Rust的开发者心生畏惧。这片文章的目的就是帮助你理解RustMacro的基本运作原理,学习如何创建自己的Rust宏。相关链接:在线学编程(https://www.oschina.net/action/GoToLink?urlhttp%3
Stella981 Stella981
3年前
Rust 语言风靡学术界
AWS将Rust编译器团队负责人收入麾下的新闻让开发者们再次聚焦于这门兼具安全性与高性能的编程语言。近日,著名科学期刊Nature刊登了一篇文章,表明Rust语言也正在成为学术界最受欢迎的编程语言之一。2015年,德国生物信息学家JohannesKöster曾用Python编写了一个流行的工作流管理器Snakemake。但是现
非凸科技 非凸科技
2年前
Rust开发者大会,内容早知道!
在量化交易中,总会发现有一部分「回撤」是在策略意料之外的,如进程闪退、上下游出问题等。那么,Rust在量化场景中的应用,相较于C,对回撤产生了什么样的影响呢?非凸科技量化策略负责人将从“自身系统稳定”与“高效应对风险”两个方面进行全面解答,欢迎锁定「本周日14:00」—分论坛「Rust商业实践」!时间:7月31日(本周日)参会:http://rust
rust入坑指南之ownership
这篇文章我们介绍一下rust的一个核心概念ownership。Ownership是Rust语言的一个核心概念,它决定了一个值在程序中的生命周期以及对其访问权限的限制。
非凸科技 非凸科技
2年前
Rust的安全性和稳健型
Rust是围绕安全性和稳健性而设计的。也就是,安全代码是不使用unsafe关键字的代码,声音代码是不会导致内存损坏或其他未定义行为的代码。“未定义行为”(UB)在C、C和Rust等语言中具有特定含义,不同于“未指定”或“实现定义”行为。Rust最重要的
文盘Rust —— rust连接oss | 京东云技术团队
对象存储是云的基础组件之一,各大云厂商都有相关产品。这里跟大家介绍一下rust与对象存储交到的基本套路和其中的一些技巧。
文盘Rust -- FFI 浅尝 | 京东云技术团队
rustFFI是rust与其他语言互调的桥梁,通过FFIrust可以有效继承C语言的历史资产。本期通过几个例子来聊聊rust与C语言交互的具体步骤。