【Rust】Rust学习 第十六章无畏并发
安全且高效的处理并发编程是 Rust 的另一个主要目标。并发编程(Concurrent programming),代表程序的不同部分相互独立的执行,而 并行编程(parallel programming)代表程序不同部分于同时执行,这两个概念随着计算机越来越多的利用多处理器的优势时显得愈发重要。由于历史原因,在此类上下文中编程一直是困难且容易出错的:Rust 希望能改变这一点。
起初,Rust 团队认为确保内存安全和防止并发问题是两个分别需要不同方法应对的挑战。随着时间的推移,团队发现所有权和类型系统是一系列解决内存安全 和 并发问题的强有力的工具!通过利用所有权和类型检查,在 Rust 中很多并发错误都是 编译时 错误,而非运行时错误。因此,相比花费大量时间尝试重现运行时并发 bug 出现的特定情况,Rust 会拒绝编译不正确的代码并提供解释问题的错误信息。因此,你可以在开发时修复代码,而不是在部署到生产环境后修复代码。我们给 Rust 的这一部分起了一个绰号 无畏并发(fearless concurrency)。无畏并发令你的代码免于出现诡异的 bug 并可以轻松重构且无需担心会引入新的 bug。
注意:出于简洁的考虑,我们将很多问题归类为 并发,而不是更准确的区分 并发和(或)并行。如果这是一本专注于并发和/或并行的书,我们肯定会更加精确的。对于本章,当我们谈到 并发 时,请自行脑内替换为 并发和(或)并行。
很多语言所提供的处理并发问题的解决方法都非常有特色。例如,Erlang 有着优雅的消息传递并发功能,但只有模糊不清的在线程间共享状态的方法。对于高级语言来说,只实现可能解决方案的子集是一个合理的策略,因为高级语言所许诺的价值来源于牺牲一些控制来换取抽象。然而对于底层语言则期望提供在任何给定的情况下有着最高的性能且对硬件有更少的抽象。因此,Rust 提供了多种工具,以符合实际情况和需求的方式来为问题建模。
16.1 使用线程同时运行代码
在大部分现代操作系统中,已执行程序的代码在一个 进程(process)中运行,操作系统则负责管理多个进程。在程序内部,也可以拥有多个同时运行的独立部分。运行这些独立部分的功能被称为 线程(threads)。
将程序中的计算拆分进多个线程可以改善性能,因为程序可以同时进行多个任务,不过这也会增加复杂性。因为线程是同时运行的,所以无法预先保证不同线程中的代码的执行顺序。这会导致诸如此类的问题:
- 竞争状态(Race conditions),多个线程以不一致的顺序访问数据或资源
- 死锁(Deadlocks),两个线程相互等待对方停止使用其所拥有的资源,这会阻止它们继续运行
- 只会发生在特定情况且难以稳定重现和修复的 bug
Rust 尝试减轻使用线程的负面影响。不过在多线程上下文中编程仍需格外小心,同时其所要求的代码结构也不同于运行于单线程的程序。
编程语言有一些不同的方法来实现线程。很多操作系统提供了创建新线程的 API。这种由编程语言调用操作系统 API 创建线程的模型有时被称为 1:1,一个 OS 线程对应一个语言线程。
很多编程语言提供了自己特殊的线程实现。编程语言提供的线程被称为 绿色(green)线程,使用绿色线程的语言会在不同数量的 OS 线程的上下文中执行它们。为此,绿色线程模式被称为 M:N 模型:M
个绿色线程对应 N
个 OS 线程,这里 M
和 N
不必相同。
每一个模型都有其优势和取舍。对于 Rust 来说最重要的取舍是运行时支持。运行时(Runtime)是一个令人迷惑的概念,其在不同上下文中可能有不同的含义。
在当前上下文中,运行时 代表二进制文件中包含的由语言自身提供的代码。这些代码根据语言的不同可大可小,不过任何非汇编语言都会有一定数量的运行时代码。为此,通常人们说一个语言 “没有运行时”,一般意味着 “小运行时”。更小的运行时拥有更少的功能不过其优势在于更小的二进制输出,这使其易于在更多上下文中与其他语言相结合。虽然很多语言觉得增加运行时来换取更多功能没有什么问题,但是 Rust 需要做到几乎没有运行时,同时为了保持高性能必需能够调用 C 语言,这点也是不能妥协的。
绿色线程的 M:N 模型需要更大的语言运行时来管理这些线程。因此,Rust 标准库只提供了 1:1 线程模型实现。由于 Rust 是较为底层的语言,如果你愿意牺牲性能来换取抽象,以获得对线程运行更精细的控制及更低的上下文切换成本,你可以使用实现了 M:N 线程模型的 crate。
使用spawn 创建新线程
为了创建一个新线程,需要调用 thread::spawn
函数并传递一个闭包(第十三章学习了闭包),并在其中包含希望在新线程运行的代码。
use std::thread;
use std::time::Duration;fn main() {thread::spawn(|| {for i in 1..10 {println!("hi number {} from the spawned thread!", i);thread::sleep(Duration::from_millis(1));}});for i in 1..5 {println!("hi number {} from the main thread!", i);thread::sleep(Duration::from_millis(1));}
}
注意这个函数编写的方式,当主线程结束时,新线程也会结束,而不管其是否执行完毕。这个程序的输出可能每次都略有不同,不过它大体上看起来像这样:
thread::sleep
调用强制线程停止执行一小段时间,这会允许其他不同的线程运行。这些线程可能会轮流运行,不过并不保证如此:这依赖操作系统如何调度线程。在这里,主线程首先打印,即便新创建线程的打印语句位于程序的开头,甚至即便我们告诉新建的线程打印直到 i
等于 9 ,它在主线程结束之前也只打印到了 5。
如果运行代码只看到了主线程的输出,或没有出现重叠打印的现象,尝试增加 range 的数值来增加操作系统切换线程的机会。
使用join 等待所有线程结束
由于主线程结束,示例中的代码大部分时候不光会提早结束新建线程,甚至不能实际保证新建线程会被执行。其原因在于无法保证线程运行的顺序!
可以通过将 thread::spawn
的返回值储存在变量中来修复新建线程部分没有执行或者完全没有执行的问题。thread::spawn
的返回值类型是 JoinHandle
。JoinHandle
是一个拥有所有权的值,当对其调用 join
方法时,它会等待其线程结束。
use std::thread;
use std::time::Duration;fn main() {let handle = thread::spawn(|| {for i in 1..10 {println!("hi number {} from the spawned thread!", i);thread::sleep(Duration::from_millis(1));}});for i in 1..5 {println!("hi number {} from the main thread!", i);thread::sleep(Duration::from_millis(1));}// 调用join确保新建线程在 main 退出前结束运行handle.join().unwrap();}
通过调用 handle 的 join
会阻塞当前线程直到 handle 所代表的线程结束。阻塞(Blocking) 线程意味着阻止该线程执行工作或退出。因为我们将 join
调用放在了主线程的 for
循环之后,运行示例应该会产生类似这样的输出:
这两个线程仍然会交替执行,不过主线程会由于 handle.join()
调用会等待直到新建线程执行完毕。
不过让我们看看将 handle.join()
移动到 main
中 for
循环之前会发生什么,如下:
use std::thread;
use std::time::Duration;fn main() {let handle = thread::spawn(|| {for i in 1..10 {println!("hi number {} from the spawned thread!", i);thread::sleep(Duration::from_millis(1));}});// 调用join确保新建线程在 main 退出前结束运行handle.join().unwrap();for i in 1..5 {println!("hi number {} from the main thread!", i);thread::sleep(Duration::from_millis(1));}
}
主线程会等待直到新建线程执行完毕之后才开始执行 for
循环,所以输出将不会交替出现,如下所示:
稍微考虑一下将 join
放置于何处这样一个细节会影响线程是否同时运行。
线程与move闭包
move
闭包,其经常与 thread::spawn
一起使用,因为它允许我们在一个线程中使用另一个线程的数据。
注意上述示例中传递给 thread::spawn
的闭包并没有任何参数:并没有在新建线程代码中使用任何主线程的数据。为了在新建线程中使用来自于主线程的数据,需要新建线程的闭包获取它需要的值。下面示例展示了一个尝试在主线程中创建一个 vector 并用于新建线程的例子,不过这么写还不能工作,如下所示:
use std::thread;fn main() {let v = vec![1, 2, 3];let handle = thread::spawn(|| {println!("Here's a vector: {:?}", v);});handle.join().unwrap();
}
闭包使用了 v
,所以闭包会捕获 v
并使其成为闭包环境的一部分。因为 thread::spawn
在一个新线程中运行这个闭包,所以可以在新线程中访问 v
。然而当编译这个例子时,会得到如下错误:
Rust 会 推断 如何捕获 v
,因为 println!
只需要 v
的引用,闭包尝试借用 v
。然而这有一个问题:Rust 不知道这个新建线程会执行多久,所以无法知晓 v
的引用是否一直有效。
下面展示了一个 v
的引用很有可能不再有效的场景:
use std::thread;fn main() {let v = vec![1, 2, 3];let handle = thread::spawn(|| {println!("Here's a vector: {:?}", v);});drop(v);handle.join().unwrap();
}
假如这段代码能正常运行的话,则新建线程则可能会立刻被转移到后台并完全没有机会运行。新建线程内部有一个 v
的引用,不过主线程立刻就使用第十五章讨论的 drop
丢弃了 v
。接着当新建线程开始执行,v
已不再有效,所以其引用也是无效的。噢,这太糟了!
通过在闭包之前增加 move
关键字,我们强制闭包获取其使用的值的所有权,而不是任由 Rust 推断它应该借用值。
use std::thread;fn main() {let v = vec![1, 2, 3];let handle = thread::spawn(move || {println!("Here's a vector: {:?}", v);});handle.join().unwrap();
}
结果
16.2 使用消息传递在线程间传送数据
一个日益流行的确保安全并发的方式是 消息传递(message passing),这里线程或 actor 通过发送包含数据的消息来相互沟通。这个思想来源于 Go 编程语言文档中 的口号:“不要通过共享内存来通讯;而是通过通讯来共享内存。”(“Do not communicate by sharing memory; instead, share memory by communicating.”)
Rust 中一个实现消息传递并发的主要工具是 通道(channel),Rust 标准库提供了其实现的编程概念。你可以将其想象为一个水流的通道,比如河流或小溪。如果你将诸如橡皮鸭或小船之类的东西放入其中,它们会顺流而下到达下游。
编程中的通道有两部分组成,一个发送者(transmitter)和一个接收者(receiver)。发送者位于上游位置,在这里可以将橡皮鸭放入河中,接收者则位于下游,橡皮鸭最终会漂流至此。代码中的一部分调用发送者的方法以及希望发送的数据,另一部分则检查接收端收到的消息。当发送者或接收者任一被丢弃时可以认为通道被 关闭(closed)了。
这里,我们将开发一个程序,它会在一个线程生成值向通道发送,而在另一个线程会接收值并打印出来。这里会通过通道在线程间发送简单值来演示这个功能。一旦你熟悉了这项技术,就能使用通道来实现聊天系统,或利用很多线程进行分布式计算并将部分计算结果发送给一个线程进行聚合。
首先,在下面示例中,创建了一个通道但没有做任何事。注意这还不能编译,因为 Rust 不知道我们想要在通道中发送什么类型:
use std::sync::mpsc;fn main() {let (tx, rx) = mpsc::channel();
}
这里使用 mpsc::channel
函数创建一个新的通道;mpsc
是 多个生产者,单个消费者(multiple producer, single consumer)的缩写。简而言之,Rust 标准库实现通道的方式意味着一个通道可以有多个产生值的 发送(sending)端,但只能有一个消费这些值的 接收(receiving)端。想象一下多条小河小溪最终汇聚成大河:所有通过这些小河发出的东西最后都会来到下游的大河。目前我们以单个生产者开始,但是当示例可以工作后会增加多个生产者。
mpsc::channel
函数返回一个元组:第一个元素是发送端,而第二个元素是接收端。由于历史原因,tx
和 rx
通常作为 发送者(transmitter)和 接收者(receiver)的缩写,所以这就是我们将用来绑定这两端变量的名字。这里使用了一个 let
语句和模式来解构了此元组;第十八章会讨论 let
语句中的模式和解构。如此使用 let
语句是一个方便提取 mpsc::channel
返回的元组中一部分的手段。
让我们将发送端移动到一个新建线程中并发送一个字符串,这样新建线程就可以和主线程通讯了,如下面示例所示。这类似于在河的上游扔下一只橡皮鸭或从一个线程向另一个线程发送聊天信息:
use std::sync::mpsc;
use std::thread;fn main() {let (tx, rx) = mpsc::channel();thread::spawn(move || {let val = String::from("hi");tx.send(val).unwrap();});
}
将 tx
移动到一个新建的线程中并发送 “hi”
这里再次使用 thread::spawn
来创建一个新线程并使用 move
将 tx
移动到闭包中这样新建线程就拥有 tx
了。新建线程需要拥有通道的发送端以便能向通道发送消息。
通道的发送端有一个 send
方法用来获取需要放入通道的值。send
方法返回一个 Result<T, E>
类型,所以如果接收端已经被丢弃了,将没有发送值的目标,所以发送操作会返回错误。在这个例子中,出错的时候调用 unwrap
产生 panic。不过对于一个真实程序,需要合理地处理它:回到第九章复习正确处理错误的策略。
我们在主线程中从通道的接收端获取值。这类似于在河的下游捞起橡皮鸭或接收聊天信息:
use std::sync::mpsc;
use std::thread;fn main() {let (tx, rx) = mpsc::channel();thread::spawn(move || {let val = String::from("hi");tx.send(val).unwrap();});// 接收数据let received = rx.recv().unwrap();println!("Got: {}", received);
}
通道的接收端有两个有用的方法:recv
和 try_recv
。这里,我们使用了 recv
,它是 receive 的缩写。这个方法会阻塞主线程执行直到从通道中接收一个值。一旦发送了一个值,recv
会在一个 Result<T, E>
中返回它。当通道发送端关闭,recv
会返回一个错误表明不会再有新的值到来了。
try_recv
不会阻塞,相反它立刻返回一个 Result<T, E>
:Ok
值包含可用的信息,而 Err
值代表此时没有任何消息。如果线程在等待消息过程中还有其他工作时使用 try_recv
很有用:可以编写一个循环来频繁调用 try_recv
,在有可用消息时进行处理,其余时候则处理一会其他工作直到再次检查。
出于简单的考虑,这个例子使用了 recv
;主线程中除了等待消息之外没有任何其他工作,所以阻塞主线程是合适的。
通道与所有权转移
所有权规则在消息传递中扮演了重要角色,其有助于我们编写安全的并发代码。防止并发编程中的错误是在 Rust 程序中考虑所有权的一大优势。现在让我们做一个试验来看看通道与所有权如何一同协作以避免产生问题:我们将尝试在新建线程中的通道中发送完 val
值 之后 再使用它。
use std::sync::mpsc;
use std::thread;fn main() {let (tx, rx) = mpsc::channel();thread::spawn(move || {let val = String::from("hi");tx.send(val).unwrap();println!("val is {}", val); // 不可以});// 接收数据let received = rx.recv().unwrap();println!("Got: {}", received);
}
这里尝试在通过 tx.send
发送 val
到通道中之后将其打印出来。允许这么做是一个坏主意:一旦将值发送到另一个线程后,那个线程可能会在我们再次使用它之前就将其修改或者丢弃。其他线程对值可能的修改会由于不一致或不存在的数据而导致错误或意外的结果。
我们的并发错误会造成一个编译时错误。send
函数获取其参数的所有权并移动这个值归接收者所有。这可以防止在发送后再次意外地使用这个值;所有权系统检查一切是否合乎规则。
发送多个值并观察接收者的等待
新建线程现在会发送多个消息并在每个消息之间暂停一秒钟。
use std::sync::mpsc;
use std::thread;
use std::time::Duration;fn main() {let (tx, rx) = mpsc::channel();thread::spawn(move || {let vals = vec![String::from("hi"),String::from("from"),String::from("the"),String::from("thread"),];for val in vals {tx.send(val).unwrap();thread::sleep(Duration::from_secs(1));}});// 接收数据for received in rx {println!("Got: {}", received);}
}
这一次,在新建线程中有一个字符串 vector 希望发送到主线程。我们遍历他们,单独的发送每一个字符串并通过一个 Duration
值调用 thread::sleep
函数来暂停一秒。
在主线程中,不再显式调用 recv
函数:而是将 rx
当作一个迭代器。对于每一个接收到的值,我们将其打印出来。当通道被关闭时,迭代器也将结束。
因为主线程中的 for
循环里并没有任何暂停或等待的代码,所以可以说主线程是在等待从新建线程中接收值。
通过克隆发送者来创建多个生产者
之前我们提到了mpsc
是 multiple producer, single consumer 的缩写。可以运用 mpsc
来扩展示例 中的代码来创建向同一接收者发送值的多个线程。这可以通过克隆通道的发送端来做到,如下面示例所示:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;fn main() {let (tx, rx) = mpsc::channel();let tx1 = mpsc::Sender::clone(&tx);thread::spawn(move || {let vals = vec![String::from("hi"),String::from("from"),String::from("the"),String::from("thread"),];for val in vals {tx1.send(val).unwrap();thread::sleep(Duration::from_secs(1));}});thread::spawn(move || {let vals = vec![String::from("more"),String::from("messages"),String::from("for"),String::from("you"),];for val in vals {tx.send(val).unwrap();thread::sleep(Duration::from_secs(1));}});for received in rx {println!("Got: {}", received);}
}
这一次,在创建新线程之前,我们对通道的发送端调用了 clone
方法。这会给我们一个可以传递给第一个新建线程的发送端句柄。我们会将原始的通道发送端传递给第二个新建线程。这样就会有两个线程,每个线程将向通道的接收端发送不同的消息。
虽然你可能会看到这些值以不同的顺序出现;这依赖于你的系统。这也就是并发既有趣又困难的原因。如果通过 thread::sleep
做实验,在不同的线程中提供不同的值,就会发现他们的运行更加不确定,且每次都会产生不同的输出。
16.3 共享状态并发
虽然消息传递是一个很好的处理并发的方式,但并不是唯一一个。再一次思考一下 Go 编程语言文档中口号的这一部分:“不要通过共享内存来通讯”(“do not communicate by sharing memory.”):
在某种程度上,任何编程语言中的通道都类似于单所有权,因为一旦将一个值传送到通道中,将无法再使用这个值。共享内存类似于多所有权:多个线程可以同时访问相同的内存位置。第十五章介绍了智能指针如何使得多所有权成为可能,然而这会增加额外的复杂性,因为需要以某种方式管理这些不同的所有者。Rust 的类型系统和所有权规则极大的协助了正确地管理这些所有权。作为一个例子,让我们看看互斥器,一个更为常见的共享内存并发原语。
互斥器一次只允许一个线程访问数据
互斥器(mutex)是 mutual exclusion 的缩写,也就是说,任意时刻,其只允许一个线程访问某些数据。为了访问互斥器中的数据,线程首先需要通过获取互斥器的 锁(lock)来表明其希望访问数据。锁是一个作为互斥器一部分的数据结构,它记录谁有数据的排他访问权。因此,我们描述互斥器为通过锁系统 保护(guarding)其数据。
互斥器以难以使用著称,因为你不得不记住:
- 在使用数据之前尝试获取锁。
- 处理完被互斥器所保护的数据之后,必须解锁数据,这样其他线程才能够获取锁。
作为一个现实中互斥器的例子,想象一下在某个会议的一次小组座谈会中,只有一个麦克风。如果一位成员要发言,他必须请求或表示希望使用麦克风。一旦得到了麦克风,他可以畅所欲言,然后将麦克风交给下一位希望讲话的成员。如果一位成员结束发言后忘记将麦克风交还,其他人将无法发言。如果对共享麦克风的管理出现了问题,座谈会将无法如期进行!
正确的管理互斥器异常复杂,这也是许多人之所以热衷于通道的原因。然而,在 Rust 中,得益于类型系统和所有权,我们不会在锁和解锁上出错。
Mutex<T>的API
作为展示如何使用互斥器的例子,让我们从在单线程上下文使用互斥器开始
use std::sync::Mutex;fn main() {let m = Mutex::new(5);{let mut num = m.lock().unwrap();*num = 6;}println!("m = {:?}", m);
}
像很多类型一样,我们使用关联函数 new
来创建一个 Mutex<T>
。使用 lock
方法获取锁,以访问互斥器中的数据。这个调用会阻塞当前线程,直到我们拥有锁为止。
如果另一个线程拥有锁,并且那个线程 panic 了,则 lock
调用会失败。在这种情况下,没人能够再获取锁,所以这里选择 unwrap
并在遇到这种情况时使线程 panic。
一旦获取了锁,就可以将返回值(在这里是num
)视为一个其内部数据的可变引用了。类型系统确保了我们在使用 m
中的值之前获取锁:Mutex<i32>
并不是一个 i32
,所以 必须 获取锁才能使用这个 i32
值。我们是不会忘记这么做的,因为反之类型系统不允许访问内部的 i32
值。
正如你所怀疑的,Mutex<T>
是一个智能指针。更准确的说,lock
调用 返回 一个叫做 MutexGuard
的智能指针。这个智能指针实现了 Deref
来指向其内部数据;其也提供了一个 Drop
实现当 MutexGuard
离开作用域时自动释放锁,这正发生于示例内部作用域的结尾。为此,我们不会冒忘记释放锁并阻塞互斥器为其它线程所用的风险,因为锁的释放是自动发生的。
丢弃了锁之后,可以打印出互斥器的值,并发现能够将其内部的 i32
改为 6。
在线程间共享 Mutex<T>
现在让我们尝试使用 Mutex<T>
在多个线程间共享值。我们将启动十个线程,并在各个线程中对同一个计数器值加一,这样计数器将从 0 变为 10。
use std::sync::Mutex;
use std::thread;fn main() {let counter = Mutex::new(0);let mut handles = vec![];for _ in 0..10 {let handle = thread::spawn(move || {let mut num = counter.lock().unwrap();*num += 1;});handles.push(handle);}for handle in handles {handle.join().unwrap();}println!("res: {}", *counter.lock().unwrap());
}
这里创建了一个 counter
变量来存放内含 i32
的 Mutex<T>
,类似前面示例那样。接下来遍历 range 创建了 10 个线程。使用了 thread::spawn
并对所有线程使用了相同的闭包:他们每一个都将调用 lock
方法来获取 Mutex<T>
上的锁,接着将互斥器中的值加一。当一个线程结束执行,num
会离开闭包作用域并释放锁,这样另一个线程就可以获取它了。
错误信息表明 counter
值在上一次循环中被移动了。所以 Rust 告诉我们不能将 counter
锁的所有权移动到多个线程中。让我们通过一个第十五章讨论过的多所有权手段来修复这个编译错误。
多线程和多所有权
在第十五章中,通过使用智能指针 Rc<T>
来创建引用计数的值,以便拥有多所有者。让我们在这也这么做看看会发生什么。将下面示例中的 Mutex<T>
封装进 Rc<T>
中并在将所有权移入线程之前克隆了 Rc<T>
。现在我们理解了所发生的错误,同时也将代码改回使用 for
循环,并保留闭包的 move
关键字:
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;fn main() {let counter = Rc::new(Mutex::new(0));let mut handles = vec![];for _ in 0..10 {let counter = Rc::clone(&counter);let handle = thread::spawn(move || {let mut num = counter.lock().unwrap();*num += 1;});handles.push(handle);}for handle in handles {handle.join().unwrap();}println!("Result: {}", *counter.lock().unwrap());
}
再一次编译并...出现了不同的错误!编译器真是教会了我们很多!
哇哦,错误信息太长不看!这里是一些需要注意的重要部分:第一行错误表明 `std::rc::Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
。编译器也告诉了我们原因 the trait bound `Send` is not satisfied
。下一部分会讲到 Send
:这是确保所使用的类型可以用于并发环境的 trait 之一。
不幸的是,Rc<T>
并不能安全的在线程间共享。当 Rc<T>
管理引用计数时,它必须在每一个 clone
调用时增加计数,并在每一个克隆被丢弃时减少计数。Rc<T>
并没有使用任何并发原语,来确保改变计数的操作不会被其他线程打断。在计数出错时可能会导致诡异的 bug,比如可能会造成内存泄漏,或在使用结束之前就丢弃一个值。我们所需要的是一个完全类似 Rc<T>
,又以一种线程安全的方式改变引用计数的类型。
原子引用计数Arc<T>
所幸 Arc<T>
正是 这么一个类似 Rc<T>
并可以安全的用于并发环境的类型。字母 “a” 代表 原子性(atomic),所以这是一个原子引用计数(atomically reference counted)类型。原子性是另一类这里还未涉及到的并发原语:请查看标准库中 std::sync::atomic
的文档来获取更多细节。其中的要点就是:原子性类型工作起来类似原始类型,不过可以安全的在线程间共享。
你可能会好奇为什么不是所有的原始类型都是原子性的?为什么不是所有标准库中的类型都默认使用 Arc<T>
实现?原因在于线程安全带有性能惩罚,我们希望只在必要时才为此买单。如果只是在单线程中对值进行操作,原子性提供的保证并无必要,代码可以因此运行的更快。
回到之前的例子:Arc<T>
和 Rc<T>
有着相同的 API,所以修改程序中的 use
行和 new
调用。
use std::sync::{Mutex, Arc};
use std::thread;fn main() {let counter = Arc::new(Mutex::new(0));let mut handles = vec![];for _ in 0..10 {let counter = Arc::clone(&counter);let handle = thread::spawn(move || {let mut num = counter.lock().unwrap();*num += 1;});handles.push(handle);}for handle in handles {handle.join().unwrap();}println!("Result: {}", *counter.lock().unwrap());
}
结果
成功了!我们从 0 数到了 10,这可能并不是很显眼,不过一路上我们确实学习了很多关于 Mutex<T>
和线程安全的内容!这个例子中构建的结构可以用于比增加计数更为复杂的操作。使用这个策略,可将计算分成独立的部分,分散到多个线程中,接着使用 Mutex<T>
使用各自的结算结果更新最终的结果。
Refce11<T>/Rc<T>与Mutex<T>/Arc<T>的相似性
你可能注意到了,因为 counter
是不可变的,不过可以获取其内部值的可变引用;这意味着 Mutex<T>
提供了内部可变性,就像 Cell
系列类型那样。正如第十五章中使用 RefCell<T>
可以改变 Rc<T>
中的内容那样,同样的可以使用 Mutex<T>
来改变 Arc<T>
中的内容。
另一个值得注意的细节是 Rust 不能避免使用 Mutex<T>
的全部逻辑错误。回忆一下第十五章使用 Rc<T>
就有造成引用循环的风险,这时两个 Rc<T>
值相互引用,造成内存泄露。同理,Mutex<T>
也有造成 死锁(deadlock) 的风险。这发生于当一个操作需要锁住两个资源而两个线程各持一个锁,这会造成它们永远相互等待。如果你对这个主题感兴趣,尝试编写一个带有死锁的 Rust 程序,接着研究任何其他语言中使用互斥器的死锁规避策略并尝试在 Rust 中实现他们。标准库中 Mutex<T>
和 MutexGuard
的 API 文档会提供有用的信息。
16.4 使用Sync和Send trait 的可扩展并发
Rust 的并发模型中一个有趣的方面是:语言本身对并发知之 甚少。我们之前讨论的几乎所有内容,都属于标准库,而不是语言本身的内容。由于不需要语言提供并发相关的基础设施,并发方案不受标准库或语言所限:我们可以编写自己的或使用别人编写的并发功能。
然而有两个并发概念是内嵌于语言中的:std::marker
中的 Sync
和 Send
trait。
通过send允许在线程间转移所有权
Send
标记 trait 表明类型的所有权可以在线程间传递。几乎所有的 Rust 类型都是Send
的,不过有一些例外,包括 Rc<T>
:这是不能 Send
的,因为如果克隆了 Rc<T>
的值并尝试将克隆的所有权转移到另一个线程,这两个线程都可能同时更新引用计数。为此,Rc<T>
被实现为用于单线程场景,这时不需要为拥有线程安全的引用计数而付出性能代价。
因此,Rust 类型系统和 trait bound 确保永远也不会意外的将不安全的 Rc<T>
在线程间发送。当尝试在示例 16-14 中这么做的时候,会得到错误 the trait Send is not implemented for Rc<Mutex<i32>>
。而使用标记为 Send
的 Arc<T>
时,就没有问题了。
任何完全由 Send
的类型组成的类型也会自动被标记为 Send
。几乎所有基本类型都是 Send
的,除了第十九章将会讨论的裸指针(raw pointer)。
Sync允许多线程访问
Sync
标记 trait 表明一个实现了 Sync
的类型可以安全的在多个线程中拥有其值的引用。换一种方式来说,对于任意类型 T
,如果 &T
(T
的引用)是 Send
的话 T
就是 Sync
的,这意味着其引用就可以安全的发送到另一个线程。类似于 Send
的情况,基本类型是 Sync
的,完全由 Sync
的类型组成的类型也是 Sync
的。
智能指针 Rc<T>
也不是 Sync
的,出于其不是 Send
相同的原因。RefCell<T>
(第十五章讨论过)和 Cell<T>
系列类型不是 Sync
的。RefCell<T>
在运行时所进行的借用检查也不是线程安全的。Mutex<T>
是 Sync
的,正如 “在线程间共享 Mutex<T>” 部分所讲的它可以被用来在多线程中共享访问。
手动实现send和sync是不安全的
通常并不需要手动实现 Send
和 Sync
trait,因为由 Send
和 Sync
的类型组成的类型,自动就是 Send
和 Sync
的。因为他们是标记 trait,甚至都不需要实现任何方法。他们只是用来加强并发相关的不可变性的。
手动实现这些标记 trait 涉及到编写不安全的 Rust 代码,第十九章将会讲述具体的方法;当前重要的是,在创建新的由不是 Send
和 Sync
的部分构成的并发类型时需要多加小心,以确保维持其安全保证。The Rustonomicon 中有更多关于这些保证以及如何维持他们的信息。
参考:无畏并发 - Rust 程序设计语言 简体中文版 (bootcss.com)
相关文章:
【Rust】Rust学习 第十六章无畏并发
安全且高效的处理并发编程是 Rust 的另一个主要目标。并发编程(Concurrent programming),代表程序的不同部分相互独立的执行,而 并行编程(parallel programming)代表程序不同部分于同时执行,这两…...
系统报错mfc100u.dll丢失的解决方法(完美解决dll问题)
系统文件mfc100u.dll丢失和出错,极有可能是盗号木马、流氓软件等恶意程序所导致,其感染相关文件并加载起来,一旦杀毒软件删除被感染的文件,就会导致相关组件缺失,游戏等常用软件运行不起来,且提示“无法启动…...
docker compose的用法
目录 一、Docker-Compose介绍 1.1 Docker-Compose的概述 1.2 Docker-Compose 用来实现Docker容器快速编排 1.3 Docker-compose模板文件简介 二、YAML简介 2.1 YAML的概述 2.2 YAML的基本语法规则 2.3 YAML支持的数据架构 三、配置内部常用字段 四、Docker-compose 常…...
Linux: 使用 ssh 连接其他服务器
通过ifconfig 查看要连接的服务器地址: ubuntuubuntu1804-0172:/media/sangfor/vdc$ ssh ubuntu192.168.11.49 输入要连接的服务器密码: ubuntua192.168.1149 s password: 连接服务器成功:...
[.NET/WPF] CommunityToolkit.Mvvm 异步指令
我们在开发中, 经常会有这样的需求: 点击按钮后, 进行一些耗时的工作工作进行时, 按钮不可再次被点击工作进行时, 会显示进度条, 或者 “加载中” 的动画 RelayCommand CommunityToolkit.Mvvm 中的 RelayCommand 除了支持最简单的同步方法, 还支持以 Task 作为返回值的异步方…...
热烈祝贺汇隆成功入选航天系统采购供应商库
经过航天系统采购平台的严审,浙江汇隆晶片技术有限公司成功入选中国航天系统采购供应商库。航天系统采购平台是航天系统内企业采购专用平台,服务航天全球范围千亿采购需求,目前,已有华为、三一重工、格力电器、科大讯飞等企业、机…...
2019年3月全国计算机等级考试真题(C语言二级)
2019年3月全国计算机等级考试真题(C语言二级) 第1题 负责数据库中查询操作的数据库语言是 A. 数据定义语言 B. 数据管理语言 C. 数据操纵语言 D. 数据控制语言 正确答案:C 第2题 有关系如下图所示,其违反了哪一类完整性约束 …...
MySQL 游标
文章目录 1.游标是什么2.MySQL 游标3.定义游标4.打开游标5.提取数据6.关闭游标参考文献 1.游标是什么 游标(Cursor)是一种用于处理查询结果集的数据库对象,它允许开发者按照特定的顺序逐行遍历查询结果集中的数据。游标通常用于在数据库中执…...
ElasticSearch 7.4学习记录(DSL语法)
上文和大家一起初次了解了很多ES相关的基础知识,本文的内容将会是实际企业中所需要的吗,也是我们需要熟练应用的内容。 面对ES,我们最多使用的就是查询,当我负责这个业务时,现不需要我去考虑如何创建索引,添…...
全志orangepi-zero2驱动编写2,控制电平高低
使用驱动编写控制高低电平 可看我前俩篇文章: 【1】全志orangepi-zeor2驱动编写 【2】驱动函数框架详解 检索芯片手册关键信息 知道GPIO基地址 知道PC偏移地址 知道想要控制的端口的信息 知道数据位如何操作 代码实操 驱动代码 #include <linux/fs.h&…...
软考高级系统架构设计师系列之:论文典型试题写作要点和写作素材总结系列文章四
软考高级系统架构设计师系列之:论文典型试题写作要点和写作素材总结系列文章四 一、论软件的静态演化和动态演化及其应用1.论文题目2.写作要点和写作素材二、论大规模分布式系统缓存设计策略1.论文题目2.写作要点和写作素材三、论基于REST服务的Web应用系统设计1.论文题目2.写…...
06.利用Redis实现点赞功能
学习目标: 提示:学习如何利用Redisson实现点赞功能 学习产出: 解决方案: 点赞后的用户记录在Redis的set数据类型中 1. 准备pom环境 <dependency><groupId>org.springframework.boot</groupId><artifactI…...
【React】生命周期和钩子函数
概念 组件从被创建到挂载到页面中运行,再到组件不用时卸载的过程。 只有类组件才有生命周期。 分为三个阶段: 挂载阶段更新阶段销毁阶段 三个阶段 挂载阶段 钩子函数 - constructor 创建阶段触发 作用:创建数据 之前定义状态是简写&…...
无涯教程-TensorFlow - 优化器
Optimizers是扩展类,其中包括用于训练特定模型的附加信息,Optimizers类使用给定的参数初始化,用于提高速度和性能,以训练特定模型。 TensorFlow的基本Optimizers是- tf.train.Optimizer 此类在tensorflow/python/training/opti…...
基于AQS+双向链表实现队列先进先出
学习AQS写的一个模拟案例 package com.tom.xiangyun.ch04_aqs;import com.tom.tuling.UnsafeFactory; import sun.misc.Unsafe;import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock;/*** 使用双向链表实现队列** author 钟棋…...
无涯教程-Perl - time函数
描述 此函数返回自纪元以来的秒数(对于大多数系统,是1970年1月1日UTC,00:00:00;对于Mac OS,是1904年1月1日,00:00:00)。适用于gmtime和本地时间。 语法 以下是此函数的简单语法- time返回值 此函数返回自纪元后数秒的整数。 例 以下是显示其基本用法的示例代…...
CUDA Bug<三>当__global__函数出现里面所有输出的数组都随机赋值了
问题具体描述: eg. __global__ void Updata_HomJm(float* H,float *HJm,float* fr,float *gr,float* ur,float* urgrJm,float*wpd,float *w, float *wJm,int n) { int idx blockIdx.x*blockDim.x threadIdx.x;float t 0.0;//H*zpint idx_Ai idx*n;for (int j…...
甜椒叶病害识别(Python代码,pyTorch框架,深度卷积网络模型,很容易替换为其它模型,带有GUI识别界面)
代码运行要求:Torch>1.13.1即可 1.数据集介绍: 第一个文件夹是细菌斑叶(3460张) 第二个文件夹是 健康(4024张) 2.整个文件夹 data文件夹存放的是未被划分训练集和测试集的原始照片 picture文件夹存放的…...
Python爬虫——scrapy_日志信息以及日志级别
日志级别(由高到低) CRITICAL: 严重错误 ERROR: 一般错误 WARNING: 警告 INFO: 一般警告 DEBUG: 调试信息 默认的日志等级是DEBUG 只要出现了DEBUG或者DEBUG以上等级的日志,那么这些…...
微信小程序 echarts 画多个横向柱状图
然后是json {"usingComponents": {"ec-canvas": "../../common/ec-canvas/ec-canvas"},"navigationBarTitleText": "主题活动" } ec-canvas获取方式 在链接里下载代码 然后copy ec-canvas文件夹到自己的项目 https://gi…...
【二叉树】572. 另一棵树的子树
572. 另一棵树的子树 解题思路 遍历二叉树的思路针对每一个节点判断该节点的子树和subtree是不是相等需要编写判断两个子树是否相等的函数 /*** Definition for a binary tree node.* public class TreeNode {* int val;* TreeNode left;* TreeNode right;* …...
220V转5V芯片三脚芯片-AH8652
220V转5V芯片三脚芯片是一种非常常见的电源管理芯片,它通常被用于将高压交流输入转为稳定的直流5V输出。芯片型号AH8652是一款支持交流40V-265V输入范围的芯片,采用了SOT23-3三脚封装。该芯片内部集成了650V高压MOS管,能够稳定地将输入电压转…...
windows系统丢失mfc120u.dll的解决方法
1.mfc120u.dll是什么 mfc120u.dll是Windows操作系统中的一个动态链接库(Dynamic Link Library,简称DLL)文件。它包含了一些用于运行C程序的函数和其他资源。这个特定的DLL文件是Microsoft Foundation Classes(MFC)库的…...
css 实现电梯导航
实现原理:利用css实现电梯导航很简单,基本原理就是通过a标签绑定跳转目标的id来实现的 html代码: <div class"body"><div class"top" id"top"></div><div class"con1" id"…...
【Spring Boot】Spring Retry减少1000 行代码讲解
文章目录 前言问题介绍解决方案Let’s start hacking!1. 设置 Spring 重试2. 重构代码 总结 前言 本文翻译自国外论坛 medium,原文地址:levelup.gitconnected.com/how-i-delet…,原文作者:Hari Ohm Prasath 使用 Spring Retry 重…...
【数据结构OJ题】相交链表
原题链接:https://leetcode.cn/problems/intersection-of-two-linked-lists/description/ 目录 1. 题目描述 2. 思路分析 3. 代码实现 1. 题目描述 2. 思路分析 看到这道题,很容易想到的方法就是暴力求解,就是将一个链表的每个结点的地址…...
【华为OD机试】最小传输时延I【2023 B卷|200分】
【华为OD机试】-真题 !!点这里!! 【华为OD机试】真题考点分类 !!点这里 !! 题目描述 某通信网络中有N个网络结点,用1到N进行标识。网络通过一个有向无环图表示, 其中图的边的值表示结点之间的消息传递时延。 现给定相连节点之间的时延列表times[i]={u,v,w},其中u表示…...
Android13 网络 Adb 默认开启
Android 13 网络 Adb 默认开启 文章目录 Android 13 网络 Adb 默认开启一、前言二、默认adb 代码实现1、修改的目录:2、具体修改:(1)在XXX_device.mk 添加属性(2)设置固定端口号(3)去…...
Git分享-规范/建议/技巧
1. Git多人协作开发流程图 1.1 processOn默认的模板 1.2 改造之后 https://www.processon.com/view/link/64ccaf56a433c931b2f9428a 访问密码:512I ① 总流程图 ② feat分支(功能/需求 分支)流程 ③ bugfix分支(紧急补丁分支&…...
vue3文件下载功能
定义方法: utils.js /**** param url 目标下载接口* param query 查询参数* param fileName 文件名称* returns {*}*/ export function downBlobFile(url: any, query: any, fileName: string) {return request({//url: url,method: get,responseType: blob,param…...
Python调用文心一言的API
最近申请了文心一言的key,然后尝试调用了一下文心一言,这里使用一个简单的方式来调用文心一言: pip install paddle-pipelinesfrom pipelines.nodes import ErnieBotapi_key "your apply key" secret_key "your apply secr…...
【计算机网络八股】计算机网络(一)
目录 计算机网络的各层协议及作用?TCP和UDP的区别?UDP 和 TCP 对应的应用场景是什么?详细介绍一下 TCP 的三次握手机制?为什么需要三次握手,而不是两次?为什么要三次握手,而不是四次?…...
记录一次arcgis engine开发版本引入问题
之前基于arcigs 10.1vs2013开发的程序,现在拿出来要改,但是目前版本是arcgis10.7vs2017/vs2019,打开后无论如何替换引用版本,都报错 (具体版本对应可以看这:ArcGIS Engine 与 Visual Studio 版本对照表_vs2019对应啥版…...
2023年Java毕业设计怎样选题,有哪些注意事项,300道Java毕业设计题目
文章目录 一、确定个人兴趣和技能二、考虑实际应用价值三、注重创新和独特性四、合理规划时间和资源五、注重实践和测试Java 毕业设计题目参考第一部分第二部分 小结 随着计算机技术的不断发展,Java编程语言已经成为了众多大学计算机专业学生必修的一门课程。而Java…...
算法-滑动窗口-串联所有单词的子串
算法-滑动窗口-串联所有单词的子串 1 题目概述 1.1 题目出处 https://leetcode.cn/problems/substring-with-concatenation-of-all-words/ 1.2 题目描述 2 滑动窗口Hash表 2.1 解题思路 构建一个大小为串联子串的总长的滑动窗口为每个words中的子串创建一个hash表, <子…...
2023年7月京东美妆护肤品小样行业数据分析(京东数据挖掘)
如今,消费者更加谨慎,消费决策也更加理性。在这一消费环境下,美妆护肤市场中,面对动辄几百上千的化妆品,小样或体验装无疑能够降低消费者的试错成本。由此,这门生意也一直备受关注。 并且,小样…...
记录Taro巨坑,找不到sass类型定义文件
问题 taronutuisassts项目里tsconfig.json一直报红报错。 找不到“sass”的类型定义文件。 程序包含该文件是因为: 隐式类型库 “sass” 的入口点 其实正常人想的肯定是装上 types/sass试试。开始我试过了,没用。删了依赖重装也没用。后面在issue中找到答案了 解决…...
CS1988|C#无法在异步方法中使用ref,in,out类型的参数的问题
CS1988|C#无法在异步方法中使用ref,in,out类型的参数 🌀|场景: BlazorServer的场景中推荐使用异步方法,使用ref,out,in为参数前缀则报错CS1988 原因如下: ref parameters are not supported in async methods because the method may not h…...
ubuntu开机失败——ACPI Error
开机循环进入GNU GRUB 或者 黑屏 1.acpioff 解决办法 1)先用下面方法进入系统 2)更改grub ref 开机循环进入GNU GRUB 或者 黑屏 有提示ACPI Error错误如图: 解决办法 1)先用下面方法进入系统 在GUN GRUB界面,选择ubun…...
搭建开发环境-操作系统篇(一键搭建开发环境)
概述 所谓工欲善其事必先利其器,搭环境往往是开发过程中卡出很多初学者的拦路虎。 对于很多老鸟来说,很多东西都已经习惯成自然,也就没有刻意和初学者说。但对于很多初学者,却是受益良多。 这个系列,先从操作系统开始…...
人工智能AI绘画接入使用文档
人工智能AI绘画接入使用 一、人工智能AI绘画二、使用步骤1、接口2、请求参数3、请求参数示例4、接口 返回示例 三、 AI绘画优秀描述例子四、 如何获取appKey和uid1、申请appKey:2、获取appKey和uid 五、重要说明六、AI绘画成果展示 一、人工智能AI绘画 AI作画,用户可以在平台上…...
如何使用PyQt进行文件操作
PyQt是一个非常强大的Python GUI库,它可以帮助我们创建漂亮的跨平台应用程序。不过,在你开始使用PyQt进行文件操作之前,我想提醒你,这并不是在操作文件系统,而是在操作文件和文件夹。 首先,我们要导入PyQt…...
阿里云CDN加速器基本概念与购买开通
文章目录 1.CDN加速器的基本概念1.1.CDN加速器基本介绍1.2.网站引入CDN加速器的架构图1.3.CDN加速器的工作原理1.4.引入CDN后域名解析变成了CNAME? 2.开通阿里云CDN加速服务 1.CDN加速器的基本概念 CDN加速器官方文档:https://help.aliyun.com/product/…...
2023河南萌新联赛第(六)场:河南理工大学-F 爱睡大觉的小C
2023河南萌新联赛第(六)场:河南理工大学-F 爱睡大觉的小C https://ac.nowcoder.com/acm/contest/63602/F 文章目录 2023河南萌新联赛第(六)场:河南理工大学-F 爱睡大觉的小C题意解题思路 题意 新学期的概…...
[C++ 网络协议编程] 域名及网络地址
1. DNS服务器 DNS(Domain Name System):是对IP地址和域名(如:www.baidu.com等)进行相互转换的系统,其核心是DNS服务器。 我们输入的www.baidu.com是域名,是一种虚拟地址,而非实际地…...
Java【HTTP】什么是 Cookie 和 Session? 如何理解这两种机制的区别和作用?
文章目录 前言一、Cookie1, 什么是 Cookie2, Cookie 从哪里来3, Cookie 到哪里去4, Cookie 有什么用 二、Session1, 什么是 Session2, 理解 Session 三、Cookie 和 Session 的区别总结 前言 各位读者好, 我是小陈, 这是我的个人主页, 希望我的专栏能够帮助到你: 📕 …...
使用U盘重装Windows10系统详细步骤及配图【官方纯净版】
文章目录 1.制作启动盘1.1准备U盘及一台电脑1.2下载win10安装包 2.安装操作系统2.1插入系统安装盘2.2设置启动盘为第一启动项2.3开始安装操作系统 3.安装成功后进入图形界面3.1启动问题3.2驱动问题3.3调出"控制面板"3.4给磁盘分区 4.win10激活 前天下午不知道怎么想的…...
数据结构之——(手撕)顺序表
本章会介绍的知识点如下图: 1: 顺序表的概念:顺序表是用一段物理地址连续的存储单元依次存储数据的线性结构,通常我们使用数组来表示,对数组进行增删查改。 顺序表的结构:逻辑结构与物理结构都是内存中一块…...
冠达管理:非银金融是什么?
非银金融(Non-banking Financial Institutions,简称非银)是指除了传统的银行以外的其他金融机构。与银行不同的是,非银金融机构没有颁发钱银的权利,但在金融市场中发挥着重要的效果。在全球范围内,非银金融…...
go 结构体
定义结构体 package mainimport "fmt"type Person struct {age, id intname, email string }func main() {var p Personfmt.Printf("p: %v\n", p)p.age 100p.name "jaja"fmt.Printf("p.name: %v\n", p.name)// 匿名结构体var P…...