17.8 C++并发与多线程-condition_variable、wait、notify_one与notify_all

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

8.condition_variable、wait、notify_one与notify_all

  8.1 条件变量std::condition_variable、wait与notify_one

    这里讲解的话题是条件变量。条件变量有什么用处呢?当然也是用在线程中,例如它用在线程A中等待一个条件满足(如等待消息队列中有要处理的数据),另外还有个线程B(专门往消息队列中扔数据),当条件满足时(消息队列中有数据时),线程B通知线程A,那么线程A就会从等待这个条件的地方往下继续执行。
    现在把代码恢复到第17.6节讲unique_lock时的代码,这段代码读者已经比较熟悉了,inMsgRecvQueue负责往消息队列中插入数据,而outMsgRecvQueue所调用的outMsgLULProc负责从消息队列中取得数据。
    整个代码看起来如下:

class A 
{
public:
//把收到的消息(玩家命令)入到一个队列的线程
void inMsgRecvQueue()
{
    for (int i = 0; i < 10000; ++i)
    {
        cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
        std::unique_lock<std::mutex> sbguard1(my_mutex);
        msgRecvQueue.push_back(i);//假设这个数字就是我收到的命令,我直接放到消息队列里来
    }
    return;
}
bool outMsgLULProc(int& command)
{
    std::unique_lock<std::mutex> sbguard1(my_mutex);
    if (!msgRecvQueue.empty())
    {
        //消息不为空
        command = msgRecvQueue.front();//返回第一个元素,但不检查元素是否存在
        msgRecvQueue.pop_front();  //移除第一个元素,但不返回
        return true;
    }
    return false;
}
//把数据从消息队列中取出的线程
void outMsgRecvQueue()
{
    int command = 0;
    for (int i = 0; i < 100000; ++i)
    {
        bool result = outMsgLULProc(command);
        if (result == true)
        {
            cout << "outMsgRecvQueue()执行了,从容器中取出一个元素" << command << endl;
            //可以考虑进行命令(数据)处理
            //....
        }
        else
        {
            //消息对列为空
            cout << "outMsgRecvQueue()执行了,但目前收消息队列中是空元素" << i << endl;
        }
    }
    cout << "end" << endl;
}
private:
    std::list<int> msgRecvQueue; //容器(消息队列),专门用于代表玩家给咱们发送过来的命令
    std::mutex my_mutex; //创建了一个互斥量 (一把锁头)
};

    main主函数内容如下:

int main()
{		
    A myobja;
    std::thread myOutnMsgObj(&A::outMsgRecvQueue, &myobja);
    std::thread myInMsgObj(&A::inMsgRecvQueue, &myobja);
    myInMsgObj.join();
    myOutnMsgObj.join();
    cout << "main主函数执行结束!" << endl;	
    return 0;
}

    现在这个代码是稳定、正常工作的。笔者希望在这个代码基础之上,引入新的类std::condition_variable的讲解。
    现在分析一下上述代码中一些不如人意的地方,如outMsgLULProc函数。可以看到,代码中是不停地尝试加锁,一旦加锁成功,代码就判断消息队列是否为空,如果不为空,就从队列中取出数据,然后处理数据、输出数据等都可以。
    但是这样不停地尝试加锁,锁住再去判断消息队列是否为空,这种代码实现方式虽然能正常工作,但可想而知,代码的效率肯定不会很高。
    有些读者也许想到了上一节所讲解的“双重锁定”或者“双重检查”。可能自己会动手修改一下代码来提高效率。例如,修改一下outMsgLULProc成员函数:

void inMsgRecvQueue()
{
    for (int i = 0; i < 10000; ++i)
    {
        cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
        std::unique_lock<std::mutex> sbguard1(my_mutex);
        msgRecvQueue.push_back(i);//假设这个数字就是我收到的命令,我直接放到消息队列里来
    }
    return;
}
bool outMsgLULProc(int& command)
{
    if (!msgRecvQueue.empty()) //不为空
    {
        std::unique_lock<std::mutex> sbguard1(my_mutex);
        if (!msgRecvQueue.empty())
        {
            //消息不为空
            command = msgRecvQueue.front();//返回第一个元素,但不检查元素是否存在
            msgRecvQueue.pop_front();  //移除第一个元素,但不返回
            return true;
        }
    }
    return false;
}

    执行起来,结果一切正常。程序整体运行是稳定的,可以认为效率上是有一定的提升。但是这种不断地测试empty的方法,肯定也是让人感觉非常不好的。
    在实际工作中,这种不好的写法很多人都在用。通过一个循环(如这里outMsgRecvQueue中的while死循环)不断地检测一个标记,当标记成立时,就去做一件事情。
    那么,能不能有更好的解决方法,避免不断地判断消息队列是否为空,而改为当消息队列不为空的时候做一个通知,相关代码段(其他线程的代码段)得到通知后再去取数据呢?这个想法很好,但要怎样实现呢?
    这就需要用到std::condition_variable,这是一个类,一个和条件相关的类,用于等待一个条件达成。这个类需要和互斥量配合工作,用的时候要生成这个类的对象,看一看代码应该怎样写,首先,在类A中定义一个新的私有成员变量:

	std::condition_variable my_cond; //生成一个条件对象

    接下来,要改造outMsgRecvQueue成员函数。改造的目标就是希望outMsgRecvQueue只有在有数据的时候才去处理,没数据的时候保持一种等待状态。
    把outMsgRecvQueue中原来的代码注释掉,写入一些新代码(请详细查看下列代码中的注释行)。完整的outMsgRecvQueue代码现在如下:

void inMsgRecvQueue()
{
    for (int i = 0; i < 10000; ++i)
    {
        cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
        std::unique_lock<std::mutex> sbguard1(my_mutex);
        msgRecvQueue.push_back(i);//假设这个数字就是我收到的命令,我直接放到消息队列里来
        my_cond.notify_one();   //尝试把卡(堵塞)在wait()的线程唤醒,但光唤醒了还不够,这里必须把互斥量解锁,另外一个线程的wait()才会继续正常工作
    }
    return;
}
void outMsgRecvQueue()
{
    int command = 0;
    while (true)
    {
        std::unique_lock<std::mutex> sbguard1(my_mutex); //临界进去
        //wait()用于等一个东西
        //如果wait()第二个参数的lambda表达式返回的是true,wait就直接返回
        //如果wait()第二个参数的lambda表达式返回的是false,那么wait()将解锁互斥量,并堵塞到这行,那堵到什么时候为止呢?堵到其他某个线程调用notify_one()通知为止
        //如果wait()不用第二个参数,那跟第二个参数为lambda表达式并且返回false效果 一样(解锁互斥量,并堵塞到这行,堵到其他某个线程调用notify_one()通知为止)
        my_cond.wait(sbguard1, [this] {
            if (!msgRecvQueue.empty())
                return true;
            return false;
        });
        //一会再写其他的...
    } //end while
}

    视线继续回到outMsgRecvQueue中来:outMsgRecvQueue线程中的wait被inMsg-RecvQueue线程中的notify_one唤醒了。就好像一个人正在睡觉,被其他人叫醒的感觉。wait被唤醒之后,开始恢复干活,恢复之后的wait做了什么事情呢?
    (1)wait不断地尝试重新获取并加锁该互斥量,若获取不到,它就卡在这里反复尝试获取,获取到了,执行流程就继续往下走。
    (2)wait在获取到互斥量并加锁该互斥量后:
    ①如果wait有第二个参数(lambda)表达式,就判断这个lambda表达式:
  · 如果lambda表达式为false,那么这个wait又对互斥量解锁,然后又堵塞在这里等待被notify_one唤醒。
  · 如果lambda表达式为true,那么wait返回,执行流程走下来(互斥量是被锁着的)。
    ②如果wait没有第二个参数表达式,则wait返回,流程走下来(注意现在互斥量是被锁着的)。
    请读者仔细考虑,lambda表达式中的if(!msgRecvQueue.empty())判断行,这行非常重要,因为唤醒这件事,存在虚假唤醒的情形,也存在一次唤醒一堆线程的情形。总之,一旦wait被唤醒后(因为此时互斥量是加锁的,多线程操作也安全),用if语句再次判断msgRecvQueue中到底有没有数据是非常正确的做法。
    现在继续完善outMsgRecvQueue函数。可以确定,流程只要能够从wait语句行走下来,msgRecvQueue中必然有数据存在(锁住了互斥量又判断了msgRecvQueue不为空)。所以下面的代码安全,没有任何问题:

void outMsgRecvQueue()
{
    int command = 0;
    while (true)
    {
        std::unique_lock<std::mutex> sbguard1(my_mutex); //临界进去
        //wait()用于等一个东西
        //如果wait()第二个参数的lambda表达式返回的是true,wait就直接返回
        //如果wait()第二个参数的lambda表达式返回的是false,那么wait()将解锁互斥量,并堵塞到这行,那堵到什么时候为止呢?堵到其他某个线程调用notify_one()通知为止
        //如果wait()不用第二个参数,那跟第二个参数为lambda表达式并且返回false效果 一样(解锁互斥量,并堵塞到这行,堵到其他某个线程调用notify_one()通知为止)
        my_cond.wait(sbguard1, [this] {
            if (!msgRecvQueue.empty())
                return true;
            return false;
        });
        //现在互斥量是锁着的,流程走下来意味着msgRecvQueue队列里必然有数据
        command = msgRecvQueue.front(); //返回第一个元素,但不检查元素是否存在
        msgRecvQueue.pop_front();  //移除第一个元素,但不返回
        sbguard1.unlock(); //因为unique_lock的灵活性,我们可以随时unlock解锁,以免锁住太长时间
        cout << "outMsgRecvQueue()执行,取出一个元素" << command << " threadid = " << std::this_thread::get_id() << endl;
    } //end while
}

    当从msgRecvQueue中取出数据,outMsgRecvQueue把互斥量解锁后(其实不用程序员解锁也可以,系统能够自动解锁),inMsgRecvQueue线程就又可以获取互斥量并能够继续往msgRecvQueue中插入数据了。
    当然,这个程序不完美,但不影响学习和研究std::condition_variable的用法。
    有几点要说明:
    (1)例如当inMsgRecvQueue执行完,若没有唤醒outMsgRecvQueue,则outMsg-RecvQueue的执行流程会一直卡在wait所在行。
    (2)当wait有第二个参数时,这个参数是一个可调用对象,如函数、lambda表达式等都属于可调用对象。后面章节也会专门讲解可调用对象。
    (3)假如outMsgRecvQueue正在处理一个事务,需要一段时间,而不是正卡在wait行进行等待,那么此时inMsgRecvQueue中调用的notify_one也许不会产生任何效果。
    通过上面的改造可以认为,程序效率肯定是有所提升,那么可以认为,这个改法还是不错的。下面进一步研究深入一点的问题。

  8.2 上述代码深入思考

    上述代码执行的比较顺利,但有一点千万不能忽略,这些代码只是一些演示代码,如果想用在商业用途中,则还要进行更严密的思考和完善。
    (1)例如,在outMsgRecvQueue中,当wait运行下来的时候,可能msgRecvQueue中包含着多条数据,不仅仅是一条,如果队列中数据过多,outMsgRecvQueue处理不过来怎么办?
    再者就是为什么队列中的数据会有多条?这说明inMsgRecvQueue和outMsgRecvQueue都会去竞争锁,但到底谁拿得到是不一定的。当inMsgRecvQueue执行了my_cond.notify_one,虽然一般都会唤醒my_cond.wait,但这不代表my_cond.wait就一定能拿到锁(也许锁立即又被inMsgRecvQueue拿去了)。
    (2)另外,notify_one是用来把wait代码行唤醒,如果当前执行的流程没有停留在wait代码行,那么notify_one的执行就等于啥也没做(没有任何效果)。这也是一个值得思考的问题。
    读者在学习多线程编程的过程中,凡是自己可能用到的多线程相关类、函数等,一定要研究明白,清晰地知道它们的工作流程,然后再使用。如果没有研究明白就使用,那很可能就会出现用错的情况,导致程序写出来不能按照预想来工作,而且这种错误非常难排查。
    当然可能出现因为想不到的原因,导致程序并没有按照期望工作,这就是经验的价值,也就是许多老程序员工资比年轻程序员高一大截的原因,每个程序员的成长都要爬过无数的坑,摔无数的跟头。笔者希望这本书能让读者尽量少摔跟头!

  8.3 notify_all

    上面学习了notify_one,用于通知一个线程(outMsgRecvQueue)某个事件的到来。假设现在有两个outMsgRecvQueue线程,来改造一下main函数看一看

int main()
{		
    A myobja;
    std::thread myOutnMsgObj(&A::outMsgRecvQueue, &myobja); //第二个参数是引用,才能保证线程里用的是同一个对象
    std::thread myOutnMsgObj2(&A::outMsgRecvQueue, &myobja);
    std::thread myInMsgObj(&A::inMsgRecvQueue, &myobja);
    myInMsgObj.join();
    myOutnMsgObj2.join();
    myOutnMsgObj.join();
    cout << "main主函数执行结束!" << endl;	
    return 0;
}

    请想想执行过程,inMsgRecvQueue调用notify_one,notify_one调用一次可以通知一个线程,但具体通知哪个线程,不一定,因为这里有两个outMsgRecvQueue线程可能都在wait。
    notify_one做不到通知多个线程,而是要改用notify_all。顾名思义,notify_all用于通知所有处于wait状态的线程。
    在这个范例中,即便使用notify_all来通知两个outMsgRecvQueue线程,当这两个线程都被唤醒后,这两个线程中的每一个也需要尝试重新获取锁,结果还是只有一个线程能获取到锁往下走,另外一个获取不到锁会继续卡在wait那里等待。所以这里用notify_all的结果和用notify_one的结果相同。请各位读者发挥想象力,想象一下notify_all的适用场景。在明白了这些函数的工作原理后,也可以尝试在网络上找一些范例进行练习,好好体会这些函数的用法。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,下面我来给你讲解一下 `condition_variable` 的经典使用实例,包括 `notify_all` 和 `notify_one` 的使用。 首先,我们需要定义一个 `std::mutex` 和一个 `std::condition_variable`,用来实现线程之间的同步和通信。 ```cpp #include <iostream> #include <thread> #include <mutex> #include <condition_variable> std::mutex mtx; std::condition_variable cv; bool ready = false; ``` 在这个例子中,我们定义了一个 `bool` 类型的变量 `ready`,用来表示线程是否准备好了。同时,我们定义了一个互斥锁 `mtx` 和一个条件变量 `cv`,用来保护 `ready` 变量,防止多个线程同时访问和修改。 接下来,我们定义两个线程,一个线程用来等待 `ready` 变量变为 `true`,另一个线程用来修改 `ready` 变量为 `true`。 ```cpp void worker_thread() { // 等待主线程通知 std::unique_lock<std::mutex> lck(mtx); cv.wait(lck, []{ return ready; }); // 等待结束,do something std::cout << "Worker thread is processing data...\n"; } int main() { // 创建工作线程 std::thread worker(worker_thread); // 等待一段时间 std::this_thread::sleep_for(std::chrono::seconds(2)); // 修改 ready 变量为 true { std::lock_guard<std::mutex> lck(mtx); ready = true; } // 通知工作线程 cv.notify_one(); // 等待工作线程结束 worker.join(); return 0; } ``` 在 `main()` 函数中,我们创建了一个工作线程 `worker`,并且在主线程中等待 2 秒钟,模拟工作线程需要等待一段时间才能开始处理数据。 在等待结束后,我们修改了 `ready` 变量为 `true`,并且调用了 `cv.notify_one()` 来通知等待的工作线程可以开始处理数据了。 在工作线程中,我们首先使用 `std::unique_lock<std::mutex>` 对互斥锁进行了锁定,并且调用了 `cv.wait(lck, []{ return ready; })` 来等待条件变量的通知。 `cv.wait()` 函数的作用是使当前线程等待条件变量的通知,当条件变量被通知后,线程会被唤醒,继续执行后面的代码。 `cv.wait()` 函数会自动释放互斥锁,并且在等待期间阻塞当前线程。 在等待期间,工作线程会一直等待,直到主线程调用 `cv.notify_one()` 来通知工作线程可以开始处理数据了。 当工作线程被唤醒后,它会继续执行后面的代码,并且输出一条信息,表示正在处理数据。 这就是 `condition_variable` 的经典使用实例,通过 `notify_all` 和 `notify_one` 函数来实现线程之间的同步和通信。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值