C++ 并发编程与多线程面试题精选
目录
前言
第一部分:线程基础与管理
问题 1:std::thread 对象的 join() 和 detach() 有什么区别?什么情况下应该使用它们?
问题 2:创建一个 std::thread 后,如果忘记调用 join() 或 detach() 会发生什么?为什么?
第二部分:数据同步与锁
问题 3:std::lock_guard 和 std::unique_lock 有什么区别和联系?各自的应用场景是什么?
第三部分:线程协作与通信
问题 4:什么是“伪唤醒”(Spurious Wakeup)?在 C++ 中如何正确处理它?
问题 5:std::async 相比直接使用 std::thread 有什么优势?
第四部分:死锁
问题 6:产生死锁需要满足哪四个必要条件?请描述一种最常用的死锁预防策略。
第五部分:原子操作与内存模型
问题 7:std::atomic 和使用互斥锁 (std::mutex) 有什么区别?它适用于什么场景?
前言
本文档根据《C++ 并发编程与多线程详解》的核心知识点,整理了一系列常见的面试问题及其详细解答,旨在帮助您巩固并发编程的核心概念,从容应对技术面试。
第一部分:线程基础与管理
问题 1:std::thread
对象的 join()
和 detach()
有什么区别?什么情况下应该使用它们?
解答:
join()
和 detach()
是管理 std::thread
对象生命周期的两种截然不同的方式。
-
join()
(等待与汇合):-
行为: 调用
join()
的线程(例如主线程)会被阻塞,直到被调用的子线程执行完毕。 -
目的: 这是一种同步操作,用于确保子线程的工作在主线程继续执行某个依赖其结果的操作之前完成。它也是一种资源回收机制,可以安全地清理与线程相关的资源。
-
使用场景:
-
当主线程需要等待子线程的计算结果时。
-
当主线程需要确保子线程在程序退出前完成所有工作(如文件写入、数据清理)。
-
这是最常用、最安全的线程管理方式。
-
-
-
detach()
(分离与托管):-
行为: 调用
detach()
会将子线程与std::thread
对象分离。此后,std::thread
对象不再代表该执行线程,而子线程会在后台独立运行,其生命周期由 C++ 运行时库和操作系统管理。 -
目的: 允许一个线程(通常称为“守护线程”或“后台线程”)在不阻塞主程序的情况下长时间运行。
-
使用场景:
-
执行无需监督的后台任务,如日志记录、网络监听、定期清理任务等。
-
主线程不关心子线程何时结束,也不需要其返回值。
-
-
风险: 必须万分小心,确保分离出去的线程在其生命周期内访问的所有数据(尤其是指针和引用)都是有效的。如果主线程结束,所有分离的线程也会被强制终止,可能导致资源未释放等问题。
-
问题 2:创建一个 std::thread
后,如果忘记调用 join()
或 detach()
会发生什么?为什么?
解答:
如果一个 std::thread
对象在析构时仍然是 joinable()
的(即它仍然关联着一个活动的执行线程,且既没有被 join()
也没有被 detach()
),那么它的析构函数会调用 std::terminate()
,导致整个程序异常终止。
原因在于 C++ 标准的 RAII (Resource Acquisition Is Initialization) 原则和对资源管理的严格要求:
-
资源所有权:
std::thread
对象拥有其关联的执行线程的“所有权”。 -
明确的生命周期管理: C++ 标准强制开发者必须明确决定如何处理这个线程资源。是等待它结束 (
join
),还是让它自生自灭 (detach
)。 -
防止资源泄漏和不确定性: 如果允许
std::thread
对象在不处理线程的情况下销毁,那么这个后台线程的命运将变得不确定。它可能会继续访问已经被销毁的栈上变量,导致未定义行为。为了从根本上杜绝这种危险情况,标准委员会决定采取最严厉的措施——直接终止程序,以引起开发者的注意。
第二部分:数据同步与锁
问题 3:std::lock_guard
和 std::unique_lock
有什么区别和联系?各自的应用场景是什么?
解答:
std::lock_guard
和 std::unique_lock
都是 C++ 标准库提供的 RAII 风格的锁管理器,用于确保互斥锁的异常安全。它们的主要区别在于灵活性和功能。
特性 |
|
|
---|---|---|
核心功能 | 在构造时锁定互斥量,在析构时释放。 | 同样具备 |
灵活性 | 功能单一,一旦创建就持有锁直到作用域结束。 | 非常灵活,提供了更多高级功能。 |
所有权 | 不可移动,不可复制。 | 可以移动( |
锁操作 | 只能在构造时锁定。 | 可以在任何时候手动 |
延迟锁定 | 不支持。 | 支持(使用 |
尝试锁定 | 不支持。 | 支持(使用 |
适用场景 | 简单锁定:当进入一个作用域就需要锁定,离开时解锁,且中间没有复杂操作时,它是首选。 | 复杂锁定:需要与 |
性能开销 | 几乎为零开销,等同于手动 | 开销略大,因为它内部需要维护一个标志位来记录是否持有锁。 |
总结与应用场景:
-
使用
std::lock_guard
:在绝大多数情况下,当你只需要简单地保护一个临界区时,应该优先选择std::lock_guard
,因为它更简单、轻量、高效。 -
使用
std::unique_lock
:当你需要更复杂的锁操作时,例如:-
配合条件变量:
std::condition_variable::wait()
要求必须使用std::unique_lock
,因为它需要在等待时解锁,被唤醒时重新加锁。 -
提前释放锁:在临界区代码执行完毕但还未离开作用域时,可以手动调用
unlock()
提前释放锁,以减小锁的粒度,提高并发性。 -
延迟加锁:与
std::lock
配合,实现对多个互斥锁的无死锁锁定。
-
第三部分:线程协作与通信
问题 4:什么是“伪唤醒”(Spurious Wakeup)?在 C++ 中如何正确处理它?
解答:
伪唤醒 是指一个正在 std::condition_variable
上等待的线程,在没有被任何 notify_one()
或 notify_all()
调用的情况下,被意外地唤醒。这是操作系统线程调度中可能发生的正常现象,在所有主流平台上都可能出现。
如果不处理伪唤醒,线程可能会在等待的条件尚未满足时就继续执行,导致程序错误或数据损坏。
处理方式:
C++ 中处理伪唤醒的唯一正确方法是使用 wait()
的谓词(predicate)版本。
std::unique_lock<std::mutex> lock(mtx);// 错误的方式:容易受到伪唤醒影响
// cv.wait(lock); // 正确的方式:使用带谓词的 wait
cv.wait(lock, [] { // 这个 Lambda 表达式就是谓词return /* 等待的条件是否为 true? (例如: !queue.empty()) */;
});
工作原理:
带谓词版本的 wait()
在内部实现了一个循环。当线程被唤醒时(无论是正常唤醒还是伪唤醒),它会:
-
重新获取互斥锁。
-
检查谓词(调用你提供的 Lambda 或函数)。
-
如果谓词返回
true
(表示条件已满足),wait()
函数返回,线程继续执行。 -
如果谓词返回
false
(表示是伪唤醒,条件尚未满足),wait()
会自动再次释放锁,让线程继续回到阻塞等待状态。
这个内部循环确保了只有在等待条件真正满足时,线程才会从 wait()
中返回。
问题 5:std::async
相比直接使用 std::thread
有什么优势?
解答:
std::async
是一个更高层次的异步操作抽象,相比于手动管理 std::thread
,它具有以下显著优势:
-
简化带返回值的任务:
-
std::thread
:本身不直接支持返回值。如果想获取子线程的计算结果,需要自己实现一套复杂的同步机制,例如通过传递指针/引用、使用std::promise
和std::future
等。 -
std::async
:直接返回一个std::future
对象。你可以在需要的时候简单地调用future.get()
来获取任务的返回值。get()
会自动阻塞直到结果可用。
-
-
异常处理更方便:
-
std::thread
:如果子线程抛出异常而未被捕获,程序会调用std::terminate()
终止。如果想在主线程中处理子线程的异常,同样需要自己设计复杂的通信机制。 -
std::async
:如果异步任务中发生异常,该异常会被捕获并存储在返回的std::future
对象中。当主线程调用future.get()
时,这个异常会被重新抛出,使得你可以在主线程的try-catch
块中优雅地处理它。
-
-
线程管理更灵活(线程池思想的雏形):
-
std::thread
:创建一个std::thread
对象通常会立即创建一个新的系统线程,这可能带来较大的开销。 -
std::async
:它接受一个启动策略参数:-
std::launch::async
:保证任务在一个新线程中异步执行。 -
std::launch::deferred
:任务被延迟执行。直到你对返回的future
调用get()
或wait()
时,任务才会在当前线程中同步执行。 -
默认策略 (
std::launch::async | std::launch::deferred
):允许C++标准库实现根据系统负载等情况自行决定是创建新线程还是延迟执行,这为未来的线程池优化提供了可能。
-
-
-
代码更简洁:
std::async
将线程创建、任务执行、结果传递和异常处理封装在一起,大大减少了模板代码,使代码更简洁、意图更明确。
第四部分:死锁
问题 6:产生死锁需要满足哪四个必要条件?请描述一种最常用的死锁预防策略。
解答:
死锁的发生必须同时满足以下四个条件,也称为“科夫曼条件”:
-
互斥 (Mutual Exclusion): 资源不能被共享,在任意时刻,一个资源只能被一个线程持有。
-
持有并等待 (Hold and Wait): 一个线程在已经持有一个或多个资源的同时,又在请求其他线程正持有的资源,并且在请求过程中不释放自己已持有的资源。
-
不可抢占 (No Preemption): 资源不能被强制地从持有它的线程中抢占,只能由持有者自愿释放。
-
循环等待 (Circular Wait): 存在一个线程等待链,其中 T1 等待 T2 的资源,T2 等待 T3 的资源,...,Tn 等待 T1 的资源,形成一个闭环。
最常用的死锁预防策略:
最实用、最常用的策略是破坏“循环等待”条件。
具体做法是:规定系统中所有互斥锁的获取必须遵循一个全局统一的、严格的顺序。
例如,可以根据互斥锁的内存地址大小来排序,或者为每个锁分配一个唯一的 ID。无论哪个线程需要同时获取多个锁,都必须按照这个预定义的顺序(比如,总是先锁地址较小的,再锁地址较大的)来获取。
这样做可以从逻辑上将资源请求的关系变成一个有向无环图(DAG),从而从根本上消除了循环等待的可能性,也就预防了死锁。
在 C++ 中,如果需要同时锁定多个互斥量,最佳实践是使用 C++17 的 std::scoped_lock
,它在内部实现了无死锁的锁定算法,为我们自动处理了这个问题。
第五部分:原子操作与内存模型
问题 7:std::atomic
和使用互斥锁 (std::mutex
) 有什么区别?它适用于什么场景?
解答:
std::atomic
和 std::mutex
都是用于线程同步的工具,但它们在原理、性能和适用场景上有本质区别。
方面 |
|
|
---|---|---|
原理 | 基于阻塞的同步机制。当一个线程无法获取锁时,它会被操作系统挂起(进入睡眠状态),直到锁被释放。 | 通常是无锁 (Lock-Free) 的同步机制。它利用特殊的 CPU 指令(如 CAS - Compare-And-Swap)来确保单个操作(读、写、修改)的原子性,不会被其他线程中断。失败的线程通常会进行“自旋”重试,而不是被挂起。 |
保护范围 | 保护一段代码块(临界区),可以包含多个操作。 | 只保护对单个变量的单个操作。 |
性能开销 | 线程的阻塞和唤醒涉及上下文切换,开销较大。 | 开销非常小,适用于高频次的简单操作。但在高竞争下,自旋可能消耗大量 CPU。 |
适用场景 | 保护复杂的、由多个步骤组成的数据结构或业务逻辑。例如,向一个队列中添加元素(可能涉及大小修改、指针移动等多个操作)。 | 对单个变量进行简单、独立的原子操作。最经典的场景就是原子计数器、读写配置标志位等。 |
总结:
-
当你需要保护一个复杂的操作(涉及多个变量或多个步骤),必须使用
std::mutex
。 -
当你只需要对一个简单的内建类型(如
int
,bool
,pointer
)进行单个、原子的读、写或“读-改-写”操作时,std::atomic
是性能更高、更轻量级的选择。