使用消息传递在线程之间传递数据

确保安全并发的一种日益流行的方法是消息传递,线程或参与者通过相互发送包含数据的消息进行通信。这是 Go 语言文档 中的一句口号:“不要通过共享内存来通信;相反,通过通信来共享内存。”

为了实现消息发送并发,Rust 的标准库提供了通道的实现。通道是一种通用的编程概念,通过它,数据从一个线程发送到另一个线程。

你可以将编程中的通道想象成定向的水道,例如小溪或河流。如果你把像橡皮鸭之类的东西放进河里,它会顺流而下到达水道的尽头。

一个通道有两半:发送端和接收端。发送端是你将橡皮鸭放入河流的上游位置,接收端是橡皮鸭最终到达的下游位置。你的代码的一部分调用发送端的方法来发送你想要发送的数据,另一部分检查接收端是否有到达的消息。如果发送端或接收端任何一半被丢弃,则称通道已关闭

在这里,我们将逐步编写一个程序,该程序有一个线程生成值并通过通道发送它们,另一个线程将接收这些值并将它们打印出来。我们将使用通道在线程之间发送简单值以说明该功能。一旦你熟悉了这项技术,你就可以为任何需要相互通信的线程使用通道,例如聊天系统或多个线程执行部分计算并将这些部分发送到一个线程以聚合结果的系统。

首先,在列表 16-6 中,我们将创建一个通道,但不对其执行任何操作。请注意,这还不会编译,因为 Rust 无法判断我们要通过通道发送的值的类型。

文件名:src/main.rs

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}

列表 16-6:创建通道并将两半分配给 txrx

我们使用 mpsc::channel 函数创建一个新通道;mpsc 代表多生产者,单消费者。简而言之,Rust 标准库实现通道的方式意味着一个通道可以有多个发送端来生成值,但只有一个接收端来消费这些值。想象一下多条溪流汇聚成一条大河:从任何溪流发送的所有东西最终都会汇入终点的一条河流。我们现在从单个生产者开始,但在我们使此示例工作后,我们将添加多个生产者。

mpsc::channel 函数返回一个元组,其第一个元素是发送端——发送器——第二个元素是接收端——接收器。缩写 txrx 传统上在许多领域中分别用于发送器接收器,因此我们将变量命名为这样的名称以指示每个端。我们正在使用带有模式的 let 语句来解构元组;我们将在第 19 章中讨论在 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 创建一个新线程,然后使用 movetx 移动到闭包中,以便新生成的线程拥有 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” 并打印它

接收器有两个有用的方法:recvtry_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.sendval 发送到通道后打印它。允许这样做将是一个坏主意:一旦该值被发送到另一个线程,该线程可能会在我们尝试再次使用该值之前修改或丢弃它。潜在地,另一个线程的修改可能会由于数据不一致或不存在而导致错误或意外结果。但是,如果我们尝试编译列表 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 循环中没有任何暂停或延迟的代码,所以我们可以判断主线程正在等待接收来自新生成线程的值。

通过克隆发送器创建多个生产者

早些时候我们提到 mpscmultiple 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,在不同的线程中为其赋予不同的值,则每次运行都将更加不确定,并且每次都会创建不同的输出。

现在我们已经了解了通道的工作原理,让我们看看另一种并发方法。