现代C++的多线程开发

2023-12-13 03:42:01

前言

早期的C++进行多线程编程,往往需要根据不同的系统编写不同的代码,但是在C++11之后,std中已经提供了多线程的支持,所以对于不同操作系统只需要编写一次代码即可。

本文记录一次多线程开发过程中,使用的C++新特性(指C++11及之后引入的库或语法规则),其中包括:

  1. std::function
  2. std::result_of / std::invoke_of
  3. std::bind
  4. std::thread
  5. std::packaged_task
  6. std::future
  7. std::promise
  8. std::shared_future
  9. std::async
  10. std::mutex
  11. std::timed_mutex
  12. std::recursive_mutex
  13. std::recursive_timed_mutex
  14. std::shared_mutex
  15. std::lock_guard
  16. std::unique_lock
  17. std::shared_lock
  18. std::try_lock
  19. std::lock
  20. std::condition_variable

注意:若无特殊说明,各种方法、类型在C++11开始支持。

函数处理

C++11之后提供了很多函数相关的模板类用于封装函数,从而替代掉通过typedef的方式定义函数类型。

函数封装 std::function

该类型是一个模板类,常常用于绑定一个函数或者函数对象(重载了()的类)。

对于该类型,可以理解成对每个函数指针都使用usingtypedef定义了相应的别名,例如:

void callback(int a, int b) {}
using CallbackFunction = void(*)(int, int);
CallbackFunction cb1 = callback;
// using std::function has the same effects.
std::function<void(int, int)> cb2 = callback;
cb1(1, 2);
cb2(1, 2);

std::function可以用来传递回调函数,相比于函数指针,它更加灵活,例如可以通过模板和模板参数包实现任意函数作为回调函数:

template<class T, class ...Args>
void test(std::function<T> cb, Args ...args)
{
    cb(args...);
}

这比使用函数指针作为类型参数要方便的多。

除此之外,std::function在多线程中可以绑定到std::packaged_task上用于异步执行。
下面给出几个特殊的实例:

  1. 在匿名函数中如果需要实现递归函数,通过将匿名函数绑定到std::function
     auto factorial = [](int n) {
        std::function<int(int)> fac = [&](int n) { return (n < 2) ? 1 : n * fac(n - 1); };
        return fac(n);
    };
    
    上面的代码去掉外层匿名函数可以实现相同的效果,这里只是展示在匿名函数内部如果需要使用递归,则可以通过std::function来实现。
  2. 可以将类成员函数绑定到std::function绑定时需要指定调用对象:
    struct Foo {
        Foo(int num) : num_(num) {}
        Foo(const Foo &other) = default;
        void print_add(int i) { std::cout << num_ + i << '\n'; }
        int num_;
    };
    std::function<void(Foo&, int)> f_add_display = &Foo::print_add;
    Foo foo(314159);
    f_add_display(foo, 1); // same as foo.print_add(1);
    
    std::function<void(Foo, int)> copy_f_add_display = &Foo::print_add;
    copy_f_add_display(foo, 1); // same as Foo(foo).print_add(1);
    
    上面的代码中需要注意第一个参数的类型,如果第一个参数的类型为引用或者指针,这是在原对象上进行操作,而如果第一个参数类型为非引用或指针,则是会通过拷贝构造函数构造一个对象绑定到参数上进行操作,可以通过将复制构造器删除来证明(将代码中的default改为delete)此时无法通过编译。
  3. std::funciton可以绑定到类成员变量上,用于获取成员变量的值:
    std::function<int(Foo &)> f_num = &Foo::num_;
    f_num(foo); // same as foo.num_, but f_num(foo) is a rvalue;
    std::function<int(Foo)> copy_f_num = &Foo::num_;
    copy_f_num(foo); // same as Foo(foo).num_, but f_num(foo) is a rvalue;
    
    上述代码同样需要注意第一个参数类型时引用与指针和不是引用与指针时的区别。同时如果返回值定义为非引用,那么结果是一个右值,当返回值定义为引用类型的时候,其结果为一个左值。

获取函数返回值类型 std::result_of / std::invoke_of

std::result_ofC++17中已被标记为deprecated(过时的),在C++20中已经被移除,取而代之的是std::invoke_of。其功能是获取一个函数对象、函数等的返回类型。

std::result_ofstd::invoke_of替代的原因是:当传入的模板不是一个可执行类型时,std::result_of的行为是未定义的,而std::invoke_of则没有type类型,除此之外,std::result_of对于一些情况下出现一些奇怪的特点:

  • 返回值不能为一个函数或者数组(但可以是指针);
  • 数组作为参数会被转换为指针;
  • 返回值和参数均不能是抽象类;
  • 参数的cv限定符会被丢弃;
  • 参数的类型不能是void

std::result_ofstd::invoke_of的使用方式略有不同,前者按照F(Args...)的方式接收模板类型,而后者按照F, Args...的方式接收参数,例如:

int test(int, int);
std::result_of<decltype(*test)(int, int)>::type a;
std::invoke_result<decltype(*test), int, int>::type b; // since C++ 17
// a and b are both int.

std::result_of / std::invoke_of的使用场景往往是需要根据传入的函数来确定其返回值的时候使用,例如在std::function中给出的回调例子,如果需要根据回调的返回值来确定调用的返回值:

// using result_of in template function, we must using && to get the type.
template<class Func, class ...Args>
typename std::result_of<Func&&(Args&&...)>::type test1(Func&& cb, Args&& ...args) {
    using ReturnType = typename std::result_of<Func&&(Args&&...)>::type;
    return cb(args...);
}

// same effects with decltype
template<class Func, class ...Args>
auto test2(Func&& cb, Args&& ...args) -> decltype(cb(args...)) {
    return cb(args...);
}

// test1 and test2 are the same.

// when parameter is std::funciton
template<class T, class ...Args>
auto test3(std::function<T>&& cb, Args&& ...args) 
-> typename std::result_of<std::function<T>(Args...)>::type
{
    return cb(args...);
}

// when parameter is std::funciton and using decltype
template<class T, class ...Args>
auto test4(std::function<T> cb, Args ...args) -> decltype(cb(args...))
{
    return cb(args...);
}

// test3 and test4 are the same.

int x() { return 0; }
int x1 = test1(x);
int x2 = test2(x);
int x3 = test3(std::function<int()>{x});
int x4 = test4(std::function<int()>{x});

上面的代码中需要注意的是,根据cppreference中的说法,为了防止出现奇怪的特点,我们会使用引用进行传递,因此在第一个实现中使用万能引用,对于上面的代码也给出了通过decltype利用返回值后置的特性实现。

decltype也可以获取函数的返回值类型,std::result_of / sdt::invoke_result也能获取返回值类型,二者最大的区别在于,前置需要传入一个值(或者变量),而后者需要传输类型。

函数绑定 std::bind

std::bind用于给函数去别名这个重新定义函数的参数顺序以及默认值。

例如我们有如下的函数:

void func(int a, int b, int c)
{
	// do someting...
	std::cout << a << b << c << sdt::endl;
}
func(1, 2, 3);

在某些情况下我们可能会需要传入默认的值,那么函数可以写成如下的形式:

void func(int a, int b, int c = 3)
{
	// do something...
	std::cout << a << b << c << sdt::endl;
}
func(1, 2); // this is ok, it is same with func(1, 2, 3);

那么如果我们在使用的时候,我们发现我们可能需要频繁的使用func(1, x, y)这个形式呢?由于默认参数只能出现在非默认参数的后面,那么此时要实现上述的功能,则必须将参数的调用顺序改变。此时我们可能需要重新定义一个函数:

void func(int x, int y)
{
	func(1, x, y);
}
int x = 0, y = 0;
((void(*)(int, int))func)(x, y); // func(int, int) and func(int, int, int = 3) are same, so we need convert to make sure the call unambiguous.

上面每次调用的时候需要转换,当然也可·可以为后定义的func取一个新名字。上面的方法最大的问题在于:我们只需要在某一个作用域频繁使用,而我们却要为整个命名空间增加一个新的函数。有没有一种方法能够只在当前的作用域生成一个函数,一旦退出当前的作用域,那么生成的函数同时失效?

通过std::bind实现:

using namespace std::placeholders; // this if for placeholders: _1 and _2
auto newFunc = std::bind(func, 1, _1, _2);
int x = 0, y = 0;
newFunc(x, y); // this is same with func(1, x, y);

std::bind返回的是一个重载了()的类,其行为根据绑定的参数决定,占位符的个数代表调用的时候需要几个参数:_1始终对应于第一个实参,_2始终对应第二个实参而我们实际调用的是func(1, _1, _2);,当然我们也可以通过std::bind()来改变参数的调用位置:

auto newFunc = std::bind(func, 1, _2, _1); // this will call func(1, _2, _1);
newFunc(x, y); // this is same with func(1, y, x); because y is _2 and x is _1.

由于std::bind()返回的是一个可调用对象,当然可以将其绑定到一个std::function上:

// the template parameter is decided by the return type of func and the num of placehoders.
std::function<void(int, int)> newFunc = std::bind(func, 1, _1, _2);
std::function<void(int, int, int)> newFunc2 = std::bind(func, _1, _2, _3);

Lambda函数也可以实现上述的所有功能,以其中一个为例:

auto newFunc = [] (int x, int y) {
	func(1, x, y);
};
int x = 0, y = 0;
newFunc(x, y); // same with func(1, x, y);

注意:当绑定的函数参数是引用的时候,如果要使用默认值进行引用传递需要用std::cref / std::ref进行封装,不然其传递的是一个值,例如:

void modifyA(int &a, int b, int c)
{
    a = -1;
    std::cout << a << b << c << std::endl;
}
int a = 0, b = 0, c = 0;
auto badFunc = std::bind(modifyA, a, b, c);
badFunc(); // after this a is still 0.
auto goodFunc = std::bind(modifyA, std::ref(a), b, c);
goodFunc(); // after this a is -1.

// implement by Lambda Function:
auto anotherGoodFunc = [&a, b, c]() {
	modifyA(a, b, c);
};
anotherGoodFunc();

多线程中的函数处理

多线程 std::thread

C++11之前想要实现多线程的功能,则必须使用操作系统提供的接口(例如在Linux中的pthread),这样的话无法做到跨平台,对于不同的平台,如果使用多线程往往需要编写两份不同的代码。而在C++11到来之后,其对不同的平台的线程库进行了封装,只需要编写一份代码则可以在不同的平台上实现多线程的功能(需要在运行的时候连接对应平台的线程库,例如在Linux平台下则需要添加-lpthread的编译选项)。

std::thread的使用非常简单,只需要在构造的时候传入一个函数或者一个可调用对象(重载了()操作符的类型)与需要的参数:

void func(int a, int b, int c) {}
std::thread t1(func, 1, 2, 3); // t1 is another thread running func(1, 2, 3);
std::thread t2([]() { std::cout << "Hello Thread!\n"; }); // t2 is another thread running a lambda function.
t1.join();
t2.join();

上面的代码中使用到了std::thread::join其功能是让当前的进程等待某个进程执行结束后再继续执行当前的进程。例如在上面的代码中如果没有join语句,那么当前的主线程可能执行结束后,t1t2依然没有执行完成,此时由于主线程(进程)被回收,对应的子线程也会被回收,即t1、t2不能继续执行,而如果调用了join则只有对应的线程执行完成后,当前的线程才会继续执行,在C++20中提出了std::jthread即在析构的时候会自动调用join的线程,其提出是为了更好的契合RAII(Resources Acquisition is Initialization,对于资源的申请在初始化的时候完成,对于资源的释放由析构函数自动完成)思想。

std::thread还有一个重要的函数detach,其能够让线程脱离std::thread独立执行,该函数往往用于实现某些后台进程的功能。一般情况下,当std::thraed创建的对象被析构之后,其管理的线程则不能继续执行。对于有些情况下,我们需要让一个线程在后台执行而不需要通过std::thread对象获取相关信息,那么我们可以让该线线程detach。这样的好处是,可以让当前的线程及时的释放资源,例如:

void daemonFunc() {
	// do something...
}
void oneThreadFunc()
{
	shared_ptr<int> integerP(new int[(int)8e6]); // big data here
	// do something...
	std::thread daemonT(daemonFunc);
	daemonT.detach();
	// if we use daemonT.join() rather than daemonT.detach() to make sure daemonT finish, the integerP will be released after the daemonT thread.
} // integerP is released after this function.
std::thread t1(oneThreadFunc);
t1.join();

对于上面的的代码我们希望通过RAII来管理数据,如果我们不通过detach,那么我们必须通过daemonT.join()来保证daemonFunc退出后再析构daemonT对象,如果这样的话integerP的释放则会被延迟到daemonT结束后,但是上面的代码通过detach让线程脱离std::thread也能执行,因此可以让integerP的释放不用等待该线程结束后。

不过需要注意的是,在使用detach的时候我们需要确定不会使用到当前作用域申请的数据,例如上面的代码中我们需要保证daemonFunc中不使用integerP所指向的数据。

多线程的函数绑定 std::packaged_task

std::packaged_task的使用方法与std::function一模一样(可以绑定任意一个可执行对象)。唯一不同的是其提供了std::packaged_task::get_future方法,该方法可以获取一个std::future对象用于获取函数的返回值(如果函数有返回值的话)。

异步获取返回值 std::future

上文提到的std::thread非常好用,但是如果我们需要处理多线程函数的返回值呢?

当然通过std::thread也是可以实现的:共享一个变量,当线程函数结束的时候,将该变量设置为函数的返回值即可,需要注意的是,这样的实现往往需要借助条件变量或者锁来实现。当需要考虑锁的时候,程序往往就会变得复杂起来,我们的需求明明很简单:获取多线程函数的返回值。能不能不用程序员管理锁即可实现该功能呢?

对于一个有返回值的多线程函数,我们只需要将其绑定到std:packaged_task上,通过std::packaged_task::get_future获取到std::future对象之后,就可以等待线程执行完成后,获取到线程的返回值了,这也是std::future的常见用法之一:

int threadFunc() { return 0; }
std::packaged_task<int()> threadTask{threadFunc};
std::future<int> result = threadTask.get_future();
std::thread t{std::move(threadTask)};
std::cout << result.get() << std::endl;
t.join(); // this is needed, when the threadFunc returns, the future can get but the thread may be stopping.

在上面的代码中一定要记住调用join因为当函数返回的时候,std::future便已经可以通过get获取值了,但是此时的线程可能并未完全停止(可能正在释放资源)。

std::future也可以在一个单线程程序中使用,其表现的行为类似于通过std::function对函数取了一个别名:

int threadFunc() { return 0; }
std::packaged_task<int()> threadTask{threadFunc};
std::future<int> result = threadTask.get_future();
result.get();

// same implementation:
std::function<int()> func(threadFun};
func();

值传递 std::promise

std::promise用于传递一个值(可以是任意类型),通过set_value进行设置,通过get_future获取std::future对象后,可以通过std::future::get方法获取传递的值,该类支持的所有功能均可以通过条件变量实现,但是使用std::promise在某些场景可以简化代码,同时也不需要我们手动进行加锁等操作。

官网给出的一个例子:

void accumulate(std::vector<int>::iterator first,
                std::vector<int>::iterator last,
                std::promise<int> accumulate_promise) {
    int sum = std::accumulate(first, last, 0);
    accumulate_promise.set_value(sum); // Notify future
}
 
void do_work(std::promise<void> barrier) {
    std::this_thread::sleep_for(std::chrono::seconds(1));
    barrier.set_value();
}
 
int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6};
    std::promise<int> accumulate_promise;
    std::future<int> accumulate_future = accumulate_promise.get_future();
    std::thread work_thread(accumulate, numbers.begin(), numbers.end(),
                            std::move(accumulate_promise));
    std::cout << "result=" << accumulate_future.get() << '\n';
    work_thread.join(); // wait for thread completion
}

上面的例子中,子线程对std::vector中的元素求和,求和完成后,通过std::promise进行传递,在主线程中通过预先获取的future进行get操作。

std::promise也可以用来传递函数,这里不给出例子。

std::promise的模板类型为void的时候,其往往可以作为一个Barrier来使用,这里的例子在std::shared_future部分给出。

共享future std::shared_future

std::shared_future是对std::future的封装,其能够让多个线程通过get获取函数的返回值,该类型的表现行为与std::conditon_variable类似。但是使用std::shared_future我们可以通过值传递的方式(这里应该是浅拷贝),让每个线程拥有自己的std::shared_future对象,这样可以不用加锁也能实现条件变量的功能,例如我们需要完成某些工作后,才能让所有的其他进程启动:

std::promise<void> ready;
std::shared_future<void> sf = ready.get_future();
std::thread t1{[sf]() { sf.get(); std::cout << "Thread 1 has started\n"; }};
std::thread t2{[sf]() { sf.wait(); std::cout << "Thread 2 has started\n"; }};
std::thread t3{[sf]() { sf.wait(); std::cout << "Thread 3 has started\n"; }}; // wait and get are the same.
std::this_thread::sleep_for(2000ms);
std::cout << "Finish Some Preparation.\n";
ready.set_value();
t1.join();
t2.join();
t3.join();

上面的代码子线程中的输出一定是晚于主线程中的输出的。

异步启动与延迟启动 std::async

在前面介绍的使用std::packaged_task可以获取多线程函数的返回值,但是上面的代码每一次都需要使用std::packaged_task进行一次封装,在大多数情况下写起来比较复杂,而std::async同样可以使用实现上述的功能,而且其代码更加简洁:

int threadFunc(int &i) { i = -1; return -1; }
int x = 0;
std::future<int> result = std::async(threadFunc, std::ref(x)); // don't forget the std::ref to make it as a reference.
std::cout << result.get() << std::endl;

除此之外,std::async还支持懒启动,即只有调用std::future::wait或者std::future::get时多线程才回开始执行,例如:

void threadFunc() { std::cout << "Thread is running...\n"; }
std::future<void> result = std::async(std::launch::deferred, threadFunc);
std::this_thread::sleep_for(2000ms);
result.wait(); // threadFunc starts to run.

锁一直是多线程中必不可少的工具。C++11之后标准库提供了各种各样的锁。

需要注意的是:下面介绍的锁均是不公平锁,被阻塞的进程在获取锁的时候不是按照阻塞的先后顺序获取锁。

互斥锁 std::mutex

互斥锁指的是任何时刻只有一个进程能够获得锁。其操作非常简单:

std::mutex mu;
mu.lock();
// do something.
mu.unlock();

上面的lock在不能获得锁的时候会被阻塞。也可以使用try_lock获取锁,当获取失败的时候,try_lock会返回false获取成功则是返回true

std::mutex mu;
if (mu.try_lock()) {
	// do something... 
	mu.unlock();
} else {
	std::cerr << "failed to get mutex\n";
}

为try_lock设置时间 std::timed_mutex

std::mute中的try_lock只会进行一次获取锁的操作,在很多时候我们希望在一段时间内一直尝试获取锁,而std::timed_mutex就能实现这项功能,std::timed_mutexstd::mutex大同小异,唯一不同的就是std::timed_mutex提供了std::timed_mutex::try_lock_forstd::timed_mutex::try_lock_until可以根据传入的参数来决定尝试获取锁的时间长短,当在指定的时间内获取到锁时返回true,否则返回false

std::timed_mutex t_mu;
if (t_mu.try_lock_for(2000ms)) {
	// ... do something
	t_mu.unlock();
} else {
	cerr << "Failed to get lock in 2000ms\n";
}

std::timed_mutexstd::mutex的扩充,其当然也有locktry_lock方法。

可重入互斥锁 std::recursive_mutex

可重入锁指的是同一个线程可以多次获取同一个锁。上文提到的std::mutex为非可重入锁,当一个已经获取std::mutex的线程继续尝试lock时,其会被一直阻塞(此时发生了单个线程的死锁)。而使用std::recursive_mutex可以保证获取了锁的线程能够重复获取该锁,可重入锁一般用于需要递归的情况:

std::recursive_mutex r_mu;
int fabonacci(int n) {
	r_mu.lock();
	// using lock to make sure the cout will be outputting right.
	std::cout << "trying to get fabonacci(" << n << ")\n";
	if (n == 1 || n == 2) {
		r_mu.unlock();
		return 1;
	} else {
		int f1 = fabonacci(n - 1);
		int f2 = fabonacci(n - 2);
		std::cout << n << " " << f1 << " " << f2  << " " << f1 + f2 << std::endl;
		r_mu.unlock();
		return f1 + f2;
	}
}

每一次获取锁了之后在return之前都应该记得释放锁,这很不符合RAII

为可重入锁try_lock设置时间 std::recursive_timed_mutex

该锁与std::recursive_mutex非常类似,两者区别与std::timed_mutexstd::mutex的区别相同,std::recursive_temed_mutex可以通过try_lock_fortry_lock_until设置尝试的时间,这里不在进行赘述。

共享锁、读写锁 std::shared_mutex

该类型时C++17开始提供的。

std::shared_mutex提供两种获取锁的方法locklock_shared分别对应写锁与读锁:

  • 当一个线程获取写锁时,其他的线程不能获取写锁或读锁;
  • 当一个线程获取读锁时,其他的线程不能获取写锁但是可以获取读锁。

该锁可以用于实现经典多线程问题:读者和写者问题。

class ThreadSafeCounter {
public:
    ThreadSafeCounter() = default;
    // Multiple threads/readers can read the counter's value at the same time.
    unsigned int get() const {
        std::shared_lock lock(mutex_);
        return value_;
    }
 
    // Only one thread/writer can increment/write the counter's value.
    void increment() {
        std::unique_lock lock(mutex_);
        ++value_;
    }
 
    // Only one thread/writer can reset/write the counter's value.
    void reset() {
        std::unique_lock lock(mutex_);
        value_ = 0;
    }
 
private:
    mutable std::shared_mutex mutex_;
    unsigned int value_{};
};
 
int main() {
    ThreadSafeCounter counter;
    auto increment_and_print = [&counter]() {
        for (int i{}; i != 3; ++i) {
            counter.increment();
            std::osyncstream(std::cout)
                << std::this_thread::get_id() << ' ' << counter.get() << '\n'; // this osyncstream is since C++ 20.
        }
    };
    std::thread thread1(increment_and_print);
    std::thread thread2(increment_and_print);
    thread1.join();
    thread2.join();
}

上面的例子来自于官网的代码,上面的ThreadSafeCounter类允许多个线程同时通过get获取值,但是只能有一个线程进行increment同时在进行increment的时候,其他线程不能通过get获取值。

上面的代码使用了std::shared_lockstd::unique_lock这一部分在后文中会进行介绍。

写线程饥饿

使用std::shared_mutex还有一个很重要的点需要注意:写进程可能会出现饥饿。

考虑这样一种情况,如果此时有个读线程已经获取了lock_shared此时写线程如果lock则会被阻塞,而在写线程被阻塞的期间,若有很多的新的读进程进行lock_shared进行读操作那么写线程必须要等待所有读线程完成操作后,释放所有的读锁后才能获取到写锁,这样的话写操作会被严重延迟。究其原因是C++的所有锁均为不公平锁。

一种简单的方法是增加一个std::mutex不管是读操作还是写操作之前都需要获取该mutex同时在获取到读锁(或写锁)后释放掉该mutex这样就能保证读线程和写线程在获取所之前会先进行一次竞争操作,便能够保证不破坏语义的情况下,防止写线程饥饿,但是这样会增加一定的开销。

通过RAII获取与释放锁

RAII指的是Resources Acquisition Is Initialization。说简单一点就是申请完资源后,不需要进行手动释放,系统自动释放资源。
例如:通过只能指针能够让在使用完资源后,不需要使用free而是由系统(析构函数)自动释放。

在多线程的开发中,在成功获取了锁之后,还需要通过unlock操作将锁释放。这样不符合RAII的思想,于是std中提供了一些能够在析构的时候自动释放锁的类型和方法。

std::lock_guard

该类型在构造的时候需要与任意一个具有lockunlock方法的锁绑定,对象一旦创建完成便获得了锁,当对象析构的时候便释放了锁,例如:

std::mutex mu;
void threadFunc()
{
	std::lock_guard lg{mu};
	// acquire the lock
	// do something...
} // there is no need to unlock

std::unique_lock

std::lock_guard实现了RAII的特点,但是其一旦获得了,便不能通过unlock进行解锁。而std::unique_lock可以在获取锁后选择unlock或者lock释放与重新获取锁:

std::mutex mu;
void threadFunc()
{
	std::unique_lock ul{mu};
	// acquire the lock
	// do something...
	ul.unlock(); // we can unlock if we need this lock no more.
	// do something...
	ul.lock(); // we can lock if we need this lock again.
} // there is no need to unlock

除此之外,std::unique_lock的构造函数还能接收第二个参数,其有三种类型:

  • std::adop_lock:传入该参入与不传表现一样,即创建完成后便获得了锁;
  • std::try_lock:传入该操作等价于在创建的时候进行一次try_lock操作,由于std::unique_lock重载了bool运算法可以通过if(name)的方式来判断是否成功获取锁;
  • std::defer_lock:传入该操作表示不获取锁,但是后续可以通过lock获取锁,该方法一般与std::lock一起使用以实现同时获取多个锁的效果。

std::shared_lock

该类型在C++14开始支持。

该类型与std::unique_lock类似唯一不同的是其调用的是绑定的锁的lock_sharedunlock_shared方法。该类型常常与std::shared_mutex一起使用,用于实现std::shared_mutex中共享锁(读锁)的RAII
其同样在构造的时候能够传入第二个参数,参数的类型与std::unique_lock的第二个参数类型相同。

奇怪的事:std::shared_mutex是在C++17才开始支持的,而std::shared_lockC++14就已经支持了,难道还有其他的支持lock_shared语义的对象我没有发现吗?或者是官方打算让用户自己实现共享锁吗?

同时获取多个锁

std::try_lock

std::try_lock是一个函数,其接受多个支持try_lock的锁对象,且同时尝试获取多个锁,若有任意一个的try_lock获取失败,那么其余已经获取成功的锁会释放,同时返回获取失败的索引下标(从0开始计算)。如果均try_lock成功那么该函数返回-1

std::mutex m1, m2;

int ret = std::try_lock(m1, m2);
if (ret == -1) {
	// do something...
	m1.unlock();
	m2.unlock(); // don't forget release the locks.
} else {
	// m1 and m2 are not got by this thread.
}

上面的代码需要手动释放锁,我们也可以将std::mutexstd::unique_lock绑定,以实现RAII

std::mutex m1, m2;

std::unique_lock<std::mutex> ul1{m1, std::defer_lock}, ul2{m2, std::defer_lock};
int ret = std::try_lock(m1, m2);
if (ret == -1) {
	// do something...
	// no need to release the locks.
} else {
	// m1 and m2 are not got by this thread.
}

std::lock

std::lock是同时获取多个锁,当不能获取某个锁的时候会被阻塞,同时保证之前已经获取的锁被释放。其使用方法与std::try_lock类似,不同的是由于保证一定要同时获得锁之后才不会被阻塞,所以该函数没有返回值。

std::mutex m1, m2;

std::lock(m1, m2); // get m1 and m2 at same time.
// do something...
m1.unlock();
m2.unlock(); // don't forget release the locks.

上面的代码需要手动释放锁,我们也可以将std::mutexstd::unique_lock绑定,以实现RAII

std::mutex m1, m2;

std::unique_lock<std::mutex> ul1{m1, std::defer_lock}, ul2{m2, std::defer_lock};
std::lock(m1, m2); // get m1 and m2 at same time.
// do something...
// no need to release the locks.

一个小问题

为什么没有实现同时获取多个共享锁(std::lock_shared)?

实现同时获取多个锁的目的是为了防止因为申请与释放锁的顺序不当防止出现死锁。对于共享锁的lock_shared如果能够成功获取,那么其他的线程依然能够通过lock_shared获取锁,那么此时一定是不会发生死锁的,所以完全没有必要实现一个std::lock_shared

条件变量 std::condition_variable

条件变量需要与锁一起使用,提供waitnotify这一对操作,通过调用wait会阻塞当前的线程,同时释放掉与条件变量绑定的锁,而notify则可以唤醒被阻塞的线程,线程一旦被唤醒其将会重新获取wait时释放的锁,如果此时锁已经被其他线程获取,那么则会进入阻塞状态,直到成功获取锁。

使用条件变量的时候,通常需要与某些变量相关联,例如某些线程等待其他的线程初始化完成后,这些进程才继续执行,那么就可以通过条件变量来实现,这里给出前面std::promise中的例子的条件变量实现:

void accumulate(std::vector<int>::iterator first,
                std::vector<int>::iterator last) {
    std::unique_lock<std::mutex> locker{mu};
    sum = std::accumulate(first, last, 0);
    cv.notify_all(); // we usually use notify_all, cause we don't know who are waiting.
}

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6};
    std::thread work_thread(accumulate, numbers.begin(), numbers.end());
    std::unique_lock<std::mutex> locker{mu};
    cv.wait(locker, []() { return sum != -1;}); // wait until sum != -1;
    std::cout << sum << std::endl;
    work_thread.join();
    return 0;
}

在使用条件变量的时候我们大多数条件下使用notify_all进行唤醒,这是因为我们并不知道都有哪些进行被阻塞,当唤醒所有被阻塞进程后,不符合退出wait条件的进程会继续被阻塞,同时需要注意的时,wait会先判断是否满足停止条件,而不是先阻塞然后唤醒后再进行判断。

又一个小问题

条件变量在wait之前需要先获取锁,然后才可以进行wait操作,那么在notify之前是否也一定需要获取锁呢?

当然不是。在大多数的情况下,进行notify之前需要获取锁是因为往往需要修改共享变量的值。而有些时候,我们在notify之前可能并不需要修改共享变量的值,那么此时则可以不用获取锁。也就是说锁并不是和notify关联的,需不需要获取锁是由我们是否需要修改共享变量而决定的,并不是由我们是否需要notify来决定,只是大多数情况我们在notify之前需要修改共享变量,这容易给人一种notify一定需要加锁才能进行的错觉。实际上一种简单的证明notify并不需要获得锁的方法是:我们在修改完共享变量之后即可释放锁,然后此时调用notify依然不会出现问题(需要保证之后不再修改共享变量),例如上面的代码如下依然没有问题:

void accumulate(std::vector<int>::iterator first,
                std::vector<int>::iterator last) {
    std::unique_lock<std::mutex> locker{mu};
    sum = std::accumulate(first, last, 0);
    locker.unlock();
    cv.notify_all(); // we can notify without any locks.
}

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6};
    std::thread work_thread(accumulate, numbers.begin(), numbers.end());
    std::unique_lock<std::mutex> locker{mu};
    cv.wait(locker, []() { return sum != -1;}); // wait until sum != -1;
    std::cout << sum << std::endl;
    work_thread.join();
    return 0;
}

后续

本文只是介绍一些现代C++多线程常用的类和方法,并没有介绍一些多线程编程的实例。后面会根据实际遇到的问题,介绍一些开发中常见的多线程问题。

参考

std::function cppreference
std::result_of / std::invoke_of cppreference
std::bind cppreference
std::thread cppreference
std::packaged_task cppreference
std::future cppreference
std::promise cppreference
std::shared_future cppreference
std::async cppreference
std::mutex cppreference
std::timed_mutex cppreference
std::recursive_mutex cppreference
std::recursive_timed_mutex cppreference
std::shared_mutex cppreference
std::lock_guard cppreference
std::unique_lock cppreference
std::shared_lock cppreference
std::condition_variable cppreference

文章来源:https://blog.csdn.net/qq_45523675/article/details/134714505
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。