17.6 C++并发与多线程-unique_lock详解

  • Post author:
  • Post category:其他



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++并发与多线程-补充知识、线程池浅谈、数量谈与总结




6.unique_lock详解


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;
}




6.1 unique_lock取代lock_guard


首先要说的是:unique_lock可以完全取代lock_guard。直接修改源代码,一共有两个地方需要修改,每个地方都直接用unique_lock替换lock_guard即可:

std::unique_lock<std::mutex> sbguard1(my_mutex);




6.2 unique_lock的第二个参数


lock_guard带的第二个参数前面讲解过了一个——std::adopt_lock。相关代码如下:

std::unique_lock<std::mutex> sbguard1(my_mutex, std::adopt_lock);




(1)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上。




(2)std::try_to_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上。




(3)std::defer_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上。




6.3 unique_lock的成员函数




(1)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);
    }
}




(2)unlock


针对加锁的互斥量,给该互斥量解锁,不可以针对没加锁的互斥量使用,否则报异常。

在加锁互斥量后,随时可以用该成员函数再重新解锁这个互斥量。当然,解锁后,若需要操作共享数据,还要再重新加锁后才能操作。

虽然unique_lock能够自动解锁,但是也可以用该函数手工解锁。所以,该函数也体现了unique_lock比lock_guard灵活的地方——随时可以解锁。




(3)try_lock


尝试给互斥量加锁,如果拿不到锁,则返回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;
        }
    }
}




(4)release


返回它所管理的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,就是高级程序员能力和实力的体现。




6.4 unique_lock所有权的传递


不难看出,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<std::mutex> 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);
    }
} 



版权声明:本文为migu123原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。