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

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详解
    •   6.1 unique_lock取代lock_guard
    •   6.2 unique_lock的第二个参数
      • (1)std::adopt_lock
      • (2)std::try_to_lock
      • (3)std::defer_lock
    •   6.3 unique_lock的成员函数
      • (1)lock
      • (2)unlock
      • (3)try_lock
      • (4)release
    •   6.4 unique_lock所有权的传递

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

你可能感兴趣的:(C++新经典,c++,开发语言)