《Rust编程与项目实战》(朱文伟,李建英)【摘要 书评 试读】- 京东图书 (jd.com)
12.2 多线程编程概述
12.2.1 线程的基本概念
线程(Thread)是程序中独立运行的一部分。线程是进程(Process)中执行运算的最小单位,是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属一个进程的其他线程共享进程所拥有的全部资源。在有操作系统的环境中,进程往往被交替地调度得以执行,线程则在进程内由程序进行调度。由于线程并发很有可能出现并行的情况,因此在并行中可能遇到的死锁、延宕错误常出现于含有并发机制的程序中。
为了解决这些问题,很多语言(如Java、C#)采用特殊的运行时(Runtime)软件来协调资源,但这样无疑极大地降低了程序的执行效率。C/C++语言在操作系统的最底层也支持多线程,且语言本身及其编译器不具备侦察和避免并行错误的能力,这对于开发者来说压力很大,开发者需要花费大量的精力避免发生错误。
Rust不依靠运行时环境,这一点像C/C++一样。但Rust在语言本身就设计了包括所有权机制在内的手段来尽可能地把最常见的错误消灭在编译阶段,这一点其他语言不具备。但这不意味着我们编程的时候可以不小心,迄今为止由于并发造成的问题还没有在公共范围内得到完全解决,仍有可能出现错误,并发编程时要尽量小心!
12.2.2 并发
我们知道,如今CPU的计算能力已经非常强大,其速度比内存要高出许多个数量级。为了充分利用CPU资源,多数编程语言都提供了并发编程的能力,Rust也不例外。
聊到并发,就离不开多进程和多线程这两个概念。其中,进程是资源分配的最小单位,而线程是程序运行的最小单位。线程必须依托于进程,多个线程之间是共享进程的内存空间的。进程间的切换复杂、CPU利用率低等缺点让我们在做并发编程时更加倾向于使用多线程的方式。
当然,多线程也有缺点。其一是程序运行顺序不能确定,因为这是由内核来控制的;其二就是多线程编程对开发者要求比较高,如果不充分了解多线程机制的话,写出的程序就非常容易出Bug。
多线程编程的主要难点在于如何保证线程安全。什么是线程安全呢?因为多个线程之间是共享内存空间的,因此存在同时对相同的内存进行写操作,进而出现写入数据互相覆盖的问题。如果多个线程对内存只有读操作,没有任何写操作,那么也就不会存在安全问题,我们可以称之为线程安全。
常见的并发安全问题有竞态条件和数据竞争两种,竞态条件是指多个线程对相同的内存区域(称之为临界区)进行了"读取−修改−写入"这样的操作。而数据竞争则是指一个线程写一个变量,而另一个线程需要读这个变量,此时两者就是数据竞争的关系。这么说可能不太容易理解,不过不要紧,稍后将举两个具体的例子帮助大家理解。不过在此之前,先介绍一下在Rust中是如何进行并发编程的。
在Rust标准库中提供了两个模块来进行多线程编程:
(1)std::thread定义一些管理线程的函数和一些底层同步原语。
(2)std::sync定义了锁、Channel、条件变量和屏障。
12.2.3 Rust线程模型
一个正在执行的Rust程序由一组本地操作系统线程组成,每个线程都有自己的堆栈和本地状态。线程可以被命名,并为低级别同步提供一些内置支持。
线程之间的通信可以通过通道、Rust的消息传递类型以及其他形式的线程同步和共享内存数据结构来完成。特别是,保证线程安全的类型可以使用原子引用计数容器Arc在线程之间轻松共享。
Rust中的致命逻辑错误会导致线程死机(也称崩溃),在此期间,线程将展开堆栈,运行析构函数并释放所拥有的资源。Rust中的线程死机可以用catch_unwnd捕获(除非使用panic=abort编译)并从中恢复,或者用resume_unwnd恢复。如果没有捕捉到死机,线程将退出,但可以选择从具有连接的其他线程检测到死机。如果主线程死机而没有捕获到死机,则应用程序将使用非零的退出代码退出。
当Rust程序的主线程终止时,即使其他线程仍在运行,整个程序也会关闭。然而,该模块为自动等待线程的终止(即加入)提供了方便的设施。
Rust 中的多线程通过 std::thread模块来实现,它提供了创建和管理线程的功能。Rust 的多线程模型采用"共享状态,可变状态"(Shared State,Mutable State)的方式,这意味着多个线程可以访问同一个数据,但需要通过锁(Lock)来保证数据的安全性。
注意,要使用Rust标准库中的线程函数,通常要在文件开头包含std::thread,比如:
use std::thread;
12.3 模块std::thread
12.3.1 spawn创建线程
在Rust中,我们可以使用std::thread::spawn函数来创建一个新的线程,也称派生线程。该函数声明如下:
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
参数f是一个闭包(Closure),是线程要执行的代码。spawn函数生成一个新线程,并返回JoinHandle(连接句柄),连接句柄提供了一个join方法,可用于连接派生的线程。如果派生的线程崩溃,join将返回一个错误信息。
如果删除连接句柄(JoinHandle),则派生的线程将隐式分离。在这种情况下,派生的线程可能不再连接。注意:程序员有责任最终连接它创建的线程或分离它们,否则将导致资源泄露。
正如用户在spawn的声明中所看到的,对spawn的闭包及其返回值都有两个约束,让我们来解释它们:
(1)静态约束意味着闭包及其返回值必须具有整个程序执行的生存期。这样做的原因是线程可以比创建它们的生存期更长。事实上,如果线程及其返回值可以比它们的调用程序更持久,我们需要确保它们在之后是有效的,因为我们不知道它们什么时候会返回,所以需要让它们尽可能长时间地有效,也就是说,直到程序结束,因此是"静态生存期"。
(2)Send约束是因为闭包需要按值从派生它的线程传递到新线程。它的返回值需要从新线程传递到连接它的线程。作为提醒,Send标记特性表示从一个线程传递到另一个线程是安全的。Sync表示在线程之间传递引用是安全的。
spawn函数的简单示例如下:
use std::thread;
fn main() {
let handle = thread::spawn(|| {
//子线程执行的代码
});
}
子进程也就是主线程的派生线程。其中的||表示闭包,该闭包中的代码将在子线程中执行。调用thread::spawn方法会返回一个句柄,该句柄拥有对线程的所有权。通过这个句柄我们可以管理线程的生命周期和操作线程。thread::spawn 函数接受一个闭包作为参数,闭包中的代码会在子线程中执行。创建的新线程是"分离的",这意味着程序无法了解派生线程何时完成或终止。下面是一个简单的实例。
【例12.1】 创建一个线程
打开VS Code,单击菜单Terminal→New Termanal,执行命令cargo new myrust来新建一个Rust工程,工程名是myrust。
在main.rs中,添加代码如下:
use std::{ thread, 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函数创建了一个新的线程,并在该线程中通过一个for循环准备打印9条信息,并且每输出一条信息就调用sleep函数休眠1毫秒。而主线程中的main函数中将打印4条信息,也是每输出一条信息就调用sleep函数休眠1毫秒。我们调用thread::sleep函数强制线程休眠一段时间,这就允许不同的线程交替执行。但要注意的是,当主线程结束的时候,整个进程就结束了,此时派生线程也会结束,所以派生线程中的打印信息是不会全部输出完毕的。也就是说,虽然某个线程休眠时会自动让出CPU,但并不保证其他线程会执行。这取决于操作系统如何调度线程。这个实例的输出结果是随机的,主线程一旦执行完成,程序就会自动退出,不会继续等待子线程。这就是子线程的输出结果为什么不全的原因。
保存文件并运行,运行结果如下:
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 4 from the main thread!
可以看到,主线程可以全部输出完毕,而派生线程则没有执行完全部for循环,符合预期。从结果中能看出两件事:第一,两个线程是交替执行的,但是并没有严格的顺序;第二,当主线程结束时,它并没有等子线程运行完。
Thread也支持通过std::thread::Builder结构体进行创建,Builder提供了一些线程的配置项,如线程名字、线程优先级、栈大小等,比如:
use std::thread;
fn main() {
let handle = Builder::new()
.name("my_thread".to_string()) //设置新线程的名称是my_thread
.stack_size(1024 * 4) //设置新线程的堆栈大小是1024*4
.spawn({
// 子线程执行的代码
});
}