Rust多线程 - 限定作用域的线程(Scoped Thread)

相关概念

什么是限定作用域的线程?

  • 定义:使用std::thread::scoped创建的线程,生命周期受限于特定作用域
  • 特性:线程在作用域结束前必须终止,无需手动管理JoinHandle

主要优点

  • 简化线程管理:
    • 无需手动调用**join()**,作用域自动确保线程退出。
    • 减少管理线程生命周期的复杂性。
  • 安全的数据访问:
    • 线程可以安全引用作用域内的本地数据,无需复制或克隆(如使用Arcclone)。
    • 编译器保证数据在作用域内有效,限制所有权的可能性。
  • 简化工作流:
    • 闭包可以直接访问本地变量,编写线程函数更直观。
    • 提高代码可读性和维护性。

局限性

  • 线程生命周期受限
    • 你不能在一个作用域中创建一个线程并期望它永远运行。
  • 强制终止
    • 父作用域在继续执行前,会强制等待所有子线程终止。

使用作用域线程

普通线程使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::{thread, time::Duration};

fn main() {
let mut handles = Vec::new();

for i in 0..5 {
let handle = thread::spawn(move || {
thread::sleep(Duration::from_secs(1));
println!("Normal thread: {}", i);
});

handles.push(handle);
}

handles.into_iter().for_each(|handle| {
handle.join().unwrap();
});
}

上面的代码创建了一个线程数组,包含了5个线程。在线程中,我们要想打印i,需要加上move关键字。并且我们需要手动处理**handle.join().unwrap()**,不然在线程执行前主线程就退出了,得不到任何输出。以上代码输出:

1
2
3
4
5
Normal thread: 1
Normal thread: 2
Normal thread: 0
Normal thread: 3
Normal thread: 4

作用域线程使用方式

1
2
3
4
5
6
7
8
9
10
11
12
use std::{thread, time::Duration};

fn main() {
thread::scope(|s| {
for i in 0..5 {
s.spawn(move || {
thread::sleep(Duration::from_secs(1));
println!("Normal thread: {}", i);
});
}
});
}

输出:

1
2
3
4
5
Normal thread: 2
Normal thread: 3
Normal thread: 4
Normal thread: 1
Normal thread: 0

可以看到,我们并没有使用handle.join().unwrap(),也输出了想要的结果。在代码中,使用的是s.spawn新建线程,而不是thread::spawn创建线程,当 scope 闭包执行结束时,Rust 会自动确保所有这些子线程都已完成,我们不再需要手动管理 JoinHandle

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::{thread, time::Duration};

fn main() {
let a = String::from("hello");
thread::scope(|s| {
for _ in 0..5 {
s.spawn(|| {
thread::sleep(Duration::from_secs(1));
println!("Normal thread: {}", a);
});
}
});
}

修改代码如上,cargo run。可以发现我们可以在线程中直接借用 a ,而不用使用 move 关键字。

可以看到,作用域线程可以安全地借用外部变量。在传统 thread::spawn 中,由于线程可能比创建它的函数活得更久,所以闭包必须获得变量的完整所有权(通过 move),且变量必须是 ‘static 的。但在这里,s.spawn 的闭包可以直接借用 main 函数中的变量 a,并且不需要 move 关键字!这是因为编译器知道,scope 会确保所有子线程在 a 被销毁前就已结束,因此借用是完全安全的。

下面代码中解释了有关生命周期的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::{thread, time::Duration};

fn main() {
// let a = String::from("Hello");

// ’scope 作用域线程可在此生命周期生成和运行
// 'scope 生命周期比 scope 函数内闭包的生命周期长
// 所以作用域线程可能活的比闭包长
thread::scope(|s| {
for i in 0..5 {
s.spawn(move || {
thread::sleep(Duration::from_secs(1));
println!("Scoped thread: {i}");
});
}
}); // non-'static
// 'env 'ENV 生命周期代表 被作用域线程借用的那些数据的生命周期 必须要长于 scope 的调用周期

// thread::spawn(||); 'static
}

‘scope 代表了作用域线程可以存活的范围,而 ‘env 代表了被线程借用的外部环境(如变量 a)的生命周期。Rust 编译器会强制要求 ‘env 必须比 ‘scope 更长,从而在编译期就杜绝了悬垂指针的风险。

作用域线程使用案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::thread;

fn main() {
const CHUNK_SIZE: usize = 10;
let numbers: Vec<u32> = (1..10000).collect();
let chunks = numbers.chunks(CHUNK_SIZE);

let total_sum = thread::scope(|s| {
let mut handles = Vec::new();

for chunk in chunks {
let handle = s.spawn(move || chunk.iter().sum::<u32>());
handles.push(handle);
}

handles.into_iter().map(|h| h.join().unwrap()).sum::<u32>()
});

println!("Total sum: {total_sum}"); // Total sum: 49995000
}

如果不用作用域线程,代码可能会这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use std::thread;

fn main() {
const CHUNK_SIZE: usize = 10;
let numbers: Vec<u32> = (1..10000).collect();

// 将数据分割成拥有所有权的块
let chunked_data: Vec<Vec<u32>> = numbers
.chunks(CHUNK_SIZE)
.map(|chunk| chunk.to_vec())
.collect();

let mut handles = Vec::new();

for chunk in chunked_data {
// 将每个块的所有权移动到线程中
let handle = thread::spawn(move || chunk.iter().sum::<u32>());
handles.push(handle);
}

let total_sum: u32 = handles
.into_iter()
.map(|h| h.join().unwrap())
.sum();

println!("Total sum: {total_sum}"); // Total sum: 49995000
}

这是个将数组分片计算综合的示例,在使用作用域线程的代码中,不用进行 to_vec() 创建数据副本的操作,因为 numbers 的生命周期持续到 main 函数结束, chunks 的生命周期跟随 numbersmain 函数结束,所以在作用域线程中可以直接转移 chunk 所有权去借用 numbers 中的数据。而在普通的线程中,无法进行该操作,普通线程中要求变量为 ‘static 生命周期才能直接借用,所以只能通过 to_vec() 转换成拥有所有权的数据,再将其所有权转入线程中。可以看到,作用域线程的处理方式更加优雅,理论上性能也更好,因为免去了拷贝操作。