thread、mutex、condition_variable、atomic、C++ 内存模型
std::thread是什么,创建线程后如果既不join也不detach会怎样mutex的作用是什么,为什么要加锁lock_guard和unique_lock的区别condition_variable是干什么的,为什么它通常要配合互斥锁一起用- 为什么
wait要写成这种形式:
1 | cv.wait(lock, [] { return 条件成立; }); |
- 什么叫虚假唤醒,为什么不能
wait一次醒了就直接往下执行 atomic和mutex的区别是什么,什么时候适合用哪个atomic<int> cnt++为什么通常是线程安全的,但vector不是改成原子就能线程安全- 什么是竞态条件(race condition)
- C++ 内存模型里,
memory_order_relaxed、acquire、release、seq_cst大概是什么意思
下面开始追问,按真实面试官的节奏压你。
追问 1
请你说一下下面这段代码为什么有问题:
1 | int x = 0; |
两个线程同时跑 f(),最后结果为什么可能不是 200000?
我希望你能讲到:
x++不是原子操作- 它至少包含读、改、写
- 多线程交错会导致丢失更新
追问 2
那如果改成:
1 | std::atomic<int> x = 0; |
是不是所有并发问题都解决了?
你要说明:
- 原子变量只能保证这个变量本身的原子读写/读改写
- 不能自动保证一组复合操作的整体原子性
- 不能替代所有锁
追问 3
为什么 condition_variable 必须搭配 unique_lock<mutex>,而不是 lock_guard<mutex>?
追问 4
生产者消费者模型你会怎么写?
至少讲清楚这几个同步关系:
- 队列为空时,消费者要等待
- 队列满时,生产者要等待
- 为什么判断条件要放在
while或谓词里,而不是if
追问 5
什么是死锁?常见成因有哪些?
你至少说到:
- 多把锁获取顺序不一致
- 持锁等待
- 忘记释放
- 循环等待
然后我会继续追问:
工程里怎么避免死锁?
追问 6
shared_mutex 是干什么的?什么场景下它比普通 mutex 更合适?
追问 7
下面这段代码有没有问题?
1 | bool ready = false; |
为什么这段代码在多线程下不一定安全?
我想听到你讲:
- 数据竞争
- 编译器重排 / CPU 重排
- 可见性问题
- 为什么需要原子变量或同步原语建立 happens-before
追问 8
什么叫 happens-before?
你不用背标准定义,但你要能讲明白:
一个线程里的写,为什么另一个线程“保证能看到”,这个保证是怎么建立起来的?
小追问 1
join() 和 detach() 的区别是什么?什么时候 detach 危险?
小追问 2
自旋锁和互斥锁的区别是什么?各自适合什么场景?
小追问 3
为什么说“原子变量无锁”不代表“没有 CPU 成本”?
一、std::thread 是什么,如果既不 join 也不 detach 会怎样
std::thread 是 C++ 标准库对线程对象的封装。
你创建一个 std::thread,本质上就是启动了一个新的执行流,同时当前线程继续往下跑。
比如:
1 | std::thread t(f); |
这里 t 只是“管理这个线程的对象句柄”,不是线程本身。
最关键的一点是:
一个 std::thread 对象在析构前,必须处于不可 join 状态。
也就是说,你要么:
t.join(),等待线程执行完- 要么
t.detach(),让线程和这个对象分离,后台独立运行 - 典型使用场景
- 日志线程:后台默默写日志,主线程不用等
- 心跳包线程:定时发心跳,不需要阻塞主逻辑
- 监控线程、后台刷新线程
- 临时任务,做完就消失,不需要返回结果
如果一个线程对象还是 joinable(),你却让它直接析构了,那么程序会直接:
1 | std::terminate() |
这点面试里一定要说清楚,不是“资源泄漏”这么轻,而是直接终止程序。
1 |
|
二、mutex 的作用是什么,为什么要加锁
mutex 的作用是做互斥访问。
也就是同一时刻只允许一个线程进入某段临界区,避免多个线程同时修改共享数据。
为什么要加锁?因为很多操作看起来是一句代码,实际上不是原子的。
比如:
1 | x++; |
底层至少可以理解成:
- 读
x x + 1- 写回
x
如果两个线程同时做,就可能互相覆盖,导致结果错乱。
加锁的本质就是把这一段复合操作包成“同一时刻只能一个线程做”。
三、lock_guard 和 unique_lock 的区别
lock_guard
最轻量,典型 RAII 锁。构造时加锁,析构时解锁。
1 | std::lock_guard<std::mutex> lg(mtx); |
优点是简单、安全、开销小。
适合那种“进入作用域就加锁,离开作用域就解锁”的固定场景。
unique_lock
更灵活。它也能 RAII 管锁,但还支持:
- 延迟加锁
- 手动
unlock()/lock() - 移动语义
- 配合
condition_variable::wait
比如:
1 | std::unique_lock<std::mutex> lk(mtx); |
所以区别一句话概括就是:
lock_guard 更轻更简单,unique_lock 更重但更灵活。
四、condition_variable 是干什么的,为什么通常要配合互斥锁
condition_variable 是用来做线程间等待/通知的。
它解决的不是“互斥”问题,而是“条件还不满足时别傻等,先睡眠;条件满足了再唤醒”。
典型场景就是生产者消费者:
- 队列空了,消费者不能继续取,要等
- 队列满了,生产者不能继续放,也要等
为什么它要配合互斥锁?因为“判断条件”和“进入等待”这两个动作必须和共享状态保护在一起,否则会丢通知或者看见不一致状态。
所以典型写法是:
1 | cv.wait(lock, [] { return 条件成立; }); |
这里锁不是装饰品,而是为了保护那个“条件变量关联的共享状态”。
五、为什么 wait 要写成 cv.wait(lock, [] { return 条件成立; });
因为 wait 不是“醒了就一定能继续执行”。
它正确语义应该是:
一直等到条件真的成立。
所以推荐写法是谓词版:
1 | cv.wait(lock, [] { return !q.empty(); }); |
这等价于内部循环检查:
1 | while (!条件成立) { |
这样做有两个原因:
1. 防止虚假唤醒
也就是线程可能在没有满足条件、甚至没人真正发出有效通知的情况下醒来。
2. 防止被唤醒时条件又被别的线程抢先改掉
比如多个消费者都被唤醒,但队列里只够一个人取。
第一个线程取完后,第二个线程醒来时条件又不成立了。
所以不能写成:
1 | if (!条件) cv.wait(lock); |
而要写成:
1 | while (!条件) cv.wait(lock); |
或者直接用带谓词版本。
六、atomic 和 mutex 的区别,什么时候用哪个
atomic
适合保护单个原子变量的读写或读改写,比如计数器、标志位、简单状态位。
比如:
1 | std::atomic<int> cnt{0}; |
这通常是线程安全的,因为这个自增是原子读改写。
mutex
适合保护一段复合逻辑或者多个共享变量之间的一致性关系。
比如:
- 修改
vector - 维护 map 和 count 的同步关系
- 先检查再更新
- 一组变量要一起保持一致
所以一句话就是:
原子适合单变量、短小同步;锁适合复杂临界区和复合不变量。
七、为什么 atomic<int> cnt++ 线程安全,但 vector 不是改成原子就能线程安全
因为 atomic 只能保证它自己这个变量的原子访问。
它不能自动让“整个对象的复杂操作”都变成原子。
vector 的 push_back 背后可能涉及:
- 读 size
- 检查 capacity
- 可能扩容
- 搬迁元素
- 更新尾指针
这是一整套复合过程,不是一个机器指令或一个原子变量能包住的。
所以你不能说“把 vector 变原子了就线程安全”,因为问题不是“一个字”能不能原子读写,而是整个容器内部状态更新必须整体同步。
八、什么是竞态条件(race condition)
竞态条件就是:
程序结果依赖于多个线程执行时序,而这个时序又没有被正确同步约束。
如果多个线程访问共享状态,最终结果取决于“谁先谁后、怎么交错”,那通常就有竞态风险。
更严格一点,如果多个线程并发访问同一内存位置,且至少有一个是写,并且没有同步,那就是数据竞争,属于未定义行为。
九、C++ 内存模型里几个常见内存序的直观理解
memory_order_relaxed
只保证这个原子操作本身是原子的。
不额外保证跨线程顺序关系。适合纯计数、统计类场景。
memory_order_release
用于“发布”一侧。
它前面的普通写,不会被重排到它后面。
常和 acquire 配对。
memory_order_acquire
用于“获取”一侧。
它后面的普通读写,不会被重排到它前面。
看到 release 发布的那个原子值后,也能看到 release 之前写入的数据。
memory_order_seq_cst
最强、最直观。
可以近似理解成“大家都像在一个全局统一顺序里观察这些原子操作”,最容易理解,但通常约束也最强。
追问 1:两个线程同时跑结果可能不是 200000
1 | int x = 0; |
两个线程同时跑,结果不一定是 200000。
原因是:
x++ 不是原子操作。
它至少包含:
- 读出
x - 加 1
- 写回
x
两个线程可能这样交错:
- 线程 A 读到 5
- 线程 B 也读到 5
- A 写回 6
- B 也写回 6
这样就丢失了一次更新。
这就是典型的“丢失更新”问题。
而且这里不只是结果不准,从标准角度讲这还是数据竞争,属于未定义行为。
追问 2:改成 std::atomic<int> x = 0; 是不是所有并发问题都解决了?
不是。
它只能保证:
x这个变量自己的读写/读改写是原子的- 比如
x++不会丢更新
但它不能自动保证:
- 一组操作整体原子
- 多个变量之间的一致性
- 复杂容器内部状态线程安全
- “先检查再执行”的复合逻辑天然安全
比如:
1 | if (x > 0) { |
即使 x 是原子,这整个“检查 + 修改”仍然不是一个整体原子事务。
所以原子变量不能替代所有锁。
追问 3:为什么 condition_variable 必须搭配 unique_lock<mutex>,而不是 lock_guard<mutex>
因为 wait 的核心动作不是简单睡眠,它要做一个原子化组合操作:
- 先释放互斥锁
- 让线程进入等待
- 被唤醒后重新加锁
- 再返回
而 lock_guard 太简单,它只有“构造加锁、析构解锁”,没有接口让 wait 在中间临时释放并重新获得这把锁。
unique_lock 则支持这种可控的 lock/unlock 生命周期,所以 condition_variable::wait 需要的是 unique_lock。
追问 4:生产者消费者模型怎么写
核心同步关系是:
- 队列为空,消费者等待
- 队列满了,生产者等待
- 判断条件必须放在 while 或谓词里,不是 if
一个简化版思路如下:
1 | std::queue<int> q; |
为什么条件判断不能用 if?
因为有虚假唤醒,也因为多个线程竞争时,醒来后条件可能已经又不成立了。
所以必须重新检查。
追问 5:什么是死锁,常见成因有哪些?工程里怎么避免?
死锁就是多个线程互相等待,谁都走不下去。
常见成因:
1. 多把锁获取顺序不一致
线程 A 先拿锁 1 再等锁 2,线程 B 先拿锁 2 再等锁 1。
2. 持锁等待
拿着一个锁不放,又去等待其他资源。
3. 忘记释放
比如异常路径、早返回路径没正确解锁。
4. 循环等待
多个线程形成等待环。
工程里避免死锁的常见手段:
- 统一多把锁的获取顺序
- 尽量缩小临界区
- 用 RAII 锁避免忘记释放
- 尽量避免持锁做耗时操作
- 多锁场景可用
std::lock - 设计上减少嵌套锁依赖
追问 6:shared_mutex 是干什么的,什么场景下更合适
shared_mutex 是读写锁。
它支持两种加锁方式:
- 共享锁:多个读线程可以同时持有
- 独占锁:写线程独占
适合:
读多写少 的场景。
比如配置中心、本地缓存、字典查询表。
如果大多数线程只是读,少量线程偶尔更新,用普通 mutex 会让所有读线程彼此阻塞;而 shared_mutex 可以让读读并发。
追问 7:下面代码为什么不安全
1 | bool ready = false; |
问题在于这在多线程下没有任何同步,是典型的数据竞争和可见性问题。
1. 数据竞争
一个线程写 ready/data,另一个线程读,没有同步。
2. 编译器/CPU 重排
即使你代码写的是:
1 | data = 42; |
也不能保证另一个线程观察到的顺序就是这样。
可能看到 ready == true 时,data 还没对它可见。
3. 可见性问题
消费者线程一直在本地缓存/寄存器视角下看旧值,不一定及时看到生产者写入。
所以这个例子要正确,需要用原子变量或锁/条件变量建立同步关系,也就是建立 happens-before。
追问 8:什么叫 happens-before
不用背标准定义,我会这样解释:
happens-before 是一种“先发生且结果对另一个线程可见”的同步保证。
也就是说,不只是代码顺序上的“我先写了”,而是语言和同步原语真正保证:
- 一个线程的写
- 在另一个线程的读之前
- 并且这个读一定能看到那个写,或者至少看到它之后的结果
这个保证怎么建立?
典型方式有:
- 同一 mutex 的 unlock 和后续 lock
- 原子的 release / acquire 配对
- 线程
join - 条件变量等待与通知配合互斥锁
没有 happens-before,就不能说“另一个线程一定看得到我的写”。
小追问 1:join() 和 detach() 的区别,什么时候 detach 危险
join()
当前线程等待目标线程结束。
线程执行完成后,资源被正确回收,生命周期关系清晰。
detach()
线程和 std::thread 对象分离,后台独立运行,之后你没法再 join 它。
detach 危险的地方在于:
- 线程可能访问已经销毁的对象
- 主线程退出时后台线程还在跑
- 生命周期难管理
- 错误传播和收尾都很麻烦
所以工程里通常优先 join,除非你非常明确这个后台线程的生命周期和资源依赖。
小追问 2:自旋锁和互斥锁的区别,各自适合什么场景
自旋锁
拿不到锁时不睡眠,一直忙等。
适合:
- 临界区特别短
- 锁持有时间很短
- 线程切换成本比等一会儿更贵
- 内核态/低延迟场景
缺点是会持续占 CPU。
互斥锁
拿不到锁时通常会阻塞/睡眠,由系统调度。
适合:
- 临界区可能较长
- 竞争不小
- 不想空转耗 CPU
所以一句话:
短临界区、高频、低延迟可考虑自旋;一般业务线程同步更多用互斥锁。
小追问 3:为什么“原子变量无锁”不代表“没有 CPU 成本”
因为“无锁”只是说它不一定通过操作系统 mutex 那种阻塞锁来实现,
不代表它没有同步代价。
原子操作仍然可能涉及:
- CPU 原子指令
- cache line 独占和一致性流量
- 内存屏障
- 流水线和乱序执行限制
- 多核之间 cache 抖动
尤其是高竞争下,一个原子热点变量会让多个核心频繁争抢 cache line,成本可能很高。
所以“lock-free”不等于“free”。
总结
std::thread 是线程对象封装,线程创建后必须在析构前 join 或 detach,否则会 std::terminate。mutex 用来保护临界区,避免多个线程同时修改共享数据;lock_guard 轻量简单,unique_lock 更灵活,尤其适合和 condition_variable 配合。条件变量用于线程间等待和通知,典型写法是 cv.wait(lock, predicate),因为要防止虚假唤醒并确保条件被重新检查。atomic 适合单个变量的原子读写和读改写,比如计数器、标志位,但不能替代所有锁,因为它无法自动保证复合操作和复杂对象的一致性。竞态条件本质上是结果依赖未同步的线程执行时序;像 x++ 这种操作不是原子动作,多个线程并发会丢失更新。C++ 内存模型里,relaxed 只保证原子性,release/acquire 用来建立跨线程可见性,seq_cst 则提供最强、最直观的全序语义。真正让一个线程的写对另一个线程“保证可见”的关键,是建立 happens-before 关系,这通常要靠锁、条件变量、原子同步或线程 join 等同步原语。