C++高频面试题精选 - 第三部分(题目21-30)

C++高频面试题精选 - 第三部分(题目21-30)
NyxXC++高频面试题精选 - 第三部分(题目21-30)
智能指针
题目21: 介绍C++11中的三种智能指针
解答:
C++11引入了三种智能指针来自动管理动态内存,解决传统裸指针容易导致的内存泄漏和悬空指针问题。
1. unique_ptr - 独占所有权:
unique_ptr表示对资源的独占所有权,一个资源同时只能被一个unique_ptr拥有。它不可拷贝,只能移动,这在编译期就保证了所有权的唯一性。当unique_ptr被销毁时,它所管理的资源也会自动释放。
特点:
- 零开销:没有引用计数,性能等同于裸指针
- 移动语义:通过std::move转移所有权
- 数组支持:unique_ptr<T[]>可以管理动态数组
- 自定义删除器:可以指定如何释放资源
适用场景:
- 明确的资源所有权,不需要共享
- 工厂函数返回动态对象
- 管理pimpl惯用法中的实现对象
- 替代裸指针的首选方案
2. shared_ptr - 共享所有权:
shared_ptr允许多个指针共享同一个对象的所有权,通过引用计数来跟踪有多少个shared_ptr指向同一资源。当最后一个shared_ptr被销毁时,资源才会被释放。
实现机制:
- 控制块:包含引用计数、弱引用计数、删除器等
- 原子操作:引用计数的增减是线程安全的
- 两次分配:对象本身和控制块(make_shared可优化为一次)
特点:
- 共享所有权:多个指针可以指向同一对象
- 线程安全的引用计数:计数的增减是原子操作
- 有开销:需要维护引用计数和控制块
- 循环引用问题:需要配合weak_ptr解决
适用场景:
- 对象所有权不明确,需要多处共享
- 需要在不同作用域间传递对象
- 需要延长对象生命周期
- 观察者模式等设计模式
3. weak_ptr - 弱引用:
weak_ptr是shared_ptr的观察者,它可以观察shared_ptr指向的对象,但不增加引用计数,因此不影响对象的生命周期。主要用于解决shared_ptr的循环引用问题。
特点:
- 不控制生命周期:不增加引用计数
- 需要lock()转换:通过lock()方法获得shared_ptr来访问对象
- 可以检测对象是否存在:expired()方法检查对象是否已销毁
- 打破循环引用:用于parent-child关系的反向引用
使用模式:
- 先检查对象是否存在
- 通过lock()获得临时的shared_ptr
- 使用shared_ptr访问对象
- shared_ptr离开作用域后自动释放
适用场景:
- 缓存系统:缓存不应阻止对象销毁
- 观察者模式:观察者不拥有被观察对象
- 双向数据结构:子节点引用父节点
- 解决循环引用
选择建议:
- 默认使用unique_ptr:独占所有权,零开销
- 需要共享时使用shared_ptr:多个所有者
- 配合weak_ptr:避免循环引用
- 避免使用裸指针:除非interfacing C API或性能极端敏感场景
口头解答:
“C++11提供了三种智能指针来自动管理内存。unique_ptr表示独占所有权,一个资源只能有一个unique_ptr拥有,它不能拷贝只能移动,好处是零开销,效率和裸指针一样。shared_ptr是共享所有权,多个指针可以指向同一资源,通过引用计数管理,最后一个指针销毁时才释放资源,适合所有权不明确的场景。weak_ptr是shared_ptr的观察者,它不影响引用计数,主要用来打破循环引用。选择的原则是:默认用unique_ptr,需要共享时用shared_ptr,配合weak_ptr避免循环引用。总之,现代C++应该尽量用智能指针代替裸指针。”
题目22: shared_ptr的循环引用问题如何解决?
解答:
问题描述:
循环引用是shared_ptr最常见的陷阱。当两个或多个对象通过shared_ptr互相持有时,它们的引用计数永远不会降到0,导致内存泄漏。典型场景是树形或图形结构中的节点互相引用。
比如父子节点关系:父节点持有子节点的shared_ptr,子节点也持有父节点的shared_ptr,这样即使外部不再使用这些节点,它们的引用计数都至少是1(互相持有),永远不会被释放。
解决方案:使用weak_ptr
核心思想是打破循环:在循环引用的链条中,至少有一个方向使用weak_ptr而不是shared_ptr。通常的设计原则是:
- 明确的所有权关系用shared_ptr(如父拥有子)
- 反向引用或观察关系用weak_ptr(如子引用父)
实际应用模式:
1. 树形结构:
- 父节点用shared_ptr持有子节点(拥有关系)
- 子节点用weak_ptr指向父节点(引用关系)
- 这样父节点销毁时,子节点也会销毁
- 子节点访问父节点时,先检查父节点是否还存在
2. 双向链表:
- 每个节点用shared_ptr指向下一个节点
- 用weak_ptr指向前一个节点
- 或者只有头节点被外部持有(shared_ptr),节点间用裸指针
3. 观察者模式:
- 被观察者(Subject)用weak_ptr持有观察者
- 观察者可以随时销毁,不影响被观察者
- 通知时先检查观察者是否还存在
4. 缓存系统:
- 缓存用weak_ptr存储对象
- 对象可以被正常销毁,不会因为在缓存中而无法释放
- 访问缓存时先检查对象是否还存在
使用weak_ptr的注意事项:
访问前必须检查:weak_ptr不能直接访问对象,必须先转换为shared_ptr。使用lock()方法,如果对象还存在则返回有效的shared_ptr,否则返回空指针。
线程安全:lock()操作是线程安全的,可以在多线程环境中安全使用。
性能考虑:weak_ptr本身也有开销(存储控制块指针),但比shared_ptr小,不影响引用计数。
替代方案:
有时可以通过重新设计避免循环引用:
- 明确所有权:重新思考谁真正拥有谁
- 使用普通指针:如果生命周期明确,可以用裸指针
- 改变数据结构:避免双向引用的设计
口头解答:
“循环引用是shared_ptr最常见的陷阱。比如两个对象互相持有对方的shared_ptr,这样它们的引用计数永远不会降到0,造成内存泄漏。典型场景是树形或图形结构,父子节点互相引用。解决办法就是用weak_ptr打破循环——它不增加引用计数,所以不会影响对象的生命周期。一般的原则是,明确的所有权关系用shared_ptr,比如父节点拥有子节点;而反向引用或观察性质的引用用weak_ptr,比如子节点引用父节点。使用weak_ptr时要注意,访问前要用lock()检查对象是否还存在,因为它指向的对象可能已经被销毁了。”
题目23: make_shared相比new有什么优势?
解答:
make_shared是创建shared_ptr的推荐方式,相比直接使用new构造shared_ptr,它有多个优势。
1. 性能优势 - 一次内存分配:
使用new创建shared_ptr需要两次内存分配:
- 第一次分配对象本身
- 第二次分配控制块(存储引用计数等信息)
使用make_shared只需要一次内存分配,将对象和控制块放在连续的内存中。这不仅提高了分配效率,还减少了内存碎片,提高了缓存局部性,访问速度更快。
2. 异常安全:
考虑函数调用的参数构造顺序:在某些情况下,使用new可能导致内存泄漏。比如函数有多个参数,每个参数都需要动态分配,如果某个分配成功后另一个分配抛出异常,前面分配的内存可能泄漏。
使用make_shared可以避免这个问题,因为它是一个完整的操作,要么全部成功,要么全部失败,不会出现部分分配的情况。
3. 代码简洁:
make_shared让代码更简洁,类型只需要写一次,而使用new需要写两次(创建对象和模板参数)。这减少了出错的可能,也让代码更易读。
4. 自动类型推导:
配合auto使用,make_shared可以自动推导类型,进一步简化代码,避免类型不匹配的错误。
劣势和限制:
1. 不支持自定义删除器:
make_shared不能指定自定义删除器。如果需要自定义如何释放资源(比如调用特定的清理函数),必须使用shared_ptr的构造函数。
2. 延迟释放内存:
由于对象和控制块在同一内存块中,即使对象被销毁(引用计数为0),如果还有weak_ptr存在(弱引用计数不为0),整块内存都不能释放。这在某些情况下可能导致内存占用时间过长。
使用new分配时,对象内存和控制块是分开的,对象销毁后可以立即释放对象的内存,只保留控制块供weak_ptr使用。
3. 不支持数组:
make_shared在C++17之前不支持创建数组,需要使用shared_ptr<T[]>配合new。C++20增加了对数组的支持。
4. 构造函数私有的情况:
如果类的构造函数是私有的(如单例模式),make_shared无法访问,必须将make_shared声明为友元,或使用其他方式。
使用建议:
优先使用make_shared:在大多数情况下,make_shared是更好的选择。
需要自定义删除器时使用构造函数:如果需要指定如何释放资源,使用shared_ptr的构造函数。
大对象配合weak_ptr时考虑使用new:如果对象很大,且可能有长期存在的weak_ptr,使用new可以让对象内存更早释放。
性能敏感场景测试:虽然make_shared通常更快,但在某些特定场景下,延迟释放的影响可能超过性能优势,需要实际测试。
口头解答:
“make_shared是创建shared_ptr的推荐方式,主要有三个好处。首先是性能,它只分配一次内存,把对象和控制块放在一起,而用new要分配两次。其次是异常安全,避免了某些特殊情况下的内存泄漏,比如在函数参数表达式中。第三是代码更简洁,类型只需要写一次。不过make_shared也有局限,它不支持自定义删除器,而且如果有weak_ptr存在,即使对象销毁了,整块内存也不能释放,因为weak_ptr还需要访问控制块。总的来说,除非需要自定义删除器或有其他特殊需求,否则应该优先使用make_shared。”
题目24: unique_ptr如何实现自定义删除器?
解答:
unique_ptr支持自定义删除器,这是它相比shared_ptr的一个重要特性。删除器作为模板参数,是类型的一部分,这意味着不同删除器的unique_ptr是不同的类型。
删除器的类型:
1. 函数指针:
最简单的删除器形式,可以使用普通函数或C风格的函数指针。适合管理C API返回的资源,如文件句柄、socket等。
2. 函数对象(Functor):
定义一个类,重载operator(),这种方式可以携带状态,更加灵活。常用于需要额外参数的清理操作。
3. Lambda表达式:
最常用的方式,语法简洁。无捕获的lambda会被优化为函数指针,不增加unique_ptr的大小;有捕获的lambda会增加大小。
应用场景:
1. 管理C资源:
C API经常返回需要特定函数释放的资源,如FILE*需要fclose,而不是delete。使用自定义删除器可以让unique_ptr管理这些资源,享受RAII的好处。
2. 管理数组:
虽然unique_ptr<T[]>专门用于数组,但有时需要特定的清理方式。自定义删除器可以指定如何释放数组内存。
3. 第三方库资源:
许多第三方库(如SDL、OpenGL等)有自己的资源管理函数。自定义删除器可以将这些资源包装为unique_ptr,统一资源管理。
4. 资源池管理:
对象可能不需要真正删除,而是归还到资源池。自定义删除器可以实现”归还”而非”销毁”。
5. 日志和调试:
删除器可以添加日志输出,帮助追踪资源的分配和释放,便于调试内存问题。
注意事项:
删除器是类型的一部分:
这意味着不同删除器的unique_ptr是不同类型,不能相互赋值。这是unique_ptr和shared_ptr的重要区别(shared_ptr的删除器不是类型的一部分)。
大小影响:
- 函数指针删除器:不增加unique_ptr大小
- 无捕获lambda:优化为函数指针,不增加大小
- 有捕获lambda或函数对象:会增加unique_ptr大小
性能考虑:
自定义删除器几乎没有运行时开销,因为是编译期确定的。但如果删除器对象很大,会增加unique_ptr的内存占用。
与shared_ptr对比:
shared_ptr的删除器不是类型的一部分,可以在运行时更改,更灵活但有额外开销。unique_ptr的删除器是编译期确定的,效率更高但灵活性较低。
最佳实践:
优先使用无捕获lambda:简洁且不增加大小。
需要状态时使用函数对象:如果删除器需要携带额外信息。
管理C资源时使用函数指针:直接指向清理函数,清晰明了。
避免复杂的删除器:保持删除器简单,复杂逻辑应该封装在RAII类中。
口头解答:
“unique_ptr的删除器是模板参数,所以可以完全自定义。最常见的是用lambda表达式或函数对象。这个特性特别适合管理非内存资源,比如文件句柄、socket、第三方库的资源等。比如管理FILE指针,我们可以用fclose作为删除器,这样unique_ptr销毁时会自动关闭文件。需要注意的是,删除器是unique_ptr类型的一部分,所以不同删除器的unique_ptr类型不同,不能互相赋值。另外,如果lambda有捕获,会增加unique_ptr的大小,无捕获的lambda则会优化为函数指针,不增加额外开销。这种设计让unique_ptr非常灵活,能适应各种资源管理需求。”
题目25: 智能指针的线程安全性如何?
解答:
智能指针的线程安全需要从两个层面理解:引用计数的线程安全和对象访问的线程安全。
shared_ptr的线程安全:
1. 引用计数操作是线程安全的:
shared_ptr的引用计数增减使用原子操作实现,因此多个线程可以安全地拷贝、赋值、销毁同一个shared_ptr。具体来说:
- 多个线程同时读取同一个shared_ptr是安全的
- 多个线程同时拷贝同一个shared_ptr是安全的
- 拷贝、赋值、析构时的引用计数修改是原子的
2. shared_ptr对象本身的修改不是线程安全的:
如果多个线程同时修改同一个shared_ptr变量(不是它指向的对象),需要外部同步。比如一个线程给shared_ptr赋新值,另一个线程读取它,这是不安全的,需要加锁保护。
3. 指向的对象访问不是线程安全的:
多个线程通过不同的shared_ptr访问同一个对象时,如果有写操作,需要额外的同步机制(如mutex)。shared_ptr只保证引用计数的线程安全,不保证对象本身的线程安全。
正确的多线程使用模式:
模式1 - 每个线程有自己的拷贝:
每个线程通过拷贝得到自己的shared_ptr,这样引用计数会原子地增加,每个线程都有自己的shared_ptr变量,互不干扰。只要对共享对象的访问是同步的(或只读),这种模式是安全的。
模式2 - 保护shared_ptr变量本身:
如果多个线程需要访问和修改同一个shared_ptr变量,需要用mutex保护。这保护的是shared_ptr变量的读写,而不是引用计数(那已经是线程安全的了)。
模式3 - 使用局部拷贝:
在需要访问共享对象时,先拷贝一份shared_ptr到局部变量,然后释放锁,再使用局部变量访问对象。这样可以减少锁的持有时间。
weak_ptr的线程安全:
weak_ptr的lock()操作是线程安全的,可以安全地在多线程中将weak_ptr转换为shared_ptr。这在实现观察者模式等场景中很有用,多个线程可以安全地检查对象是否还存在并获取访问权。
unique_ptr的线程安全:
unique_ptr表示独占所有权,通常不涉及多线程共享的问题。如果需要在线程间转移所有权,必须通过移动语义,这本身需要外部同步。由于unique_ptr不可拷贝,自然避免了很多多线程问题。
常见陷阱:
陷阱1 - 误以为shared_ptr完全线程安全:
很多人认为shared_ptr是线程安全的就可以随意在多线程中使用,实际上只有引用计数是线程安全的。对象访问和shared_ptr变量的修改都需要额外保护。
陷阱2 - 忘记保护对象本身:
即使shared_ptr的引用计数是线程安全的,多个线程通过它访问同一对象时,仍需要同步。这是对象本身的线程安全问题,不是shared_ptr的责任。
陷阱3 - 竞态条件:
在检查shared_ptr是否为空和使用它之间,可能发生竞态条件。应该先拷贝到局部变量,再检查和使用。
性能考虑:
原子操作的开销:虽然引用计数的原子操作是线程安全的,但原子操作比普通操作慢。在单线程场景下,这是不必要的开销。
false sharing:多个线程频繁修改引用计数可能导致cache line的false sharing,影响性能。可以通过每个线程持有自己的shared_ptr拷贝来缓解。
锁竞争:如果需要用mutex保护shared_ptr变量,可能产生锁竞争。应该尽量减少临界区,使用局部拷贝等技巧。
口头解答:
“智能指针的线程安全需要分两个层面理解。对于shared_ptr,引用计数的增减是原子操作,所以多个线程同时拷贝同一个shared_ptr是安全的。但是,访问shared_ptr指向的对象本身不是线程安全的,多线程读写需要额外加锁。还有个容易忽略的点,同时修改shared_ptr本身(比如赋值)也需要保护,因为这不只是改引用计数,还要修改指针值。所以正确的做法是,把shared_ptr作为局部变量或者用互斥锁保护共享的shared_ptr。unique_ptr因为独占所有权,一般不涉及多线程共享。weak_ptr的lock操作是线程安全的,可以放心使用。总之,shared_ptr只保证引用计数的线程安全,对象访问和指针变量的修改都需要额外考虑。”
多线程与并发
题目26: C++11中的std::thread如何使用?
解答:
std::thread是C++11引入的线程库,提供了跨平台的线程抽象,使多线程编程变得更加简单和标准化。
基本概念:
thread对象代表一个执行线程。创建thread对象时,可以传入任何可调用对象(函数、lambda、函数对象、成员函数等)作为线程的执行体。线程在创建时立即开始执行,与主线程并发运行。
创建和启动线程的方式:
可以用普通函数、lambda表达式、函数对象或成员函数创建线程。对于成员函数,需要传递对象指针或引用。参数通过值传递,如果需要传引用,必须使用std::ref包装。
join和detach:
每个thread对象必须在销毁前调用join()或detach(),否则程序会终止。
join()会阻塞当前线程,等待被join的线程执行完毕。这是最常用的方式,保证线程完成后再继续。适用于需要等待结果或确保任务完成的场景。
detach()将线程分离,让它独立运行。主线程不再等待,线程会在后台执行直到完成。使用detach要特别小心,确保线程访问的数据在线程结束前仍然有效,避免悬空引用。
参数传递:
默认情况下,参数是值传递,会拷贝到线程的内部存储。如果要传递引用,必须使用std::ref或std::cref。对于指针参数,要确保指针指向的对象在线程执行期间有效。传递成员函数时,需要传递this指针。
线程标识和属性:
每个线程有唯一的ID,可以通过get_id()获取。可以用this_thread命名空间中的函数获取当前线程ID、让出CPU时间片(yield)、休眠等。hardware_concurrency()返回硬件支持的并发线程数,可用于决定创建多少个工作线程。
线程的生命周期:
thread对象不可拷贝,只能移动。这保证了每个线程有唯一的拥有者。可以将thread对象放入容器,但需要使用移动语义。thread对象销毁时,如果线程仍在运行且未join或detach,程序会调用std::terminate()终止。
异常处理:
线程中抛出的异常不会传播到创建线程的地方。如果线程中有异常未被捕获,程序会调用std::terminate()。因此应该在线程函数内部捕获所有异常,或使用异常传递机制(如std::promise/future)。
常见陷阱:
陷阱1 - 忘记join或detach:
如果thread对象销毁时既没有join也没有detach,程序会终止。要养成良好习惯,总是显式选择join还是detach。
陷阱2 - detach后的悬空引用:
detach后线程独立运行,如果它访问的局部变量已经销毁,会导致未定义行为。要确保线程访问的数据生命周期足够长。
陷阱3 - 竞态条件:
多个线程访问共享数据时,如果没有适当的同步,会产生竞态条件。应该使用mutex、atomic等同步机制。
陷阱4 - 参数传递的陷阱:
容易忘记使用std::ref,导致意外的拷贝。或者传递指向局部变量的指针,导致悬空指针。
最佳实践:
优先使用RAII包装:创建RAII类来管理线程的join/detach,确保不会遗漏。
传递参数时明确意图:值传递就明确拷贝,引用传递就用std::ref。
避免detach:除非确实需要”发射后不管”的行为,否则优先用join。
使用线程池:频繁创建销毁线程开销大,考虑使用线程池复用线程。
异常安全:在线程函数内部捕获异常,或使用promise/future传递异常。
口头解答:
“C++11的thread库让多线程编程变得简单多了。创建线程很直接,把可调用对象传给thread构造函数就行,可以是函数、lambda、成员函数或函数对象。关键是要记得join或detach,join会阻塞等待线程结束,detach让线程独立运行。不做这两个操作之一,析构时会异常终止程序。参数传递要注意,默认是值传递,如果要传引用必须用std::ref包装。还要小心线程的生命周期,特别是detach后,要确保线程访问的数据还有效。一般来说,join更安全,detach要特别小心。另外thread不可拷贝只能移动,这保证了线程所有权的唯一性。”
题目27: mutex、lock_guard、unique_lock的区别和使用场景
解答:
这三个都是用于线程同步的工具,但抽象层次和灵活性不同。
mutex - 基础互斥量:
mutex是最基础的互斥同步原语,提供lock()和unlock()两个基本操作。调用lock()会阻塞直到获得锁,unlock()释放锁。直接使用mutex容易出错,因为需要手动配对lock和unlock,如果中间抛异常或提前返回,unlock可能不会执行,导致死锁。因此实际开发中很少直接使用mutex,而是配合RAII包装器。
lock_guard - 简单的RAII包装:
lock_guard是最简单的RAII锁包装器,构造时自动加锁,析构时自动解锁。它的设计哲学是简单直接,提供基本的异常安全保证。
特点:
- 构造时加锁,析构时解锁
- 不能手动unlock,锁的持有时间等于对象生命周期
- 不可拷贝、不可移动
- 零开销,性能最优
- 适合简单的临界区保护
使用场景:绝大多数情况下的临界区保护,只需要在一个作用域内持有锁。
unique_lock - 灵活的RAII包装:
unique_lock提供更多的灵活性,可以手动lock/unlock,支持延迟加锁、尝试加锁、超时等高级功能。
特点:
- 可以手动lock/unlock,灵活控制锁的持有时间
- 支持延迟加锁(构造时不加锁,稍后再加)
- 支持尝试加锁(try_lock)和超时加锁
- 可以移动(不可拷贝),可以转移锁的所有权
- 必须配合条件变量使用
- 有轻微的额外开销(需要维护锁的状态)
使用场景:
- 需要条件变量时(condition_variable要求unique_lock)
- 需要手动控制锁的持有时间
- 需要尝试加锁而不是阻塞
- 需要在不同作用域间转移锁
三者对比:
复杂度:
- mutex:需要手动管理,容易出错
- lock_guard:简单,自动管理,适合90%的场景
- unique_lock:灵活,功能强大,但有额外开销
性能:
- lock_guard开销最小
- unique_lock有轻微的额外开销(维护状态标志)
- 但通常这点开销可以忽略
功能:
- lock_guard功能单一
- unique_lock功能丰富,支持更多操作
使用建议:
默认使用lock_guard:在大多数简单的临界区保护场景,lock_guard足够且最高效。
需要条件变量时用unique_lock:条件变量的wait()需要unique_lock,因为wait时需要释放锁。
需要灵活控制时用unique_lock:如提前释放锁、延迟加锁、尝试加锁等。
避免直接使用mutex:除非有特殊需求,否则总是通过RAII包装器使用。
高级用法:
延迟加锁:
可以创建unique_lock但不立即加锁,稍后根据条件再加锁。这在锁之前需要做一些准备工作时很有用。
尝试加锁:
使用try_lock()尝试获取锁,如果失败不阻塞而是继续执行其他逻辑。适合实现非阻塞的并发算法。
超时加锁:
使用try_lock_for或try_lock_until设置超时时间,避免无限等待。适合需要响应性的场景。
转移所有权:
unique_lock可以移动,因此可以将锁的所有权转移给其他函数或存储在容器中。这在实现复杂的同步模式时很有用。
C++17的改进:
C++17引入了scoped_lock,可以同时锁定多个mutex,避免死锁。它是lock_guard的升级版,支持多个锁。对于需要同时锁定多个mutex的场景,scoped_lock是最佳选择,它内部使用std::lock算法避免死锁。
口头解答:
“这三个是递进关系。mutex是最基础的互斥量,直接lock和unlock,但不安全,异常时可能不解锁。lock_guard是RAII封装,构造时自动加锁,析构时自动解锁,异常安全,是最常用的选择。unique_lock更灵活,可以手动lock/unlock,可以延迟加锁,可以尝试加锁,还可以配合条件变量使用,但开销比lock_guard稍大。选择原则很简单:普通临界区用lock_guard,需要条件变量或灵活控制时用unique_lock,基本不直接用mutex。大多数情况lock_guard就够了,它简单高效又安全。C++17还增加了scoped_lock,可以同时锁多个mutex,避免死锁。”
题目28: 什么是死锁?如何避免?
解答:
死锁的定义:
死锁是指两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行,永久阻塞的状态。这是多线程编程中最严重的问题之一。
经典场景:
最简单的死锁场景是两个线程、两个锁:线程A持有锁1等待锁2,线程B持有锁2等待锁1。双方都在等对方释放资源,形成循环等待,永远无法解除。
死锁的四个必要条件:
理解这四个条件有助于找到预防死锁的方法:
- 互斥条件:资源不能被多个线程同时使用
- 持有并等待:线程持有至少一个资源的同时等待获取其他资源
- 不可剥夺:资源不能被强制从持有者那里拿走
- 循环等待:存在一个线程循环等待链
只要破坏其中任何一个条件,就可以避免死锁。
避免死锁的方法:
方法1 - 固定加锁顺序:
最简单有效的方法是规定所有线程必须按相同的顺序获取锁。如果总是先锁A再锁B,就不会出现线程1持有A等B,线程2持有B等A的情况。这破坏了循环等待条件。
实践中可以给锁分配全局的顺序号,总是按序号从小到大获取锁。
方法2 - 使用std::lock:
C++标准库提供了std::lock函数,可以同时锁定多个mutex,保证要么全部锁定,要么都不锁定,避免部分锁定导致的死锁。
C++17的scoped_lock更方便,它是对std::lock的RAII封装,推荐使用。
方法3 - 尝试加锁:
使用try_lock尝试获取锁,如果失败就释放已有的锁并重试。这种方法可能需要多次尝试,但避免了阻塞等待。注意重试时应该加入随机延迟,避免活锁(所有线程同时重试又同时失败)。
方法4 - 超时机制:
给锁操作设置超时时间,如果在指定时间内无法获得锁,就放弃并释放已有的锁。这可以避免无限期等待,但需要妥善处理超时的情况。
方法5 - 锁的层次结构:
建立锁的层次,高层次的锁只能在获取所有低层次锁之后才能获取。这强制了加锁顺序,避免循环等待。
方法6 - 减少锁的使用:
最根本的方法是减少需要同时持有多个锁的场景。可以通过重新设计,使用无锁数据结构,或减小临界区的范围来实现。
设计层面的预防:
减小临界区:
锁应该保护尽可能小的代码段。提前准备好数据,只在必要时加锁,操作完立即解锁。这减少了持有锁的时间,降低死锁概率。
避免嵌套锁:
尽量设计成不需要同时持有多个锁。如果一个操作需要多个资源,考虑重新设计或使用更粗粒度的锁。
使用无锁数据结构:
对于某些场景,可以使用原子操作和无锁数据结构,完全避免使用锁。
单一锁策略:
使用一个全局锁保护所有共享数据,虽然可能影响性能,但能彻底避免死锁。
检测和恢复:
虽然预防更好,但有时也需要检测机制。可以使用超时检测:如果获取锁的时间超过合理范围,可能发生了死锁。发现死锁后的处理策略包括:终止某个线程,回滚事务,或重启整个系统。
实际开发建议:
设计评审:在设计阶段就考虑锁的顺序和范围。
代码规范:建立团队的加锁规范,统一加锁顺序。
工具辅助:使用线程分析工具检测潜在的死锁。
简化设计:优先考虑简单的设计,避免复杂的多锁场景。
测试:进行压力测试和并发测试,暴露潜在的死锁问题。
口头解答:
“死锁是多线程编程的经典问题,简单说就是大家互相等对方,谁都走不了。最常见的是两个线程以不同顺序获取两个锁。避免死锁有几个办法:最简单的是固定加锁顺序,所有地方都按同样顺序获取锁;更好的是用C++11的std::lock或C++17的scoped_lock,它能原子地锁多个mutex,保证要么全锁上,要么全不锁。还可以用try_lock尝试获取锁,失败就放弃重试。或者设置超时,避免无限等待。实践中,最好的办法是减少锁的使用,尽量用无锁数据结构或者减小临界区,从根本上降低死锁风险。另外设计时要仔细考虑,避免需要同时持有多个锁的场景。”
题目29: 什么是条件变量?如何使用?
解答:
基本概念:
条件变量(condition_variable)是线程间同步的机制,允许一个或多个线程等待某个条件成立,而其他线程可以通知它们条件已满足。这是实现生产者-消费者等模式的关键工具。
工作原理:
条件变量总是配合mutex使用。等待线程首先获取mutex,然后检查条件,如果条件不满足,调用wait()。wait()会原子地释放mutex并使线程进入等待状态。当其他线程通过notify_one()或notify_all()发出通知时,等待线程被唤醒,重新获取mutex,然后检查条件是否真正满足。
为什么需要条件变量:
如果没有条件变量,线程只能用忙等待(busy waiting)的方式反复检查条件,这会浪费CPU资源。条件变量允许线程真正休眠,直到被通知才醒来,效率高得多。
使用模式:
等待方:
- 获取mutex(通过unique_lock)
- 检查条件(通过谓词)
- 如果条件不满足,调用wait()
- wait()会释放锁并等待
- 被唤醒后重新获取锁,再次检查条件
- 条件满足后执行操作
- 释放锁(unique_lock析构时自动释放)
通知方:
- 获取mutex
- 修改共享状态(使条件可能成立)
- 释放mutex(可选,但推荐)
- 调用notify_one()或notify_all()
虚假唤醒:
这是使用条件变量必须理解的概念。线程可能在条件实际上并未满足时被唤醒,这叫虚假唤醒。原因可能是系统实现细节、信号干扰等。因此wait()必须在循环或使用谓词形式,确保条件真正满足才继续。
使用谓词形式的wait()是最佳实践,它会自动处理虚假唤醒:wait会在内部循环检查谓词,直到谓词返回true才真正返回。
wait的三种形式:
- wait(lock):无条件等待,需要手动循环检查
- wait(lock, predicate):等待直到谓词为true(推荐)
- wait_for/wait_until:带超时的等待
notify_one vs notify_all:
notify_one()唤醒一个等待线程,适合只需要一个线程处理的场景。
notify_all()唤醒所有等待线程,适合所有线程都应该检查条件的场景,或者条件可能满足多个线程的需求。
选择原则:如果不确定,用notify_all()更安全,虽然可能有些性能损失。
经典应用:生产者-消费者模式:
这是条件变量最常见的应用场景。生产者生产数据放入队列,消费者从队列取数据。当队列满时,生产者等待;当队列空时,消费者等待。条件变量完美地协调了这个过程。
实现要点:
- 使用两个条件变量分别控制”队列非满”和”队列非空”
- 或使用一个条件变量,两边都等待并检查各自的条件
- 注意边界条件(队列满、队列空)
- 合理使用notify_one或notify_all
常见错误:
错误1 - 不使用谓词:
直接使用wait(lock)而不是wait(lock, predicate),容易受虚假唤醒影响。应该总是使用谓词形式。
错误2 - 在锁外修改条件:
修改共享状态时没有持有锁,导致条件变化和通知之间的竞态条件。
错误3 - 忘记通知:
修改了条件但忘记调用notify,导致等待线程永久阻塞。
错误4 - 死锁:
wait()必须使用unique_lock,不能用lock_guard,因为wait需要能够释放和重新获取锁。
性能考虑:
通知前释放锁:在调用notify之前释放mutex可以减少锁竞争。被唤醒的线程能立即获取锁,而不是醒来后发现锁还被占用。
使用notify_one而非notify_all:如果只需要一个线程响应,notify_one效率更高,避免惊群效应。
减少虚假唤醒的影响:使用谓词形式的wait,让库自动处理虚假唤醒。
口头解答:
“条件变量是线程间同步的利器,用于等待某个条件成立。经典应用就是生产者-消费者模式。使用时要注意几点:一是必须配合unique_lock,因为wait时需要释放锁;二是一定要用谓词形式的wait,避免虚假唤醒——系统可能在条件未满足时也唤醒线程,谓词会自动检查并继续等待。三是修改条件和通知要配合好,通常是先改条件,再通知。还有个细节,通知前可以先释放锁,让被唤醒的线程能立即获取锁,提高效率。总的来说,条件变量让线程间的协调变得很优雅,避免了忙等待,是实现复杂同步模式的关键工具。”
题目30: std::atomic的作用和使用场景
解答:
基本概念:
std::atomic提供原子操作,保证对变量的读写是原子的、不可分割的,多线程访问时不会出现数据竞争。这是实现无锁编程的基础。
为什么需要atomic:
普通变量的读写不是原子的,即使是简单的i++,在汇编层面也是读-改-写三步操作。多线程同时执行会导致数据竞争。使用mutex可以解决,但有开销。atomic提供了无锁的解决方案,性能更好。
支持的类型:
atomic支持整数类型、指针类型、bool等。对于自定义类型,如果满足特定条件(trivially copyable等)也可以使用,但通常只对简单类型有效。
基本操作:
- load():原子读取
- store():原子写入
- exchange():原子交换(设置新值,返回旧值)
- compare_exchange_weak/strong:CAS操作(比较并交换)
对于整数类型,还支持fetch_add、fetch_sub等原子算术操作。
CAS操作:
Compare-And-Swap是原子操作的核心,是实现无锁算法的基础。它原子地比较变量值和预期值,如果相等就设置新值,返回是否成功。weak版本可能虚假失败(即使值相等也可能返回false),但性能更好;strong版本不会虚假失败,但可能慢一些。
内存序:
这是atomic的高级特性。C++提供了多种内存序,控制操作的可见性和顺序:
- relaxed:最宽松,只保证原子性
- acquire/release:适合生产者-消费者
- seq_cst:顺序一致(默认),最严格
大多数情况使用默认的seq_cst即可。性能敏感时可以考虑relaxed或acquire/release,但需要深入理解内存模型。
使用场景:
场景1 - 计数器:
多线程的计数器是atomic的典型应用。比如统计请求数、访问次数等,使用atomic
场景2 - 标志位:
控制线程停止、状态切换等。atomic
场景3 - 无锁数据结构:
实现无锁栈、队列等数据结构,通过CAS操作保证并发安全。这需要深入理解算法和内存模型,难度较大。
场景4 - 单例模式的双重检查锁定:
使用atomic可以实现线程安全的单例,且性能优于mutex版本。
场景5 - 序列号生成:
使用fetch_add原子地递增,生成唯一的序列号,无需锁。
与mutex的比较:
性能:atomic通常更快,特别是在竞争不激烈的情况下。mutex涉及系统调用,开销较大。
适用性:atomic只适合简单操作(读写、简单算术),mutex适合保护复杂的临界区。
易用性:mutex配合RAII更容易使用和理解,atomic需要理解内存模型。
选择原则:简单操作用atomic,复杂临界区用mutex。
常见陷阱:
陷阱1 - 过度使用:
不是所有多线程问题都适合用atomic。复杂的操作仍需要mutex。
陷阱2 - 误解原子性:
atomic保证单个操作是原子的,但多个操作的组合不是。比如if(flag)后再修改,中间可能被其他线程改变。
陷阱3 - 忽略内存序:
在X86等强内存序架构上,relaxed和seq_cst差别不大,但在ARM等弱内存序架构上,错误的内存序会导致问题。
陷阱4 - 性能假设:
虽然atomic通常比mutex快,但在高度竞争的场景下,可能不如预期。要实际测试。
最佳实践:
默认使用seq_cst:除非确实需要优化,否则使用默认的顺序一致内存序。
简单操作用atomic:计数、标志位等简单场景。
复杂操作用mutex:需要保护多个变量或复杂逻辑时,用mutex更清晰。
避免过度优化:premature optimization is the root of all evil,先保证正确,再考虑性能。
学习无锁编程:如果要深入使用atomic,需要系统学习无锁编程和内存模型。
口头解答:
“atomic提供无锁的原子操作,保证操作的原子性,不会被中断。它适合简单的操作,比如计数器、标志位、单一变量的读写。常用的操作有load、store、exchange和compare_exchange,后者是实现无锁算法的基础。atomic还支持不同的内存序,默认的顺序一致最安全但性能稍差,relaxed最快但需要仔细考虑内存可见性。选择时要注意,atomic只适合简单操作,复杂的临界区还是要用mutex。但对于计数、标志等场景,atomic的性能优势明显,因为避免了上下文切换和锁竞争。不过atomic的内存序比较复杂,如果不是特别需要,用默认的就好,不要过早优化。”
第三部分总结:
本部分涵盖了智能指针(题目21-25)和多线程并发(题目26-30)的核心内容:
智能指针:
- unique_ptr、shared_ptr、weak_ptr的特性和使用场景
- 循环引用问题及解决方案
- make_shared的优势
- 自定义删除器的实现
- 智能指针的线程安全性
多线程与并发:
- std::thread的创建和管理
- mutex、lock_guard、unique_lock的区别
- 死锁的原因和预防方法
- 条件变量的工作原理和使用
- atomic原子操作的应用场景
这些内容是现代C++多线程编程的基础,掌握它们对于编写线程安全的代码至关重要。
下一部分预告:
第四部分(题目31-40)将深入模板编程和C++高级特性,包括模板特化、SFINAE、可变参数模板、移动语义、lambda表达式等现代C++的核心特性。
进度提示:
目前已完成30题(60%),还剩20题。继续保持这个节奏!







