(一)多线程那些事儿:怎么用
(一)多线程那些事儿:怎么用
cpu 的单核性能提升接近瓶颈,多核是未来 cpu 的趋势。而多线程就能充分利用多核技术。但使用多线程不一定只是为了追求性能,使用多线程的意义也在于提升处理任务数量(请求个数)的能力,比如说即时通讯系统中,发起多个会话就是如此。不开线程就没办法,发起新的一个会话。当然多进程也能做这些事情,至于多线程和多进程的比较使用,则是另一个话题,也不在此展开。
可以说多线程是比较重要,且属于程序开发中比较基础的能力。而多线程的使用可以从两个角度出发去理解:
- 多线程的创建和相关管理手段
- 多线程的通信和同步方式
0. concepts
0.1 introduction
-
std::thread
,std::async
,std::packaged_task
的区别是什么? - 为什么多线程如何传引用?如何传类成员函数?
- 什么是虚假唤醒?
-
std::lock_guard
和std::unique_lock
有什么不同? -
std::condition_variable
和std::future
怎么同步? - 什么是 CAS?
- 什么是内存序?什么是先行发生,什么是并行发生?
0.2 怎么理解线程和任务?
在软件开发中,开发者的目标是并发/并行处理多个任务。而线程是作为实现并发处理的常见手段,其核心作用是承载和执行任务。
随着线程池和并行库等高级工具的广泛应用,线程的管理逻辑被封装在内核中,外部调用者无需感知线程的具体创建、调度和销毁过程。这种抽象设计带来了便利,但也容易导致概念混淆。例如,在使用并行库执行任务时,开发者可能默认是通过多线程实现并行,但实际上底层实现可能采用协程、异步 I/O 或其他非线程技术。以OpenMP
的嵌套并行为例,其内层并行任务在默认配置下并不会创建新的线程,而是复用外层线程资源,这进一步凸显了线程与任务的本质差异。
因此,明确区分线程与任务的概念至关重要:
- 任务:指需要完成的具体工作单元,是开发者定义的逻辑操作,如数据处理、函数调用等;
- 线程:作为操作系统分配的执行资源,是任务的实际载体,具备独立的执行上下文和系统调度能力。
准确理解这两个概念,不仅有助于更清晰地描述并发模型,也能在技术沟通和性能优化中避免歧义,提升协作效率。
0.3 多线程相关的性能开销有多少?
| 操作类型 | CPU时钟周期范围 | 时间范围(纳秒) |
| L1 缓存读取 | 3-4 | 9-12ns |
| L2 缓存读取 | 10-12 | 30-36ns |
| L3 缓存读取 | 30-70 | 90-210ns |
| 主内存读取 | 100-150 | 300-450ns |
| NUMA(不同插槽的原子操作/CAS,估计值) | 100-300 | 300-900ns |
| NUMA(不同插槽的 L3 缓存读取) | 100-300 | 300-900ns |
| 分配+释放配对(小对象,内存池方式) | 5-15 | 10-50ns |
| 分配+释放配对(小对象,new和delete方式) | 200-500 | 600-1500ns |
| ------------------------- | -------- | ---------- |
| 内核调用 | 1000-1500 | 3-4.5μs |
| 线程上下文切换(直接成本) | 2k | 6μs |
| 线程上下文切换(总成本,包括缓存失效) | 10k- 1m | 30μs - 3ms |
| ------------------------- | -------- | ---------- |
| 磁盘访问(SSD) | 100k- 1m | 30μs - 300μs |
| 磁盘访问(HDD) | 1m- 10m | 300μs - 3ms |
| 线程创建 | 1m- 10m | 300μs - 3ms |
| 线程销毁 | 1m- 10m | 300μs - 3ms |
在多线程性能分析中,虽然具体操作开销因硬件架构、系统负载等因素存在差异,但不同操作的开销量级关系具有普遍参考价值。深入理解这些量级差异及其影响因素,是实现系统性能优化的核心。
从开销量级来看,线程创建与销毁的开销通常在 300 微秒至 3 毫秒之间。当单个任务执行时间达到 50 毫秒以上时,此类开销对任务整体耗时的影响相对较小;但对于本身是 5ms 左右的任务来说,在特定场景下却可能引发显著的性能瓶颈。
以 IO 密集型任务为例,缓存机制对性能的影响尤为关键。当 L1/L2 缓存频繁失效,数据读取被迫降级至 L3 缓存甚至主内存时,访问延迟可能激增百倍。假设某任务中 IO 操作占比 80%,计算操作占比 20%,且 IO 操作高度依赖缓存命中,缓存失效将严重拖慢任务执行效率。
此外,锁与同步机制产生的开销本质上也与 IO 存在关联。锁操作通过总线指令锁定数据资源,当其他线程尝试访问被锁定的数据时,会因资源不可用而进入阻塞状态,这种阻塞表现为数据输入输出的延迟,因此可将其纳入广义的 IO 开销范畴。
因此,在分析多线程应用性能时,既可以采用传统的 IO 密集型/CPU 密集型分类,也可以从缓存读写、CPU 计算、同步开销三个维度展开分析。无论采用何种视角,通过优化缓存管理、减少不必要的上下文切换与同步操作,都是提升多线程系统性能的关键路径。
0.4 线程状态切换图
+-------+ +-------+
| New | -> | Ready |
+-------+ +-------+
^ |
| v
+-----------------------+
| Running |
+-----------------------+
| ^ |
v | v
+-------+ +-------+
| waiting/Blocked| | Terminated|
+-------+ +-------+
如果把上述看成一个状态机,New
和Terminated
其实就是起点和终点,相对重要的是其余三个状态。首先是只有处于Running
状态的才会消耗CPU资源。
- 什么时候会进入
waiting/Blocked
状态?- 拿不到锁。
mtx.lock();
如果拿不到锁,当前锁就进入waiting/Blocked
了 - 调用
wait()
的时候,拿不到锁 - 调用
sleep()
- 发起内存/硬盘/网络等
io
操作
- 拿不到锁。
- 只有
Running
状态的才在真实消耗CPU计算资源
1. 多线程的创建和管理手段
c++多线程创建和管理的手段就三种:std::thread
、std:async
和std::packaged_task
。
如果用一句话总结区别的话就是,不需要线程返回结果就是用std::thread
,需要线程返回结果就是用std::async
和std::packaged_task
;不想管理线程的,就是用std::async
,想提升性能去管理线程的话,就是std::packaged_task
和std::thread
。
1.1 thread
-
适用场景:
- 需要直接控制线程的生命周期:当你需要精细控制线程的创建、启动、暂停、恢复和终止时,使用
std::thread
是合适的选择。 - 需要共享资源的复杂同步:当多个线程需要访问共享资源,并且需要复杂的同步机制(如互斥锁、条件变量)时,
std::thread
提供了更大的灵活性。 - 需要高性能:在某些高性能计算场景中,直接使用
std::thread
可以避免一些抽象层带来的开销。
- 需要直接控制线程的生命周期:当你需要精细控制线程的创建、启动、暂停、恢复和终止时,使用
-
特点
- 直接控制:
std::thread
提供了对线程的直接控制,可以精细地管理线程的创建、执行和销毁。 - 手动管理:需要手动管理线程的生命周期,包括创建、等待(
join
)和分离(detach
)线程。 - 无返回值:
std::thread
无法直接返回任务的结果,需要使用其他同步机制(如std::promise
和std::future
)来获取结果。
- 直接控制:
1.2 async
std::async
是一个强大的工具,是 C++11 中引入的一个用于启动异步任务的函数。通过合理使用 std::async
和选择适当的启动策略,可以提高程序的并发性能和响应速度。它自动处理线程的创建和销毁,使得异步编程变得更简单。如果你只需要在后台运行一个任务并获取其结果,那么std::async
通常是最好的选择。
-
适用场景:
- 简单的异步任务:当你需要启动一个简单的异步任务,并且不需要显式管理线程时,使用
std::async
是最方便的选择。 - 任务的启动策略:当你希望任务可以根据需要立即执行或延迟执行时,
std::async
提供了灵活的启动策略(如std::launch::async
和std::launch::deferred
)。 - 需要返回值的异步任务:当你需要启动一个异步任务并获取其返回值时,
std::async
会返回一个std::future
对象,方便获取结果。
- 简单的异步任务:当你需要启动一个简单的异步任务,并且不需要显式管理线程时,使用
-
应用场景
- 简单异步任务:适用于简单的异步任务,不需要复杂的线程管理。
- 自动管理需求:适用于希望自动管理线程生命周期的场景,减少手动管理的复杂性。多为同步计算和异步计算同时存在的场景。或者更进一步,用来处理 io 耗时而非 cpu 密集任务的。比如说要播放一个特效动画,然后计算伤害,特效动画开一个 async 去做,后面继续计算等等。
template <class Fn, class... Args>
std::future<typename std::result_of<Fn(Args...)>::type>
async(std::launch policy, Fn&& f, Args&&... args);
1.3 packaged_task
std::packaged_task
是 C++11 中引入的一个类模板,用于将一个可调用对象(task
,如函数、lambda 表达式或函数对象)包装成任务,并将其结果存储在一个 std::future
对象中,以便稍后获取。
- 适用场景:
- 需要更高的灵活性:当你需要将任务与线程分离,并在不同的时间和上下文中启动任务时,使用
std::packaged_task
是合适的选择。 - 复杂的任务管理:当你需要显式管理任务的生命周期,并且可能需要将任务传递给其他线程或存储在容器中时,
std::packaged_task
提供了更高的灵活性。 - 需要返回值的任务:与
std::async
类似,std::packaged_task
也会返回一个std::future
对象,用于获取任务的结果。
- 需要更高的灵活性:当你需要将任务与线程分离,并在不同的时间和上下文中启动任务时,使用
2. 多线程间的通信、同步手段
相较于进程,线程是共享同一进程的地址空间的,线程间的通信将会很容易,直接就可以通过全局变量来交换数据。但这种访问的便利性也带来了一些风险,通常当有多个线程访问相同的共享数据时,做出的操作往往是不安全的。这就需要线程同步。所谓的线程同步,就是指多线程通过特定的设置(如互斥量、事件对象、临界区)来控制线程之间的执行顺序。
同步的目的是协同、协助、互相配合线程的运行,而不是同时进行。例如,一个线程完成任务后,另一个线程才开始执行。线程同步通过建立线程之间的执行顺序关系,确保线程按预定的顺序运行。如果没有同步机制,线程将各自独立运行,可能导致资源竞争和数据不一致的问题。
下面介绍 c++标准库中支持的常见同步手段。
- 互斥锁:
mutex
/shared_mutex
/recursive_mutex
- 条件变量:
condition_variable
- 承诺和期值:
promise
/future
- 原子对象:
atomic
还有一些 posix 标准支持的信号量,但标准库出来之后,现在用得很少了,但 C++20 又引入了,我了解不深,就不介绍了。
2.1 互斥锁
- 适用场景:
- 互斥锁:当你需要确保只有一个线程可以访问共享资源时,使用
std::mutex
是合适的选择。
- 互斥锁:当你需要确保只有一个线程可以访问共享资源时,使用
- 解释:
-
std::mutex
提供了互斥锁,确保只有一个线程可以访问共享资源。 - 使用
std::lock_guard
自动管理锁的生命周期,避免死锁。
-
2.1.1 c++标准库直接支持的锁类型
-
std::mutex
:基本的互斥锁。 -
std::recursive_mutex
:递归互斥锁,允许同一线程多次获取锁。 -
std::shared_mutex
:读写锁,允许多个线程同时读取,但写入时需要独占锁。 -
std::timed_mutex
:带有超时功能的互斥锁。 -
std::shared_timed_mutex
:带有超时功能的共享互斥锁。
2.1.2 锁策略/锁管理/上锁方式
-
std::lock
- 功能:一个函数模板,用于使用死锁避免算法锁定多个可锁对象。
- 特点:确保多个锁按顺序锁定,避免死锁。
- 使用场景:适用于需要同时锁定多个互斥锁的场景,确保线程安全。
- 如何使用:
- 调用
std::lock
函数时传入多个互斥锁,使用死锁避免算法锁定所有互斥锁。 - 在锁定成功后,可以使用
std::unique_lock
或std::lock_guard
管理这些锁。
-
std::try_lock
- 功能:一个函数模板,尝试获取多个互斥锁的所有权,如果无法获取则返回。
- 特点:用于尝试锁定多个互斥锁,而不会阻塞线程。
- 使用场景:适用于需要尝试锁定多个互斥锁的场景,避免线程阻塞。
- 如何使用:
- 调用
std::try_lock
函数时传入多个互斥锁,尝试获取所有互斥锁的所有权。 - 如果成功获取所有互斥锁,返回 -1;如果无法获取某个互斥锁,返回该互斥锁的索引。
-
std::unique_lock
- 功能:提供更多灵活性和控制的锁管理类,可以延迟锁定、手动锁定和解锁、传递锁的所有权。
- 特点:适用于复杂的锁定需求,支持延迟锁定和手动解锁。
- 使用场景:适用于需要灵活控制锁定和解锁的场景,如需要在不同函数间传递锁的所有权。
- 如何使用:
- 创建
std::unique_lock
对象时可以选择立即锁定或延迟锁定互斥锁。 - 可以手动调用
lock
和unlock
方法来控制锁定和解锁。 - 可以将
std::unique_lock
对象传递给其他函数,传递锁的所有权。
-
std::lock_guard
- 功能:简单的 RAII 风格的锁管理类,在构造时自动锁定互斥锁,在析构时自动解锁互斥锁。
- 特点:适用于基本的锁定需求,确保在作用域结束时自动释放锁。
- 使用场景:适用于简单的临界区保护,确保在函数退出时自动释放锁。
- 如何使用:
- 创建
std::lock_guard
对象时传入互斥锁,自动锁定互斥锁。 - 在作用域结束时,
std::lock_guard
会自动解锁互斥锁。
-
std::scoped_lock
- 功能:同时锁定多个互斥锁,并在作用域结束时自动解锁。
- 特点:RAII 风格的锁管理器,确保在作用域结束时自动释放锁,避免死锁和资源泄漏。
- 使用场景:适用于需要同时锁定多个互斥锁的场景,确保线程安全。
- 如何使用:
- 创建
std::scoped_lock
对象时传入多个互斥锁。 - 在作用域结束时,
std::scoped_lock
会自动解锁所有互斥锁。
2.1.3 未支持的锁类型/其他锁概念
-
自旋锁(Spin Lock)
- 功能:自旋锁在等待锁时会不断循环检查锁的状态,而不是阻塞线程。
- 特点:适用于高频率锁定和解锁的场景,避免线程上下文切换的开销。
- 使用场景:适用于锁持有时间短、频繁加锁解锁的场景,如短时间的临界区保护。
- 如何实现:通常使用原子操作(如
std::atomic_flag
)实现,通过不断尝试获取锁来实现自旋。
-
分段锁(Segmented Locks)
- 功能:将资源分割成多个部分,并对每个部分使用单独锁的机制,以减少锁竞争。
- 特点:通过分段锁减少锁竞争,提高并发性能。
- 使用场景:适用于需要对大块资源进行并发访问的场景,如哈希表、缓存等。
- 如何实现:将资源分割成多个部分,每个部分使用单独的锁,线程只锁定需要访问的部分。
-
乐观锁(Optimistic Lock)
- 功能:假设并发冲突不会频繁发生,在访问资源时不加锁,而是在提交修改时检查冲突,如果发生冲突则重试。
- 特点:通过减少锁的使用提高并发性能,适用于低冲突的场景。
- 使用场景:适用于低冲突的场景,如数据库读操作、缓存等。
- 如何实现:CAS+版本号,在提交修改时检查版本号或时间戳是否变化,如果发生冲突则重试。
2.2 condition_variable
条件变量是一种“事件通知机制”,它本身不提供、也不能够实现“互斥”的功能。因此,条件变量通常(也必须)配合互斥量来一起使用,其中互斥量实现对“共享数据”的互斥(即同步),而条件变量则去执行 “通知共享数据状态信息的变化”的任务。比如通知队列为空、非空,或任何其他需要由线程处理的共享数据的状态变化。可以说,条件变量是程序用来等待某个状态为真的机制。而这个状态必须得是线程安全的,因此需要搭配互斥量使用。
在 c++中,条件变量的关键词是std::condition_variable
。它可以用来在多线程环境中实现复杂的同步模式。以下是一些常见的用法:
- 等待通知:一个线程可以使用
std::condition_variable::wait
或wait_for
/wait_until
方法来等待另一个线程的通知。当wait
被调用时,当前线程将被阻塞,直到另一个线程调用std::condition_variable::notify_one
或notify_all
方法。 - 条件等待:
std::condition_variable::wait
方法还可以接受一个谓词(返回bool
的函数或函数对象).只有当这个谓词返回true
时,wait
才会返回。这可以用来实现条件等待:线程等待某个条件成立。 - 唤醒一个或多个线程:可以使用
std::condition_variable::notify_one
方法唤醒一个等待的线程,或者使用std::condition_variable::notify_all
方法唤醒所有等待的线程。
- 适用场景:
- 条件变量:当你需要线程间的同步和通信时,使用
std::condition_variable
是合适的选择。
- 条件变量:当你需要线程间的同步和通信时,使用
- 解释:
-
std::condition_variable
提供了条件变量,用于线程间的同步和通信。 - 一个线程等待条件变量,另一个线程设置条件并通知等待的线程。
-
2.3 promise/ future
future
和promise
一般都是成对使用的。future
用于多线程获得返回值,promise
用于多线程设置值。
- 适用场景:
- 线程之间的值传递:当你需要在线程之间传递值时,使用
std::promise
和std::future
是合适的选择。
- 线程之间的值传递:当你需要在线程之间传递值时,使用
- 解释:
-
std::promise
用于设置值,std::future
用于获取值。 - 在一个线程中设置值,在另一个线程中获取值,实现线程间的值传递
-
2.3.1 shared_future
std::future
对象的设计是唯一所有权的,也就是说,一旦你从一个std::future
对象中获取了值,这个std::future
对象就不能再被用来获取值。这是因为std::future::get
方法会移动(而不是复制)值或异常,这样可以避免不必要的复制,但也意味着你只能从一个std::future
对象中获取值一次。
因此,如果你需要在多个线程中共享同一个值,你不能直接使用std::future
,而应该使用std::shared_future
.std::shared_future
对象可以被多次拷贝和读取,这意味着你可以在多个线程中共享同一个值。
- shared_future 可以直接对一个 promise 变量使用 get_future 方法吗
不可以。std::promise
的get_future
方法只能返回一个std::future
对象,而不是std::shared_future
对象。如果你想要一个std::shared_future
对象,你需要首先从std::promise
获取一个std::future
对象,然后调用std::future
的share
方法来获取一个std::shared_future
对象。例如:
std::promise<int> p;
std::future<int> f = p.get_future();
std::shared_future<int> sf = f.share();
2.4 atomic
std::call_once 和 std::once_flag:这些用于确保某个函数或操作只被调用一次,即使在多线程环境中也不会被重复执行。
- 解决多线程下共享变量的问题(i++,指令重排问题):对于共享变量的访问进行加锁,加锁可以保证对临界区的互斥访问,
- C++11 提供了一些原子变量与原子操作解决用户加锁操作麻烦或者容易出错的问题
- C++11 标准在标准库 atomic 头文件提供了模版 std::atomic<>来定义原子量,而对于大部分内建类型,C++11 提供了一些特化,如,std::atomic_int (std::atomic
)等 - 自定义类型变成原子变量的条件是该类型必须为Trivially Copyable 类型(简单的判断标准就是这个类型可以用 std::memcpy 按位复制)
-
atomic 有一个成员函数 is_lock_free,这个成员函数可以告诉我们到底这个类型的原子量是使用了原子 CPU 指令实现了无锁化,还是依然使用的加锁的方式来实现原子操作
- 适用场景:
- 原子操作:当你需要进行原子操作以确保线程安全时,使用
std::atomic
是合适的选择。
- 原子操作:当你需要进行原子操作以确保线程安全时,使用
- 解释:
-
std::atomic
提供了原子操作,确保多个线程对共享变量的操作是线程安全的。 - 在多个线程中对
std::atomic
变量进行操作,避免数据竞争。
-
99. quiz
1. 线程不使用 join
也不使用 detach
,会发生什么?
在主线程退出时,可能会导致一些未定义行为,因为线程对象将被销毁,但线程本身可能仍在运行。
- 可能发生的情况:
- 程序可能终止,但线程可能仍在运行:
- 如果主线程退出,而被创建的线程仍在运行,可能导致程序终止,但线程继续执行。这可能导致线程无法正确完成其任务,因为主线程已经退出。
- 程序可能会崩溃:
- 这是由于线程对象的销毁可能涉及到一些资源的释放,而线程本身仍在访问这些资源,导致未定义行为。
- 资源泄漏:
- 如果线程分配了一些资源(例如内存),但在线程执行完毕前这些资源没有被释放,可能会导致资源泄漏。
- 程序可能终止,但线程可能仍在运行:
2. 为什么多线程传引用要用std::ref
问题出在 std::thread
不能直接传递引用类型参数。std::thread
在创建线程时会复制传入的参数。因为主线程和子线程是两个声明周期,如果直接传递引用,当主线程中的对象销毁后,线程函数中的引用就会成为悬空引用,访问悬空引用会导致未定义行为。
如果能够直接传递引用类型参数,很容易使得开发人员因一时疏忽导致产生了悬空引用。为了保证安全性,如果要使用T&
则需要开发人员主动使用std::ref
,否则会报错。因此来避免不小心而导致的悬空引用。
#include <iostream>
#include <thread>
void modifyValue(int& num) {
num = 100;
std::cout << "Value inside thread: " << num << std::endl;
}
int main() {
int value = 10;
// std::thread t(modifyValue, value); // error
std::thread t(modifyValue, std::ref(value));
t.join();
std::cout << "Value outside thread: " << value << std::endl;
return 0;
}
3. std::lock_guard
和std::unique_lock
有什么不同?
std::lock_guard
和 std::unique_lock
都是 RAII(Resource Acquisition Is Initialization)风格的互斥锁包装器,它们在构造时自动锁定互斥锁,在析构时自动解锁互斥锁。这种设计可以确保在函数退出(无论是正常退出还是异常退出)时自动释放锁,从而避免因忘记解锁而导致的死锁。std::lock_guard
和std::unique_lock
的主要区别在于:
-
延迟锁定:
-
std::lock_guard
则在创建时必须立即上锁,然后只能在结束时解锁。 -
std::unique_lock
可以在任意时候上锁和解锁。
-
-
所有权传递:
-
std::lock_guard
则不可移动。 -
std::unique_lock
是可移动的,这意味着你可以将锁的所有权从一个std::unique_lock
对象转移到另一个。例如:
std::mutex mtx; std::unique_lock<std::mutex> lock1(mtx); std::unique_lock<std::mutex> lock2 = std::move(lock1); // 此时 lock1 不再拥有锁的所有权,lock2 拥有
-
在使用std::wait
的时候一定不能用std::lock_guard
。因为调用 wait()
时,是先获取锁,然后检查条件。如果条件不满足需要再释放锁,从而允许其他线程访问共享资源。而std::lock_guard
不可以手动解锁,所以无法在 wait()
期间临时释放锁。std::unique_lock
则可以在 wait()
期间自动解锁和重新加锁,这样就可以避免死锁或竞态条件。
在一些简单的作用域加锁场景中,std::lock_guard
因其简单轻量可能是更好的选择;而在需要更灵活控制锁的生命周期和所有权的复杂场景下,std::unique_lock
则更为适用。
4. std::unique_lock
提供的锁策略参数是什么?
std::adopt_lock
/std::defer_lock
和 std::try_to_lock
都是 std::unique_lock
的构造函数可以接受的锁策略参数,它们的含义和使用场景如下:
- std::adopt_lock:这个策略表示互斥锁在构造锁对象时已经被锁定。当你已经手动锁定了一个互斥锁,然后想要将它的管理权交给
std::unique_lock
时,可以使用std::adopt_lock
.这样,std::unique_lock
在构造时就不会再次尝试锁定互斥锁,而是直接接管已经被锁定的互斥锁。 - std::defer_lock:这个策略表示在构造
std::unique_lock
时不锁定互斥锁。你可以稍后手动调用std::unique_lock::lock
方法来锁定互斥锁。这个策略在你需要延迟锁定互斥锁的情况下很有用。 - std::try_to_lock:这个策略表示在构造
std::unique_lock
时尝试锁定互斥锁,如果互斥锁已经被锁定,则立即返回,不会阻塞。你可以检查std::unique_lock::owns_lock
方法的返回值,来判断是否成功锁定了互斥锁。
5. 什么是虚假唤醒?为什么会有虚假唤醒?可以避免虚假唤醒吗?
std::mutex mtx;
std::condition_variable cond;
std::deque<int> queue;
// error
int dequeue() {
std::unique_lock<std::mutex> lock(mtx);
if (queue.empty()) { // 使用 if 判断,可能会出现虚假唤醒
cond.wait(lock);
}
int top = queue.front();
queue.pop_front();
return top;
}
// ok_case1
int dequeue2() {
std::unique_lock<std::mutex> lock(mtx);
while (queue.empty()) { // 不能用 if,必须用 while,避免虚假唤醒
cond.wait(lock);
}
int top = queue.front();
queue.pop_front();
return top;
}
// ok_case2
int dequeue3() {
std::unique_lock<std::mutex> lock(mtx);
cond.wait(lock, [] { return !queue.empty(); }); // 使用 lambda 表达式检查条件
int top = queue.front();
queue.pop_front();
return top;
}
在多线程编程中,使用条件变量(Condition Variable)进行线程同步时,可能会遇到虚假唤醒的问题。以下是对虚假唤醒及其处理方法的详细解释:
-
什么是虚假唤醒? 虚假唤醒(Spurious Wakeup)是指线程在等待条件变量时,即使没有任何线程调用
notify_one
或notify_all
,也会被唤醒的情况,即wait
的时候被唤醒了。这种现象在某些操作系统和硬件平台上可能会发生。 -
为什么会有虚假唤醒? 虚假唤醒的原因可能包括:
-
操作系统的调度策略
:操作系统可能会出于各种原因唤醒等待的线程,例如资源重新分配或优先级调整。 -
硬件中断
:硬件中断可能会导致等待的线程被唤醒。 -
其他系统级别的事件
:系统级别的事件(如信号处理)也可能导致线程被唤醒。
-
-
总结
-
虚假唤醒
:虚假唤醒是指线程在等待条件变量时,即使没有任何线程调用notify_one
或notify_all
,也会被唤醒的情况。 -
原因
:虚假唤醒可能由操作系统的调度策略、硬件中断或其他系统级别的事件引起。 -
处理方法
:为了正确处理虚假唤醒,通常在一个while
循环中调用.wait()
方法,确保每次被唤醒时都重新检查条件是否满足。
-
6. 如果对std::future
不调用 get(),会发生什么?
std::future
通常由std::async
、std::packaged_task
等创建。当创建了std::future
对象,但未调用get()
方法时,在std::future
对象被销毁的过程中,如果其关联的异步操作(例如通过std::async
启动的任务)尚未完成,程序会发生阻塞,一直到异步操作完成为止。
这是由于std::future
的析构函数会主动检查关联的异步操作状态。若操作未完成,析构函数便会进入阻塞等待状态,目的是确保异步操作能够安全结束,防止在未完成的情况下被强制终止。
若不想在std::future
对象被销毁时出现阻塞情况,可以调用std::future::detach()
方法。该方法会使std::future
对象与它所关联的异步操作相互分离,如此一来,即便std::future
对象后续被销毁,异步操作依旧会继续执行。不过需要注意的是,调用detach()
后,将无法再通过该std::future
对象获取异步操作的结果。例如:
std::future<void> fut = std::async([]{ std::this_thread::sleep_for(std::chrono::seconds(2)); });
fut.detach();
// 这里 fut 与异步操作分离,fut 销毁时不会阻塞,同时也无法获取异步操作结果
7. 什么是析构竞争?
简单来说,当线程 A 正在销毁对象,而线程 B 同时调用该对象的方法时,就会出现问题。具体而言,若多个线程持有该对象的裸指针或引用,当某个线程对其进行析构,其他线程再访问这个对象,就可能出现崩溃或者未定义行为。
解决此问题,一般借助智能指针来管理这个对象。如果对象的生命周期需要在多处进行管理,可使用std::shared_ptr
;若只有一处能管理对象生命周期,通常搭配std::weak_ptr
和锁来处理。std::weak_ptr
能够判断对象的生命周期是否依然有效,若有效,则获取锁。同样,在析构对象时,也需要获取这个锁才能进行析构操作。
Enjoy Reading This Article?
Here are some more articles you might like to read next: