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

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

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

你可以将编程中的通道想象成一条有方向的水道,比如溪流或河流。如果你将像橡皮鸭这样的东西放入河流中,它会顺流而下,最终到达水道的尽头。

通道有两个部分:发送端和接收端。发送端是上游位置,你将橡皮鸭放入河流的地方,而接收端是橡皮鸭最终到达的下游位置。代码的一部分调用发送端的方法来发送数据,另一部分检查接收端以获取到达的消息。如果发送端或接收端中的任何一个被丢弃,通道就被认为是关闭的。

在这里,我们将逐步构建一个程序,其中一个线程生成值并通过通道发送它们,另一个线程接收这些值并打印出来。我们将使用通道在线程之间发送简单的值来说明这一特性。一旦你熟悉了这项技术,你可以将通道用于任何需要相互通信的线程,例如聊天系统或一个系统中多个线程执行部分计算并将结果发送到一个线程进行汇总。

首先,在 Listing 16-6 中,我们将创建一个通道,但不对它做任何操作。请注意,这还不会编译,因为 Rust 无法确定我们希望通过通道发送什么类型的值。

文件名: src/main.rs

use std::sync::mpsc;

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

Listing 16-6: 创建一个通道并将其两端分配给 txrx

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

mpsc::channel 函数返回一个元组,第一个元素是发送端——发送器,第二个元素是接收端——接收器。缩写 txrx 传统上在许多领域中分别用于表示发送器接收器,因此我们这样命名变量以指示每一端。我们使用带有模式的 let 语句来解构元组;我们将在第 19 章讨论 let 语句中模式的使用和解构。现在,知道使用 let 语句这种方式是提取 mpsc::channel 返回的元组部分的便捷方法。

让我们将发送端移动到一个生成的线程中,并让它发送一个字符串,这样生成的线程就可以与主线程通信,如 Listing 16-7 所示。这就像将橡皮鸭放入上游的河流中,或者从一个线程发送聊天消息到另一个线程。

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();
    });
}

再次,我们使用 thread::spawn 创建一个新线程,然后使用 movetx 移动到闭包中,这样生成的线程就拥有了 tx。生成的线程需要拥有发送器才能通过通道发送消息。

发送器有一个 send 方法,它接受我们想要发送的值。send 方法返回一个 Result<T, E> 类型,因此如果接收器已经被丢弃并且没有地方发送值,发送操作将返回一个错误。在这个例子中,我们调用 unwrap 以便在出现错误时 panic。但在实际应用中,我们会正确处理它:回到第 9 章回顾正确的错误处理策略。

在 Listing 16-8 中,我们将在主线程中从接收器获取值。这就像从河流的尽头取出橡皮鸭,或者接收一条聊天消息。

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}");
}

接收器有两个有用的方法:recvtry_recv。我们使用 recv,它是接收的缩写,它将阻塞主线程的执行并等待直到有值通过通道发送。一旦有值发送,recv 将在一个 Result<T, E> 中返回它。当发送器关闭时,recv 将返回一个错误,表示不再有值到来。

try_recv 方法不会阻塞,而是立即返回一个 Result<T, E>:如果有消息可用,则返回一个包含消息的 Ok 值,如果这次没有消息,则返回一个 Err 值。如果这个线程在等待消息时有其他工作要做,使用 try_recv 是有用的:我们可以编写一个循环,每隔一段时间调用 try_recv,如果有消息则处理它,否则做其他工作一段时间,直到再次检查。

在这个例子中,我们为了简单起见使用了 recv;主线程除了等待消息外没有其他工作要做,因此阻塞主线程是合适的。

当我们运行 Listing 16-8 中的代码时,我们将看到从主线程打印的值:

Got: hi

完美!

通道和所有权转移

所有权规则在消息发送中起着至关重要的作用,因为它们帮助你编写安全的并发代码。防止并发编程中的错误是在整个 Rust 程序中考虑所有权的优势。让我们做一个实验来展示通道和所有权如何一起工作以防止问题:我们将尝试在生成的线程中使用一个 val 值,我们将其发送到通道之后。尝试编译 Listing 16-9 中的代码,看看为什么这段代码不被允许。

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.sendval 发送到通道后打印它。允许这样做将是一个坏主意:一旦值被发送到另一个线程,该线程可能会在我们尝试再次使用该值之前修改或丢弃它。潜在情况下,另一个线程的修改可能会导致错误或由于数据不一致或不存在而产生意外结果。然而,如果我们尝试编译 Listing 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 函数获取其参数的所有权,当值被移动时,接收器获取它的所有权。这阻止了我们在发送值后意外地再次使用它;所有权系统检查一切是否正常。

发送多个值并观察接收器等待

Listing 16-8 中的代码编译并运行了,但它没有清楚地展示两个独立的线程通过通道进行通信。在 Listing 16-10 中,我们做了一些修改,将证明 Listing 16-8 中的代码是并发运行的:生成的线程现在将发送多条消息,并在每条消息之间暂停一秒钟。

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}");
    }
}

这次,生成的线程有一个字符串向量,我们希望将其发送到主线程。我们遍历它们,逐个发送,并通过调用 thread::sleep 函数暂停一秒钟。

在主线程中,我们不再显式调用 recv 函数:相反,我们将 rx 视为一个迭代器。对于每个接收到的值,我们打印它。当通道关闭时,迭代将结束。

运行 Listing 16-10 中的代码时,你应该看到以下输出,每行之间有一秒钟的暂停:

Got: hi
Got: from
Got: the
Got: thread

因为我们在主线程的 for 循环中没有暂停或延迟的代码,我们可以看出主线程正在等待从生成的线程接收值。

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

之前我们提到 mpsc多生产者,单消费者的缩写。让我们使用 mpsc 并扩展 Listing 16-10 中的代码,创建多个线程,这些线程都向同一个接收器发送值。我们可以通过克隆发送器来实现这一点,如 Listing 16-11 所示。

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--
}

这次,在我们创建第一个生成的线程之前,我们在发送器上调用 clone。这将给我们一个新的发送器,我们可以将其传递给第一个生成的线程。我们将原始发送器传递给第二个生成的线程。这给了我们两个线程,每个线程向同一个接收器发送不同的消息。

当你运行代码时,你的输出应该看起来像这样:

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

你可能会看到值的顺序不同,这取决于你的系统。这就是并发既有趣又困难的原因。如果你尝试在不同的线程中给 thread::sleep 不同的值,每次运行都会更加不确定,并产生不同的输出。

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