使用消息传递在线程之间传输数据
一种越来越流行的确保安全并发的方法是消息传递,其中线程或actor通过相互发送包含数据的消息进行通信。以下是Go 语言文档中的一句口号:“不要通过共享内存来通信;而是通过通信来共享内存。”
为了实现消息发送的并发性,Rust 的标准库提供了通道的实现。通道是一种通用的编程概念,通过它将数据从一个线程发送到另一个线程。
你可以把编程中的通道想象成一个定向的水道,例如小溪或河流。如果你把像橡皮鸭这样的东西放入河中,它将顺流而下到达水道的尽头。
通道有两半:发送器和接收器。发送器是上游的位置,你把橡皮鸭放入河流;接收器是橡皮鸭最终到达的下游位置。代码的一部分在发送器上调用方法,并发送想要发送的数据,而另一部分检查接收端是否有到达的消息。如果发送器或接收器被丢弃,则称通道为关闭的。
在这里,我们将逐步实现一个程序,该程序有一个线程生成值并通过通道发送它们,另一个线程将接收这些值并打印出来。我们将使用通道在线程之间发送简单值,以说明此功能。一旦你熟悉了该技术,就可以将通道用于任何需要相互通信的线程,例如聊天系统或多个线程执行计算的一部分并将这些部分发送给一个线程以聚合结果的系统。
首先,在清单 16-6 中,我们将创建一个通道,但不对其进行任何操作。请注意,这还无法编译,因为 Rust 无法知道我们想通过通道发送哪种类型的值。
文件名: src/main.rs
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
}
清单 16-6:创建一个通道并将两半分配给 tx
和 rx
我们使用 mpsc::channel
函数创建一个新的通道;mpsc
代表多生产者,单消费者。简而言之,Rust 标准库实现通道的方式意味着一个通道可以有多个产生值的发送端,但只有一个消耗这些值的接收端。想象一下多条河流汇入一条大河:任何一条河流中发送的所有东西最终都会汇入末尾的一条河流。我们现在从一个生产者开始,但是当我们使此示例工作时,我们将添加多个生产者。
mpsc::channel
函数返回一个元组,它的第一个元素是发送端——发送器,第二个元素是接收端——接收器。缩写 tx
和 rx
在许多领域传统上分别用于发送器和接收器,因此我们将我们的变量命名为这样以指示每一端。我们正在使用一个带有模式的 let
语句来解构元组;我们将在第 18 章中讨论 let
语句中模式的使用和解构。现在,请注意,使用 let
语句的这种方式是一种方便的方法来提取 mpsc::channel
返回的元组的各个部分。
让我们将发送端移动到一个新生成的线程中,并让它发送一个字符串,以便生成线程与主线程通信,如清单 16-7 所示。这就像将橡皮鸭放在河流的上游或从一个线程向另一个线程发送聊天消息一样。
文件名: src/main.rs
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(); }); }
清单 16-7:将 tx
移动到生成线程并发送 “hi”
同样,我们使用 thread::spawn
创建一个新线程,然后使用 move
将 tx
移动到闭包中,以便生成线程拥有 tx
。生成线程需要拥有发送器才能通过通道发送消息。发送器有一个 send
方法,该方法接受我们想要发送的值。send
方法返回一个 Result<T, E>
类型,因此如果接收器已经被丢弃并且没有地方发送值,则发送操作将返回一个错误。在此示例中,我们调用 unwrap
以在发生错误时 panic。但是在实际应用程序中,我们将正确地处理它:返回第 9 章以回顾正确错误处理的策略。
在清单 16-8 中,我们将从主线程中的接收器获取值。这就像从河尽头的水中检索橡皮鸭或接收聊天消息一样。
文件名: src/main.rs
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}"); }
清单 16-8:在主线程中接收值 “hi” 并打印它
接收器有两个有用的方法:recv
和 try_recv
。我们正在使用 recv
,它是 receive 的缩写,它将阻塞主线程的执行并等待直到值被发送到通道。一旦发送了一个值,recv
将在 Result<T, E>
中返回它。当发送器关闭时,recv
将返回一个错误,表示不会有更多值到来。
try_recv
方法不会阻塞,而是会立即返回一个 Result<T, E>
:如果有一个消息可用,则返回一个包含消息的 Ok
值,如果没有消息,则返回一个 Err
值。如果此线程在等待消息时还有其他工作要做,则使用 try_recv
非常有用:我们可以编写一个循环,该循环会定期调用 try_recv
,如果消息可用则处理消息,否则在再次检查之前执行其他工作。
为了简单起见,我们在本示例中使用了 recv
;我们没有让主线程做任何其他工作,除了等待消息,因此阻塞主线程是合适的。
当我们运行清单 16-8 中的代码时,我们将看到从主线程打印的值
Got: hi
完美!
通道和所有权转移
所有权规则在消息发送中起着至关重要的作用,因为它们可以帮助你编写安全、并发的代码。在整个 Rust 程序中考虑所有权是防止并发编程中出现错误的优势。让我们做一个实验来展示通道和所有权如何协同工作以防止问题:我们将在发送到通道后尝试在生成线程中使用 val
值。尝试编译清单 16-9 中的代码,看看为什么不允许这样做
文件名: src/main.rs
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}");
}
清单 16-9:尝试在我们将其发送到通道后使用 val
在这里,我们尝试在通过 tx.send
将其发送到通道后打印 val
。允许这样做是个坏主意:一旦值被发送到另一个线程,该线程可能会在我们再次尝试使用该值之前对其进行修改或丢弃。潜在地,由于数据不一致或不存在,其他线程的修改可能会导致错误或意外结果。但是,如果我们尝试编译清单 16-9 中的代码,Rust 会给我们一个错误
$ cargo run
Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
--> src/main.rs:10:26
|
8 | let val = String::from("hi");
| --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
9 | tx.send(val).unwrap();
| --- value moved here
10 | println!("val is {val}");
| ^^^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0382`.
error: could not compile `message-passing` (bin "message-passing") due to 1 previous error
我们的并发错误导致了编译时错误。send
函数获取其参数的所有权,当值被移动时,接收器将获取它的所有权。这会阻止我们在发送后意外地再次使用该值;所有权系统会检查一切是否正常。
发送多个值并观察接收器等待
清单 16-8 中的代码已编译并运行,但是它并没有清楚地向我们展示两个单独的线程正在通过通道相互通信。在清单 16-10 中,我们进行了一些修改,这将证明清单 16-8 中的代码正在并发运行:生成线程现在将发送多个消息,并且每个消息之间暂停一秒钟。
文件名: src/main.rs
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}");
}
}
清单 16-10:发送多个消息并在每个消息之间暂停
这次,生成线程有一个我们想要发送到主线程的字符串向量。我们迭代它们,单独发送每个字符串,并通过调用 thread::sleep
函数并使用 1 秒的 Duration
值在每个字符串之间暂停。
在主线程中,我们不再显式调用 recv
函数:而是将 rx
视为迭代器。对于接收到的每个值,我们都会打印它。当通道关闭时,迭代将结束。
运行清单 16-10 中的代码时,你将看到以下输出,每行之间有 1 秒的暂停
Got: hi
Got: from
Got: the
Got: thread
因为我们在主线程的 for
循环中没有任何暂停或延迟的代码,所以我们可以知道主线程正在等待接收来自生成线程的值。
通过克隆发送器创建多个生产者
前面我们提到 mpsc
是 multiple producer, single consumer 的缩写。让我们使用 mpsc
并扩展清单 16-10 中的代码以创建多个线程,这些线程都将值发送到同一接收器。我们可以通过克隆发送器来实现,如清单 16-11 所示
文件名: src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
// --snip--
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
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}");
}
// --snip--
}
清单 16-11:从多个生产者发送多个消息
这次,在创建第一个生成线程之前,我们在发送器上调用 clone
。这将为我们提供一个新的发送器,我们可以将其传递给第一个生成线程。我们将原始发送器传递给第二个生成线程。这给了我们两个线程,每个线程都向一个接收器发送不同的消息。
当你运行代码时,你的输出应该如下所示
Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you
你可能会看到值的顺序不同,具体取决于你的系统。这就是使并发既有趣又困难的原因。如果你尝试使用 thread::sleep
,在不同线程中为其赋予不同的值,则每次运行都将更加不确定,并且每次都会产生不同的输出。
现在我们已经了解了通道的工作原理,让我们看看另一种并发方法。