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

1,316 阅读3分钟

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

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

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

1. 竞态条件

#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,也很难区分调用了哪些类的方法。

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中有很多方法可以修复上面的错误。 先看看互斥锁通道

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中。

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.生命周期和引用

#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++中的数据,而这些引用必须一直存在,直到引用的对方使用完它为止,而且没有自动的方法来检查引用是否存在。

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

use std::sync::Arc;
fn main() {
    let data = Arc::new("AAAAAAAAAAAAAAAAAAAAAAAA!".to_owned());
    {
        let data = data.clone();
        std::thread::spawn(move || println!("{}", data));
    }
}

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

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.处理来自线程的错误

#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++中处理线程的错误是相当复杂的。

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()

#include <iostream>
#include <thread>
int main() {
    std::thread([] { std::cout << “Hello!”; });
    return 0;
}

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

fn main() {
    std::thread::spawn(|| println!(“Hello!”));
}

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

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

#include <iostream>
#include <thread>
int main() {
    std::thread t {[] { std::cout << "Hello!"; }};
    t.detach();
    t.join();
    return 0;
}

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

fn main() {
    let thread = std::thread::spawn(|| println!("Hello!"));
    thread.join().unwrap();
}

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

5. 引用传参

#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)可以修复这个问题)。

fn main() {
    let hello = "Hello!".to_owned();
    std::thread::spawn(move || println!("{}", hello))
        .join()
        .unwrap();
}

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

6. 从线程中返回值

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

#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的线程提供了一种直接返回值的机制,这种操作内置在线程中。

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);
}