我在认真地思考一个问题:在工作中都用并发来处理哪些业务场景?
首先想到的是:并行发起多个网络调用同时操作数据。打个比方,已知我的当前位置,既要从数据库中查询附近的餐厅,又要请求第三方获取当前道路的拥堵情况,这两个不相干的逻辑就可以并发处理。
创建线程
需要操作系统都提供了用于创建线程的API
,这种直接利用操作系统API
来创建线程的模型常常被称为1:1
模型,它意味着一个操作系统线程对应一个语言线程。
也有许多编程语言提供了自身特有的线程实现,这种由程序语言提供的线程常常被称为绿色线程green thread
,绿色线程会在拥有不同数量系统线程的环境下运行它们。绿色线程也被称为M:N
模型,表示M
个绿色线程对应N
个系统线程。
Go
语言运行时是goroutine
就属于绿色线程,M
表示系统线程,M
和操作系统交互实现最终的并行。
RUST
标准库只提供了1:1
线程模型实现,但得益于RUST
良好的底层抽象能力,RUST
社区涌现出很多支持M:N
线程模型的第三方包,比如takio
。
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::spawn
函数来创建线程,它接收一个闭包函数作为参数,该闭包会包含我们想要在新线程中运行的代码。
上述代码是不稳定的,不能保证新线程一定会被执行。当主线程返回时,会提前终止新线程继续执行。所以新线程的输出一直都是不完整的。
thread::spawn
的返回值类型是一个自持有所有权的JoinHandle
,调用它的join
方法可以阻塞当前线程直到新线程运行结束。
在线程中使用move
== 借用和所有权规则在新线程中依然有效,借用保证数据需要满足生命期的约束,而所有权保证数据不能被重复使用。==
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| println!("Here's a vector:{:?}", v));
handle.join().unwrap();
}
在主线程创建一个动态数组,然后在新线程中使用它。但代码为什么会编译报错呢?
RUST
在推导出如何捕获v
之后决定让新线程借用v
,因为闭包中的println!
只需要使用v
的引用。但这就出现了一个问题:由于RUST
不知道新线程会运行多久,所以它无法确定v
的引用是否一直有效。
新线程在捕获v
的引用再使用时极有可能不再有效了。
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || println!("Here's a vector:{:?}", v));
// drop(v);
handle.join().unwrap();
}
move
闭包常常被用来与thread::spawn
函数配合使用,它允许你在某个线程中使用来自另一个线程的数据。
通过在闭包前添加move
关键字,我们会强制闭包获取它所需值的所有权,而不仅仅是基于RUST
的推导来获取值的借用。
通过将v
的所有权转移给新线程,我们就必须保证主线程不能再使用v
,如果我们打开注释部分的代码,程序会因为违反了所有权规则而再次编译失败。
并发累加
假设存在长度为 10 的整数数组,现在启用 10 个线程来异步对它们求和,在过程中引入锁来避免竞态竞争的影响。
这种场景属于 共享状态 的并发,多个线程可以同时访问相同的内存地址。Rust 比较牛的地方在于:只要我们的代码顺利通过编译,就基本上不会出现那些莫名其妙的并发问题。
下面是多线程相加的示例,代码总共启动了 10 个线程,为了保证新线程一定得到执行,将每个线程的返回值保存到数据组中,最后循环这个数组,阻塞等待每个线程正常执行返回。不过,线程的执行顺序是没有保证的。
use std::sync::Arc;
use std::sync::Mutex;
use std::thread;
fn main() {
let sum = Arc::new(Mutex::new(0));
let numbers: Vec<i32> = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
let mut handlers = vec![];
for i in numbers {
let dup = sum.clone();
let handle = thread::spawn(move || {
let mut num = dup.lock().unwrap();
*num += i;
});
handlers.push(handle);
}
for handle in handlers {
handle.join().unwrap();
}
println!("total:{}", sum.lock().unwrap())
}
这算是一种处理模式,如果省略 join
的等待,当主线程运行结束,过程中创建出来的新线程就会被停止,而不管它是否执行完成。
spawn
use std::thread;
fn main() {
let handler = thread::spawn(|| {
// thread code
println!("thread");
});
println!("main");
handler.join().unwrap();
}
我们调用 thread::spawn 函数来创建线程,它接收一个闭包函数作为参数,函数类型实现了 FnOnce,闭包中实现异步执行的代码。FnOnce特性闭包只能被执行一次。
Go语言开启协程也使用了闭包,但它依赖 sync.WaitGroup对象来等待多个协程执行结束,相当于依赖了一个独立的三方,模式上属于1:N 的控制。
而 rust 依赖 spawn 的返回值来等待线程执行结束,它返回 JoinHandle 类型对象,调用它的 join 方法可以阻塞当前线程直到对应的异步线程运行结束。但每次仅仅只能管控一个异步的线程,模式上属于 1:1 的控制。
Unwrap() 属于处理 result 类型的快捷方法。如果 result 是成功的结果,unwrap 也会返回成功的结果。如果是错误的结果,方法会发生 panic。常见的还有 expect() 方法,可以给 panic 指定错误信息。
通道channel
Go 语言的 channel 通讯的口号:Do not communicate by sharing memory; instead, share memory by communicating,rust 中 channel 也展示了同样的设计理念。
通过调用mpsc::channel
函数创建了一个新通道, channel
函数会返回 2 个对象,消息的发送者 Sender 和接收者 Receiver。Rust 可以推断出 channel 的具体类型,当然也可以明确类型声明。,代码中用来绑定它们的变量名称为tx
和rx
,这也是许多场景下发送者和接受者的惯用写法。
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
fn main() {
let (tx, rx): (Sender<String>, Receiver<String>) = mpsc::channel();
thread::spawn(move || {
let val: String = String::from("hello");
tx.send(val).unwrap();
// println!("val is {}", val);
});
let received: String = rx.recv().unwrap();
println!("Got:{}", received);
}
为了让新线程拥有tx
的所有权,我们使用move
关键字将tx
移动到闭包环境中,新线程必须拥有通道发送端的所有权才能通过通道发送消息。
发送端提供了send
方法发送数据,这个方法返回Result<T,E>
类型的值来作为结果。当接收端已经被丢弃而无法继续传递内容时,执行发送操作便会返回一个错误。
为什么新线程没有调用join
进行线程阻塞呢?因为recv
方法会阻塞主线程的执行直到有值被传入通道。一旦有值传入通道,recv
就会将它包裹在Result<T,E>
中返回。而如果通道的发送端全部关闭了,recv
则会返回一个错误来表明通道再也没有可接收的值了。
如果我们打开注释部分的代码,在调用send
之后试图打印这个值,RUST
会触发编译报错。send
函数会获取参数的所有权,并在参数传递时将所有权转移给接受者,这可以阻止我们意外地使用已经发送的值。
接受者迭代处理
use std::sync::mpsc::{self};
use std::thread;
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();
}
});
for received in rx {
println!("Got:{}", received)
}
}
在主线程,我们将rx
视为迭代器,而不再显示地调用recv
函数。我们依次打印出每个接收到的值,并在通道关闭时退出循环。
通过这个迭代方式不需要显示地判断发送端是否已经关闭,Go
语言的通道也实现了这样的处理模式。
Doc下示例代码,对于 std::sync::mpsc 中 mpsc 的全称描述: Multi-producer, single-consumer FIFO queue communication primitives. 多生产者,单消费者。
因为有所有权转移的限制,Sender 可以克隆多个副本,并在不同的线程中向 channel 写入,但 Receiver 没有实现 Clone 特性,有且只能存在一个。如果要实现多个线程从同一个通道接收值,就需要使用 Mutex。
下面是官方提供的 channel 示例,这个例子丰富地演示了 channel 通讯的细节。
use std::sync::mpsc::{Sender, Receiver};
use std::sync::mpsc;
use std::thread;
static NTHREADS: i32 = 3;
fn main() {
// Channels have two endpoints: the `Sender<T>` and the `Receiver<T>`,
// where `T` is the type of the message to be transferred
// (type annotation is superfluous)
let (tx, rx): (Sender<i32>, Receiver<i32>) = mpsc::channel();
let mut children = Vec::new();
for id in 0..NTHREADS {
// The sender endpoint can be copied
let thread_tx = tx.clone();
// Each thread will send its id via the channel
let child = thread::spawn(move || {
// The thread takes ownership over `thread_tx`
// Each thread queues a message in the channel
thread_tx.send(id).unwrap();
// Sending is a non-blocking operation, the thread will continue
// immediately after sending its message
println!("thread {} finished", id);
});
children.push(child);
}
// Here, all the messages are collected
let mut ids = Vec::with_capacity(NTHREADS as usize);
for _ in 0..NTHREADS {
// The `recv` method picks a message from the channel
// `recv` will block the current thread if there are no messages available
ids.push(rx.recv());
}
// Wait for the threads to complete any remaining work
for child in children {
child.join().expect("oops! the child thread panicked");
}
// Show the order in which the messages were sent
println!("{:?}", ids);
}
共享状态的并发
消息传递确实是一种不错的并发通讯机制,但它并不是唯一的解决方案。再次思考一下Go
编程文档前半句所说的:通过共享内存来通讯。基于共享内存的并发通讯机制更类似于多重所有权概念:多个线程可以同时访问相同的内存地址。
互斥体(mutex
)是英文mutual exclusion
的缩写。也就是说,一个互斥体在任意时刻只允许一个线程访问数据。为了访问互斥体中的数据,线程必须首先发出信号来获取互斥体中的锁(lock
)。锁是互斥体的一部分,这种数据结构被用来记录当前谁拥有数据的唯一所有权。
使用互斥体必须牢记下面的两条规则:
- 必须在使用数据前尝试获取锁
- 必须在使用完互斥体守护的数据后释放锁,这样其它线程才能继续完成获取锁的操作
在现实世界可以对互斥体进行这样一个隐喻,你可以把它想象成一场仅有单个话筒的座谈会议。每个人在讲话前必须发出信号来试图获取这个话筒的所有权。演讲者在拿到话筒后可以使用任意长时间,并接着将话筒递给下一个请求发言者。一旦针对话筒的管理出现了失误,整个座谈会就无法按照计划进行下去!
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>
实例。为了访问Mutex<T>
中的数据,我们首先调用它的lock
方法来获取锁。这个调用会阻塞当前线程直到我们取得锁为止。
当前线程对于lock
函数的调用会在其它持有锁的线程发生panic
时失败。示例的处理方式:代码使用unwrap
在意外发生时触发当前线程的panic
。
一旦获取了锁,我们便可以将它的值num
视作一个指向了内部数据的可变引用。RUST
系统限制了我们在使用m
之前必须执行加锁操作。因为Mutex<i32>
并不是i32
类型,所以我们必须获取锁才能使用i32
值。
Mutex<T>
其实是一个智能指针。更准确地说,对lock
的调用会返回一个名为MutexGuard
的智能指针。这个智能指针通过Deref
来执行存储在内部的数据,它还会通过Drop
来完成自己离开作用域时的自动解锁操作。这种释放过程发生在内部作用域的结尾处。因此,我们不会因为忘记释放锁而导致其它线程无法继续使用互斥体,锁的释放过程是自动发生的。
lock
函数的返回类型声明是LockResult<MutexGuard<'_, T>>
,结构体中指定了生命周期声明。
在多线程间共享Mutex<T>
下面的例子中,我们会依次启动10个线程,并在每个线程中分别为共享的计数器值加1。但代码无法正在通过编译:
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!("Result: {}", *counter.lock().unwrap());
}
因为counter
的所有权被移动到了多线程中。如果我们尝试多重所有权来修复这个编译错误:借助智能指针Rc<T>
提供的引用计数为单个值赋予多个所有者,依然无法正常编译。
std::rc::Rc<std::sync::Mutex<i32>>
类型无法安全地在线程间传递。RC<T>
在跨线程使用时并不安全。Rc<T>
管理引用计数时,它会在每次clone
的过程中增加引用计数,并在克隆出的实体被丢弃时减少引用计数,但它并没有任何并发原语来保证修改计数的过程不会被另一个线程所打断。
Arc<T>
原子引用计数
Arc<T>
类型拥有类似Rc<T>
的行为,还可以被安全地用于并发场景。它名字中的A代表着原子(atomic
),表明自己是一个原子引用计数(atomically reference counted
)类型。
修改之后的代码如下,可以按预期数据结果:
use std::sync::{Arc, Mutex};
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());
}
Sync
和Send trait
Send
允许线程间转移所有权的Send trait
,只有实现了Send trait
的类型才可以安全地在线程间转移所有权。任何完全由Send
类型组成的复合类型都会被自动标记为Send
。
Send
作用类型是值传递,而Sync
是引用传递。
Sync
只有实现了Sync trait
的类型才可以安全地被多个线程引用。换句话说,对于任意类型T
,如果&T
(也就是T
的引用)满足约束Send
,那么T
就满足Sync
。这意味着T
的引用能够被安全地传递至另外的线程中。
与Send
类似,所有原生类型都满足Sync
约束,而完全由满足Sync
的类型组成的复合类型也都会被自动识别为满足Sync
类型。