C++ std::condition_variable 条件变量类探索:解锁条件变量的底层原理

简介: C++ std::condition_variable 条件变量类探索:解锁条件变量的底层原理

简介

C++ 标准库中的 std::condition_variable 类提供了一些接口,用于线程同步和条件等待。按照功能分类,它们分为以下几类:

  1. 等待(Wait):
  • wait(std::unique_lock& lock): 当前线程等待,直到条件变量被通知。在等待期间,锁会被解锁。
  • wait(std::unique_lock& lock, Predicate pred): 当前线程等待,直到条件变量被通知且谓词 pred 返回 true。在等待期间,锁会被解锁。
  1. 带超时的等待(Timed wait):
  • wait_for(std::unique_lock& lock, const std::chrono::duration& rel_time): 当前线程等待,直到条件变量被通知或超时。在等待期间,锁会被解锁。
  • wait_for(std::unique_lock& lock, const std::chrono::duration& rel_time, Predicate pred): 当前线程等待,直到条件变量被通知且谓词 pred 返回 true 或超时。在等待期间,锁会被解锁。
  • wait_until(std::unique_lock& lock, const std::chrono::time_point& abs_time): 当前线程等待,直到条件变量被通知或达到指定的绝对时间点。在等待期间,锁会被解锁。
  • wait_until(std::unique_lock& lock, const std::chrono::time_point& abs_time, Predicate pred): 当前线程等待,直到条件变量被通知且谓词 pred 返回 true 或达到指定的绝对时间点。在等待期间,锁会被解锁。
  1. 通知(Notify):
  • notify_one(): 随机唤醒一个等待中的线程。
  • notify_all(): 唤醒所有等待中的线程。

以上就是 std::condition_variable 类提供的所有接口。这些接口用于在多线程环境中实现同步和条件等待,以确保线程安全地访问共享资源。

等待(Wait)

std::condition_variable::wait() 函数有两个重载版本:一个只接受 std::unique_lock 参数,另一个接受 std::unique_lock 和一个谓词(predicate)参数。在我们这个例子中,我们使用了第二个重载版本,它接受一个谓词参数。

当我们调用带有谓词参数的 wait() 方法时,这个谓词将用于检查条件是否满足。在内部,wait() 函数会在一个循环中执行以下操作:

  1. 检查谓词是否返回 true。如果返回 true,则跳出循环,线程继续执行。
  2. 如果谓词返回 false,线程进入等待状态,等待条件变量被通知。
  3. 当条件变量被通知(通过 notify_one()notify_all())时,线程从等待状态唤醒,并重新检查谓词。

wait(std::unique_lockstd::mutex& lock)

wait(std::unique_lock& lock)std::condition_variable 类中的一个成员函数,它用于阻塞当前线程,直到条件变量被通知。在等待期间,传递给 wait() 的互斥锁会被解锁,从而允许其他线程锁定该互斥锁并访问共享资源。当条件变量被通知后,锁会重新锁定,线程会继续执行。

以下是 wait() 方法的基本使用方法:

std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void worker() {
    // ... do some work ...
    {
        std::unique_lock<std::mutex> lock(mtx);
        ready = true;
    }
    cv.notify_one();
}
void main_thread() {
    std::unique_lock<std::mutex> lock(mtx);
    while (!ready) {
        cv.wait(lock);
    }
    // ... continue processing when ready ...
}

在这个示例中,主线程等待工作线程完成任务并将 ready 变量设为 true。主线程在 cv.wait(lock) 处阻塞,直到工作线程调用 cv.notify_one()

底层逻辑:

  1. 当前线程调用 wait() 函数时,会将传入的 std::unique_lock 对象解锁,释放对互斥锁的控制。
  2. 当前线程进入阻塞状态,等待条件变量的通知。在这个阶段,其他线程可以获取互斥锁并访问共享资源。
  3. 当其他线程调用 notify_one()notify_all() 时,至少一个正在等待的线程(在本例中是主线程)会被唤醒。
  4. 当前线程在唤醒后会尝试重新获取传入的 std::unique_lock 对象(即重新锁定互斥锁)。
  5. 互斥锁重新锁定后,当前线程会从 wait() 函数返回,继续执行后续代码。

需要注意的是,虽然条件变量的通知机制可以唤醒等待的线程,但在某些情况下(例如虚假唤醒),线程可能会在条件未满足的情况下被唤醒。因此,在使用 wait() 时,通常需要将其放在一个循环中,并在循环条件中检查等待的条件是否满足。这样可以确保线程只在条件真正满足时才会继续执行。

wait(std::unique_lockstd::mutex& lock, Predicate pred)

当线程被唤醒时,wait() 函数会执行传递给它的第二个参数(谓词)。这个谓词用于检查条件是否满足。只有当谓词返回 true 时,线程才会继续执行。否则,线程会再次进入等待状态。

谓词可以是任何可调用对象,包括函数、成员函数、静态成员函数、lambda 表达式等。关键是它必须能够被调用,并且返回一个布尔值(truefalse)来表示条件是否满足。

以下是一些可以作为谓词的示例:

  1. Lambda 表达式:
m_cond_var.wait(lock, [this] { return m_signal_received; });
  1. 成员函数(使用 std::bind() 绑定):
bool is_signal_received() const { return m_signal_received; }
// ...
m_cond_var.wait(lock, std::bind(&PlayMangent::is_signal_received, this));
  1. 静态成员函数:
static bool is_signal_received_static(const PlayMangent* self) { return self->m_signal_received; }
// ...
m_cond_var.wait(lock, std::bind(&PlayMangent::is_signal_received_static, this));
  1. 全局函数:
bool is_signal_received_global(const PlayMangent* self) { return self->m_signal_received; }
// ...
m_cond_var.wait(lock, std::bind(is_signal_received_global, this));
  1. 函数对象(仿函数,Functor):
class SignalChecker {
public:
    explicit SignalChecker(const PlayMangent* play_mangent) : m_play_mangent(play_mangent) {}
    bool operator()() const { return m_play_mangent->m_signal_received; }
private:
    const PlayMangent* m_play_mangent;
};
// ...
SignalChecker checker(this);
m_cond_var.wait(lock, checker);

总之,谓词可以是任何可调用对象,只要它能够返回一个表示条件是否满足的布尔值即可。您可以根据您的需求和编程风格选择合适的谓词类型。

Predicate

在 C++ 中,Predicate 是一个泛指,用于描述可调用对象(例如函数、函数指针、Lambda 表达式、仿函数等),这些对象接受一个或多个参数,返回一个布尔值(truefalse)。Predicate 在许多 C++ 标准库中的算法和函数中使用,作为参数来表示条件。

当谈论 std::condition_variable::wait() 方法及其重载版本时,Predicate 是一个可调用对象,它不接受任何参数并返回一个布尔值。这个谓词用于在调用 wait() 方法时检查等待的条件是否满足。如果谓词返回 true,则线程继续执行;如果谓词返回 false,则线程继续等待。

以下是一个简单的示例,使用 Lambda 表达式作为谓词:

std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void main_thread() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return ready; });
    // ... continue processing when ready ...
}

在这个示例中,谓词是一个 Lambda 表达式 [] { return ready; },它检查 ready 变量是否为 true。当 ready 变为 true 时,线程从 wait() 返回并继续执行。

虚假唤醒

虚假唤醒是指一个线程在没有收到条件变量通知的情况下仍然从等待状态唤醒。虚假唤醒可能发生在任何平台,包括 ARM 系统。虽然虚假唤醒在实际中相对罕见,但仍然需要考虑它们以确保代码的正确性。

虚假唤醒的具体原因可能因系统和库的实现而异。以下是一些可能导致虚假唤醒的原因:

  1. 操作系统实现:操作系统在实现线程调度时,可能会因为内部调度策略、优化或者错误而导致虚假唤醒。在某些情况下,操作系统可能允许条件变量在没有实际通知的情况下唤醒线程,以确保系统的活跃度和响应能力。
  2. 信号处理:如果在等待条件变量时,线程收到一个信号(例如,由于操作系统中断或其他线程发送的信号),线程可能会因为处理信号而唤醒。在这种情况下,唤醒并不是由于条件变量的通知,而是由于信号处理导致的。
  3. 库实现:虚假唤醒有时可能是库实现的副作用。例如,C++ 标准库中的 std::condition_variable 的实现可能会因为内部实现细节、优化或错误而导致虚假唤醒。

为了应对虚假唤醒,我们需要在等待条件变量时使用一个谓词(predicate)。谓词是一个函数或者 lambda 表达式,它返回一个布尔值。当谓词为 true 时,线程将从等待状态唤醒并继续执行。这样可以确保线程只在满足特定条件时才从等待状态唤醒,从而避免虚假唤醒带来的问题。

当一个线程在等待条件变量时,它实际上是在等待满足某个特定条件。在 C++ 中,std::condition_variablewait() 函数有两种重载形式:一种只接受一个互斥锁(std::unique_lock),另一种接受一个互斥锁和一个谓词。如果你使用的是第一种重载形式,那么线程在被唤醒后会继续执行,而不会检查条件是否满足。在这种情况下,如果发生虚假唤醒,线程将继续执行,即使条件未满足。

为了避免这个问题,我们应该使用第二种重载形式,也就是带有谓词的 wait() 函数。谓词是一个返回布尔值的函数或 lambda 表达式,用于检查条件是否满足。当谓词为 true 时,线程会从等待状态唤醒并继续执行。在这种情况下,即使发生虚假唤醒,线程仍然会检查谓词。如果谓词为 false,线程将重新进入等待状态,直到条件满足(谓词返回 true)。

所以,当我们提到虚假唤醒时,我们实际上是指线程在没有收到条件变量通知的情况下从等待状态唤醒。如果我们使用带有谓词的 wait() 函数,那么即使发生虚假唤醒,线程仍然会检查条件是否满足,并在条件未满足时重新进入等待状态。这就是为什么我们需要使用带有谓词的 wait() 函数来保护我们的代码免受虚假唤醒的影响。

在之前给出的示例代码中,我们使用了一个谓词:

m_cond_var.wait(lock, [this] { return m_signal_received; });

这个谓词检查 m_signal_received 的值。只有当 m_signal_receivedtrue 时,线程才会从等待状态唤醒并继续执行。这样,即使发生虚假唤醒,线程仍然会检查条件是否满足,从而避免因虚假唤醒导致的问题。

std::condition_variable 在 Linux 系统下通常是基于 pthread 库实现的。因此,它的底层实际上使用了 POSIX 线程库(pthread)提供的条件变量函数,例如 pthread_cond_initpthread_cond_waitpthread_cond_signal 等。

POSIX 线程库中的条件变量也可能会遇到虚假唤醒的问题。在使用 pthread_cond_wait 等待条件变量时,我们通常需要将其放在一个循环中,检查条件是否满足。例如:

pthread_mutex_lock(&mutex);
while (!condition_is_met) {
    pthread_cond_wait(&cond_var, &mutex);
}
pthread_mutex_unlock(&mutex);

在这个示例中,我们将 pthread_cond_wait 放在一个循环中,并检查 condition_is_met 是否为 true。只有当条件满足时(condition_is_met == true),线程才会继续执行。这样,即使发生虚假唤醒,线程仍然会检查条件是否满足,并在条件不满足时重新进入等待状态。这与 C++ std::condition_variable 使用谓词的方法类似。

所以,POSIX 线程库中的条件变量也可能会遇到虚假唤醒的问题,但是我们可以通过在循环中检查条件是否满足的方法来避免受到虚假唤醒的影响。

带超时的等待(Timed wait)

std::condition_variable 类提供了带超时的等待接口,它们允许在等待条件变量时设置超时限制。这些接口有两种形式:wait_forwait_untilwait_for 使用相对时间等待,而 wait_until 使用绝对时间点等待。每种形式都有两个重载版本:一个只接受超时时间,另一个还接受谓词(Predicate)。

  1. wait_for(std::unique_lock& lock, const std::chrono::duration& rel_time)
    当前线程等待条件变量被通知或超时。在等待期间,互斥锁会被解锁。如果在超时时间内条件变量未被通知,线程将因超时而唤醒。
  2. wait_for(std::unique_lock& lock, const std::chrono::duration& rel_time, Predicate pred)
    当前线程等待条件变量被通知且谓词 pred 返回 true,或者超时。在等待期间,互斥锁会被解锁。如果在超时时间内条件变量未被通知或谓词未返回 true,线程将因超时而唤醒。
  3. wait_until(std::unique_lock& lock, const std::chrono::time_point& abs_time)
    当前线程等待条件变量被通知或达到指定的绝对时间点。在等待期间,互斥锁会被解锁。如果在指定的绝对时间点之前条件变量未被通知,线程将因超时而唤醒。
  4. wait_until(std::unique_lock& lock, const std::chrono::time_point& abs_time, Predicate pred)
    当前线程等待条件变量被通知且谓词 pred 返回 true,或者达到指定的绝对时间点。在等待期间,互斥锁会被解锁。如果在指定的绝对时间点之前条件变量未被通知或谓词未返回 true,线程将因超时而唤醒。

底层原理:

  1. 当调用超时等待接口时,当前线程会解锁传入的互斥锁,允许其他线程获取锁并访问共享资源。
  2. 当前线程进入阻塞状态,等待条件变量的通知或超时。
  3. 当其他线程调用 notify_one()notify_all() 时,至少一个正在等待的线程(可能是当前线程)会被唤醒。如果超时发生,当前线程也会被唤醒。
  4. 当前线程在唤醒后会尝试重新获取传入的互斥锁。
  5. 对于带谓词的重载版本,唤醒后的线程会检查谓词是否满足(返回 true)。如果谓词返回 true,线程会继续执行;如果谓词返回 false,线程将继续等待,直到超时或谓词满足。

注意:在所有超时等待接口中,如果因超时而唤醒,线程需要检查条件是否满足以正确处理超时情况。如果使用带谓词的重载版本,谓词会自动进行这个检查。

示例:

std::mutex mtx;
std::condition_variable cv;
bool data_ready = false;
void worker_thread() {
    std::unique_lock<std::mutex> lock(mtx);
    bool timeout_occurred = cv.wait_for(lock, std::chrono::seconds(5), [&] { return data_ready; });
    if (timeout_occurred) {
        std::cout << "Timeout occurred" << std::endl;
    } else {
        std::cout << "Data is ready" << std::endl;
    }
}
void main_thread() {
    // ... do some work ...
    // Notify worker_thread that data is ready
    {
        std::lock_guard<std::mutex> lock(mtx);
        data_ready = true;
    }
    cv.notify_one();
}

在这个示例中,worker_thread 使用 wait_for 方法等待最多 5 秒。如果在 5 秒内 data_ready 变量变为 true,线程继续执行;否则,线程因超时而唤醒,并输出 “Timeout occurred”。

超时等待的干扰因素

超时等待受到一些因素的影响,例如调度器策略、系统负载、硬件性能等。这些因素可能导致实际等待时间与设定的超时时间有所不同。在高负载或资源受限的情况下,线程可能需要等待比设定的超时时间更长的时间才能被唤醒。

关于系统时间的影响,这取决于使用的超时等待接口和底层实现。在C++11及其后续版本中,std::chrono 提供了不同类型的时钟,它们对系统时间变化的敏感程度不同。

  1. std::chrono::system_clock:这个时钟是受系统时间影响的墙上时钟(wall-clock time)。如果在等待期间系统时间发生变化(例如,由于手动更改时间或自动同步时间),这可能会影响到 wait_until 方法使用 system_clock 的超时行为。但是,这对 wait_for 方法没有影响,因为它使用的是相对时间。
  2. std::chrono::steady_clock:这个时钟提供单调递增的时间,不受系统时间的影响。它通常用于测量时间间隔。当使用 steady_clock 时,wait_until 方法不会受到系统时间变化的影响。

建议在涉及超时的场景中使用 std::chrono::steady_clock,以避免受到系统时间变化的影响。然而,请注意,C++标准库中的默认实现可能因平台而异。为了确保使用稳定的时钟,请显式指定 std::chrono::steady_clock

通知(Notify)

std::condition_variable 的通知(Notify)接口用于唤醒等待中的线程。这些方法通常在条件满足时调用,以唤醒正在等待条件变量的一个或多个线程。通知接口有两个方法:

  1. notify_one(): 此方法随机唤醒一个等待中的线程。如果有多个线程正在等待条件变量,只有一个会被选择并唤醒。选择哪个线程是不确定的。在实际应用中,为了避免竞争,我们通常在已经持有互斥锁的情况下调用此方法。然而,调用 notify_one() 本身并不需要持有互斥锁。
  2. notify_all(): 此方法唤醒所有等待中的线程。如果有多个线程正在等待条件变量,它们都将被唤醒并开始执行。这个方法在需要通知所有等待线程的场景中很有用。与 notify_one() 类似,我们通常在已经持有互斥锁的情况下调用此方法。然而,调用 notify_all() 本身并不需要持有互斥锁。

底层原理:

C++ std::condition_variable 的底层实现通常依赖于操作系统提供的原生条件变量支持,例如在 POSIX 系统中使用 pthread_cond_signal()pthread_cond_broadcast() 函数,或在 Windows 系统中使用 WakeConditionVariable()WakeAllConditionVariable() 函数。

当调用 notify_one()notify_all() 时,底层实现会通知操作系统去唤醒一个或所有正在等待条件变量的线程。这个过程通常需要涉及到操作系统调度器,以便在唤醒的线程获得 CPU 时间片并开始执行。

需要注意的是,在调用通知方法之后,线程不会立即开始执行,因为它们还需要重新获取互斥锁。当一个线程被唤醒时,它会尝试重新获取互斥锁。一旦锁被成功获取,线程将继续执行。如果有多个线程同时被唤醒,它们将竞争互斥锁,而只有一个线程能够继续执行,其他线程将继续等待锁。


目录
相关文章
|
2天前
|
存储 编译器 C语言
【c++丨STL】string类的使用
本文介绍了C++中`string`类的基本概念及其主要接口。`string`类在C++标准库中扮演着重要角色,它提供了比C语言中字符串处理函数更丰富、安全和便捷的功能。文章详细讲解了`string`类的构造函数、赋值运算符、容量管理接口、元素访问及遍历方法、字符串修改操作、字符串运算接口、常量成员和非成员函数等内容。通过实例演示了如何使用这些接口进行字符串的创建、修改、查找和比较等操作,帮助读者更好地理解和掌握`string`类的应用。
16 2
|
8天前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
35 5
|
15天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
46 4
|
16天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
43 4
|
1月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
28 4
|
1月前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
25 4
|
1月前
|
存储 安全 C++
【C++打怪之路Lv8】-- string类
【C++打怪之路Lv8】-- string类
22 1
|
1月前
|
存储 编译器 C++
【C++类和对象(下)】——我与C++的不解之缘(五)
【C++类和对象(下)】——我与C++的不解之缘(五)
|
1月前
|
编译器 C++
【C++类和对象(中)】—— 我与C++的不解之缘(四)
【C++类和对象(中)】—— 我与C++的不解之缘(四)
|
1月前
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
54 1