在Rust中无畏并发(对比c++) 在Rust中无畏并发(对

在Rust中无畏并发(对比c++)

在程序中使用多线程非常困难, 我将演示如何通过使用Rust来避免c++中的一些缺陷,从而使多线程编程变的至少更容易一些。
记住一点: 复杂性不会消失只会转移。

在真实的代码库中,多线程编程通常更复杂,并且防止C++中的这些错误可能会更加困难。在Rust中,编译器仍将检查在那些复杂的情况下,提前阻止你犯下这些错误。

  1. 竞态条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
cpp复制代码#include <thread>
#include <iostream>
#include <string>
#include <thread>
int main() {
auto data = std::string{"Hello, world!"};
// 启动第一个线程
auto thread1 = std::thread([&] {
data = std::string{"AAAAAAAAAAAAAAAAAAAAAAAA!"};
});
// 启动第二个线程
auto thread2 = std::thread([&] {
for (auto&& c : data) {
c += 1;
}
});
thread1.join();
thread2.join();
std::cout << data << '\n';
}

C++中,没有什么能阻止你引入竞争条件,并且在最糟糕的情况下甚至可以访问无效内存。
如果Thread1Thread2循环期间更新数据data,则Thread2很容易访问已释放的数据data。
在比较大的CodeBases中,即使使用std::atomicstd::mutex,也很难区分调用了哪些类的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
rust复制代码fn main() {
let mut data = "Hello, world!".to_owned();
// error[E0373]: closure may outlive the current function,
// but it borrows `data`, which is owned by the current function
std::thread::spawn(||
data = "AAAAAAAAAAAAAAAAAAAAAAAA!".to_owned()
);
// error[E0502]: cannot borrow `data` as immutable
// because it is also borrowed as mutable
// error[E0373]: closure may outlive the current function,
// but it borrows `data`, which is owned by the current function
std::thread::spawn(|| {
for x in data.chars() {
println!("{}", x);
}
});
}

Rust不会让我们这样做。
因为在Rust中,任何变量都可以具有无限数量的不可变的引用,或单一的可变引用。

这意味着不会在安全的Rust代码中出现这些竞态条件(race condition)的错误。
Rust中有很多方法可以修复上面的错误。
先看看互斥锁通道

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Rust复制代码use std::sync::{Arc, Mutex};
fn main() {
let shared_data = Arc::new(Mutex::new(
"码小菜".to_owned()
));
let t1 = {
// 在arc上clone只会增加变量的引用计数,是零成本的
let shared_data = shared_data.clone();
std::thread::spawn(move || {
let mut data = shared_data.lock().unwrap();
*data = "Hello, 码小菜!\n".to_owned();
})
};
let t2 = {
let shared_data = shared_data.clone();
std::thread::spawn(move || {
let mut data = shared_data.lock().unwrap();
*data = "Goodbye, 码小菜!\n".to_owned();
})
};
t1.join().unwrap();
t2.join().unwrap();
println!("{}", shared_data.lock().unwrap());
}

在上面这个例子中,使用了线程安全的引用计数指针(Arc)来存储数据,确保数据生命周期足够长。
如果只传参不可变数据,使用Arc就足够了。
但是由于想要改变共享数据的状态,还需要将其包装在Ruststd::sync::Mutex中。

1
2
3
4
5
6
7
8
9
Rust复制代码use std::{sync::mpsc::channel, thread};
fn main() {
let (s, r) = channel();
let (s2, r2) = channel();
thread::spawn(move || s.send("Hello, ".to_owned()).unwrap());
thread::spawn(move || s2.send("world!").unwrap());
let message = r.recv().unwrap() + r2.recv().unwrap();
println!("{}", message);
}

对于在线程之间传递数据,可以使用通道(channel)来代替。
这些通道(channel)只允许发送线程安全的类型;如果试图发送一个线程不安全的类型跨线程使用(如Rc),
将会得到一个编译器错误。要想更快地实现多生产者多消费者模型,可以看下crossbeam_channel.

2.生命周期和引用

1
2
3
4
5
6
7
8
9
cpp复制代码#include <string>
#include <thread>
int main() {
{
auto data = std::string{"码小菜"};
std::thread([&] { data.push_back('!'); }).detach();
}
std::this_thread::sleep_for(std::chrono::milliseconds(500));
}

在这个例子中,生成的线程可能正在访问无效的内存,数据data的析构函数在另外一个线程开始访问时可能早已经被调用。
现在的问题是: 我们需要确保在线程中使用的数据比线程本身活得更久。
在许多不同的场景中(不仅仅是多线程的场景),我们使用引用传递c++中的数据,而这些引用必须一直存在,直到引用的对方使用完它为止,而且没有自动的方法来检查引用是否存在。

1
2
3
4
5
6
Rust复制代码fn main() {
let data = "AAAAAAAAAAAAAAAAAAAAAAAA!".to_owned();
// error[E0373]: closure may outlive the current function,
// but it borrows `data`, which is owned by the current function
std::thread::spawn(|| println!("{}", data));
}

正如在第1部分的示例中看到的那样,编译器会抱怨,借来的变量生命周期不够长。
因为线程是独立运行的,所以这些线程中所指向的数据引用必须在整个程序运行期间都存在。

1
2
3
4
5
6
7
8
Rust复制代码use std::sync::Arc;
fn main() {
let data = Arc::new("AAAAAAAAAAAAAAAAAAAAAAAA!".to_owned());
{
let data = data.clone();
std::thread::spawn(move || println!("{}", data));
}
}

正如在前面的示例中看到的那样,可以使用Arc来确保数据生命周期足够长。

1
2
3
4
5
6
7
8
9
10
11
12
Rust复制代码use crossbeam_utils::thread;
fn main() {
let data = "DATA".to_owned();
thread::scope(|s| {
let data = &data;
for c in data.chars() {
s.spawn(move |_| {
println!("{}: {}", data, c);
});
}
}).unwrap();
}

还可以使用来自crossbeam_utils的范围线程。
其原理就是: 当派生的线程需要访问栈上的变量时,主动为其创建一个作用域。

3.处理来自线程的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
cpp复制代码#include <exception>
#include <iostream>
#include <stdexcept>
#include <thread>
static std::exception_ptr ep = nullptr;
int main() {
std::thread([] {
try {
throw std::runtime_error("error");
} catch (...) {
ep = std::current_exception();
}
}).join();
if (ep) {
try {
std::rethrow_exception(ep);
} catch(std::runtime_error& e) {
std::cout << e.what() << "\n";
}
}
}

上面的示例 我简单的写了下处理单个线程异常的最小代码,在c++中处理线程的错误是相当复杂的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Rust复制代码fn main() {
let thread = std::thread::spawn(|| {
// ...
// 修改这里的0可以观察下其他的结果如何处理
match 0 {
0 => Ok("ok"),
1 => Err("error"),
_ => panic!("something went wrong"),
}
});
match thread.join() {
// 返回ok,没有panic
Ok(Ok(x)) => println!("OK {}", x),
// 返回错误,没有panic
Ok(Err(x)) => println!("Error: {}", x),
Err(_) => println!("Thread panicked"),
}
}

在Rust中,可以选择处理来自线程的panic(或者只是调用.unwrap()来终止,如果可以肯定线程永远不会恐慌的话),
并且可以返回Result<T, E>来表示线程可能会失败。请注意,对于大多数线程,都不需要处理这两个中的任何一个。

4.Join() 和 detach()

1
2
3
4
5
6
cpp复制代码#include <iostream>
#include <thread>
int main() {
std::thread([] { std::cout << “Hello!”; });
return 0;
}

在c++中,如果忘记join线程, 那么主线程会立马退出。
如果线程是joinable状态的,那么线程(std::thread)的析构函数将调用std::terminate
当线程调用析构函数的时候它可能还在运行。

1
2
3
Rust复制代码fn main() {
std::thread::spawn(|| println!(“Hello!”));
}

在Rust中,线程在其句柄被删除时隐式分离(运行到作用域外),因此不可能犯这种错误。(注意,你可能看不到打印Hello!在主线程终止后)

当我没有在任何地方保存线程句柄时,我希望线程分离,否则我不会删除它。
c++将这种直观的行为视为不可恢复的运行时错误。

1
2
3
4
5
6
7
8
cpp复制代码#include <iostream>
#include <thread>
int main() {
std::thread t {[] { std::cout << "Hello!"; }};
t.detach();
t.join();
return 0;
}

试图join一个分离的线程会导致崩溃。

1
2
3
4
Rust复制代码fn main() {
let thread = std::thread::spawn(|| println!("Hello!"));
thread.join().unwrap();
}

当作用域消失,自动删除线程句柄时线程被分离时,这个问题就不存在了。

  1. 引用传参

1
2
3
4
5
6
7
8
9
10
11
12
cpp复制代码#include <iostream>
#include <thread>
int main() {
std::string hello {"Hello!"};
std::thread {
[&](const std::string& hi) {
std::cout << std::boolalpha << (&hi == &hello);
},
hello
}.join();
return 0;
}

即使在你不希望它用值传递参数的情况下,(std::thread)也会用值传参(std::ref)可以修复这个问题)。

1
2
3
4
5
6
Rust复制代码fn main() {
let hello = "Hello!".to_owned();
std::thread::spawn(move || println!("{}", hello))
.join()
.unwrap();
}

Rust中,你不能向线程传递参数,而是借用(borrow)(使用scope线程)或将变量移动(move)到闭包中。

  1. 从线程中返回值

如果想在c++中使用线程来正确地返回一个值,需要使用一些额外的同步机制。我这里给出两个最明显的答案: 通过引用或者std::future

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
28
29
30
31
cpp复制代码#include <chrono>
#include <future>
#include <thread>
#include <optional>
#include <iostream>
using namespace std::chrono_literals;

void reference_store() {
auto data = std::optional<std::string>{std::nullopt};
auto t = std::thread([&] {
std::this_thread::sleep_for(500ms);
data = "码小菜";
});
t.join();
std::cout << *data << '\n';
}

void future() {
auto promise = std::promise<std::string>{};
auto future = promise.get_future();
auto t = std::thread([](auto promise) {
std::this_thread::sleep_for(500ms);
promise.set_value("码小菜");
}, std::move(promise));
std::cout << future.get() << '\n';
t.join();
}
int main() {
reference_store();
future();
}

Rust的线程提供了一种直接返回值的机制,这种操作内置在线程中。

1
2
3
4
5
6
7
8
Rust复制代码fn main() {
let thread = std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_millis(500));
"码小菜".to_owned()
});
let result = thread.join().unwrap();
println!("{}", result);
}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%