《C++并发实例 - 异步并发操作》4.1等待事件和其他条件

第四章 异步并发操作

本章介绍
等待一个事件
等待带有 future 的一次性事件
有时间限制的等待
使用操作同步来简化代码

在上一章中,我们研究了保护线程之间共享数据的各种方法。但有时您不仅需要保护数据,还需要同步不同线程上的操作。例如,一个线程可能需要等待另一个线程完成任务,然后第一个线程才能完成自己的任务。一般来说,希望线程等待特定事件发生或条件成立是很常见的。尽管可以通过定期检查“任务完成”标志或共享数据中存储的类似标志来做到这一点,但这远非理想。像这样在线程之间同步操作的需求是一种常见的场景,C++ 标准库以条件变量(condition variables)和 future 的形式提供了处理它的工具。

在本章中,我将讨论如何使用条件变量和 future 等待事件,以及如何使用它们来简化操作的同步。

4.1 等待事件和其他条件

假设您乘坐夜间火车旅行。确保您在正确的车站下车的一种方法是整晚保持清醒并注意火车的停靠位置。你不会错过车站,但到了那里你会很累。或者,你可以查看时刻表,了解火车预计何时到达,提前设置闹钟,然后睡觉。这样可行;你不会错过车站,但如果火车晚点,你就会起得太早。还有一种可能是你的闹钟电池没电了,你会睡得太久而错过电台。理想的情况是,如果你可以去睡觉,并在火车到达你的车站时有人或某事叫醒你,无论什么时候。

这与线程有什么关系?好吧,如果一个线程正在等待第二个线程完成一项任务,它有多种选择。首先,它可以继续检查共享数据中的标志(受互斥锁保护),并让第二个线程在完成任务时设置该标志。这在两个方面是浪费的:线程消耗宝贵的处理时间来重复检查标志,并且当互斥体被等待线程锁定时,它不能被任何其他线程锁定。这两种方法都不利于线程进行等待,因为它们限制了资源被正在等待的线程访问,甚至阻止它在完成后设置标志。这就像整夜不眠地与火车司机交谈:他必须开得更慢,因为你一直分散他的注意力,所以需要更长的时间才能到达那里。类似地,等待线程正在消耗系统中其他线程可以使用的资源,并且最终可能会等待更长的时间。

第二种选择是使用 std::this_thread::sleep_for() 函数让等待线程在检查之间休眠一小段时间(请参见第 4.3 节):

bool flag;
std::mutex m;
void wait_for_flag()
{
    std::unique_lock<std::mutex> lk(m);
    while(!flag)
    {
        lk.unlock();            - [1] Unlock the mutex
        std::this_thread::sleep_for(std::chrono::milliseconds(100)); -[2] Sleepfor100ms
        lk.lock();              - [3] Relock the mutex
    }
}

在循环中,该函数在睡眠 [2] 之前解锁互斥锁[1],并在 [3] 之后再次锁定它,因此另一个线程有机会获取它并设置标志。

这是一个改进,因为线程在休眠时不会浪费处理时间,但很难获得正确的休眠周期。检查之间的睡眠时间太短,线程仍然浪费检查处理时间;睡眠时间太长,即使它正在等待的任务完成,线程也会继续睡眠,从而引入延迟。这种睡眠过度很少会对程序的运行产生直接影响,但这可能意味着快节奏(fast-paced)的游戏中丢帧或实时应用程序中的时间片过度延长。

第三个也是首选选项是使用 C++ 标准库中的工具来等待事件本身。等待另一个线程触发事件(例如前面提到的管道pipeline中存在额外工作)的最基本机制是条件变量(condition variable)。从概念上讲,条件变量与某个事件或其他条件相关联,一个或多个线程可以等待该条件得到满足。当某个线程确定满足条件时,它可以通知一个或多个等待条件变量的线程,以便唤醒它们并允许它们继续处理。

4.1.1 使用条件变量等待条件

标准 C++ 库提供的不是一种而是两种条件变量的实现:std::condition_variable 和 std::condition_variable_any。这两个都在 <condition_variable> 函数头中声明。在这两种情况下,它们都需要使用互斥锁来提供适当的同步;前者仅限于与 std::mutex 一起使用,而后者可以与满足类似互斥体的最低标准的任何东西一起使用,因此有 _any 后缀。由于 std::condition_variable_any 更通用,因此在大小、性能或操作系统资源方面可能会产生额外成本,因此除非需要额外的灵活性,否则应首选 std::condition_variable。

那么,如何使用 std::condition_variable 来处理简介中的示例——如何让等待工作的线程休眠,直到有数据要处理?以下列表显示了使用条件变量执行此操作的一种方法

Listing 4.1 Waiting for data to process with a std::condition_variable

std::mutex mut;
std::queue<data_chunk> data_queue;  -[1]
std::condition_variable data_cond;
void data_preparation_thread()
{
    while(more_data_to_prepare())
    {
        data_chunk const data=prepare_data();
        std::lock_guard<std::mutex> lk(mut);
        data_queue.push(data);          -[2]
        data_cond.notify_one();         -[3]
    }
}
void data_processing_thread()
{
    while(true)
    {
        std::unique_lock<std::mutex> lk(mut);   -[4]
        data_cond.wait(
            lk,[]{return !data_queue.empty();});    -[5]
        data_chunk data=data_queue.front();
        data_queue.pop();
        lk.unlock();        -[6]
        process(data);
        if(is_last_chunk(data))
            break;
    }
}

首先,您有一个队列 [1],用于在两个线程之间传递数据。当数据准备好时,准备数据的线程使用 std::lock_guard 锁定保护队列的互斥锁,并将数据推送到队列 [2] 上。然后它调用 std::condition_variable 实例上的 notify_one() 成员函数来通知等待线程(如果有的话) [3].

在栅栏的另一边,您有正在处理的线程。该线程首先锁定互斥锁,但这次使用 std::unique_lock 而不是 std::lock_guard e——您很快就会明白原因。然后,线程调用std::condition_variable 的 wait(),传入锁对象和一个表达等待条件 [5] 的 lambda 函数。 Lambda 函数是 C++11 中的一项新功能,允许您将匿名函数编写为另一个表达式的一部分,并且它们非常适合为标准库函数(例如 wait())指定谓词(specifying predicates)。在本例中,简单的 lambda 函数 []{return !data_queue.empty();} 检查 data_queue 是否不为空,即队列中是否有可处理数据。附录 A 的 A.5 节更详细地描述了 Lambda 函数。

wait() 的实现会检查条件(通过调用提供的 lambda 函数)并在满足条件时返回(lambda 函数返回 true)。如果不满足条件(lambda 函数返回 false),wait() 将解锁互斥锁并将线程置于阻塞或等待状态。当准备数据线程通过调用notify_one()通知条件变量时,线程从休眠状态中醒来(解除阻塞),重新获取互斥锁并再次检查条件,当条件满足时保存互斥锁锁定从wait( )中返回。如果条件不满足,则线程解锁互斥体并恢复等待。这就是为什么使用 std::unique_lock 而不是 std::lock_guard —— 等待线程必须在等待时解锁互斥锁,然后再次锁定它,而 std::lock_guard 不提供这种灵活性。如果互斥体在线程休眠时保持锁定状态,则数据准备线程将无法锁定互斥体以将元素添加到队列中,并且等待线程将永远无法看到其条件得到满足。

listing 4.1 使用一个简单的 lambda 函数作为 wait [5],它检查队列是否不为空,但任何函数或可调用对象都可以被传递。如果你已经有一个检查条件的函数(可能比这种简单示例复杂),那么可以直接传入这个函数;无需将其包装在 lambda 中。在调用 wait() 期间,条件变量可以多次检查提供的条件;但是,它总是在互斥锁锁定的情况下执行此操作,并且当(且仅当)提供的测试函数返回 true 时才会立即返回。当等待线程重新获取互斥锁并检查条件时,如果它不是直接响应来自另一个线程的通知,则称为虚假唤醒(spurious wake)。由于任何此类虚假唤醒的数量和频率都是不确定的,因此不建议使用具有负面影响的函数进行条件检查。如果这样做,您必须做好负面影响多次发生的准备。

解锁 std::unique_lock 的灵活性不仅仅用于调用 wait();在有数据需要处理时它也有用 [5] 。处理数据可能是一项耗时的操作,正如您在第 3 章中看到的,在互斥锁上持有锁的时间超过必要的时间是一个坏主意。

使用队列在线程之间传输数据(如listing 4.1 所示)是一种常见的情况。如果做得好,同步可以限制在队列本身,这大大减少了可能出现的同步问题和竞争场景的数量。鉴于此,现在让我们从listing 4.1 中提取一个通用的线程安全队列。

4.1.2 使用条件变量构建线程安全队列

如果您要设计一个通用队列,那么值得花几分钟考虑可能需要的操作,就像您在第 3.2.3 节中对线程安全堆栈所做的那样。让我们从 C++ 标准库中寻找灵感,其形式为 std::queue<> 容器适配器,如下列表所示。

Listing4.2 std::queue interface

class queue {
    public:
    explicit queue(const Container&);
    explicit queue(Container&& = Container());
    template <class Alloc> explicit queue(const Alloc&);
    template <class Alloc> queue(const Container&, const Alloc&);
    template <class Alloc> queue(Container&&, const Alloc&);
    template <class Alloc> queue(queue&&, const Alloc&);
    void swap(queue& q);
    bool empty() const;
    size_type size() const;
    T& front();
    const T& front() const;
    T& back();
    const T& back() const;
    void push(const T& x);
    void push(T&& x);
    void pop();
    template <class... Args> void emplace(Args&&... args);
};

如果忽略构造、赋值和交换操作,则剩下三组操作:查询整个队列状态的操作(empty() 和 size())、查询队列元素的操作(front () 和 back()),以及修改队列的操作(push()、pop() 和 emplace())。这与第 3.2.3 节中的堆栈相同,因此您在接口中固有的竞争场景方面存在相同的问题。总的来说,您需要将 front() 和 pop() 组合到单个函数调用中,就像为堆栈组合 top() 和 pop() 一样。不过,listing 4.1 中的代码有一个新的细微差别:当使用队列在线程之间传递数据时,接收线程通常需要等待数据。让我们提供 pop() 的两个变体:try_pop(),它尝试从队列中弹出值,但即使没有要检索的值也会立即返回(带有失败指示),以及 wait_and_pop(),它将等待,直到有要检索的值。如果您从栈示例中获得启发,您的接口将如下所示。

Listing 4.3 The interface of your thread safe_queue

#include <memory>   - For std::shared_ptr
template<typename T>
class threadsafe_queue
{
public:
    threadsafe_queue();
    threadsafe_queue(const threadsafe_queue&);
    threadsafe_queue& operator=(
        const threadsafe_queue&) = delete;  - Disallow assignment for simplicity
    void push(T new_value);
    bool try_pop(T& value);     -[1]
    std::shared_ptr<T> try_pop();   -[2]
    void wait_and_pop(T& value);
    std::shared_ptr<T> wait_and_pop();
    bool empty() const;
};

正如您对栈所做的那样,您减少了构造函数并消除了赋值,以简化代码。和以前一样,您还提供了 try_pop() 和 wait_for_pop() 的两个版本。 try_pop() [1] 的第一个重载将检索到的值存储在引用的变量中,因此它可以使用返回值作为状态;如果它检索到一个值,则返回 true,否则返回 false(参见 A.2 节)。第二个重载 [2] 不能这样做,因为它直接返回检索到的值。但如果没有要检索的值,则返回的指针可以设置为 NULL。

那么,这一切与清单 4.1 有什么关系呢?好吧,你可以从那里提取push() 和 wait_and_pop() 的代码,如下面的列表所示。

Listing 4.4 Extracting push() and wait_and_pop() from listing 4.1

#include <condition_variable>
template<typename T>
class threadsafe_queue
{
private:
    std::mutex mut;
    std::queue<T> data_queue;
    std::condition_variable data_cond;
public:
    void push(T new_value)
    {
        std::lock_guard<std::mutex> lk(mut);
        data_queue.push(new_value);
        data_cond.notify_one();
    }
    void wait_and_pop(T& value)
    {
        std::unique_lock<std::mutex> lk(mut);
        data_cond.wait(lk,[this]{return !data_queue.empty();});
        value=data_queue.front();
        data_queue.pop();
    }
}
threadsafe_queue<data_chunk> data_queue;    -[1]
void data_preparation_thread()
{
    while(more_data_to_prepare())
    {
        data_chunk const data=prepare_data();
        data_queue.push(data);      -[2]
    }
}
void data_processing_thread()
{
    while(true)
    {
        data_chunk data;
        data_queue.wait_and_pop(data);      -[3]
        process(data);
        if(is_last_chunk(data))
            break;
    }
};

互斥锁和条件变量现在包含在 threadsafe_queue 实例中,因此不再需要单独的变量 [1],并且调用 push() 不需要外部同步[2]。另外,wait_and_pop() 负责处理条件变量wait [3]。

wait_and_pop() 的另一个重载现在编写起来很简单,其余函数几乎可以从listing 3.5 中的栈示例中逐字复制。最终的队列实现如下所示。

Listing 4.5 Full class definition for a thread-safe queue using condition variables

#include <queue>
#include <memory>
#include <mutex>
#include <condition_variable>

template<typename T>
class threadsafe_queue
{
private:
    mutable std::mutex mut;     - The mutex must be mutable
    std::queue<T> data_queue;
    std::condition_variable data_cond;
public:
    threadsafe_queue()
    {}
    threadsafe_queue(threadsafe_queue const& other)
    {
        std::lock_guard<std::mutex> lk(other.mut);
        data_queue=other.data_queue;
    }
    void push(T new_value)
    {
        std::lock_guard<std::mutex> lk(mut);
        data_queue.push(new_value);
        data_cond.notify_one();
    }
    void wait_and_pop(T& value)
    {
        std::unique_lock<std::mutex> lk(mut);
        data_cond.wait(lk,[this]{return !data_queue.empty();});
        value=data_queue.front();
        data_queue.pop();
    }
    std::shared_ptr<T> wait_and_pop()
    {
        std::unique_lock<std::mutex> lk(mut);
        data_cond.wait(lk,[this]{return !data_queue.empty();});
        std::shared_ptr<T> res(std::make_shared<T>(data_queue.front()));
        data_queue.pop();
        return res;
    }
    bool try_pop(T& value)
    {
       std::lock_guard<std::mutex> lk(mut);
       if(data_queue.empty())
           return false;
       value=data_queue.front();
       data_queue.pop();
       return true;
    }
    std::shared_ptr<T> try_pop()
    {
       std::lock_guard<std::mutex> lk(mut);
       if(data_queue.empty())
           return std::shared_ptr<T>();
       std::shared_ptr<T> res(std::make_shared<T>(data_queue.front()));
       data_queue.pop();
       return res;
    }
    bool empty() const
    {
       std::lock_guard<std::mutex> lk(mut);
       return data_queue.empty();
    }
};

尽管empty()是一个const成员函数,并且拷贝构造函数的另一个参数是一个const引用,但其他线程可能对该对象有非const引用,并且正在调用变体成员函数,所以我们仍然需要锁定互斥锁。由于锁定互斥量是一种变体操作,因此互斥量对象必须标记为可变 [1],以便可以在 empty() 和拷贝构造函数中将其锁定。

当有多个线程等待同一事件时,条件变量也很有用。如果线程用于划分工作负载,因此只有一个线程应该响应通知,则可以使用与listing 4.1 所示完全相同的结构;只需运行多个数据处理线程实例。当新数据准备好时,对notify_one()的调用将触发一个正在wait()的线程检查其条件,从而从wait()返回(因为您刚刚向data_queue添加了一项)。无法保证哪个线程会收到通知,即使有一个线程正在等待通知,所有处理线程可能仍在处理数据。

另一种可能性是多个线程正在等待同一事件,并且所有线程都需要响应。这种情况可能发生在共享数据正在初始化的情况下,并且处理线程都可以使用相同的数据但需要等待它被初始化(尽管对此有更好的机制;请参阅第 3 章中的 3.3.1 节),或者线程需要等待共享数据的更新,例如定期重新初始化。在这些情况下,准备数据的线程可以对条件变量调用notify_all()成员函数,而不是notify_one()。顾名思义,这会导致当前执行 wait() 的所有线程检查它们正在等待的条件。

如果等待线程只等待一次,那么当条件为真时,它将永远不会再次等待该条件变量,那么条件变量可能不是同步机制的最佳选择。尤其是等待特定可用数据块时。在这种情况下,future可能更合适。

posted @ 2024-07-25 17:40  李思默  阅读(75)  评论(0)    收藏  举报