C++ 线程安全下Lock 类的两种使用方式

“不定义,做一个保持好奇心的普通人”

 

꿈을 이루게 될 거예요.

2018.12.19

快三年了:

 

Mutex 又称互斥量,C++ 11中与 Mutex 相关的类(包括锁类型)和函数都声明在 头文件中,所以如果你需要使用 std::mutex,就必须包含 头文件。

头文件介绍

Mutex 系列类(四种)

  • std::mutex,最基本的 Mutex 类。
  • std::recursive_mutex,递归 Mutex 类。
  • std::time_mutex,定时 Mutex 类。
  • std::recursive_timed_mutex,定时递归 Mutex 类。

Lock 类(两种)

  • std::lock_guard,与 Mutex RAII 相关,方便线程对互斥量上锁。
  • std::unique_lock,与 Mutex RAII 相关,方便线程对互斥量上锁,但提供了更好的上锁和解锁控制。

其他类型

  • std::once_flag
  • std::adopt_lock_t
  • std::defer_lock_t
  • std::try_to_lock_t

函数

  • std::try_lock,尝试同时对多个互斥量上锁。
  • std::lock,可以同时对多个互斥量上锁。
  • std::call_once,如果多个线程需要同时调用某个函数,call_once 可以保证多个线程对该函数只调用一次。

std::mutex

下面以 std::mutex 为例介绍 C++11 中的互斥量用法。

std::mutex 是C++11 中最基本的互斥量,std::mutex 对象提供了独占所有权的特性——即不支持递归地对 std::mutex 对象上锁,而 std::recursive_lock 则可以递归地对互斥量对象上锁。

std::mutex 的成员函数

  • 构造函数,std::mutex不允许拷贝构造,也不允许 move 拷贝,最初产生的 mutex 对象是处于 unlocked 状态的。
  • lock(),调用线程将锁住该互斥量。线程调用该函数会发生下面 3 种情况:(1). 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁。(2). 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住。(3). 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
  • unlock(), 解锁,释放对互斥量的所有权。
  • try_lock(),尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞。线程调用该函数也会出现下面 3 种情况,(1). 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量。(2). 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉。(3). 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。

 

接下来

重点说一下lock_guard 和 unique_lock

 

※※std::lock_guard

std::lock_gurad 是 C++11 中定义的模板类。定义如下:
template class lock_guard;

注意:无论是std::mutex还是std::lock_gurad、std::unique_lock 都是类,需要创建自己的对象使用!!!

lock_guard 对象呢通常是用来管理一个 std::mutex 类型的对象,即通过定义一个 lock_guard 一个对象来管理 std::mutex 的上锁和解锁。在 lock_guard 初始化的时候进行上锁,然后在 lock_guard 析构的时候进行解锁。值得注意的是,lock_guard 对象并不负责管理 std::mutex 对象的生命周期,lock_guard 对象只是简化了 mutex 对象的上锁和解锁操作,方便线程对互斥量上锁,即在某个 lock_guard 对象的声明周期内,它所管理的锁对象会一直保持上锁状态;而 lock_guard 的生命周期结束之后,它所管理的锁对象会被解锁(注:类似 shared_ptr 等智能指针管理动态分配的内存资源 ),也就是说在使用 lock_guard 的过程中,如果 std::mutex 的对象被释放了,那么在 lock_guard 析构的时候进行解锁就会出现空指针错误之类。

在 lock_guard 对象构造时,传入的 Mutex 对象(即它所管理的 Mutex 对象)会被当前线程锁住。在lock_guard 对象被析构时,它所管理的 Mutex 对象会自动解锁,由于不需要程序员手动调用 lock 和 unlock 对 Mutex 进行上锁和解锁操作,因此这也是最简单安全的上锁和解锁方式,尤其是在程序抛出异常后先前已被上锁的 Mutex 对象可以正确进行解锁操作,极大地简化了程序员编写与 Mutex 相关的异常处理代码。

例:

//
// Created by zxkj on 2018.12.19
//
 
#include 
#include 
#include 
#include 
 
std::mutex mtx;
using std::cout;
using std::endl;
 
void print_event(int x)
{
  if (x % 2 == 0)
    {
      cout << x << "is event" << endl;
    }
  else
    {
      throw(std::logic_error("not event"));
    }
}
 
void print_id(int id)
{
  try
    {
      std::lock_guard lck(mtx);
      print_event (id);
    }
  catch(std::logic_error&)
    {
      cout << "[exception caught]\n";
    }
 
}
 
int main()
{
  std::thread threads[10];
  for (int i = 0; i < 10; ++i)
    {
      threads[i] = std::thread(print_id, i+1);
    }
 
  for (auto &th : threads)
    {
      th.join ();
    }
 
 
  return 0;
}


 ※※std::unique_lock

unique_lock 和 lock_guard 一样,对 std::mutex 类型的互斥量的上锁和解锁进行管理,一样也不管理 std::mutex 类型的互斥量的声明周期。但是它的使用更加的灵活。std::unique_lock 的构造函数的数目相对来说比 std::lock_guard 多,其中一方面也是因为 std::unique_lock 更加灵活,从而在构造 std::unique_lock 对象时可以接受额外的参数。总地来说,std::unique_lock 构造函数如下:

(1) 默认构造函数
    新创建的 unique_lock 对象不管理任何 Mutex 对象。
(2) locking 初始化
    新创建的 unique_lock 对象管理 Mutex 对象 m,并尝试调用 m.lock() 对 Mutex 对象进行上锁,如果此时另外某个 unique_lock 对象已经管理了该 Mutex 对象 m,则当前线程将会被阻塞。
(3) try-locking 初始化
    新创建的 unique_lock 对象管理 Mutex 对象 m,并尝试调用 m.try_lock() 对 Mutex 对象进行上锁,但如果上锁不成功,并不会阻塞当前线程。
(4) deferred 初始化
    新创建的 unique_lock 对象管理 Mutex 对象 m,但是在初始化的时候并不锁住 Mutex 对象。 m 应该是一个没有当前线程锁住的 Mutex 对象。
(5) adopting 初始化
    新创建的 unique_lock 对象管理 Mutex 对象 m, m 应该是一个已经被当前线程锁住的 Mutex 对象。(并且当前新创建的 unique_lock 对象拥有对锁(Lock)的所有权)。
(6) locking 一段时间(duration)
    新创建的 unique_lock 对象管理 Mutex 对象 m,并试图通过调用 m.try_lock_for(rel_time) 来锁住 Mutex 对象一段时间(rel_time)。
(7) locking 直到某个时间点(time point)
    新创建的 unique_lock 对象管理 Mutex 对象m,并试图通过调用 m.try_lock_until(abs_time) 来在某个时间点(abs_time)之前锁住 Mutex 对象。
(8) 拷贝构造 [被禁用]
    unique_lock 对象不能被拷贝构造。
(9) 移动(move)构造
    新创建的 unique_lock 对象获得了由 x 所管理的 Mutex 对象的所有权(包括当前 Mutex 的状态)。调用 move 构造之后, x 对象如同通过默认构造函数所创建的,就不再管理任何 Mutex 对象了。

#include 
#include 
#include 
std::mutex foo,bar;
void task_a() {
	std::lock(foo, bar);//foo和bar已被当前线程锁住
	/*******************************************************
	*adopting 初始化:
	*adopt_lock 是一个常量对象,通常作为参数传入给unique_lock 或 
	*lock_guard 的构造函数。新创建的 unique_lock 对象管理 Mutex 
	*对象 m, m 应该是一个已经被当前线程锁住的 Mutex 对象。
	*******************************************************/
	std::unique_lock lck1(foo, std::adopt_lock);
	std::unique_lock lck2(bar, std::adopt_lock);
	std::cout << "task a\n";
}
void task_b() {
	//新创建的 unique_lock 对象不管理任何 Mutex 对象。
	std::unique_lock lck1, lck2;
	/******************************************************
	* deferred 初始化:
	*新创建的 unique_lock 对象管理 Mutex 对象 m,但是在初始化
	*的时候并不锁住 Mutex 对象。 m 应该是一个没有当前线程锁住的 
	*Mutex 对象。
	******************************************************/
	lck1 = std::unique_lock(bar, std::defer_lock);
	lck2 = std::unique_lock(foo, std::defer_lock);
	std::lock(lck1, lck2);
	std::cout << "task b\n";
}
int main() {
	std::thread th1(task_a);
	std::thread th2(task_b);
	th1.join();
	th2.join();
	system("pause");
	return EXIT_SUCCESS;
}

 

总结:

    1. unique_lock比lock_guard使用更加灵活,功能更加强大。使用unique_lock需要付出更多的时间、性能成本。std::unique_lock也可以提供自动加锁、解锁功能

    2. std::lock_guard 在构造函数中进行加锁,析构函数中进行解锁;是RAII模板类的简单实现,功能简单。

    3.大部分情况下,两者的功能是一样的,不过unique_lock 比lock_guard 更灵活.unique_lock提供了lock, unlock, try_lock等接口.
lock_guard没有多余的接口,构造函数时拿到锁,析构函数时释放锁,lock_guard 比unique_lock 要省时.

   4. lock_guard 同一时间锁住两个mutex, 再创建guards用来管理锁的释放工作;

       unique_lock  先创建guards, 再同时锁住两个锁。

 

 ※※std::condition_variable

是C++标准程序库中的一个头文件,定义了C++11标准中的一些用于并发编程时表示条件变量的类与方法等。

互斥锁std::mutex是一种最常见的线程间同步的手段,但是在有些情况下不太高效。

假设想实现一个简单的消费者生产者模型,一个线程往队列中放入数据,一个线程往队列中取数据,取数据前需要判断一下队列中确实有数据,由于这个队列是线程间共享的,所以,需要使用互斥锁进行保护,一个线程在往队列添加数据的时候,另一个线程不能取,反之亦然。用互斥锁实现如下:

#include 
#include 
#include 
#include 

std::deque q;
std::mutex mu;

void function_1() {
    int count = 10;
    while (count > 0) {
        std::unique_lock locker(mu);
        q.push_front(count);
        locker.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
        count--;
    }
}

void function_2() {
    int data = 0;
    while ( data != 1) {
        std::unique_lock locker(mu);
        if (!q.empty()) {
            data = q.back();
            q.pop_back();
            locker.unlock();
            std::cout << "t2 got a value from t1: " << data << std::endl;
        } else {
            locker.unlock();
        }
    }
}
int main() {
    std::thread t1(function_1);
    std::thread t2(function_2);
    t1.join();
    t2.join();
    return 0;
}

//输出结果
//t2 got a value from t1: 10
//t2 got a value from t1: 9
//t2 got a value from t1: 8
//t2 got a value from t1: 7
//t2 got a value from t1: 6
//t2 got a value from t1: 5
//t2 got a value from t1: 4
//t2 got a value from t1: 3
//t2 got a value from t1: 2
//t2 got a value from t1: 1

可以看到,互斥锁其实可以完成这个任务,但是却存在着性能问题。

首先,function_1函数是生产者,在生产过程中,std::this_thread::sleep_for(std::chrono::seconds(1));表示延时1s,所以这个生产的过程是很慢的;function_2函数是消费者,存在着一个while循环,只有在接收到表示结束的数据的时候,才会停止,每次循环内部,都是先加锁,判断队列不空,然后就取出一个数,最后解锁。所以说,在1s内,做了很多无用功!这样的话,CPU占用率会很高,可能达到100%(单核)。

这就引出了条件变量(condition variable),c++11中提供了#include 头文件,其中的std::condition_variable可以和std::mutex结合一起使用,其中有两个重要的接口,notify_one()wait()wait()可以让线程陷入休眠状态,在消费者生产者模型中,如果生产者发现队列中没有东西,就可以让自己休眠,但是不能一直不干活啊,notify_one()就是唤醒处于wait中的其中一个条件变量(可能当时有很多条件变量都处于wait状态)。那什么时刻使用notify_one()比较好呢,当然是在生产者往队列中放数据的时候了,队列中有数据,就可以赶紧叫醒等待中的线程起来干活了。

使用条件变量修改后如下:

#include 
#include 
#include 
#include 
#include 

std::deque q;
std::mutex mu;
std::condition_variable cond;

void function_1() {
    int count = 10;
    while (count > 0) {
        std::unique_lock locker(mu);
        q.push_front(count);
        locker.unlock();
        cond.notify_one();  // Notify one waiting thread, if there is one.
        std::this_thread::sleep_for(std::chrono::seconds(1));
        count--;
    }
}

void function_2() {
    int data = 0;
    while ( data != 1) {
        std::unique_lock locker(mu);
        while(q.empty())
            cond.wait(locker); // Unlock mu and wait to be notified
        data = q.back();
        q.pop_back();
        locker.unlock();
        std::cout << "t2 got a value from t1: " << data << std::endl;
    }
}
int main() {
    std::thread t1(function_1);
    std::thread t2(function_2);
    t1.join();
    t2.join();
    return 0;
}

上面的代码有三个注意事项:

  1. function_2中,在判断队列是否为空的时候,使用的是while(q.empty()),而不是if(q.empty()),这是因为wait()从阻塞到返回,不一定就是由于notify_one()函数造成的,还有可能由于系统的不确定原因唤醒(可能和条件变量的实现机制有关),这个的时机和频率都是不确定的,被称作伪唤醒,如果在错误的时候被唤醒了,执行后面的语句就会错误,所以需要再次判断队列是否为空,如果还是为空,就继续wait()阻塞。
  2. 在管理互斥锁的时候,使用的是std::unique_lock而不是std::lock_guard,而且事实上也不能使用std::lock_guard,这需要先解释下wait()函数所做的事情。可以看到,在wait()函数之前,使用互斥锁保护了,如果wait的时候什么都没做,岂不是一直持有互斥锁?那生产者也会一直卡住,不能够将数据放入队列中了。所以,wait()函数会先调用互斥锁的unlock()函数,然后再将自己睡眠,在被唤醒后,又会继续持有锁,保护后面的队列操作。lock_guard没有lockunlock接口,而unique_lock提供了。这就是必须使用unique_lock的原因。
  3. 使用细粒度锁,尽量减小锁的范围,在notify_one()的时候,不需要处于互斥锁的保护范围内,所以在唤醒条件变量之前可以将锁unlock()

还可以将cond.wait(locker);换一种写法,wait()的第二个参数可以传入一个函数表示检查条件,这里使用lambda函数最为简单,如果这个函数返回的是truewait()函数不会阻塞会直接返回,如果这个函数返回的是falsewait()函数就会阻塞着等待唤醒,如果被伪唤醒,会继续判断函数返回值。

void function_2() {
    int data = 0;
    while ( data != 1) {
        std::unique_lock locker(mu);
        cond.wait(locker, [](){ return !q.empty();} );  // Unlock mu and wait to be notified
        data = q.back();
        q.pop_back();
        locker.unlock();
        std::cout << "t2 got a value from t1: " << data << std::endl;
    }
}

 除了notify_one()函数,c++还提供了notify_all()函数,可以同时唤醒所有处于wait状态的条件变量。

 

※※提供一个线程安全的堆栈实例(.hpp):

#ifndef THRAEDSAFE_STACK_HPP
#define THRAEDSAFE_STACK_HPP

#include 
#include 
#include 
#include 

template
class threadsafe_stack
{
private:
    mutable std::mutex mut;
    std::stack data_stack;
    std::condition_variable data_cond;
public:
    threadsafe_stack()
    {}
    threadsafe_stack(threadsafe_stack const& other)
    {
        std::lock_guard lk(other.mut);
        data_stack=other.data_stack;
    }

    void push(T new_value)
    {
        std::lock_guard lk(mut);
        data_stack.push(new_value);
        data_cond.notify_one();
    }

    void wait_and_pop(T& value)
    {
        std::unique_lock lk(mut);
        data_cond.wait(lk,[this]{return !data_stack.empty();});
        value=data_stack.top();
        data_queue.pop();
    }

    std::shared_ptr wait_and_pop()
    {
        std::unique_lock lk(mut);
        data_cond.wait(lk,[this]{return !data_stack.empty();});
        std::shared_ptr res(std::make_shared(data_stack.top()));
        data_queue.pop();
        return res;
    }

    bool try_pop(T& value)
    {
        std::lock_guard lk(mut);
        if(data_stack.empty())
            return false;
        value=data_stack.top();
        data_stack.pop();
        return true;
    }

    std::shared_ptr try_pop()
    {
        std::lock_guard lk(mut);
        if(data_stack.empty())
            return std::shared_ptr();
        std::shared_ptr res(std::make_shared(data_stack.top()));
        data_stack.pop();
        return res;
    }

    bool empty() const
    {
        std::lock_guard lk(mut);
        return data_stack.empty();
    }
};

#endif

 

ok~

未来再见!

你可能感兴趣的:(C++ 线程安全下Lock 类的两种使用方式)