17.1 C++并发与多线程-基础概念与实现
17.2 C++并发与多线程-线程启动、结束与创建线程写法
17.3 C++并发与多线程-线程传参详解、detach坑与成员函数作为线程函数
17.4 C++并发与多线程-创建多个线程、数据共享问题分析与案例代码
17.5 C++并发与多线程-互斥量的概念、用法、死锁演示与解决详解
17.6 C++并发与多线程-unique_lock详解
17.7 C++并发与多线程-单例设计模式共享数据分析、解决与call_once
17.8 C++并发与多线程-condition_variable、wait、notify_one与notify_all
17.9 C++并发与多线程-async、future、packaged_task与promise
17.10 C++并发与多线程-future其他成员函数、shared_future与atomic
17.11 C++并发与多线程-Windows临界区与其他各种mutex互斥量
17.12 C++并发与多线程-补充知识、线程池浅谈、数量谈与总结
unique_lock是一个类模板,它的功能与lock_guard类似,但是比lock_guard更灵活。在日常的开发工作中,一般情况下,lock_guard就够用了(推荐优先考虑使用lock_guard),但是,读者以后可能参与的实际项目千奇百怪,说不准就需要用unique_lock里面的功能,而且如果阅读别人的代码,也可能会遇到unique_lock,所以这里讲一讲unique_lock。
上一节学习了lock_guard,已经知道了lock_guard能够取代mutex(互斥量)的lock和unlock函数。lock_guard的简单工作原理就是:在lock_guard的构造函数里调用了mutex的lock成员函数,在lock_guard的析构函数里调用了mutex的unlock成员函数。
unique_lock和lock_guard一样,都是用来对mutex(互斥量)进行加锁和解锁管理,但是,lock_guard不太灵活:构造lock_guard对象的时候lock互斥量,析构lock_guard对象的时候unlock互斥量。相比之下,unique_lock的灵活性就要好很多,当然,灵活性高的代价是执行效率差一点,内存占用的也稍微多一点。
class A
{
public:
//把收到的消息入到队列的线程
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; i++)
{
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
msgRecvQueue.push_back(i); //假设这个数字就是我收到的命令,我直接放到消息队列里来
}
}
//把数据从消息队列中取出的线程
void outMsgRecvQueue()
{
for (int i = 0; i < 100000; i++)
{
if (!msgRecvQueue.empty())
{
int command = msgRecvQueue.front();//返回第一个元素但不检查元素存在与否
msgRecvQueue.pop_front(); //移除第一个元素但不返回
//这里可以考虑处理数据
//......
cout << "outMsgRecvQueue()执行了,目前收消息队列中是元素:" << command << endl;
}
else
{
//cout << "outMsgRecvQueue()执行了,但目前收消息队列中是空元素" << i << endl;
}
}
cout << "end" << endl;
}
private:
std::list<int> msgRecvQueue; //容器(收消息队列),专门用于代表玩家给咱们发送过来的命令
};
{
A myobja;
std::thread myOutnMsgObj(&A::outMsgRecvQueue, &myobja); //注意这里第二个参数必须是引用(用std::ref也可以),才能保证线程里用的是同一个对象
std::thread myInMsgObj(&A::inMsgRecvQueue, &myobja);
myInMsgObj.join();
myOutnMsgObj.join();
cout << "main主函数执行结束!" << endl;
}
首先要说的是:unique_lock可以完全取代lock_guard。直接修改源代码,一共有两个地方需要修改,每个地方都直接用unique_lock替换lock_guard即可:
std::unique_lock<std::mutex> sbguard1(my_mutex);
lock_guard带的第二个参数前面讲解过了一个——std::adopt_lock。相关代码如下:
std::unique_lock<std::mutex> sbguard1(my_mutex, std::adopt_lock);
则铁定出现异常(因为互斥量还没有被lock呢),此时将程序代码中每个出现std::unique_lock的行修改为如下两行即可:
my_mutex.lock();
std::unique_lock<std::mutex> sbguard1(my_mutex, std::adopt_lock);
执行起来,一切都没有问题。
到目前为止,看到的unique_lock还是依旧和lock_guard功能一样,但笔者刚才说过,unique_lock更占内存,运行效率差一点,但也更灵活。它的灵活性怎样体现呢?
现在介绍两行有趣的代码,后面会用到,这两行代码可以让线程休息一定的时间:
std::chrono::milliseconds dura(200); //卡在这里200毫秒
std::this_thread::sleep_for(dura);
bool outMsgLULProc(int& command)
{
std::unique_lock<std::mutex> sbguard1(my_mutex);
//std::chrono::milliseconds dura(20000); 定义一个时间相关对象,初值2万,单位毫秒, 卡在这里20秒
std::chrono::milliseconds dura(200); //卡在这里200毫秒
std::this_thread::sleep_for(dura);
if (!msgRecvQueue.empty())
{
//消息不为空
command = msgRecvQueue.front(); //返回第一个元素,但不检查元素是否存在;
msgRecvQueue.pop_front(); //移除第一个元素,但不返回;
return true;
}
return false;
}
运行起来并跟踪调试不难发现,一旦outMsgLULProc被卡住20s,则inMsgRecvQueue这个线程因为lock不成功,也会被卡20s。因为main主函数中outMsgRecvQueue线程先被创建,所以一般会先执行(不是绝对的,也可能后执行),因此其调用的outMsgLULProc函数也会率先lock成功互斥量
这时unique_lock的灵活性就体现出来了。如果unique_lock拿不到锁,那么不让它卡住,可以让它干点别的事。
这就引出了unique_lock所支持的另一个第二参数:std::try_to_lock。总结:使用std::adopt_lock的前提是开发者需要先把互斥量lock上。
这个第二参数的含义是:系统会尝试用mutex的lock去锁定这个mutex,但如果没锁成功,也会立即返回,并不会阻塞在那里(使用std::try_to_lock的前提是程序员不能自己先去lock这个mutex,因为std::try_to_lock会尝试去lock,如果程序员先lock了一次,那这里就等于再次lock了,两次lock的结果就是程序卡死了)。
当然,如果lock了,在离开sbguard1作用域或者从函数中返回时会自动unlock。
修改inMsgRecvQueue函数代码,在其中使用std::unique_lock以及第二参数std::try_to_lock。代码如下:
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; ++i)
{
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
std::unique_lock<std::mutex> sbguard1(my_mutex, std::try_to_lock);
if (sbguard1.owns_lock()) //条件成立表示拿到了锁头
{
//拿到了锁头,离开sbguard1作用域锁头会自动释放
msgRecvQueue.push_back(i); //假设这个数字就是我收到的命令,我直接放到消息队列里来
//.....
//其他处理代码
}
else
{
//没拿到锁
cout << "inMsgRecvQueue()执行,但没拿到锁,只能干点别的事" << i << endl;
}
}
}
然后可以把outMsgLULProc函数中代码行“std::chrono::milliseconds dura(20000);”休息的时间改短一点,方便设置断点观察(否则在inMsgRecvQueue中的if条件内设置断点会很难有机会触发到)。修改为:
执行起来不难发现,即便是outMsgLULProc函数休息的时候,inMsgRecvQueue函数的代码也不会卡住,总是不断地在执行下面这行代码:
总结:使用std::try_to_lock的前提是开发者不可以自己把互斥量lock上。
unique_lock所支持的另一个第二参数:std::defer_lock(用这个defer_lock的前提是程序员不能自己先去lock这个mutex,否则会报异常)。
std::defer_lock的意思就是初始化这个mutex,但是这个选项表示并没有给这个mutex加锁,初始化了一个没有加锁的mutex。那读者可能有疑问:弄一个没加锁的mutex干什么呢?这个问题问得好,这个没加锁的mutex也同样体现了unique_lock的灵活性,通过这个没加锁的mutex,可以灵活地调用很多unique_lock相关的成员函数。
借着这个std::defer_lock参数的话题,介绍一下unique_lock这个类模板的一些重要的成员函数,往下看。
总结:使用std::defer_lock的前提是开发者不可以自己把互斥量lock上。
给互斥量加锁,如果无法加锁,会阻塞一直等待拿到锁。改造一下inMsgRecvQueue函数的代码,其他代码不需要改动,执行起来,一切正常。
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; ++i)
{
std::unique_lock<std::mutex> sbguard1(my_mutex, std::defer_lock);
sbguard1.lock(); //反正unique_lock能自动解锁,不用自己解,所以这里只管加锁
msgRecvQueue.push_back(i);
}
}
针对加锁的互斥量,给该互斥量解锁,不可以针对没加锁的互斥量使用,否则报异常。
在加锁互斥量后,随时可以用该成员函数再重新解锁这个互斥量。当然,解锁后,若需要操作共享数据,还要再重新加锁后才能操作。
虽然unique_lock能够自动解锁,但是也可以用该函数手工解锁。所以,该函数也体现了unique_lock比lock_guard灵活的地方——随时可以解锁。
尝试给互斥量加锁,如果拿不到锁,则返回false;如果拿到了锁,则返回true。这个成员函数不阻塞。改造一下inMsgRecvQueue函数的代码,如下:
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; ++i)
{
std::unique_lock<std::mutex> sbguard1(my_mutex, std::defer_lock);
if (sbguard1.try_lock() == true) //返回true表示拿到了锁,自己不用管unlock问题
{
msgRecvQueue.push_back(i);
}
else
{
cout << "抱歉,没拿到锁,做点别的事情吧!" << endl;
}
}
}
返回它所管理的mutex对象指针,并释放所有权。也就是这个unique_lock和mutex不再有关系。严格区别release和unlock这两个成员函数的区别,unlock只是让该unique_lock所管理的mutex解锁而不是解除两者的关联关系。
一旦解除该unique_lock和所管理的mutex的关联关系,如果原来mutex对象处于加锁状态,则程序员有责任负责解锁。
改造一下inMsgRecvQueue函数的代码,如下:
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; ++i)
{
std::unique_lock<std::mutex> sbguard1(my_mutex); //mutex锁定
std::mutex* p_mtx = sbguard1.release(); //现在关联关系解除,程序员有责任自己解锁了,其实这个就是my_mutex,现在sbguard1已经不和my_mutex关联了(可以设置断点并观察)
msgRecvQueue.push_back(i);
p_mtx->unlock();//因为前面已经加锁,所以这里要自己解锁了
}
}
总结:其实,这些成员函数并不复杂。lock了,就要unlock,就是这样简单。使用了unique_lock并对互斥量lock之后,可以随时unlock。当需要访问共享数据的时候,可以再次调用lock来加锁,而笔者要重点强调的是,lock之后,不需要再次unlock,即便忘记了unlock也无关紧要,unique_lock会在离开作用域的时候检查关联的mutex是否lock,如果lock了,unique_lock会帮助程序员unlock。当然,如果已经unlock,unique_lock就不会再做unlock的动作。
为什么lock中间又需要unlock然后再次lock呢?因为读者要明白一个原则:锁住的内容越少,执行得越快,执行得快,尽早把锁解开,其他线程lock时等待的时间就越短,整个程序运行的效率就越高。所以有人也把用锁锁住的代码多少称为锁的粒度,粒度一般用粗细描述:
· 锁住的代码少,粒度就细,程序执行效率就高。
· 锁住的代码多,粒度就粗,程序执行效率就低(因为其他线程访问共享数据等待的时间会更长)。
所以,程序员要尽量选择合适粒度的代码进行保护,粒度太细,可能漏掉要保护的共享数据(这可能导致程序出错甚至崩溃),粒度粗了,可能影响程序运行效率。选择合适的粒度,灵活运用lock和unlock,就是高级程序员能力和实力的体现。
不难看出,unique_lock要发挥作用,应该和一个mutex(互斥量)绑定到一起,这样才是一个完整的能发挥作用的unique_lock。
换句话说,通常情况下,unique_lock需要和一个mutex配合使用或者说这个unique_lock需要管理一个mutex指针(或者说这个unique_lock正在管理这个mutex)。
读者应该知道,一个mutex应该只和一个unique_lock绑定,不会有人把一个mutex和两个unique_lock绑定吧?那是属于自己给自己找不愉快。
这里引入“所有权”的概念。所有权指的就是unique_lock所拥有的这个mutex,unique_lock可以把它所拥有的mutex传递给其他的unique_lock。所以,unique_lock对这个mutex的所有权是属于可以移动但不可以复制的,这个所有权的传递与unique_ptr智能指针的所有权传递非常类似。
改造一下inMsgRecvQueue函数的代码,如下:
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; ++i)
{
std::unique_lock<std::mutex> sbguard1(my_mutex);
//std::unique_lock sbguard10(sbguard1); //复制所有权,不可以
std::unique_lock<std::mutex> sbguard10(std::move(sbguard1)); //移动语义,这可以现在my_mutex和sbguard10绑定到一起了。设置断点调试,移动后sbguard1指向空,sbguard10指向了该my_mutex
msgRecvQueue.push_back(i);
}
}
另外,返回unique_lock类型,这也是一种用法(程序写法)。将来读者看到类似代码的时候,也要能够理解。
在类A中增加一个成员函数,代码如下
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 < 100000; ++i)
{
std::unique_lock<std::mutex> sbguard1 = rtn_unique_lock();
msgRecvQueue.push_back(i);
}
}