C++ 线程互斥量 【掘金日新计划】

106 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

C++ 线程互斥量

多线程为什么要加锁

看下面这段代码,两个线程每一个线程对 a 加 10000,那么 a 最后的输出应该是 20000,但实际并不是


#include <iostream>

#include <thread>

#include <mutex>

#include <unistd.h>

std::mutex mtx;
int a = 0;

void func1()
{
  for (int i = 0; i < 10000; ++i)
  {
    a ++;
  }
}

void func2()
{
  for (int i = 0; i < 10000; ++i)
  {
    a ++;
  }
}

int main()
{
  std::thread t1(func1);
  std::thread t2(func2);
  t1.join();
  t2.join();
  std::cout << a << std::endl;
}

在上面的这段代码中会发现 打印出来的 a 并不等于20000

对上述的代码进行汇编: g++ -S main.cpp -lpthread -o main.s    

可以得到 func1 函数中的部分汇编代码如下:

  movl  a(%rip), %eax   # 1. 把 a 的 值放到 eax 寄存器中
  movl  a(%rip), %eax   # 2. eax寄存器的值加上 1
  movl  %eax, a(%rip)   # 3. eax寄存器的值放回到 a 中

理想的情况下应该是线程1 执行完 123, 线程2 再执行 123

| t1 线程             | t2 线程              |
| ------------------  | ------------------- |
| movl  a(%rip), %eax |                     |
| addl  $1, %eax      |                     |
| movl  %eax, a(%rip) |                     |
|                     | movl  a(%rip), %eax |
|                     | movl  a(%rip), %eax |
|                     | movl  %eax, a(%rip) |
但实际上并不是如此,实际上线程 1 和线程 2 是交替运行的,如下:

| t1 线程             | t2 线程              |
| ------------------  | ------------------- |
| movl  a(%rip), %eax |                     |
| addl  $1, %eax      |                     |
|                     | movl  a(%rip), %eax |
|                     | movl  a(%rip), %eax |
|                     | movl  %eax, a(%rip) |
|  movl %eax, a(%rip) |                     |

当线程 1 中 eax 寄存器加 1(此时 a = 0),轮到线程 2 开始运行, 线程 2 把 a(a = 0)的值移动到 eax 寄存器中,此时 eax=1 被覆盖。最后结果可能会出现 1 中 a = 1, 线程 2 中 a = 1; 因此需要通过互斥量来保证当线程操作共享变量的时候 不会被其他线程所打断

std::mutex


#include <iostream>
#include <thread>
#include <mutex>
#include <unistd.h>

std::mutex mtx;
int a = 0;
  
void func1()
{
  for (int i = 0; i < 10000; ++i)
  {
    mtx.lock();
    a ++;
    mtx.unlock();
  }
}

void func2()
{
  for (int i = 0; i < 10000; ++i)
  {
    mtx.lock();
    a ++;
    mtx.unlock();
  }
}

int main()
{
  std::thread t1(func1);
  std::thread t2(func2);
  t1.join();
  t2.join();
  std::cout << a << std::endl;
}

通过互斥量 std:: mutex 来保证当线程操作共享变量的时候 不会被其他线程所打断

std::lock_guard

在使用 std::mutex 的时候可能会忘记释放锁,其他线程一直无法获取锁,则其他线程的程序可能会无法执行 使用示例

std::lock_guard<std::mutex> lock(some_mutex);
std::lock_guard<std::mutex> lock(some_mutex, std::adopt_lock); // 避免 lock_guard 对 some_mutex 再次上锁

std::unique_lock

    自动加锁解锁,可以手动解锁,手动加锁
使用实例:

std::unique_lock<std::mutex> lock(some_mutex)
std::unique_lock<std::mutex> lock(some_mutex, std::adopt_lock); // 表示 some_mutex 已经上锁,并不是 some_mutex 实际上锁的状态
std::unique_lock<std::mutex> lock(some_mutex, std::defer_lock); // 表示 some_mutex 没有上锁,并不是 some_mutex 实际未上锁的状态

相比于 std::lock_guard , std::unique_lock 虽然更灵活,但是会占用更多的内存空间

std::adopt_lock, std::defer_lock, std::try_to_lock

  • std::adopt_lock: 假设对应的互斥量已经上锁了,避免再次上锁

  • std::defer_lock: 表示对应的互斥量应保持解锁状态,如果这个锁实际上了锁的,那么在对应的 unique_lock 将不会自动解锁

  • std::try_to_lock: 表示尝试为对应的互斥量上锁,并根据尝试上锁的状态来定义 unique_lock 是否上锁的状态

C++17 读写锁

C++17开始支持读写锁 std::shared_mutex

  • 加读锁:std::shared_lock lk(some_shared_mutex)

  • 加写锁:std::unique_lock lk(some_shared_mutex)

代码示例:


#include <iostream>
#include <thread>
#include <mutex>
#include <shared_mutex>
#include <unistd.h>

int num = 0;
std::shared_mutex mtx;

void read1()
{
  while (true)
  {
    std::shared_lock lk(mtx);
    std::cout << "read1: " << num << std::endl;
    sleep(2);
  }
}

void read2()
{
  while (true)
  {
    std::shared_lock lk(mtx);
    std::cout << "read2: " << num << std::endl;
    sleep(2);
  }
}

void write1()
{
  while (true)
  {
    sleep(5);
    std::unique_lock lk(mtx);
    std::cout << "------------------ write: " << ++num << std::endl;
    sleep(2);
  }
}  

int main()
{
  std::thread t1(read1);
  std::thread t2(read2);
  std::thread t3(write1);
  t1.join();
  t2.join();
  t3.join();
}
  • 编译运行:g++ -std=c++17 main.cpp -lpthread

在上述代码中, read1,read2 函数使用读锁可以同时读数据,当 write1 函数开始写入数据的时候,read1,read2 不能读取数据

死锁

产生死锁的四个条件  

  1. 互斥条件:资源是独占的且排他使用,进程互斥使用资源,即任意时刻一个资源只能给一个进程使用,其他进程若申请一个资源,而该资源被另一进程占有时,则申请者等待直到资源被占有者释放。      

  2. 不可剥夺条件:进程所获得的资源在未使用完毕之前,不被其他进程强行剥夺,而只能由获得该资源的进程资源释放。      

  3. 请求和保持条件:进程每次申请它所需要的一部分资源,在申请新的资源的同时,继续保持占用已分配到的资源。  

  4. 循环等待条件:在发生死锁时必然存在一个进程等待队列{P1,P2,…,Pn},其中P1等待P2占有的资源,P2等待P3占有的资源,…,Pn等待P1占有的资源,形成一个进程等待环路,环路中每一个进程所占有的资源同时被另一个申请,也就是前一个进程占有后一个进程所深情地资源。    

以上条件满足一个即可构成死锁        

避免死锁的几个建议

  1. 避免锁的嵌套

  2. 使用固定顺序获取锁

    假设有一个资源需要 2 个及以上的锁保护互斥量的时候,按照一定的顺序去获取锁,例如:变量 x 需要互斥量 A, B, C 保护,那么可以按照先获取锁 A,再获取锁 B,最后获取 C 的顺序去获取锁。

  1. 超时放弃:当线程获取了一个锁的时候再获取另一个锁,如果在一定时间后还不能获得则放弃并释放已经获得的锁。

  2. 避免在持有锁时调用用户提供的代码

    假设你是一个伟大的工程师,你现在需要写一个动态库提供给别人使用,你的库中有一个类中包含一个互斥量,当你的类在已经获取锁的情况下去调用用户的代码,而用户的代码中也需要去申请一个锁,此时就会造成死锁。


namespace YourLib   // 自己创建的库

{
    std::mutex mtx;
class Lib
{
public:
    typedef void (*func_pointer)(void); // 定义函数指针
    void lib_func(func_pointer p)
    {
        mtx.lock();
        std::cout << "lib_func" << std::endl;
        p();
        mtx.unlock();
    }
};
}

void user_function()
{
    YourLib::mtx.lock();
    std::cout << "user_function" << std::endl;
    YourLib::mtx.unlock();
}

int main()
{
    YourLib::Lib lib;
    // lib.lib_func(user_function);  // 以函数的形式运行而不以线程的方式运行互斥锁并不起作用
    std::thread t(&YourLib::Lib::lib_func, lib, user_function);
    t.join();
}

Tips

双重检查锁

    线程函数在使用 resource 前都需要检查 resource 是否被实例化,为了防止 resource 被多次实例化,对其上锁通常锁的调度过程较慢(可以通过 不能交替打印代码 测试下)    


// 饿汉单例模式:

class Resource* resource_ptr;
std::mutex resource_mutex;

void foo()  
{
  // 所有的线程在使用 resource_ptr 的时候都需要抢夺锁,耗费大量时间
  std::unique_lock<std::mutex> lk(resource_mutex);  // 所有线程在此序列化
  if(!resource_ptr)
  {
    resource_ptr = new Resource;  // 只有初始化过程需要保护
  }
  lk.unlock();
  resource_ptr->do_something();
}

使用双重检查锁优化

void undefined_behaviour_with_double_checked_locking()
{
  if(!resource_ptr)  // 1
  {
    std::lock_guard<std::mutex> lk(resource_mutex);
    if(!resource_ptr)  // 2     防止当线程调度的时候 resource_ptr 被赋值
    {
      resource_ptr = new Resourcce;  // 3
    }
  }
  resource_ptr->do_something();  // 4
}

在上述代码 1 中,可以避免不必要的加锁,优化程序执行的时间,但是在实际使用的过程种可能会带来错误。  

在代码 3 中,我们所理解的实现步骤应该 是下面这三步:

  1. 申请一块内存。

  2. 执行构造函数对其进行初始化。

  3. 将地址赋 值给 ptr。

但是实际上编译器对指令进行重新排序之后的实现步骤是这样的:

  1. 申请一块内存。

  2. 将地址赋值给 ptr。

  3. 执行构造函数对其进行初始化。由于线 程的切换是按指令切换的,也就是说在执行这一个指令之前或者之后切换到另一 个线程,那么当线程 A 在执行完前两步(1.申请一块内存。2.将地址赋值给 ptr) 的时候,此时 ptr 已经有了值,那么此时切换到线程 B 的时候,判断 ptr 不为 NULL,然后将其返回,那么线程 B 所得到的 ptr 是一个还没有进行初始化的一个对象,会带来安全隐患    

reference

解决方案

  1. 使用数据栅栏,数据栅栏可以保证程序执行的顺序  

  2. std::once_flag, std::call_once()保证要执行的函数只会被执行一次


std::shared_ptr<some_resource> resource_ptr;

std::once_flag resource_flag;  // 1

void init_resource()
{
  resource_ptr.reset(new some_resource);
}

void foo()
{
  std::call_once(resource_flag,init_resource);  // 可以完整的进行一次初始化
  resource_ptr->do_something();
}

reference