2022年了,你还不了解C++多线程同步?

一、起因

今天刷多线程类别的题目时,遇到最基础的一道题:按次序打印:力扣; 有兴趣的可以看一下题目描述,属于最基础的多线程互斥,目的是不论何种情况下,三个线程都得按顺序1、2、3执行,不能乱了顺序。解题思路大家应该都能想到,依次加锁解锁,按照线程1、2、3顺序依次解开线程锁即可。但是翻阅解题思路时发现某个大神(ID=**Zhengyu Chen**)用标题的五种方式依次实现了一遍,膜拜!特此整理来学习一遍。

多线程

二、题目描述

给你一个类:

public class Foo {
public void first() { print("first"); }
public void second() { print("second"); }
public void third() { print("third"); }
}

三个不同的线程 A、B、C 将会共用一个 Foo 实例。

  • 线程 A 将会调用 first() 方法
  • 线程 B 将会调用 second() 方法
  • 线程 C 将会调用 third() 方法

请设计修改程序,以确保 second() 方法在 first() 方法之后被执行,third() 方法在 second() 方法之后被执行。

题目来源:力扣(LeetCode)链接:力扣

三、解题方案

1. 互斥锁

互斥锁是用来防止多个线程同时访问共享资源对象的机制,在同一时间只有一个线程可以拥有一个特定的锁对象,其他线程如果尝试获取锁会阻塞直到锁资源被释放或直接返回失败。

针对这道题我们可以用两个互斥锁来阻塞 second 和 third 函数,分别在 first 和 second 执行结束后解锁。

class Foo {
mutex mtx1, mtx2;
public:
Foo() {
mtx1.lock(), mtx2.lock();
}
void first(function printFirst) {
printFirst();
mtx1.unlock();
}
void second(function printSecond) {
mtx1.lock();
printSecond();
mtx1.unlock();
mtx2.unlock();
}
void third(function printThird) {
mtx2.lock();
printThird();
mtx2.unlock();
}
};


这段代码能够运行,但实际上这种使用 mutex 的方法是错误的,因为根据 c++ 标准,在一个线程尝试对一个 mutex 对象进行 unlock 操作时,mutex 对象的所有权必须在这个线程上。也就是说,应该由同一个线程来对一个 mutex 对象进行 lock 和 unlock 操作,否则会产生未定义行为。题目中提到了 first, second, third 三个函数分别是由三个不同的线程来调用的,但我们是在 Foo 对象构造时(可以是在 create 这几个线程的主线程中,也可以是在三个线程中的任意一个)对两个 mutex 对象进行 lock 操作的,因此,调用 first 和 second 函数的两个线程中至少有一个在尝试获取其他线程所拥有的 mutex 对象的所有权。

另外,如果非要讨论这个解法有什么优化的余地的话,因为 mutex 对象本身是不保护任何数据的,我们只是通过 mutex 的机制来保护数据被同时访问,所以最好使用 lock_guard 或者 unique_lock 提供的 RAII 机制来管理 mutex 对象,而不是直接操作 mutex 对象;其中 lock_guard 只拥有构造和析构函数,用来实现 RAII 机制,而 unique_lock 是一个完整的 mutex 所有权包装器,封装了所有 mutex 的函数。

lock_guard和unique_lock都是RAII机制下的锁,即依靠对象的创建和销毁也就是其生命周期来自动实现一些逻辑,而这两个对象就是在创建时自动加锁,在销毁时自动解锁。所以如果仅仅是依靠对象生命周期实现加解锁的话,两者是相同的,都可以用,因跟生命周期有关,所以有时会用花括号指定其生命周期。但lock_guard的功能仅限于此。unique_lock是对lock_guard的扩展,允许在生命周期内再调用lock和unlock来加解锁以切换锁的状态。


class Foo {
mutex mtx_1, mtx_2;
unique_lock lock_1, lock_2;
public:
Foo() : lock_1(mtx_1, try_to_lock), lock_2(mtx_2, try_to_lock) {
}
void first(function printFirst) {
printFirst();
lock_1.unlock();
}
void second(function printSecond) {
lock_guard guard(mtx_1);
printSecond();
lock_2.unlock();
}
void third(function printThird) {
lock_guard guard(mtx_2);
printThird();
}
};


2. 条件变量

条件变量一般和互斥锁搭配使用,互斥锁用于上锁,条件变量用于在多线程环境中等待特定事件发生。针对这道题我们可以分别在 first 和 second 执行完之后修改特定变量的值(例如修改成员变量 k 为特定值),然后通知条件变量,唤醒下一个函数继续执行。

  • std::condition_variable 是一种用来同时阻塞多个线程的同步原语(synchronization primitive)
  • std::condition_variable 必须和 std::unique_lock 搭配使用
class Foo {
condition_variable cv;
mutex mtx;
int k = 0;
public:
void first(function printFirst) {
printFirst();
lock_guard guard(mtx);
k = 1;
cv.notify_all(); // 通知其他所有在等待唤醒队列中的线程
}
void second(function printSecond)
{
unique_lock lock(mtx); // lock mtx
cv.wait(lock, [this](){ return k == 1; }); // unlock mtx,并阻塞等待唤醒通知,需要满足 k == 1 才能继续运行
printSecond();
k = 2;
cv.notify_one(); // 随机通知一个(unspecified)在等待唤醒队列中的线程
}
void third(function printThird) {
unique_lock lock(mtx); // lock mtx
cv.wait(lock, [this](){ return k == 2; }); // unlock mtx,并阻塞等待唤醒通知,需要满足 k == 2 才能继续运行
printThird();
}
};


std::condition_variable::wait 函数会执行三个操作:先将当前线程加入到等待唤醒队列,然后 unlock mutex 对象,最后阻塞当前线程。它有两种重载形式,第一种只接收一个 std::mutex 对象,此时线程一旦接受到唤醒信号(通过 std::condition_variable::notify_one 或 std::condition_variable::notify_all 进行唤醒),则无条件立即被唤醒,并重新 lock mutex;第二种重载形式还会接收一个条件(一般是 variable 或者 std::function),即只有当满足这个条件时,当前线程才能被唤醒。它在 gcc 中的实现也很简单,只是在第一种重载形式之外加了一个 while 循环来保证只有在满足给定条件后才被唤醒,否则重新调用 wait 函数:

template
void wait(unique_lock& __lock, _Predicate __p)
{
while (!__p())
wait(__lock);
}


3. 信号量

信号量是用来实现对共享资源的同步访问的机制,其使用方法和条件变量类似,都是通过主动等待和主动唤醒来实现的。

c++ 标准库中并没有信号量的实现和封装,我们可以用 c 语言提供的 库来解题 :

#include 
class Foo {
private:
sem_t sem_1, sem_2;
public:
Foo() {
sem_init(&sem_1, 0, 0), sem_init(&sem_2, 0, 0);
}
void first(function printFirst) {
printFirst();
sem_post(&sem_1);
}
void second(function printSecond) {
sem_wait(&sem_1);
printSecond();
sem_post(&sem_2);
}
void third(function printThird) {
sem_wait(&sem_2);
printThird();
}
};


4. 异步操作

异步操作是一种,在不需要等待被调用方返回结果之前,就让操作继续进行下去的方法。针对这道题可以使用基于 future/promise 的异步编程模型。

future 和 promise 起源于函数式编程,其目的是将值(future)和计算方式(promise)分离,使得 promise 可以异步地修改 future,从而提高代码的可读性,并减少通信延迟。std::future 是用来获取异步操作结果的模板类;std::packaged_task, std::promise, std::async 都可以进行异步操作,并拥有一个 std::future 对象,用来存储它们所进行的异步操作返回或设置的值(或异常),这个值会在将来的某一个时间点,通过某种机制被修改后,保存在其对应的 std::future 对象中:对于 std::promise,可以通过调用 std::promise::set_value 来设置值并通知 std::future 对象:

class Foo {
promise pro1, pro2;
public:
void first(function printFirst) {
printFirst();
pro1.set_value();
}
void second(function printSecond) {
pro1.get_future().wait();
printSecond();
pro2.set_value();
}
void third(function printThird) {
pro2.get_future().wait();
printThird();
}
};


std::future::wait 和 std::future::get 都会阻塞地等待拥有它的 promise 对象返回其所存储的值,后者还会获取 T 类型的对象;这道题只需要利用到异步通信的机制,所以并没有返回任何实际的值。

std::packaged_task 是一个拥有 std::future 对象的 functor,将一系列操作进行了封装,在运行结束之后会将返回值保存在其所拥有的 std::future 对象中;同样地,在这道题中只需要利用到其函数运行结束之后通知 std::future 对象的机制:


class Foo {
function task = []() {};
packaged_task pt_1{ task }, pt_2{ task };
public:
void first(function printFirst) {
printFirst();
pt_1();
}
void second(function printSecond) {
pt_1.get_future().wait();
printSecond();
pt_2();
}
void third(function printThird) {
pt_2.get_future().wait();
printThird();
}
};


5. 原子操作

我们平时进行的数据修改都是非原子操作,如果多个线程同时以非原子操作的方式修改同一个对象可能会发生数据争用,从而导致未定义行为;而原子操作能够保证多个线程顺序访问,不会导致数据争用,其执行时没有任何其它线程能够修改相同的原子对象。

针对这道题,我们可以让 second 和 third 函数等待原子变量被修改为某个值后再执行,然后分别在 first 和 second 函数中来修改这个原子变量。

c++ 11 提供了 std::atomic 模板类来构造原子对象:

class Foo {
public:
atomic last_func = 0;
Foo() {
}
void first(function printFirst) {
while(last_func.load(memory_order_acquire) != 0) {
this_thread::sleep_for(1ms);
}
// printFirst() outputs "first". Do not change or remove this line.
printFirst();
last_func.store(1, memory_order_release);
}
void second(function printSecond) {
while(last_func.load(memory_order_acquire) != 1) {
this_thread::sleep_for(1ms);
}
// printSecond() outputs "second". Do not change or remove this line.
printSecond();
last_func.store(2, memory_order_release);
}
void third(function printThird) {
while(last_func.load(memory_order_acquire) != 2) {
this_thread::sleep_for(1ms);
}
// printThird() outputs "third". Do not change or remove this line.
printThird();
}
};

值得注意的是,原子操作的实现跟处理器和操作系统内核相关,因此 c++ 标准并没有规定 atomic 的实现是否是无锁的(lock-free),只规定了需要提供一个 is_lock_free() 来查询当前编译器对 atomic 的实现是否是无锁的。

发表评论
留言与评论(共有 0 条评论) “”
   
验证码:

相关文章

推荐文章