1. 1. thread、mutex、condition_variable、atomic、C++ 内存模型
    1. 1.1. 一、std::thread 是什么,如果既不 join 也不 detach 会怎样
    2. 1.2. 二、mutex 的作用是什么,为什么要加锁
    3. 1.3. 三、lock_guard 和 unique_lock 的区别
      1. 1.3.1. lock_guard
      2. 1.3.2. unique_lock
    4. 1.4. 四、condition_variable 是干什么的,为什么通常要配合互斥锁
    5. 1.5. 五、为什么 wait 要写成 cv.wait(lock, [] { return 条件成立; });
      1. 1.5.1. 1. 防止虚假唤醒
      2. 1.5.2. 2. 防止被唤醒时条件又被别的线程抢先改掉
    6. 1.6. 六、atomic 和 mutex 的区别,什么时候用哪个
      1. 1.6.1. atomic
      2. 1.6.2. mutex
    7. 1.7. 七、为什么 atomic<int> cnt++ 线程安全,但 vector 不是改成原子就能线程安全
    8. 1.8. 八、什么是竞态条件(race condition)
    9. 1.9. 九、C++ 内存模型里几个常见内存序的直观理解
      1. 1.9.1. memory_order_relaxed
      2. 1.9.2. memory_order_release
      3. 1.9.3. memory_order_acquire
      4. 1.9.4. memory_order_seq_cst
  2. 2. 追问 1:两个线程同时跑结果可能不是 200000
  3. 3. 追问 2:改成 std::atomic<int> x = 0; 是不是所有并发问题都解决了?
  4. 4. 追问 3:为什么 condition_variable 必须搭配 unique_lock<mutex>,而不是 lock_guard<mutex>
  5. 5. 追问 4:生产者消费者模型怎么写
  6. 6. 追问 5:什么是死锁,常见成因有哪些?工程里怎么避免?
    1. 6.1. 1. 多把锁获取顺序不一致
    2. 6.2. 2. 持锁等待
    3. 6.3. 3. 忘记释放
    4. 6.4. 4. 循环等待
  7. 7. 追问 6:shared_mutex 是干什么的,什么场景下更合适
  8. 8. 追问 7:下面代码为什么不安全
    1. 8.1. 1. 数据竞争
    2. 8.2. 2. 编译器/CPU 重排
    3. 8.3. 3. 可见性问题
  9. 9. 追问 8:什么叫 happens-before
    1. 9.1. 小追问 1:join() 和 detach() 的区别,什么时候 detach 危险
      1. 9.1.1. join()
      2. 9.1.2. detach()
    2. 9.2. 小追问 2:自旋锁和互斥锁的区别,各自适合什么场景
      1. 9.2.1. 自旋锁
      2. 9.2.2. 互斥锁
    3. 9.3. 小追问 3:为什么“原子变量无锁”不代表“没有 CPU 成本”
    4. 9.4. 总结

thread、mutex、condition_variable、atomic、C++ 内存模型

thread、mutex、condition_variable、atomic、C++ 内存模型

  1. std::thread 是什么,创建线程后如果既不 join 也不 detach 会怎样
  2. mutex 的作用是什么,为什么要加锁
  3. lock_guardunique_lock 的区别
  4. condition_variable 是干什么的,为什么它通常要配合互斥锁一起用
  5. 为什么 wait 要写成这种形式:
1
cv.wait(lock, [] { return 条件成立; });
  1. 什么叫虚假唤醒,为什么不能 wait 一次醒了就直接往下执行
  2. atomicmutex 的区别是什么,什么时候适合用哪个
  3. atomic<int> cnt++ 为什么通常是线程安全的,但 vector 不是改成原子就能线程安全
  4. 什么是竞态条件(race condition)
  5. C++ 内存模型里,memory_order_relaxedacquirereleaseseq_cst 大概是什么意思

下面开始追问,按真实面试官的节奏压你。

追问 1

请你说一下下面这段代码为什么有问题:

1
2
3
4
5
6
7
int x = 0;

void f() {
for (int i = 0; i < 100000; i++) {
x++;
}
}

两个线程同时跑 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
2
3
4
5
6
7
8
9
10
11
12
bool ready = false;
int data = 0;

void producer() {
data = 42;
ready = true;
}

void consumer() {
while (!ready) {}
cout << data << endl;
}

为什么这段代码在多线程下不一定安全?

我想听到你讲:

  • 数据竞争
  • 编译器重排 / 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(),让线程和这个对象分离,后台独立运行
  • 典型使用场景
    1. 日志线程:后台默默写日志,主线程不用等
    2. 心跳包线程:定时发心跳,不需要阻塞主逻辑
    3. 监控线程、后台刷新线程
    4. 临时任务,做完就消失,不需要返回结果

如果一个线程对象还是 joinable(),你却让它直接析构了,那么程序会直接:

1
std::terminate()

这点面试里一定要说清楚,不是“资源泄漏”这么轻,而是直接终止程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
// 必须包含线程头文件
#include <thread>
#include <chrono> // 用于延时

// 1. 普通函数:线程要执行的任务
void print_task(int num, const std::string& str) {
// 模拟耗时任务
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "线程执行:数字=" << num << ",字符串=" << str << std::endl;
}

// 2. 仿函数(可选)
class Task {
public:
void operator()() {
std::cout << "仿函数线程执行" << std::endl;
}
};

int main() {
std::cout << "主线程开始" << std::endl;

// ====================== 用法1:线程执行普通函数 ======================
// 创建线程:第一个参数是函数名,后面是函数参数
std::thread t1(print_task, 100, "Hello Thread");

// ====================== 用法2:线程执行 Lambda 表达式 ======================
std::thread t2([]() {
std::this_thread::sleep_for(std::chrono::milliseconds(500));
std::cout << "Lambda 线程执行" << std::endl;
});

// ====================== 用法3:线程执行仿函数 ======================
Task task;
std::thread t3(task);

// 等待线程执行完毕(必须写,否则主线程退出会导致子线程崩溃)
t1.join();
t2.join();
t3.join();

std::cout << "主线程结束" << std::endl;
return 0;
}

二、mutex 的作用是什么,为什么要加锁

mutex 的作用是做互斥访问
也就是同一时刻只允许一个线程进入某段临界区,避免多个线程同时修改共享数据。

为什么要加锁?因为很多操作看起来是一句代码,实际上不是原子的。

比如:

1
x++;

底层至少可以理解成:

  1. x
  2. x + 1
  3. 写回 x

如果两个线程同时做,就可能互相覆盖,导致结果错乱。
加锁的本质就是把这一段复合操作包成“同一时刻只能一个线程做”。


三、lock_guardunique_lock 的区别

lock_guard

最轻量,典型 RAII 锁。构造时加锁,析构时解锁。

1
std::lock_guard<std::mutex> lg(mtx);

优点是简单、安全、开销小。
适合那种“进入作用域就加锁,离开作用域就解锁”的固定场景。

unique_lock

更灵活。它也能 RAII 管锁,但还支持:

  • 延迟加锁
  • 手动 unlock() / lock()
  • 移动语义
  • 配合 condition_variable::wait

比如:

1
2
3
std::unique_lock<std::mutex> lk(mtx);
lk.unlock();
lk.lock();

所以区别一句话概括就是:

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
2
3
while (!条件成立) {
cv.wait(lock);
}

这样做有两个原因:

1. 防止虚假唤醒

也就是线程可能在没有满足条件、甚至没人真正发出有效通知的情况下醒来。

2. 防止被唤醒时条件又被别的线程抢先改掉

比如多个消费者都被唤醒,但队列里只够一个人取。
第一个线程取完后,第二个线程醒来时条件又不成立了。

所以不能写成:

1
if (!条件) cv.wait(lock);

而要写成:

1
while (!条件) cv.wait(lock);

或者直接用带谓词版本。


六、atomicmutex 的区别,什么时候用哪个

atomic

适合保护单个原子变量的读写或读改写,比如计数器、标志位、简单状态位。

比如:

1
2
std::atomic<int> cnt{0};
cnt++;

这通常是线程安全的,因为这个自增是原子读改写。

mutex

适合保护一段复合逻辑或者多个共享变量之间的一致性关系

比如:

  • 修改 vector
  • 维护 map 和 count 的同步关系
  • 先检查再更新
  • 一组变量要一起保持一致

所以一句话就是:

原子适合单变量、短小同步;锁适合复杂临界区和复合不变量。


七、为什么 atomic<int> cnt++ 线程安全,但 vector 不是改成原子就能线程安全

因为 atomic 只能保证它自己这个变量的原子访问。
它不能自动让“整个对象的复杂操作”都变成原子。

vectorpush_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
2
3
4
5
6
7
int x = 0;

void f() {
for (int i = 0; i < 100000; i++) {
x++;
}
}

两个线程同时跑,结果不一定是 200000。
原因是:

x++ 不是原子操作。

它至少包含:

  1. 读出 x
  2. 加 1
  3. 写回 x

两个线程可能这样交错:

  • 线程 A 读到 5
  • 线程 B 也读到 5
  • A 写回 6
  • B 也写回 6

这样就丢失了一次更新。
这就是典型的“丢失更新”问题。

而且这里不只是结果不准,从标准角度讲这还是数据竞争,属于未定义行为。


追问 2:改成 std::atomic<int> x = 0; 是不是所有并发问题都解决了?

不是。

它只能保证:

  • x 这个变量自己的读写/读改写是原子的
  • 比如 x++ 不会丢更新

但它不能自动保证:

  • 一组操作整体原子
  • 多个变量之间的一致性
  • 复杂容器内部状态线程安全
  • “先检查再执行”的复合逻辑天然安全

比如:

1
2
3
if (x > 0) {
x--;
}

即使 x 是原子,这整个“检查 + 修改”仍然不是一个整体原子事务。

所以原子变量不能替代所有锁。


追问 3:为什么 condition_variable 必须搭配 unique_lock<mutex>,而不是 lock_guard<mutex>

因为 wait 的核心动作不是简单睡眠,它要做一个原子化组合操作

  1. 先释放互斥锁
  2. 让线程进入等待
  3. 被唤醒后重新加锁
  4. 再返回

lock_guard 太简单,它只有“构造加锁、析构解锁”,没有接口让 wait 在中间临时释放并重新获得这把锁。

unique_lock 则支持这种可控的 lock/unlock 生命周期,所以 condition_variable::wait 需要的是 unique_lock


追问 4:生产者消费者模型怎么写

核心同步关系是:

  • 队列为空,消费者等待
  • 队列满了,生产者等待
  • 判断条件必须放在 while 或谓词里,不是 if

一个简化版思路如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
std::queue<int> q;
std::mutex mtx;
std::condition_variable not_empty, not_full;
const size_t CAP = 100;

void producer(int x) {
std::unique_lock<std::mutex> lk(mtx);
not_full.wait(lk, [] { return q.size() < CAP; });
q.push(x);
lk.unlock();
not_empty.notify_one();
}

int consumer() {
std::unique_lock<std::mutex> lk(mtx);
not_empty.wait(lk, [] { return !q.empty(); });
int v = q.front();
q.pop();
lk.unlock();
not_full.notify_one();
return v;
}

为什么条件判断不能用 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
2
3
4
5
6
7
8
9
10
11
12
bool ready = false;
int data = 0;

void producer() {
data = 42;
ready = true;
}

void consumer() {
while (!ready) {}
cout << data << endl;
}

问题在于这在多线程下没有任何同步,是典型的数据竞争和可见性问题。

1. 数据竞争

一个线程写 ready/data,另一个线程读,没有同步。

2. 编译器/CPU 重排

即使你代码写的是:

1
2
data = 42;
ready = true;

也不能保证另一个线程观察到的顺序就是这样。
可能看到 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 是线程对象封装,线程创建后必须在析构前 joindetach,否则会 std::terminatemutex 用来保护临界区,避免多个线程同时修改共享数据;lock_guard 轻量简单,unique_lock 更灵活,尤其适合和 condition_variable 配合。条件变量用于线程间等待和通知,典型写法是 cv.wait(lock, predicate),因为要防止虚假唤醒并确保条件被重新检查。atomic 适合单个变量的原子读写和读改写,比如计数器、标志位,但不能替代所有锁,因为它无法自动保证复合操作和复杂对象的一致性。竞态条件本质上是结果依赖未同步的线程执行时序;像 x++ 这种操作不是原子动作,多个线程并发会丢失更新。C++ 内存模型里,relaxed 只保证原子性,release/acquire 用来建立跨线程可见性,seq_cst 则提供最强、最直观的全序语义。真正让一个线程的写对另一个线程“保证可见”的关键,是建立 happens-before 关系,这通常要靠锁、条件变量、原子同步或线程 join 等同步原语。