Rust-线程:使用消息传递在线程间传送数据
一个日益流行的确保安全并发的方式是消息传递(message passing),这里线程或actor通过发送包含数据的消息来相互沟通。这个思想来源于Go编程语言文档中的口号:“不要通过共享内存来通讯;而是通过通讯来共享内存。” ("Do not communicate by sharing memory; instead, share memory by communicating.")
Rust中一个实现消息传递并发的主要工具是通道(channel),Rust标准库提供了其实现的编程概念。你可以将其想像为一个水流的通道,比如何流或小溪。如果你将诸如小船之类的东西放入其中,它们会顺流而下到达下游。
编程中的通道有两部分组成,一个发送者(transmitter)和一个接收者(receiver)。发送者位于上游戏位置,接收者则位于下游,代码中的一部分调用发送者的方法以及希望发送的数据,另一部分则检查接收端收到消息。当发送者或者接上者任一被丢弃时可以认为通道被关闭(closed)了。
这里,我们将开发一个程序,它会在一个线程生成值向通道发送,而在另一个线程生成值向通道发送,而在另一个线程会接收值并打印出来。这里会通过通道在线程间发送简单值来展示这个功能。
首先,在示例1中,创建了一个通道但没有做任何事。
let (tx,rx) = mpsc::channel();
创建一个通道,并将其两端赋值给tx和rx,注意这还不能编译,因为Rust不知道我们想要在通道中发送什么类型:
error[E0282]: type annotations needed for `(Sender<T>, std::sync::mpsc::Receiver<T>)` --> src/main.rs:60:19 | 60 | let (tx,rx) = mpsc::channel(); | ------- ^^^^^^^^^^^^^ cannot infer type for type parameter `T` declared on the function `channel` | | | consider giving this pattern the explicit type `(Sender<T>, std::sync::mpsc::Receiver<T>)`, where the type parameter `T` is specified
这里使用 mpsc::channel 函数创建一个新的通道;mpsc是 多个生产者,单个消费者 (multiple producer, single consumer) 的缩写。简而言这,Rust标准库实现通道的方式意味着一个通道可以有多个产生值的发送端,但只能有一人消费这些值的接收端。
mpsc::channel函数返回一个元组:第一个元素是发送端,而第二个元素是接收端。由于历史原因,tx和rx通常作为发送者和接收者的缩写。这里使用let语句是一个方便提取mpsc::channel返回的元组中的一部分的手段。
让我们将发送端移动到一个新建线程中并发送一个字符串,这样新建线程就可以和主线程通讯了。以下示例2,将tx移动到一个新建的线程中并发送"hi":
let (tx,rx) = mpsc::channel(); thread::spawn(move || { let val = String::from("hi"); tx.send(val).unwrap(); });
这里再次使用thread::spawn来创建一个新线程并使用move将tx移动到闭包中这样新建线程就拥有tx了。新建线程需要拥有通道的发送端以便能向通道发送消息。
通道的发送端有一个send方法用来获取需要放入通道的值。send方法返回一个Result<T,E>类型,所以如果接收端已经被丢弃,将没有发送值的目标,所以发送操作会返回错误。
在示例3中,我们在主线程中从通道的接收端获取值。这类似在河的下游捞起橡皮鸭或接收聊天信息:
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);
在主线程中接收并打印内容"hi"。
通道的接收端有两个有用的方法:recv 和 try_recv。这里,我们使用了recv,这是receive的缩写。这个方法会阻塞主线程执行直到从通道中接收一个值。一旦发送了一个值,recv会在一个Result<T,E>中返回它。当通道发送端关闭,recv会返回一个错误表明不会再有新的值到来了。
try_recv不会阻塞,相反它立刻返回一个Result<T,E>:ok值包含可用的信息,而Err值代表此时没有任何消息。如果线程在等待消息过程中还有其他工作时使用try_recv很有用:可以编写一个循环来频繁调用try_recv,在有可用消息进行处理,其余时候则处理一会其他工作直到再次检查。
出于简单的考虑,这个例子使用了recv;主线程中除了等待消息之外没有任何其他工作,所以阻塞主线程是合适的。
如果运行示例3的代码,我们将会看到主线程打印出这个值:
Got: hi
通道与所有权转移
所有权规则在消息传递中扮演了重要角色,其有助于我们编写安全的并发代码。防止并发编程中的错误是在Rust程度中考虑所有权的一大优势。现在让我们做一个试验看看通道与所有权如何一同协作以避免产生问题:我们将尝试在新建线程中的通道中发送完val值之后再使用它。尝试编译示例4中的代码并看看为何这是不允许的:
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);
示例4:在我们已经发送到通道后,尝试使用val引用
这里尝试在通过tx.send发送val到通道中之后将其打印出来。允许这么做是一个坏主意:一旦将值发送到另一个线程后,那个线程可能会在我们再次使用它之前就将其修改或者丢弃。其他线程对值可能的修改会由于不一致或不存在的数据而导致错误或意外的结果。然而,尝试编译示例4的代码时,Rust会给出一个错误:
error[E0382]: borrow of moved value: `val` --> src/main.rs:57:31 | 55 | let val = String::from("hi"); | --- move occurs because `val` has type `String`, which does not implement the `Copy` trait 56 | tx.send(val).unwrap(); | --- value moved here 57 | println!("val is {}", val); | ^^^ value borrowed here after move
我们的并发错误会造成一个编译时错误。send函数获取其参数的所有权并移动这个值归接收者所有。这可以防止在发送后再次意外的使用这个值;所有权系统检查一切是否合乎规则。
发送多个值并观察接收者的等待
示例3中的代码可以编译和运行。不过它并没有明确的告诉我们两个独立的线程通过通道相互通讯。示例5则有一些改进会证明示例3中的代码是并发执行的:新建线程现在会发送多个消息并在每个消息之间暂停一秒钟。
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); }
示例5:发送多个消息,并在每次发送后暂停一段时间
这一次,在新建线程中有一个字符串vector希望发送到主线程。我们遍历他们,单独的发送每一个字符串并通过一个Duration值调用thread::sleep函数来暂停一秒。
在主线程中,不再显示调用recv函数:而是将rx当作一个迭代器。对于每一个接收到的值,我们将其打印出来。当通道被关闭时,迭代器也将结束。
运行示例5的代码,如下输出,每一行都会暂停一秒:
Got: hi
Got: from
Got: the
Got: thread
因为主线程中的for循环里并没有任何暂停或等待的代码,所以可以说主线程是在等待从新建线程中接收值。
通过克隆发送者来创建多个生产者
之前我们提到了mpsc是multiple producer,single consumer的缩写。可以运用mpsc来扩展示例5中的代码来创建向同一接收者发送值的多个线程。这可以通过克隆通道的发送端来做到,如示例6所示:
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("message"), 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); }
示例6:从多个生间者发送多个消息
这一次,在创建新线程这前,我们对通道的发送端调用了clone方法。这会给我们一个可以传递给第一个新建线程的发送端句柄。我们会将原始的通道发送端传递给第二个新建线程。这样就会有两个线程,每个线程将向通道的接收端发送不同的消息。
如果运行这些代码,我们可能会看到这样的输出:
Got: hi Got: more Got: message Got: from Got: for Got: the Got: thread Got: you
虽然你可能会看到这些值以不同的顺序出现;这依赖于你的系统。这也就是并发既有趣又困难的原因。如果通过thread::sleep做实验,在不同的线程中提供不同的值,就会发现他们的运行更加不确定,且每次都会产生不同的输出。