# 一、Intro
完全无任何线程同步措施,线程会相互竞争打断,本例中,线程 A 的操作无法保证完整性:
1 | void ThreadA() { |
每个线程操作公共资源前先加锁再操作,可以保证对资源的访问不会被打断,但无协调同步:
1 | void ThreadA() { |
加入条件变量进行协同,A 线程在完成处理后通知 B 线程:
1 | void ThreadA() { |
但如果 A 线程的速度很快,A 线程仍然会多次竞争到锁然后多次访问资源、多次 notify 线程 B,尽管线程 B 具备访问公共资源的条件(条件变量成立),但常常会被线程 A 抢占 —— 除非线程 A 通过 sleep 休眠来放弃 CPU。
这里只要让线程 A 稍微休眠仅仅 1 ms,线程 B 就能立刻抢占到 CPU,又加之条件变量的条件满足,故线程 B 得以访问公共资源:
1 | void ThreadA() { |
与之相反的是,线程 B 并不会由于频繁调用而持续占用 CPU,因为其调用的
wait
函数会阻塞并等待互斥锁可用,此时其将放弃 CPU。# 二、常见场景
# 1、A 线程和 B 线程轮流执行,互为条件
1 |
|
# 2、A 线程执行一次后,B 线程执行多次,轮流执行
# 3、A 线程执行多次后,B 线程执行一次,轮流执行
# 4、A 线程执行多次后,B 线程执行多次,轮流执行
1 | void threadFuncA() { |
以上三种情况只需要在各自线程中维护一个 cnt 变量即可,只有运行一定次数后再去 notify 其他线程。
# 5、A 线程执行到某个节点后需要等待 B 线程执行到某个节点,然后 A 线程再执行
1 | bool isConditionMeet = false; |
利用条件变量,维护一个全局的布尔变量,用于控制 A、B 线程的先后运行顺序(先运行 B 线程),然后在需要协同的某个节点开始 wait,等待另一个线程执行完需要的依赖然后 notify。这里其实用两个条件变量更加便于理解且清晰。
# 6、A 线程执行到某个节点后需要等待 B 线程执行到某个节点,然后一起继续执行
B 线程到了相应节点后,notify A 线程,自己则照常继续往下执行即可。
A 线程 wait 等待 B 线程执行到相应的节点然后接收到通知后往下执行。
用到一个 mutex 和一个 condition_variable,可以不需要维护一个全局的布尔变量。
# 三、相关函数
# 1、std::condition_variable
std::condition_variable
是 C++ 标准库中的一种线程同步原语,用于实现线程之间的协作和通信。它通常与互斥锁( std::mutex
)一起使用,用于阻塞一个或多个线程,直到另一个线程修改了共享变量(条件)并通知了 condition_variable
。
想要修改共享变量的线程必须满足以下条件:
1. 获取一个 std::mutex
互斥锁(典型例子是通过 std::lock_guard
)
2. 当持有锁时修改该共享变量
3. 利用条件变量调用 notify_one
或者 notify_all
(可以先释放锁再 notify)
想要等待条件变量的线程必须满足以下条件:
1. 获取用于保护共享变量的 std::unique_lock<std::mutex>
2. 执行以下操作中的任意一个:
检测条件变量是否被 notify
利用条件变量调用 wait 函数(会自动释放锁并挂起线程,直到被 notify 或时间到期或虚假唤醒)
如果条件不满足则继续等待
# a)std::condition_variable::wait
wait 会阻塞当前线程,直到条件变量被 notify(或发生虚假唤醒),有两种重载形式:
1 | void wait( std::unique_lock<std::mutex>& lock ); |
当一个条件变量调用 wait 函数时,会自动释放当前已经持有的锁,然后阻塞当前线程,等待条件变量被 notify。当 notify_one
或 notify_all
被执行后,线程会被解除阻塞。当被解除阻塞后,在 wait 函数退出前,锁会被重新获取。
第二种重载形式相较于第一种,多了一个谓词作为条件,其含义为:当条件不满足时,将持续等待。其能够有效避免虚假唤醒。
第二种重载形式等价于:
1 | while (!stop_waiting()) |
这意味着:
1. 当第一次调用 wait 时,如果条件为真,不会发生阻塞,不需要 notify 就能继续执行后续代码
2. 一旦阻塞发生,则必须等待 notify 以解除阻塞,光是条件为真不管用
3. 如果被 notify 了,不一定会解除阻塞,需要进一步判断条件是否为真,以防止虚假唤醒
1 | std::mutex mtx; |
# b)std::condition_variable::wait_for
和 wait 相似,有两个重载版本:
1 | template< class Rep, class Period > |
与 wait 不同的是, wait_for
允许线程在等待条件一段时间后自动醒来,即使条件尚未满足。线程可以指定一个时间段作为参数,如果在指定时间内条件未满足,线程将自动醒来。
另外,wait_for 具有函数返回值,用于区分是否超时、是否满足条件:
对于第一种重载版本:
返回 std::cv_status::timeout
如果超时了
返回 std::cv_status::no_timeout
如果未超时
对于第二种重载版本:
返回 false 如果超时后条件仍未满足( stop_waiting
仍为 false),否则返回 true
1 | std::mutex mtx; |
# c)std::condition_variable::notify_one and notify_all
唤醒等待在当前条件变量的任意一个线程或所有线程。
# 2、lock_guard
#CPP 新特性 #CPP11std::lock_guard
是 C++ 标准库中的一个类,用于管理互斥锁(mutex)的自动加锁和解锁操作。它是 C++11 引入的一部分,旨在简化多线程编程中的锁管理,以防止忘记在离开作用域时解锁互斥锁,从而避免死锁等问题
std::lock_guard
具有以下主要特点和用途:
- 自动加锁和解锁:当创建
std::lock_guard
对象时,它会自动锁定关联的互斥锁;当std::lock_guard
对象离开其作用域时(例如,通过函数返回或作用域结束),它会自动释放锁。 - 异常安全:
std::lock_guard
提供了异常安全性,即使在作用域中抛出异常,也会在作用域结束时正确释放锁,从而防止资源泄漏。 - 简化代码:使用
std::lock_guard
可以大大简化管理互斥锁的代码,避免了手动加锁和解锁,减少了程序出错的可能性。
1 | // 示例 |
1 | // 工程中实际应用 |
需要注意的是, std::lock_guard
通常是一个更安全和方便的替代品,而不是手动使用 std::unique_lock
或 std::lock
函数。它是一种简单的 RAII(资源获取即初始化)包装,可用于管理互斥锁的生命周期。
参考链接:
std: :lock_guard
# 3、unique_lock
std::unique_lock
是 C++ 标准库中的一个模板类,用于管理互斥锁( std::mutex
)的生命周期,提供更大的灵活性和功能,以确保在作用域结束时自动释放锁。 std::unique_lock
也是一种用于 RAII(资源获取即初始化)风格的锁管理。
std::unique_lock
具有以下主要特点和用途:
- 自动加锁和解锁:当创建
std::unique_lock
对象时,它可以选择是否自动锁定关联的互斥锁,还可以在其作用域结束时自动解锁互斥锁。这提供了更大的灵活性,因为您可以手动控制锁定和解锁的时机。 - 异常安全:
std::unique_lock
提供了异常安全性,即使在作用域中抛出异常,也会在作用域结束时正确释放锁,从而防止资源泄漏。 - 支持延迟锁定:
std::unique_lock
允许您在等待条件变量时解锁互斥锁,然后重新锁定互斥锁,以提高效率和减少锁的占用时间。 - 手动锁定和解锁:您可以随时手动锁定和解锁互斥锁,以执行更复杂的操作。
从上面这些特点看来,其实 unique_lock
和 lock_guard
颇有相似之处,都是 RAII 风格,都有相关的功能。但相比之下, unique_lock
有更大的灵活性,其可以控制加锁解锁的时机,可以手动加锁解锁,可以延迟锁定。二者的具体差异如下:
- 灵活性:
std::lock_guard
具有较低的灵活性,因为它只提供了自动加锁和解锁的功能。一旦创建,它不允许手动解锁或重新锁定互斥锁。这使得它非常适合那些不需要更复杂锁定操作的简单场景。std::unique_lock
具有更高的灵活性,因为它允许您手动锁定和解锁互斥锁。这意味着您可以选择何时锁定和解锁,还可以执行更复杂的操作,如延迟锁定和递归锁定。
- 锁定和解锁:
std::lock_guard
在构造时自动锁定互斥锁,并在作用域结束时自动解锁。没有手动解锁的选项。std::unique_lock
在构造时可以选择是否锁定互斥锁(std::defer_lock
),以及在作用域结束时是否自动解锁(std::adopt_lock
)。您还可以随时手动解锁或重新锁定互斥锁(lock
、unlock
)。
- 延迟锁定和条件变量:
std::unique_lock
对于支持条件变量的操作非常有用,因为它可以在等待条件变量时解锁互斥锁(wait
),然后重新锁定互斥锁。这允许线程在等待条件时不占用锁,提高了效率。std::lock_guard
不支持这种延迟锁定操作。
具体地,对于 unique_lock
,按照其默认的初始化方式,其在创建时自动加锁,离开作用域时自动解锁:
1 | { |
如果需要控制使其在创建时不立即 / 自动加锁,则:
1 | { |
如果需要控制使其在离开作用域时不立即 / 自动解锁,则:
1 | { |
这表明在创建该
unique_lock
时 mtx 已经被上锁了,并且在其作用域结束时不要自动解锁。参考链接:
unique_lock constructor
lock_tag
# 4、scoped_lock
#CPP 新特性 #CPP17std::scoped_lock
是 C++17 标准引入的一个 RAII 风格的模板类,用于管理多个互斥锁的生命周期。它是 std::lock
的一种更安全和方便的替代品,用于在一个作用域内同时锁定多个互斥锁,以避免死锁和提供异常安全性。
std::scoped_lock
具有以下主要特点和用途:
- 同时锁定多个互斥锁:
std::scoped_lock
允许您在一个作用域内同时锁定多个互斥锁,而不需要手动编写多次锁定的代码。 - 自动解锁:与
std::unique_lock
类似,std::scoped_lock
会在其作用域结束时自动解锁所有已锁定的互斥锁,无论是正常退出还是由于抛出异常而退出。 - 避免死锁:
std::scoped_lock
在锁定多个互斥锁时会采用死锁避免算法,以避免死锁。 - 异常安全:
std::scoped_lock
提供了异常安全性,确保即使在作用域中抛出异常,已锁定的互斥锁也会被正确释放。
std::scoped_lock
和 std::lock_guard
的主要差异在于前者能够管理多个互斥锁,其余都大同小异。
对于两者的差异,可参考:scoped_lock vs lock_guard
scoped_lock
和lock_guard
的区别?
在 C++17 标准中, std::scoped_lock
和 std::lock_guard
都是用于管理互斥锁(mutex)的锁类型,它们确保在作用域结束时自动释放锁。然而,它们有一些不同之处,主要体现在它们的设计目的和功能上。
std::scoped_lock
- 功能:
std::scoped_lock
主要用于同时锁定多个互斥锁。它提供了一个锁定多个互斥锁的安全机制,防止死锁的发生。 - 特性:支持锁定多个互斥锁,并按照指定的顺序锁定,防止死锁。
- 引入: C++17 标准引入。
std::lock_guard
- 功能:
std::lock_guard
用于在单个互斥锁上实现简单的 RAII(资源获取即初始化)风格的锁定。它在构造时锁定互斥锁,在析构时自动释放锁。 - 特性:只能锁定一个互斥锁,适用于简单的互斥锁管理。
- 引入: C++11 标准引入。
具体区别
- 锁定多个互斥锁:
std::scoped_lock
可以同时锁定多个互斥锁,防止死锁。例如:1
2std::mutex m1, m2;
std::scoped_lock lock(m1, m2);std::lock_guard
只能锁定一个互斥锁。1
2std::mutex m;
std::lock_guard<std::mutex> lock(m);
- 用法简洁性:
- 对于单个互斥锁,两者用法几乎一样,但
std::scoped_lock
在 C++17 中更推荐,因为它的名称更具描述性。 - 对于多个互斥锁,
std::scoped_lock
提供了更简洁的语法。
示例代码对比
使用
std::scoped_lock
:1
2
3
4
5[[nodiscard]] size_t get_tasks_queued() const
{
const std::scoped_lock tasks_lock(tasks_mutex);
return tasks.size();
}使用
std::lock_guard
:1
2
3
4
5[[nodiscard]] size_t get_tasks_queued() const
{
const std::lock_guard<std::mutex> tasks_lock(tasks_mutex);
return tasks.size();
}
在这个具体的例子中,锁定一个互斥锁时,两者的效果是相同的。选择使用哪一个更多的是风格和一致性的考虑。如果代码库已经在使用 std::scoped_lock
来锁定单个或多个互斥锁,那么继续使用 std::scoped_lock
可能更一致。
结论
- 单个互斥锁:
std::lock_guard
和std::scoped_lock
都可以使用。std::scoped_lock
的名称在语义上可能更明确。 - 多个互斥锁:使用
std::scoped_lock
。 - 代码一致性:根据代码库的风格和惯例来选择。C++17 及以后的代码可能更倾向于使用
std::scoped_lock
。
总的来说:
使用 std::lock_guard
如果需要在整个作用域中管理一个互斥锁
使用 std::scoped_lock
如果需要在整个作用域中管理多个互斥锁(明确多于一个)
使用 std::unique_lock
如果需要在作用域中解锁互斥锁(包括使用条件变量)
# 5、atomic
std::atomic
是 C++ 标准库提供的一组类型和函数,用于支持原子操作。原子操作是一种多线程编程中的同步机制,确保共享数据的并发访问是安全的,避免数据竞争和并发问题。 std::atomic
提供了一些可以在多线程环境中进行原子操作的基本数据类型,如整数、布尔值等。
std::atomic
具有以下特点:
- 原子性:
std::atomic
操作是原子的,要么完全执行,要么不执行。这意味着它们不会被其他线程中断,也不会导致竞态条件。 - 不需要互斥锁:
std::atomic
操作通常不需要显式的互斥锁,因为它们是原子的。这有助于提高多线程程序的性能。 - 内存顺序(Memory Order):
std::atomic
操作允许您指定内存顺序,以控制操作的顺序和可见性,以满足程序的需求。
常见的 std::atomic
类型包括 std::atomic<int>
, std::atomic<bool>
, std::atomic<std::shared_ptr<T>>
等,您可以根据需要选择合适的类型。
在多线程语境下, std::atomic
的常见用法包括:
- 实现原子计数器:
std::atomic
可用于实现线程安全的计数器,如统计某个事件发生的次数。 - 管理共享标志位:
std::atomic<bool>
常用于管理共享标志位,用于控制线程的启动、停止或某个状态的切换。 - 无锁数据结构:
std::atomic
用于创建无锁数据结构,如无锁队列、无锁堆栈,以提高多线程程序的性能。 - 原子操作函数:
std::atomic
类型提供了一系列原子操作函数,如store
、load
、exchange
、compare_exchange
等,用于执行各种原子操作。 - 控制并发访问:
std::atomic
可以用于确保多线程环境中的共享数据的一致性,以避免竞态条件。
简单来说,对于简单的内置变量,可通过 atomic
来进行多线程操作而不用使用锁。
头文件:
1 |
atomic 是个模板,其对于变量的初始化:
1 | std::atomic<bool> isReady(false); |
变量取值:
1 | int ret = num.load(); |
变量设值:
1 | isReady.store(true); |
TBD,更多细节,参考:
https://www.educative.io/answers/what-is-atomic-type-in-cpp
https://stackoverflow.com/questions/31978324/what-exactly-is-stdatomic(回答 2)
# 四、相关概念
# 1、虚假唤醒
虚假唤醒(Spurious Wakeup)是多线程编程中一个重要的概念,指的是在没有收到明确的通知的情况下,等待中的线程会偶尔自发地从休眠状态醒来。这种情况可能发生在使用条件变量( std::condition_variable
)等线程同步机制时。
虚假唤醒有以下关键特点和考虑事项:
- 无通知情况下醒来:在条件变量的等待期间,线程可能因为某些系统或实现细节而在没有任何明确通知的情况下醒来。这是一个与多线程编程相关的现象,可能是由操作系统、编译器或硬件的特定行为导致的。
- 检查条件的必要性:虚假唤醒的发生意味着等待线程必须谨慎处理醒来的情况。因此,在等待条件满足时,线程应该总是在一个循环中检查条件,而不是假设条件一定已满足。
- 条件互斥:通常,虚假唤醒会伴随互斥锁的使用。线程在等待前获取互斥锁,然后在等待期间释放它,以确保其他线程能够访问共享资源。虚假唤醒可能会导致等待线程在检查条件前重新获取互斥锁。
- 实现相关:虚假唤醒的频率可能因操作系统或编译器的实现而异。有些操作系统 / 编译器可能更容易发生虚假唤醒,而其他可能较少发生。
# 2、概念辨析
以下概念都与多线程编程和并发相关:
- 阻塞(Blocking):
- 阻塞是指线程暂停其执行,等待某种事件的发生,通常是等待某个条件的满足或资源的可用性。在阻塞状态下,线程不会占用 CPU 时间,直到条件满足或资源可用时才会继续执行。
- 休眠(Sleeping):
- 休眠是阻塞的一种形式,线程在休眠状态下会进入一种低功耗状态,以节省系统资源。通常,线程会在休眠一段时间后自动醒来,或者通过外部事件唤醒。
- 挂起(Suspending):
- 挂起是指将线程的执行暂停,使其暂时不可运行。这可以是手动挂起线程,也可以是由操作系统或调度程序执行。线程在挂起状态下不会占用 CPU 时间,需要显式恢复才能继续执行。
- 忙等待(Busy-Waiting):
- 忙等待是一种线程等待条件满足的方式,它通过不断检查条件的变化来等待,而不是进入休眠状态。忙等待会占用大量的 CPU 时间,通常不是一种高效的等待方式,应该避免在需要长时间等待的情况下使用。
- 自旋(Spinning):
- 自旋是忙等待的一种形式,线程在自旋状态下会重复执行某个操作,通常是检查条件是否满足。自旋通常用于需要极短等待时间的情况,以避免进入和退出休眠状态的开销。
这些概念在多线程编程中有不同的用途和场景。选择适当的等待方式取决于具体的需求和性能要求。通常,阻塞和休眠是较为高效的等待方式,而忙等待和自旋适用于某些特定的情况。挂起通常用于需要手动控制线程生命周期的情况。
# 五、关于 wait 的一些实验
对于带条件的 wait,有以下几个实验:
在下面这个实验中,我们让线程 A 先运行,在线程 B 还未执行 notify 之前,线程 A 就跳过了 wait 并打印相关信息。这里我们让 A 线程中的休眠时间尽可能短,使得一旦线程 B notify,线程 A 马上解除阻塞。
1 | std::mutex mtx; |
上面这个实验表明:当调用
cv.wait(lck, [](){ return isGood; });
时,如果条件满足,则不会阻塞,不需要被 notify 就能直接执行后续代码。其实就是验证了前文所说的:
当第一次调用 wait 时,如果条件为真,不会发生阻塞,不需要 notify 就能继续执行后续代码
在下面这个实验中,我们去掉了线程 B 中的 notify 动作,只修改 isGood 但不通知。同时,我们更改了线程 A 的休眠时间,让其在第一次执行之后等待足够长的时间以确保线程 B 已经将 isGood 改为 true。
1 | void ThreadA() { |
上面这个实验进一步证明:当我们调用带条件的 wait 时,首先会判断条件是否满足,如果满足,则根本不需要 notify 就能往下走。如果把上面线程 A 的休眠时间改成 50 ms,则会产生截然不同的结果:由于休眠时间很短,线程 A 第一次输出打印信息并将 isGood 改为 false 后,由于线程 B 还来不及更改 isGood 的值,线程 A 再次进入作用域中,此时判断 isGood 条件仍为 false,于是阻塞。后续即使线程 B 中更改了 isGood 的值为 true,但由于没有 notify 函数,无法唤醒,线程 A 将永远阻塞在 wait 函数上。
下面这个实验则是在同一个作用域中多次调用 wait,只要条件满足,不阻塞,不需要 notify。
1 | void ThreadA() { |
在下面这个实验中,我们在线程 B 中并不修改条件使其为真,但却 notify,模拟虚假唤醒。于是显然,线程 A 仍然阻塞在 wait。
1 | void ThreadA() { |
综上,带条件的 wait 完全可以视作:
1 | while (!stop_waiting()) |
参考链接:
C++ 多线程原语
the End.