线程学习五:线程加锁(1)
1.锁:mutex(互斥量)
锁,是生活中应用十分广泛的一种工具。锁的本质属性是为事物提供“访问保护”,例如:大门上的锁,是为了保护房子免于不速之客的到访;
自行车的锁,是为了保护自行车只有owner才可以使用;保险柜上的锁,是为了保护里面的合同和金钱等重要东西……
在c++等高级编程语言中,锁也是用来提供“访问保护”的,不过被保护的东西不再是房子、自行车、金钱,而是内存中的各种变量。
此外,计算机领域对于“锁”有个响亮的名字——mutex(互斥量),学过操作系统的同学对这个名字肯定很熟悉。
Mutex,互斥量,就是互斥访问的量。这种东东只在多线程编程中起作用,在单线程程序中是没有什么用处的。
从c++11开始,c++提供了std::mutex类型,对于多线程的加锁操作提供了很好的支持。下面看一个简单的例子,对于mutex形成一个直观的认识。
Demo1——无锁的情况
// 假定有一个全局变量counter,启动两个线程,每个都对该变量自增10000次,最后输出该变量的值。在第一个demo中,我们不加锁
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>
int counter = 0;
void increase(int time) {
for (int i = 0; i < time; i++) {
// 当前线程休眠1毫秒
std::this_thread::sleep_for(std::chrono::milliseconds(1));
counter++;
}
}
int main(int argc, char** argv) {
std::thread t1(increase, 10000);
std::thread t2(increase, 10000);
t1.join();
t2.join();
std::cout << "counter:" << counter << std::endl;
return 0;
}
// 编译方法: g++ -g test.cpp -o test -std=c++11 -lpthread
PS: 为了显示多线程竞争导致结果不正确的现象,在每次自增操作的时候都让当前线程休眠1毫秒
如果没有多线程编程的相关经验,我们可能想当然的认为最后的counter为20000,如果这样想的话,那就大错特错了。下面是两次实际运行的结果:
出现上述情况的原因是:
自增操作"counter++"不是原子操作,而是由多条汇编指令完成的。多个线程对同一个变量进行读写操作就会出现不可预期的操作。
以上面的demo1作为例子:假定counter当前值为10,线程1读取到了10,线程2也读取到了10, 分别执行自增操作,线程1和线程2分别将自增的结果写回counter,不管写入的顺序如何,counter都会是11,
但是线程1和线程2分别执行了一次自增操作,我们期望的结果是12!!!!
Demo2——加锁的情况
// 定义一个std::mutex对象用于保护counter变量。对于任意一个线程,如果想访问counter,首先要进行"加锁"操作,如果加锁成功,则进行counter的读写,读写操作完成后释放锁(重要!!!); 如果“加锁”不成功,则线程阻塞,直到加锁成功。
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>
int counter = 0;
std::mutex mtx; // 保护counter
void increase(int time) {
for (int i = 0; i < time; i++) {
mtx.lock();
// 当前线程休眠1毫秒
std::this_thread::sleep_for(std::chrono::milliseconds(1));
counter++;
mtx.unlock();
}
}
int main(int argc, char** argv) {
std::thread t1(increase, 10000);
std::thread t2(increase, 10000);
t1.join();
t2.join();
std::cout << "counter:" << counter << std::endl;
return 0;
}
看几次运行结果:
这次运行结果和我们预想的一致,原因就是“利用锁来保护共享变量”,在这里共享变量就是counter(多个线程都能对其进行访问,所以就是共享变量啦)。
1.1 mutex类总结
定义于头文件 <mutex>
class mutex; (C++11 起)
mutex 类是能用于保护共享数据免受从多个线程同时访问的同步原语。
mutex 提供排他性非递归所有权语义:
-
调用方线程从它成功调用 lock 或 try_lock 开始,到它调用 unlock 为止占有 mutex 。
-
线程占有 mutex 时,所有其他线程若试图要求 mutex 的所有权,则将阻塞(对于 lock 的调用)或收到 false 返回值(对于 try_lock ).
-
调用方线程在调用 lock 或 try_lock 前必须不占有 mutex 。
若 mutex 在仍为任何线程所占有时即被销毁,或在占有 mutex 时线程终止,则行为未定义。 mutex 类满足互斥体 (Mutex) 和标准布局类型 (StandardLayoutType) 的全部要求。
std::mutex 既不可复制亦不可移动。
其他类型的mutex参考https://zh.cppreference.com/w/cpp/thread
mutex类的成员介绍
构造函数: 构造互斥(公开成员函数)
析构函数: 销毁互斥(公开成员函数)
operator=[被删除]: 不可复制赋值(公开成员函数)
/*************** 锁定 **********************/
lock: 锁定互斥,若互斥不可用则阻塞(公开成员函数)
try_lock: 尝试锁定互斥,若互斥不可用则返回(公开成员函数)
unlock: 解锁互斥(公开成员函数)
/*************** 原生句柄 **********************/
native_handle: 返回底层实现定义的原生句柄(公开成员函数)
注意
通常不直接使用 std::mutex, std::unique_lock 、 std::lock_guard 或 std::scoped_lock (C++17 起)以更加异常安全的方式管理锁定。
2. lock_guard
虽然std::mutex可以对多线程编程中的共享变量提供保护,但是直接使用std::mutex的情况并不多。因为仅使用std::mutex有时候会发生死锁。
回到上边的例子,考虑这样一个情况:假设线程1上锁成功,线程2上锁等待。但是线程1上锁成功后,抛出异常并退出,没有来得及释放锁,导致线程2“永久的等待下去”(线程2:我的心在等待永远在等待……),此时就发生了死锁。
Demo3——死锁的情况(仅仅为了演示,不要这么写代码哦)
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>
int counter = 0;
std::mutex mtx; // 保护counter
void increase_proxy(int time, int id) {
for (int i = 0; i < time; i++) {
mtx.lock();
// 线程1上锁成功后,抛出异常:未释放锁
if (id == 1) {
throw std::runtime_error("throw excption....");
}
// 当前线程休眠1毫秒
std::this_thread::sleep_for(std::chrono::milliseconds(1));
counter++;
mtx.unlock();
}
}
void increase(int time, int id) {
try {
increase_proxy(time, id);
}
catch (const std::exception& e){
std::cout << "id:" << id << ", " << e.what() << std::endl;
}
}
int main(int argc, char** argv) {
std::thread t1(increase, 10000, 1);
std::thread t2(increase, 10000, 2);
t1.join();
t2.join();
std::cout << "counter:" << counter << std::endl;
return 0;
}
执行后,结果如下图所示:
程序并没有退出,而是永远的“卡”在那里了,也就是发生了死锁。
那么这种情况该怎么避免呢? 这个时候就需要std::lock_guard登场了。std::lock_guard只有构造函数和析构函数。
简单的来说:当调用构造函数时,会自动调用传入的对象的lock()函数,而当调用析构函数时,自动调用unlock()函数(这就是所谓的RAII,读者可自行搜索)。我们修改一下demo3。
Demo4——避免死锁,lock_guard
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>
int counter = 0;
std::mutex mtx; // 保护counter
void increase_proxy(int time, int id) {
for (int i = 0; i < time; i++) {
// std::lock_guard对象构造时,自动调用mtx.lock()进行上锁
// std::lock_guard对象析构时,自动调用mtx.unlock()释放锁
std::lock_guard<std::mutex> lk(mtx);
// 线程1上锁成功后,抛出异常:未释放锁
if (id == 1) {
throw std::runtime_error("throw excption....");
}
// 当前线程休眠1毫秒
std::this_thread::sleep_for(std::chrono::milliseconds(1));
counter++;
}
}
void increase(int time, int id) {
try {
increase_proxy(time, id);
}
catch (const std::exception& e){
std::cout << "id:" << id << ", " << e.what() << std::endl;
}
}
int main(int argc, char** argv) {
std::thread t1(increase, 10000, 1);
std::thread t2(increase, 10000, 2);
t1.join();
t2.join();
std::cout << "counter:" << counter << std::endl;
return 0;
}
执行上述代码,结果为:
结果符合预期。所以,推荐使用std::mutex和std::lock_guard搭配使用,避免死锁的发生。
std::lock_guard的第二个构造函数
实际上,std::lock_guard有两个构造函数,参考https://en.cppreference.com/w/cpp/thread/lock_guard/lock_guard
在demo4中我们使用了第1个构造函数,第3个为拷贝构造函数,定义为删除函数。这里我们来重点说一下第2个构造函数。
第2个构造函数有两个参数,其中第二个参数类型为:std::adopt_lock_t。这个构造函数假定:当前线程已经上锁成功,所以不再调用lock()函数。这里不再给出具体的例子,如果想了解这种构造函数是如何工作的,
可以参考 https://en.cppreference.com/w/cpp/thread/lock_tag_t
3. unique_lock
3.1 unique_lock取代lock_guard
unique_lock是个类模板,工作中,一般lock_guard(推荐使用);lock_guard取代了mutex的lock()和unlock();
unique_lock比lock_guard灵活很多,效率上差一点,内存占用多一点。
3.2 unique_lock的第二个参数
unique_lock可以带第二个参数:
std::unique_lock<std::mutex> lock(g_mtx, std::adopt_lock); // // std::adopt_lock标记作用;
3.2.1 std::adopt_lock
表示这个互斥量已经被lock了(你必须要把互斥量提前lock了 ,否者会报异常);
std::adopt_lock标记的效果就是假设调用一方已经拥有了互斥量的所有权(已经lock成功了);通知lock_guard不需要再构造函数中lock这个互斥量了。
PS:std::lock_guard也可以带std::adopt_lock标记,含义相同
3.2.2 std::try_to_lock
我们会尝试用mutex的lock()去锁定这个mutex,但如果没有锁定成功,我也会立即返回,并不会阻塞在那里;
用这个try_to_lock的前提是你自己不能先lock。实例代码如下:
3.2.3 std::defer_lock
用std::defer_lock的前提是,你不能自己先lock,否则会报异常
std::defer_lock的意思就是并没有给mutex加锁:初始化了一个没有加锁的mutex。
3.3 unique_lock的成员函数
3.3.1 lock()/unlock()
lock(): 加锁
unlock(): 解锁
defer_lock、lock()与unlock() 实例代码 如下:
void inMsgRecvQueue()
{
for (int i = 0; i < 10000; i++)
{
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
std::unique_lock<std::mutex> sbguard(my_mutex, std::defer_lock);//没有加锁的my_mutex
sbguard.lock();//咱们不用自己unlock
// 处理共享代码...
// 因为有一些非共享代码要处理
sbguard.unlock();
// 处理非共享代码...
sbguard.lock();
// 处理共享代码...
msgRecvQueue.push_back(i);
// ...
// 其他处理代码
sbguard.unlock();// 画蛇添足,但也可以
}
}
3.3.2 try_lock()
尝试给互斥量加锁,如果拿不到锁,返回false,如果拿到了锁,返回true,这个函数是不阻塞的;实例代码如下:
void inMsgRecvQueue()
{
for (int i = 0; i < 10000; i++)
{
std::unique_lock<std::mutex> sbguard(my_mutex, std::defer_lock);// 没有加锁的my_mutex
if (sbguard.try_lock() == true)//返回true表示拿到锁了
{
msgRecvQueue.push_back(i);
// ...
// 其他处理代码
}
else
{
//没拿到锁
cout << "inMsgRecvQueue()执行,但没拿到锁头,只能干点别的事" << i << endl;
}
}
}
3.3.3 release()
返回它所管理的mutex对象指针,并释放所有权;也就是说,这个unique_lock和mutex不再有关系。严格区分unlock()与release()的区别,不要混淆。
如果原来mutex对像处于加锁状态,你有责任接管过来并负责解锁。(release返回的是原始mutex的指针)。实例代码如下:
void inMsgRecvQueue()
{
for (int i = 0; i < 10000; i++)
{
std::unique_lock<std::mutex> sbguard(my_mutex);
std::mutex *ptx = sbguard.release(); // 现在你有责任自己解锁了
msgRecvQueue.push_back(i);
ptx->unlock(); //自己负责mutex的unlock了
}
}
为什么有时候需要unlock();因为你lock()锁住的代码段越少,执行越快,整个程序运行效率越高。有人也把锁头锁住的代码多少成为锁的粒度,粒度一般用粗细来描述;
a)锁住的代码少,这个粒度叫细,执行效率高;
b)锁住的代码多,这个粒度叫粗,执行效率低;
要学会尽量选择合适粒度的代码进行保护,粒度太细,可能漏掉共享数据的保护,粒度太粗,影响效率。选择合适的粒度是高级程序员能力和实力的体现;
3.4 unique_lock所有权的传递
std::unique_lock<std::mutex> sbguard(my_mutex);//所有权概念
sbguard拥有my_mutex的所有权;sbguard可以把自己对mutex(my_mutex)的所有权转移给其他的unique_lock对象;
所以unique_lock对象这个mutex的所有权是可以转移,但是不能复制。
std::unique_lock<std::mutex> sbguard1(my_mutex);
std::unique_lock<std::mutex> sbguard2(sbguard1);//此句是非法的,复制所有权是非法的
std::unique_lock<std::mutex> sbguard2(std::move(sbguard)); // 移动语义,现在先当与sbguard2与my_mutex绑定到一起了
// 现在sbguard1指向空,sbguard2指向了my_mutex
方法1 :std::move()
方法2:return std:: unique_lock<std::mutex> 代码如下:
std::unique_lock<std::mutex> rtn_unique_lock()
{
std::unique_lock<std::mutex> tmpguard(my_mutex);
return tmpguard;//从函数中返回一个局部的unique_lock对象是可以的。三章十四节讲解过移动构造函数。
//返回这种举报对象tmpguard会导致系统生成临时unique_lock对象,并调用unique_lock的移动构造函数
}
void inMsgRecvQueue()
{
for (int i = 0; i < 10000; i++)
{
std::unique_lock<std::mutex> sbguard1 = rtn_unique_lock();
msgRecvQueue.push_back(i);
}
}
注:该文是C++11并发多线程视频教程笔记,详情学习:https://study.163.com/course/courseMain.htm?courseId=1006067356
总结一下,就是lock_guard 配合 adopt_lock使用,unique_lock配合 defer_lock使用。在两种情况都能使用时,推荐使用lock_guard,因为它更快并且使用的内存空间更少。unique_lock支持所有权的传递,所以更加灵活。
以上来自于
https://zh.cppreference.com/w/cpp/thread
https://zhuanlan.zhihu.com/p/91062516
https://blog.csdn.net/u012507022/article/details/85909567