C++中的事件Event(基于条件变量的封装)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/FlushHip/article/details/82840301

考虑这样一个场景,现在有两条线程,一条线程负责往队列中塞元素,另一条线程负责从队列中取元素。其实就是简单的生产者消费者队列,取元素的这个线程需要注意队列中是否有元素,如果没有元素就不能取,于是我就搞一个循环,一直取看队列是否为空:

for( ; !que.empty(); ) {}

这样子确实可以实现没有元素就等待,有元素就取元素,但是这样搞,这条形成一直处于CPU100%运行的状态,好像这种情况叫自旋。这样和浪费资源,于是,想出第二个方法:线程休眠是不是就不浪费资源了,对,于是我们让线程休眠一段时间,然后再去看看队列是否为空,于是有了下面的代码:

for( ; !que.empty(); std::this_thread::sleep_for(std::chrono::seconds(1))) {}

这样子确实不浪费资源,但是存在一个问题:响应不及时。还是讲一个故事来说说。

假如你今天晚上通宵打了一晚上游戏,明天要坐十小时的火车,你准备在或者上补一觉,到站下车,但是,你怎么就知道火车到站了呢?要么你就不睡,眼睛一直睁着,但是这样搞,你可能会猝死;要么你就睡一个小时就起来看看是否到站了,如果没到站继续睡,但是万一你刚睡着,火车到站了,这个时候就蛋疼了。那怎么办呢?很简单,睡之前告诉下乘务员,让他到站叫你。

那么这个“到站叫你”这个操作在C++如何实现呢,C++11有了std::condition_variable,条件变量,在生产者消费者队列中,取元素的线程就是要等待队列不为空这种条件:

con.wait();

只要生产者往队列中塞元素,就调用con.notify(),这个操作就是通知。这样子就可以很好地解决这个问题。

条件变量可以很好处理线程的同步关系。利用条件变量去等待条件是多线程编程的利器。

那么条件变量std::condition_variable该如何使用?

条件变量的成员函数:

  • notify_onenotify_all
  • waitwait_forwait_until

一个等待,一个唤醒。对,条件变量的使用就是这么简单。

条件变量通常和锁一起使用,也就是说,条件变量在等待的时候会释放锁,当条件变化时得到锁,如果发现条件不是正在等待的,马上又释放锁。

用条件变量示范一下上面说的那个生产者消费者队列:

std::condition_variable con;
std::mutex mu;
std::queue<int> que;

void produce()
{
    std::lock_guard<std::mutex> lock(mu);
    que.push(std::rand());
    con.notify_one();
}

void consume()
{
    std::unique_lock<std::mutex> lock(mu);
    con.wait(lock, [] () { return !que.empty(); });
    auto t = que.front();
    que.pop();
}

使用wait的地方使用std::unique_lock是因为std::unique_lockstd::lock_guard更好的灵活性(条件变量在等待的时候会释放锁,当条件变化时得到锁,如果发现条件不是正在等待的,马上又释放锁,std::unique_lock可以中途解锁,而std::lock_guard没有解锁这个成员函数,作用域一开始就加锁,直到作用域结束才解锁),

写到这里好像就结束了,但是条件变量就是完美的解决办法吗,它就没有一点缺陷?正文开始。

produceconsume的调用顺序不定,有没有可能consumeproduce之后调用,这种情况consume会永远阻塞;

有可能notify还没调用,wait就直接返回了,这是完全有可能的,这和条件变量的底层实现有关,不可避免。

以下是维基百科的一段话:

“This means that when you wait on a condition variable, the wait may (occasionally) return when no thread specifically broadcast or signaled that condition variable. Spurious wakeups may sound strange, but on some multiprocessor systems, making condition wakeup completely predictable might substantially slow all condition variable operations. The race conditions that cause spurious wakeups should be considered rare.”

因此,条件变量会导致两个问题:

  • 提前唤醒
  • 虚假唤醒

那么如何对这两个问题亡羊补牢呢?对于提前唤醒,可以在wait之前判断下条件,如果已经符合条件就不wait了。

于是消费者可以下面这个样子:

void consume()
{
    std::unique_lock<std::mutex> lock(mu);
    if (que.empty()) {
        con.wait(lock, [] () { return !que.empty(); });
    }
    auto t = que.front();
    que.pop();
}

那么虚假唤醒呢?可以在wait返回之后,在对条件进行判断,如果wait的返回不是因为条件得到了满足,那么就继续wait。于是消费者又可以搞成下面这样:

void consume()
{
    std::unique_lock<std::mutex> lock(mu);
    if (que.empty()) {
        con.wait(lock, [] () { return !que.empty(); });
        if (que.empty()) {
	        con.wait(lock, [] () { return !que.empty(); });
	    }
    }
    auto t = que.front();
    que.pop();
}

但是,有更精简的写法:

void consume()
{
    std::unique_lock<std::mutex> lock(mu);
    while (que.empty()) {
        con.wait(lock, [] () { return !que.empty(); });
    }
    auto t = que.front();
    que.pop();
}

其实上面说了两个问题的解决措施在C++11的std::condition_variable上使用是多此一举的,这两个问题标准库早就考虑到了,我们可以看看标准库条件变量的std::condition_variable::wait源码:

// <condition_variable>
template<typename _Predicate>
void wait(unique_lock<mutex>& __lock, _Predicate __p)
{
    while (!__p())
        wait(__lock);
}

所以,我上面的代码所有使用到std::condition_variable的地方都可以直接使用,如果把std::condition_variable换成POSIX的pthread_con_t,上面的这些操作都是有意义的,我这里用std::condition_variable替代POSIX的pthread_con_t只是为了演示方便。

那么标题中提到的事件Event是什么东西,其实这个东西就是对条件变量的再次封转。使得代码更加简洁,逻辑更加清晰。至于为什么取名为事件Event,想想条件变量的适用场景,当发生特定事件(条件得到满足)时,执行特定操作,Event把执行的特定时间这项工作交给用户,自己则负责告诉用户:“你期待的事件现在已经发生了,赶快执行你的操作吧”。

于是,Event的定义是:Event是一个同步对象,它允许一条线程通知另外的一条或者多条线程特定的事件已经发生了

剩下的就是Event的实现了。

综合上面的分析,用一个标志变量来标识特定的事件是否发生了(条件满足就设置标志变量,把业务逻辑分离出去),再用一个标志变量标识是否全部唤醒。

class Event
{
public:
    Event() = default;

    void Wait()
    {
        std::unique_lock<std::mutex> lock(mu);
        con.wait(lock, [this] () { return this->flag || this->all; });
        if (!all)
            flag = false;
    }

    template <typename _Rep, typename _Period>
    bool WaitFor(const std::chrono::duration<_Rep, _Period> & duration)
    {
        std::unique_lock<std::mutex> lock(mu);
        bool ret = true;
        ret = con.wait_for(lock, duration, [this] () { return this->flag || this->all; });
        if (ret && !all)
            flag = false;
        return ret;
    }

    template <typename _Clock, typename _Duration>
    bool WaitUntil(const std::chrono::time_point<_Clock, _Duration> & point)
    {
        std::unique_lock<std::mutex> lock(mu);
        bool ret = true;
        ret = con.wait_until(lock, point, [this] () { return this->flag || this->all; });
        if (ret && !all)
            flag = false;
        return ret;
    }

    void NotifyOne()
    {
        std::lock_guard<std::mutex> lock(mu);
        flag = true;
        con.notify_one();
    }

    void NotifyAll()
    {
        std::lock_guard<std::mutex> lock(mu);
        all = true;
        con.notify_all();
    }

    void Reset()
    {
        std::lock_guard<std::mutex> lock(mu);
        flag = all = false;
    }

private:
    Event(const Event &) = delete;
    Event & operator = (const Event &) = delete;

private:
    bool flag = false;
    bool all = false;

    std::mutex mu;
    std::condition_variable con;
};

把生产者消费者队列那个程序用Event改写,然后在main函数开两条线程测试一下。

Event event;
std::queue<int> que;
std::mutex mu;

void produce()
{
    std::cout << "produce" << std::endl;
    do {
        std::lock_guard<std::mutex> lock(mu);
        que.push(std::rand());
    } while (false);
    event.NotifyOne();
    std::cout << "produce end" << std::endl;
}

void consume()
{
    std::cout << "consume" << std::endl;
    event.Wait();
    std::unique_lock<std::mutex> lock(mu);
    auto t = que.front();
    que.pop();
    std::cout << "consume end" << std::endl;
}

int main()
{
    auto th = std::thread(consume);
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::thread(produce).join();
    th.join();
    return 0;
}

结果的话,看代码就知道了,不过使用Event特别容易出现死锁,避免把Event的操作放在锁的范围中,因为Event本身就有一把锁,锁套锁,一不小心,加锁顺序错乱就死锁了。这个体会下上面的程序就明白了。

到这里,博文就结束了。Event的简单封装的背后其实也是有这么长一段心路历程的,还是要多挖掘。


参考:

猜你喜欢

转载自blog.csdn.net/FlushHip/article/details/82840301