开启掘金成长之旅!这是我参与「掘金日新计划 · 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 不能读取数据
死锁
产生死锁的四个条件
-
互斥条件:资源是独占的且排他使用,进程互斥使用资源,即任意时刻一个资源只能给一个进程使用,其他进程若申请一个资源,而该资源被另一进程占有时,则申请者等待直到资源被占有者释放。
-
不可剥夺条件:进程所获得的资源在未使用完毕之前,不被其他进程强行剥夺,而只能由获得该资源的进程资源释放。
-
请求和保持条件:进程每次申请它所需要的一部分资源,在申请新的资源的同时,继续保持占用已分配到的资源。
-
循环等待条件:在发生死锁时必然存在一个进程等待队列{P1,P2,…,Pn},其中P1等待P2占有的资源,P2等待P3占有的资源,…,Pn等待P1占有的资源,形成一个进程等待环路,环路中每一个进程所占有的资源同时被另一个申请,也就是前一个进程占有后一个进程所深情地资源。
以上条件满足一个即可构成死锁
避免死锁的几个建议
-
避免锁的嵌套
-
使用固定顺序获取锁
假设有一个资源需要 2 个及以上的锁保护互斥量的时候,按照一定的顺序去获取锁,例如:变量 x 需要互斥量 A, B, C 保护,那么可以按照先获取锁 A,再获取锁 B,最后获取 C 的顺序去获取锁。
-
超时放弃:当线程获取了一个锁的时候再获取另一个锁,如果在一定时间后还不能获得则放弃并释放已经获得的锁。
-
避免在持有锁时调用用户提供的代码
假设你是一个伟大的工程师,你现在需要写一个动态库提供给别人使用,你的库中有一个类中包含一个互斥量,当你的类在已经获取锁的情况下去调用用户的代码,而用户的代码中也需要去申请一个锁,此时就会造成死锁。
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 中,我们所理解的实现步骤应该 是下面这三步:
-
申请一块内存。
-
执行构造函数对其进行初始化。
-
将地址赋 值给 ptr。
但是实际上编译器对指令进行重新排序之后的实现步骤是这样的:
-
申请一块内存。
-
将地址赋值给 ptr。
-
执行构造函数对其进行初始化。由于线 程的切换是按指令切换的,也就是说在执行这一个指令之前或者之后切换到另一 个线程,那么当线程 A 在执行完前两步(1.申请一块内存。2.将地址赋值给 ptr) 的时候,此时 ptr 已经有了值,那么此时切换到线程 B 的时候,判断 ptr 不为 NULL,然后将其返回,那么线程 B 所得到的 ptr 是一个还没有进行初始化的一个对象,会带来安全隐患
解决方案
-
使用数据栅栏,数据栅栏可以保证程序执行的顺序
-
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();
}