- C++ 并发编程详细讲解,包含代码示例与底层机制分析
- 对应面试问答见 C++ 基础
- 完整系列:C++ 基础-初级、C++ 基础-中级、C++ 基础-高级、C++ STL、03-Resources/cpp/C++ 并发编程、03-Resources/cpp/C++ 网络编程、03-Resources/cpp/C++ 数据结构、03-Resources/cpp/C++ 设计模式、03-Resources/cpp/C++ 基本算法、03-Resources/cpp/C++ Linux 编程
- 当前覆盖:锁(lock_guard/unique_lock/读写锁/自旋锁)、线程(join/detach/jthread)、atomic、内存序、无锁/CAS/ABA、伪共享、future/async、协程、线程池、IPC、死锁
什么是进程?什么是线程?有什么区别?
定义
- 进程(Process):操作系统资源分配的基本单位,是程序的一次执行实例,拥有独立的地址空间、文件描述符、堆栈、全局变量等资源。进程之间默认互不干扰,通信需借助 IPC。
- 线程(Thread):操作系统 CPU 调度的基本单位,是进程内的执行流。同一进程的多个线程共享地址空间、堆、全局变量、文件描述符,但各自拥有独立的栈、寄存器和程序计数器(PC)。
一句话记忆:进程是资源分配的单位,线程是 CPU 调度的单位;一个进程至少有一个主线程。
核心区别
| 维度 | 进程 | 线程 |
|---|---|---|
| 资源 | 独立的地址空间与系统资源 | 共享进程的地址空间与资源 |
| 开销 | 创建/切换开销大(建立地址空间、页表) | 创建/切换开销小(只需栈 + 寄存器上下文) |
| 隔离性 | 强,一个进程崩溃不影响其它 | 弱,一个线程崩溃可能拖垮整个进程 |
| 通信 | 需 IPC(管道/共享内存/消息队列) | 直接读写共享内存(但需加锁/原子同步) |
| 地址空间 | 独立虚拟地址空间 | 共享同一虚拟地址空间 |
| 调度单位 | 资源单位,调度按线程进行 | 内核调度的基本单位 |
补充要点
- 创建机制:Linux 下
fork创建进程用写时复制(COW),clone(CLONE_THREAD)创建线程共享地址空间,线程更轻量。 - 上下文切换:线程切换只换寄存器和栈,不切换地址空间(不刷新 TLB),因此比进程切换快。
- C++ 体现:
std::thread/std::jthread创建的是线程;多进程需fork+ IPC 配合。
先答「进程是资源分配单位、线程是 CPU 调度单位」,再补三点:① 线程共享堆/全局/文件描述符、独享栈;② 线程切换比进程快(不切地址空间、不刷 TLB);③ 进程隔离强、线程隔离弱(一线程崩可能带走整个进程)。能说出 Linux fork 写时复制、线程用 clone 是加分项。
C++ 中 lock_guard 和 unique_lock 的区别?
核心区别
两者都是 RAII 风格的锁管理(构造加锁、析构解锁),区别在灵活性和功能:
lock_guard | unique_lock | |
|---|---|---|
| 灵活性 | 低(只加锁/解锁) | 高(可延迟加锁、提前解锁) |
| 能否手动解锁 | ❌ | ✅ unlock() |
| 能否延迟加锁 | ❌ | ✅ defer_lock |
| 能否转移所有权 | ❌ 不可移动 | ✅ 可移动(std::move) |
| 开销 | 更低 | 略高(多状态标志) |
| 配合条件变量 | ❌ | ✅(condition_variable 需要) |
lock_guard —— 简单自动锁
std::mutex mtx;
void f() {
std::lock_guard<std::mutex> lock(mtx); // 构造即加锁
// 临界区
// 作用域结束(包括异常)自动解锁
}unique_lock —— 灵活锁
std::mutex mtx;
// 1. 延迟加锁:先创建,稍后手动加锁
std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 不加锁
lock.lock(); // 稍后手动加锁
lock.unlock(); // 提前解锁
// 2. 配合条件变量(必须用 unique_lock)
std::condition_variable cv;
std::unique_lock<std::mutex> lk(mtx);
cv.wait(lk, [] { return ready; }); // 条件变量要求 unique_lock
// 3. 可移动所有权
std::unique_lock<std::mutex> lk2 = std::move(lock);使用选择
- 简单加锁/解锁 → 用
lock_guard(开销小、够用) - 需要延迟加锁、提前解锁、配合条件变量、转移锁所有权 → 用
unique_lock
回答时先讲两者都是 RAII 锁,核心区别在灵活性:lock_guard 简单(只加锁解锁、开销低),unique_lock 灵活(延迟加锁、手动解锁、可移动、配合条件变量)。重点点出条件变量必须用 unique_lock。
C++ 中 thread 的 join 和 detach 的区别?
核心区别
join() | detach() | |
|---|---|---|
| 语义 | 主线程等待子线程执行完 | 主线程不等待,子线程独立运行 |
| 归属 | 线程对象仍管理线程 | 线程对象与线程脱离关系 |
| 资源回收 | join 后线程资源回收 | 线程结束自行回收,但主线程无法控制 |
| 后续 joinable | false | false |
join —— 等待线程结束
#include <thread>
void task() { /* 耗时工作 */ }
std::thread t(task);
// ... 主线程做其他事
t.join(); // 阻塞,直到 t 执行完
// join 后主线程才能继续,保证 t 完成用途:主线程需要等待子线程结果,保证线程执行完再继续(如计算任务汇总)。
detach —— 分离线程
std::thread t(task);
t.detach(); // t 与线程脱离,主线程不等待
// 主线程继续执行,t 独立在后台运行
// 线程结束后系统自动回收资源用途:后台任务,主线程不关心其结束(如守护线程、日志线程)。
detach 后主线程无法控制子线程,且子线程不能访问主线程已销毁的局部变量(否则悬挂引用/指针):
void bad() {
int local = 42;
std::thread t([&local] { std::cout << local; }); // 捕获局部变量引用
t.detach(); // ❌ 危险!local 可能已销毁,子线程访问悬挂引用
}joinable 检查
线程对象析构前,必须 join 或 detach,否则析构时 std::terminate:
std::thread t(task);
// 析构前必须 join 或 detach
if (t.joinable()) {
t.join(); // 或 t.detach();
}回答时讲清核心:join 是”主线程等待子线程结束”,detach 是”分离、主线程不等待、子线程独立后台运行”。补充 detach 的风险(访问已销毁变量)和”析构前必须 join/detach,否则 terminate”。
C++ 中 jthread 和 thread 的区别?
核心区别
std::jthread(C++20)是对 std::thread 的改进,解决两个痛点:自动 join 和协作式取消。
std::thread | std::jthread | |
|---|---|---|
| 引入版本 | C++11 | C++20 |
| 析构行为 | 未 join/detach 时 std::terminate | 自动 join |
| 协作式取消 | ❌ | ✅ 支持(stop_token) |
| 头文件 | <thread> | <thread> |
1. 自动 join(最重要的改进)
std::thread 析构时如果还没 join/detach,会调用 std::terminate(崩溃):
void bad() {
std::thread t(task);
// 忘记 join/detach
} // ❌ std::thread 析构 → std::terminate,程序崩溃
void good() {
std::jthread t(task);
// 不需要手动 join
} // ✅ jthread 析构自动 join,安全2. 协作式取消(stop_token)
jthread 支持通过 stop_token 请求线程协作式停止:
#include <thread>
#include <iostream>
void worker(std::stop_token st) {
while (!st.stop_requested()) { // 检查是否请求停止
// 工作...
}
}
int main() {
std::jthread t(worker);
// ...
t.request_stop(); // 请求停止(协作式,worker 检查到后退出)
// t 析构时也会自动 request_stop + join
}jthread 自动把 stop_token 传给线程函数(只要函数接受 std::stop_token 参数)。
总结
- jthread = thread + 自动 join + stop_token 协作取消
- 现代 C++(C++20+)优先用 jthread,避免忘记 join 的崩溃问题
回答时抓住两点:① 自动 join(thread 析构未 join 会 terminate,jthread 自动 join,更安全);② stop_token 协作式取消(优雅停止线程)。结论是现代 C++ 优先用 jthread。
C++ 中如何设计一个线程安全的类?
什么是线程安全
当多个线程同时访问一个对象(读写其成员)时,若不需要调用方额外同步、结果依然正确且无数据竞争,这个类就是线程安全的。核心是消除数据竞争(data race):多个线程同时访问同一内存且至少有一个写。
设计手段对比
| 手段 | 适用场景 | 说明 |
|---|---|---|
互斥锁 std::mutex | 复杂临界区、多变量联动 | 保护复合操作的原子性 |
原子变量 std::atomic | 单个标量(计数器/标志位) | 无锁、开销小 |
读写锁 std::shared_mutex | 读多写少 | 多读并发、写独占 |
| 不可变对象 | 状态创建后不再变 | 天然线程安全 |
单次初始化 std::call_once | 懒加载单例 | 保证只初始化一次 |
基本原则
- 用锁封装内部状态,不在类外部暴露可被并发修改的内部数据。
- 不返回内部指针/引用(否则调用方可能绕过锁修改)。
- 临界区尽量小,锁内不做重操作、不调用未知代码(避免死锁)。
- 成员函数整体加锁时,
const方法里的锁要声明为mutable。 - 简单计数/标志优先用
atomic,性能更好。
示例:线程安全计数器
#include <mutex>
#include <atomic>
// 1. 互斥锁版本:适合复杂逻辑
class ThreadSafeCounter {
public:
void increment() {
std::lock_guard<std::mutex> lock(mtx_); // RAII 加锁
++count_;
}
int get() const {
std::lock_guard<std::mutex> lock(mtx_); // const 方法里锁需 mutable
return count_;
}
private:
mutable std::mutex mtx_;
int count_ = 0;
};
// 2. 原子版本:适合简单计数,无锁、更快
class AtomicCounter {
public:
void increment() { count_.fetch_add(1, std::memory_order_relaxed); }
int get() const { return count_.load(std::memory_order_relaxed); }
private:
std::atomic<int> count_{0};
};常见坑
class Bad {
public:
int& get() { return data_; } // ❌ 返回内部引用,调用方可绕过锁修改
std::mutex mtx_; // ❌ 公有锁,调用方可能误用/忘记解锁
private:
int data_;
};
回答结构:先定义”线程安全 = 并发访问无数据竞争、结果正确”,再列手段(锁 / 原子 / 读写锁 / 不可变对象),最后强调两大原则——不返回内部引用/指针、临界区最小化;简单标量用 atomic,复杂临界区用 mutex。
C++ 有哪些进程间通信的方式?
概述
进程间通信(IPC)用于不同进程间交换数据。C++ 标准库本身不提供 IPC 机制,需借助操作系统 API(POSIX / System V / Windows)。
主要方式对比
| 方式 | 数据传输 | 是否同步 | 特点 |
|---|---|---|---|
| 管道(匿名 pipe) | 字节流 | 阻塞/半双工 | 仅父子/亲缘进程,单向 |
| 命名管道 FIFO | 字节流 | 可阻塞 | 无亲缘进程可通信,有文件名 |
| 消息队列 | 结构化消息 | 可同步 | 消息有边界,可指定优先级 |
| 共享内存 | 任意数据 | 需配合信号量 | 最快,需同步机制保护 |
| 信号 signal | 仅通知 | 异步 | 传递事件,不传数据(少量) |
| socket | 字节流/数据报 | 可同步 | 跨主机、跨进程通用 |
| 信号量 semaphore | 计数 | 同步原语 | 用于互斥/同步,非数据传递 |
示例:匿名管道
#include <unistd.h>
#include <sys/wait.h>
#include <cstring>
#include <cstdio>
int main() {
int fd[2];
pipe(fd); // fd[0] 读端,fd[1] 写端
pid_t pid = fork();
if (pid == 0) { // 子进程:写
close(fd[0]);
const char* msg = "hello parent";
write(fd[1], msg, strlen(msg) + 1);
close(fd[1]);
} else { // 父进程:读
close(fd[1]);
char buf[64];
read(fd[0], buf, sizeof(buf));
printf("parent got: %s\n", buf);
close(fd[0]);
wait(nullptr);
}
}共享内存(最高性能)
共享内存把同一块物理内存映射到多个进程地址空间,无需内核拷贝,因此最快;但多个进程并发读写需自行加同步(信号量/互斥锁),是最高效也最复杂的方式。
按”数据传输能力 + 是否跨主机 + 性能”分类回答:管道/FIFO/消息队列/socket 走内核拷贝、较慢但易用;共享内存最快但要配信号量同步;信号只传事件不传数据。重点点出共享内存是最快的 IPC(绕过内核拷贝)以及socket 是唯一可跨主机的通用方式。
请介绍 C++ 中 future、promise、packaged_task、async 的区别?
一句话定位
| 组件 | 角色 | 关键能力 |
|---|---|---|
std::future | 结果句柄(只读) | get() 阻塞取结果,wait() 等待 |
std::promise | 手动设置结果 | set_value() / set_exception() 供生产者写入 |
std::packaged_task | 包装可调用对象 | 可 get_future()、可指定在哪个线程执行 |
std::async | 自动异步执行 | 启动一个任务并返回 future |
promise是「结果的写端」,future是「结果的读端」,二者通过get_future()配对。packaged_task把「可调用对象」和「future」打包,执行后自动向 future 写结果。async是最上层封装:自动创建线程/延迟执行,直接返回 future。
示例
#include <future>
#include <thread>
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
// 1. async:自动异步执行,返回 future
std::future<int> f1 = std::async(std::launch::async, add, 1, 2);
std::cout << f1.get() << "\n"; // 阻塞取结果
// 2. packaged_task:包装任务,手动决定执行线程
std::packaged_task<int(int,int)> task(add);
std::future<int> f2 = task.get_future(); // 先取 future
std::thread t(std::move(task), 3, 4); // 在指定线程执行
std::cout << f2.get() << "\n";
t.join();
// 3. promise:完全手动控制结果写入
std::promise<int> p;
std::future<int> f3 = p.get_future();
std::thread producer([&p] { p.set_value(42); }); // 手动设值
std::cout << f3.get() << "\n";
producer.join();
}关系总结
async 内部 = 创建 packaged_task(或等价机制)+ 启动线程 + 返回 future;packaged_task 内部 ≈ promise + 可调用对象。按抽象层次:async > packaged_task > promise+future。
用「生产者写结果 / 消费者读结果」模型讲:promise 是写端(手动 set_value),future 是读端(get 阻塞取),packaged_task 把任务和 future 打包、可指定线程,async 是最上层封装、自动异步执行并返回 future。一句话:promise 配对 future,packaged_task 包装任务,async 自动跑任务。
C++ 的 async 使用时有哪些注意事项?
1. 必须接收返回值,否则会同步执行
async 返回的 future 若被丢弃,其析构会阻塞直到任务完成——等于退化为同步执行:
std::async(std::launch::async, doWork); // ❌ 临时 future 立即析构 → 阻塞等任务完成
// 相当于同步调用,失去异步意义
auto f = std::async(std::launch::async, doWork); // ✅ 保存 future2. launch 策略
| 策略 | 行为 |
|---|---|
std::launch::async | 必须新线程异步执行 |
std::launch::deferred | 延迟执行,直到调用 get()/wait() 时在当前线程执行 |
| 两者都传 / 默认 | 由实现决定(可能异步或延迟) |
auto f = std::async(std::launch::deferred, add, 1, 2);
// 此时 add 尚未执行
std::cout << f.get() << "\n"; // 这里才在当前线程执行 add3. 异常会延迟到 get() 时抛出
任务内部抛出的异常会被捕获存入 future,直到调用 get() 才重新抛出:
std::future<int> f = std::async([]() -> int { throw std::runtime_error("err"); });
try {
f.get(); // 这里才抛出异常
} catch (const std::exception& e) {
std::cout << e.what() << "\n";
}4. get() 只能调用一次
future::get() 会移动结果,第二次调用是未定义行为(通常抛 future_error)。多消费者应使用 shared_future。
5. 资源问题
大量 async 可能耗尽线程资源;对任务数量多的场景应使用线程池而非无脑 async。同时 async 拿不到取消、优先级等控制。
① 不接收返回值 → 退化为同步;② 默认策略「实现决定」→ 要确定性就显式指定 launch::async;③ get() 只能调一次,重复调用是 UB。
如何理解 C++ 中的 atomic?
定义
std::atomic<T> 提供原子操作:对该变量的读写、增减、交换等操作不可分割,不会被其他线程的中间状态打断,从而避免数据竞争(data race)。如 i++ 在普通 int 上是「读-改-写」三步、可被打断;std::atomic<int> 的 fetch_add 则是原子一步完成。
底层实现
- 对可无锁类型(如
int、指针),编译成单条 CPU 指令(x86 上lock xadd/lock cmpxchg,通过LOCK前缀锁总线/缓存行保证原子性)。 - 对不可无锁类型(大对象、自定义结构),可能退化用内部锁实现。
- 可用
is_lock_free()检测当前类型是否真正无锁。
核心操作与内存序
#include <atomic>
std::atomic<int> cnt{0};
cnt.store(10); // 原子写
int v = cnt.load(); // 原子读
cnt.fetch_add(1); // 原子加,返回旧值
cnt.fetch_sub(1); // 原子减
cnt.exchange(20); // 原子交换,返回旧值
bool ok = cnt.compare_exchange_weak(v, 30); // CAS:等于 v 则改为 30
// 指定内存序(默认 seq_cst,最强)
cnt.fetch_add(1, std::memory_order_relaxed); // 只要原子性、不强求顺序CAS(compare-and-swap)
compare_exchange_weak/strong 是无锁编程的核心原语:比较当前值是否为期望值,是则替换为新值,否则更新期望值。可用于实现无锁栈、无锁队列、自旋锁等。
int expected = 20;
// 若 cnt==20,则置为 30,返回 true;否则 expected 更新为实际值,返回 false
if (cnt.compare_exchange_strong(expected, 30)) {
// 交换成功
}关键概念
std::atomic_flag:最轻量的原子布尔,只有test_and_set/clear,天然无锁,常用于自旋锁。- 原子性 ≠ 顺序性:原子操作保证单次操作不被拆开,但多个原子操作之间的执行顺序由内存序决定。
核心三点:① atomic 把「读-改-写」变成不可分割的原子操作,消除数据竞争;② 底层靠 CPU 单条原子指令(lock 前缀 / CAS)实现,小类型通常 is_lock_free 无锁;③ CAS(compare_exchange)是无锁编程的基石。补充:原子性不保证顺序,顺序靠内存序。
什么场景下使用锁?什么场景下使用原子变量?
核心判断
原子变量 atomic | 锁 mutex | |
|---|---|---|
| 保护对象 | 单个标量(int/指针/布尔) | 复合临界区(多变量、多步骤、复杂结构) |
| 开销 | 低(无阻塞,一条指令) | 较高(可能阻塞、系统调用、上下文切换) |
| 阻塞行为 | 无锁/无阻塞(通常) | 可能阻塞线程 |
| 适用操作 | 简单的 load/store/增减/CAS | 任意多条指令的原子性 |
| 典型场景 | 计数器、标志位、引用计数、无锁结构 | 容器增删、多字段一致性、金融交易 |
决策原则
- 能用原子就用原子:只有一个简单标量要保护(计数器、开关标志、状态枚举)时,
atomic无阻塞、性能最好。 - 必须用锁:当需要保证多条操作作为一个整体原子时(如「先检查再修改」、同时更新两个变量、修改容器),原子变量无能为力。
示例
#include <atomic>
#include <mutex>
#include <vector>
#include <algorithm>
// 场景 1:简单计数器 → 原子变量
std::atomic<int> count{0};
void addCount() { count.fetch_add(1); }
// 场景 2:复合操作(先判断再插入)→ 必须用锁
std::mutex mtx;
std::vector<int> v;
void insertIfAbsent(int x) {
std::lock_guard<std::mutex> lock(mtx);
// 检查 + 插入 必须作为整体原子执行,原子变量无法覆盖
if (std::find(v.begin(), v.end(), x) == v.end())
v.push_back(x);
}进阶:CAS 实现无锁
对简单的复合操作(如无锁栈的 push/pop),可用 CAS 循环(compare_exchange)替代锁,但实现复杂、易出错,只在性能关键路径上使用。
一句话:单个标量用 atomic(无锁快),多指令/多变量的复合临界区用锁。可补充:复杂结构也可用 CAS 做无锁,但难度大、易错,非性能瓶颈不轻易自研。
C++ 中锁的底层原理是什么?
分层实现
现代锁的实现是多级降级:先自旋、再挂起,核心依赖 CPU 的**原子指令(CAS)**与操作系统的 futex(快速用户态互斥)。
| 层次 | 机制 | 说明 |
|---|---|---|
| 用户态自旋 | 原子指令 + 忙等 | 短时间锁竞争时在用户态空转,避免内核切换 |
| 内核态挂起 | futex / 信号量 | 长时间竞争时让线程睡眠,释放 CPU |
| CPU 底层 | lock 前缀 / CAS | 保证「比较-交换」的原子性 |
1. CAS(比较并交换)—— 原子原语
锁的加锁本质是把一个状态变量从 0 改为 1,且要原子完成,靠 CPU 的 CAS 指令:
// 伪代码:自旋锁
#include <atomic>
std::atomic_flag lock = ATOMIC_FLAG_INIT;
void lock_it() {
while (lock.test_and_set(std::memory_order_acquire)) {
// 忙等(自旋):一直尝试直到成功
}
}
void unlock_it() { lock.clear(std::memory_order_release); }test_and_set 内部是一条 lock xchg(x86)原子指令,硬件保证同一时刻只有一个线程能把它从 false 改成 true。
2. 自旋 vs 挂起
- 自旋锁(spinlock):拿不到锁时忙等循环。锁持有时间极短时高效(避免上下文切换),但长时间自旋浪费 CPU。
- 挂起(mutex 默认):拿不到锁时让线程进入睡眠,释放 CPU,等锁释放再唤醒。适合临界区较长的情况。
3. futex(Linux)
std::mutex 在 Linux 上通常基于 futex:先用原子变量在用户态快速加锁;只有发生竞争时才通过 futex 系统调用进入内核让线程睡眠,从而**「无竞争零系统调用,有竞争才进内核」**。
// futex 思想(简化)
// 1. 用户态 CAS:锁空闲则直接抢到,不发系统调用
// 2. CAS 失败:futex_wait 挂起线程
// 3. 解锁时若有人等待:futex_wake 唤醒讲清锁 = CAS 原子指令 + 自旋/挂起策略 + futex:CAS 是底层原语,自旋适合短临界区、挂起适合长临界区,Linux 的 mutex 用 futex 做到「无竞争纯用户态、有竞争才进内核」。这是区分「背过 API」和「懂原理」的关键。
请介绍 C++ 的 6 种内存序?
为什么需要内存序
CPU 和编译器会对指令重排(reordering),多核下不同线程看到的操作顺序可能不一致。内存序(memory order)约束「一个线程的原子操作对其他线程可见的顺序」,在性能与一致性之间权衡。
六种内存序
| 内存序 | 全称 | 语义 | 典型用途 |
|---|---|---|---|
memory_order_relaxed | 宽松 | 只保证原子性,不保证顺序 | 计数器、无依赖统计 |
memory_order_consume | 消费 | 依赖关系弱同步(不推荐) | 极少用,建议用 acquire |
memory_order_acquire | 获取 | 本线程后续读写不得重排到它之前 | 读锁 / 读共享标志 |
memory_order_release | 释放 | 本线程之前的读写不得重排到它之后 | 写锁 / 发布数据 |
memory_order_acq_rel | 获取+释放 | 兼具 acquire 和 release | RMW 操作(CAS、fetch_add) |
memory_order_seq_cst | 顺序一致 | 全局单一顺序,最强、默认 | 需要强一致性的场景 |
acquire / release 配对(核心)
release 写入「发布」数据,acquire 读取「获取」数据,二者配对建立同步关系,常用于无锁的「生产者发布、消费者读取」:
#include <atomic>
#include <thread>
#include <string>
#include <cassert>
std::atomic<bool> ready{false};
std::string data;
void producer() {
data = "hello"; // 普通写
ready.store(true, std::memory_order_release); // release 发布
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {} // acquire 获取
// acquire 成功后,一定能看到 producer 在 release 之前的所有写
assert(data == "hello");
}seq_cst vs relaxed
seq_cst(默认):所有线程看到同一条全局操作顺序,最易理解但性能开销最大(常需内存屏障)。relaxed:只保证单个变量自身原子,不保证与其他操作的相对顺序,性能最好、最难用对。
std::atomic<int> a{0}, b{0};
// relaxed 下,另一线程可能看到 a=1, b=0(顺序不定)
// seq_cst 下,顺序对所有线程一致consume 语义含糊、编译器支持差,基本不用;默认 seq_cst 最安全,性能敏感且能论证正确性时才用 acquire/release,仅纯计数才用 relaxed。
C++ 的条件变量为什么要配合锁使用?
核心原因
条件变量用于「等待某个条件成立」。锁(std::mutex)保护的是条件所依赖的共享状态(谓词);两者配合的根本原因是避免丢失唤醒(lost wakeup)——即「检查条件」和「进入等待」必须原子地完成。
问题:不加锁会发生丢失唤醒
// ❌ 错误示范:检查条件与等待之间存在窗口期
// 线程 A 检查 ready == false,准备 sleep
// —— 就在此时,线程 B 把 ready 置 true 并 notify ——
// 但 A 还没进入 sleep,错过了这次通知 → 永远睡下去
while (!ready) {
// sleep 之前 ready 被改 + notify,通知丢失
}正确用法:锁保护谓词 + 原子解锁等待
wait 的语义是:在进入等待前,原子地「解锁互斥量并挂起」;被唤醒后重新加锁再返回。这样「检查条件 → 解锁 → 挂起」不会被插入其他写操作:
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
// 消费者
void consumer() {
std::unique_lock<std::mutex> lock(mtx); // 1. 先加锁保护 ready
cv.wait(lock, [] { return ready; }); // 2. 原子地 解锁+挂起;唤醒后重新加锁
// 到这里 ready 必为 true
}
// 生产者
void producer() {
{
std::lock_guard<std::mutex> lock(mtx); // 修改条件也要加锁
ready = true;
}
cv.notify_one(); // 通知(可不在锁内)
}为什么必须用 unique_lock
条件变量在 wait 期间要临时解锁、唤醒后重新加锁,只有 unique_lock 支持这种「延迟/多次加解锁」,lock_guard 做不到。
回答两条:① 锁保护条件依赖的共享状态(谓词),保证「检查 + 等待」原子、防止丢失唤醒;② wait 需要「先解锁再挂起、唤醒后重新加锁」,这要求 unique_lock。可补充:生产方修改条件也必须在锁内。
C++ 中如何使用线程局部存储?它的原理是什么?
定义
thread_local 修饰的变量每个线程各有一份独立副本,互不干扰;线程首次访问时构造,线程结束时析构。用于存放「线程私有」的状态,避免共享数据加锁。
用法
#include <thread>
#include <iostream>
thread_local int tls_counter = 0; // 每个线程一份
void worker() {
++tls_counter; // 只修改本线程自己的副本
std::cout << tls_counter << "\n";
}
int main() {
std::thread t1(worker); // 输出 1
std::thread t2(worker); // 输出 1(各自独立)
t1.join(); t2.join();
}适用场景:每线程独立的错误码 errno、随机数引擎、连接池/对象池、日志缓冲等。
与 static 的区别
static | thread_local | |
|---|---|---|
| 副本数 | 全进程一份 | 每线程一份 |
| 共享性 | 所有线程共享(需同步) | 线程私有(无需同步) |
| 生命周期 | 程序生命周期 | 线程生命周期 |
底层原理
- 操作系统/编译器为每个线程维护一块 TLS(Thread Local Storage) 区域,通过段寄存器定位。
- x86-64 上常用 FS/GS 段寄存器指向当前线程的 TCB(线程控制块),TLS 变量就存放在 TCB 附近,访问时通过
FS/GS加上偏移取回当前线程的那份数据。 - Windows 用
TLS分配 +__declspec(thread);Linux 用pthread_key或 ELF 的__thread/ TLS 段。
讲清「每线程一份副本、线程私有、无需加锁」的语义,再点原理:TLS 存在线程控制块里,靠 FS/GS 段寄存器定位当前线程的数据。能说出 static(全局一份)与 thread_local(每线程一份)的区别是加分点。
如何解决 C++ 中条件变量的信号丢失和虚假唤醒问题?
两个经典问题
| 问题 | 现象 | 原因 |
|---|---|---|
| 信号丢失(lost wakeup) | 消费者错过了通知,永久阻塞 | 「检查条件」与「等待」之间被插入写操作 |
| 虚假唤醒(spurious wakeup) | wait 无通知也返回 | OS/库实现允许被意外唤醒 |
统一解法:谓词 + 循环
带谓词的 wait 或 while 循环能同时解决两个问题:唤醒后重新检查条件,条件不满足则继续等待——既防止虚假唤醒(条件没变就接着睡),又配合锁避免信号丢失:
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
const int MAX = 10;
// 生产者
void produce(int x) {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return q.size() < MAX; }); // 队列未满才生产
q.push(x);
cv.notify_one();
}
// 消费者
void consume() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return !q.empty(); }); // 队列非空才消费
int x = q.front();
q.pop();
cv.notify_one();
}等价写法
带谓词的 cv.wait(lock, pred) 等价于:
while (!pred()) {
cv.wait(lock);
}- 用 while 而不是 if:被唤醒后必须重新判断条件,这是抵御虚假唤醒的关键。
- 锁内改条件、锁内等:生产者在锁内修改条件、消费者在锁内等待,杜绝「改条件发生在等待之前」造成的信号丢失。
核心答案:用带谓词的 wait(本质是 while 循环)——唤醒后重查条件,同时解决「虚假唤醒」和「信号丢失」。要点:① 用 while 不用 if;② 条件必须在锁内读写。
什么情况下会出现死锁?如何避免死锁?
定义与四个必要条件
死锁:多个线程互相等待对方持有的资源,导致都无法继续。发生需同时满足四个条件:
- 互斥:资源同一时刻只能被一个线程占用。
- 占有且等待:持有资源的同时等待别的资源。
- 不可剥夺:已持有的资源不能被强行抢走。
- 循环等待:线程间形成「A 等 B、B 等 A」的环。
破坏任意一个即可避免死锁,最常见的是破坏「循环等待」(统一加锁顺序)。
常见场景
#include <mutex>
std::mutex a, b;
void f1() {
std::lock_guard<std::mutex> l1(a);
std::lock_guard<std::mutex> l2(b); // 先 a 后 b
}
void f2() {
std::lock_guard<std::mutex> l1(b); // ❌ 先 b 后 a,与 f1 顺序相反
std::lock_guard<std::mutex> l2(a); // 可能死锁
}避免手段
- 统一加锁顺序:所有线程按相同顺序获取多把锁(破坏循环等待)。
- 一次性加锁:用
std::lock或std::scoped_lock(C++17)同时锁多把,内部避免死锁:
#include <mutex>
std::mutex a, b;
void safe() {
std::scoped_lock lock(a, b); // 一次性锁住,内部处理顺序,异常安全
// 同时持有 a、b
}- try_lock 超时:拿不到锁就不阻塞、回退或重试(破坏占有且等待):
std::unique_lock<std::mutex> l1(a, std::defer_lock);
std::unique_lock<std::mutex> l2(b, std::defer_lock);
if (std::try_lock(l1, l2) == -1) { /* 成功获得两把 */ }
else { /* 至少一把没拿到,不强行等待 */ }- 避免嵌套锁 / 锁内不调用未知代码:缩小持锁范围、持锁时不回调可能再拿锁的函数。
- RAII:确保异常路径也解锁,避免「持锁死等」。
先答四个必要条件(互斥、占有且等待、不可剥夺、循环等待),再给解法:统一加锁顺序、std::scoped_lock 一次性加锁、try_lock 超时回退、避免嵌套锁。能用 std::scoped_lock 替代两把 lock_guard 是加分项。
C++ 如何实现线程池?给出大体思路?
核心组成
线程池 = 任务队列 + 一组工作线程 + 条件变量 + 停止标志。工作线程从队列取任务执行,避免频繁创建/销毁线程。
| 组件 | 作用 |
|---|---|
任务队列 std::queue | 存放待执行任务(可用 std::function 包装) |
| 工作线程组 | 循环从队列取任务并执行 |
mutex + condition_variable | 保护队列 + 空队列时挂起/唤醒 |
| 停止标志 | 析构时通知线程退出 |
future(可选) | 让 submit 返回结果 |
骨架代码
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <vector>
#include <future>
class ThreadPool {
public:
explicit ThreadPool(size_t n) : stop_(false) {
for (size_t i = 0; i < n; ++i)
workers_.emplace_back([this] { workerLoop(); });
}
// 提交任务,返回 future 以获取结果
template <class F, class... Args>
auto submit(F&& f, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>> {
using R = std::invoke_result_t<F, Args...>;
auto task = std::make_shared<std::packaged_task<R()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...));
std::future<R> res = task->get_future();
{
std::lock_guard<std::mutex> lock(mtx_);
tasks_.emplace([task] { (*task)(); });
}
cv_.notify_one();
return res;
}
~ThreadPool() {
{
std::lock_guard<std::mutex> lock(mtx_);
stop_ = true;
}
cv_.notify_all();
for (auto& w : workers_) w.join();
}
private:
void workerLoop() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task();
}
}
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex mtx_;
std::condition_variable cv_;
bool stop_;
};关键点
- submit 返回 future:用
packaged_task包装任务,让调用方异步拿结果。 - 析构顺序:先置
stop_标志并notify_all,再join所有线程。 - 空队列挂起:
cv_.wait带谓词,避免虚假唤醒和空转。 - 可扩展:任务优先级、动态扩缩容、饱和策略(拒绝/阻塞)。
讲清「任务队列 + 工作线程 + 条件变量 + 停止标志」四要素,重点说 submit 用 packaged_task 返回 future、析构时先置停止标志再 join。能画出这个结构图基本就过关。
C++ 的有栈协程和无栈协程有什么区别?
核心区别
| 有栈协程(stackful) | 无栈协程(stackless) | |
|---|---|---|
| 独立栈 | ✅ 每个协程有独立运行时栈 | ❌ 复用调用者栈 |
| 挂起点 | 任意位置可挂起(可嵌套调用) | 仅能在顶层或 co_await 处挂起 |
| 内存开销 | 大(每个协程分配栈,如几 MB 或可调) | 小(仅存状态变量) |
| 切换开销 | 较高(保存/恢复完整寄存器上下文) | 低(只是状态机跳转) |
| 实现方式 | 保存上下文(寄存器)切换 | 编译器把协程体转成状态机 |
| 代表 | boost.coroutine、Lua 协程 | C++20 coroutine、Python asyncio |
无栈协程(C++20 采用)
C++20 的协程是无栈的:编译器把 co_await/co_yield 之间的代码编译成一个状态机对象(coroutine frame),挂起时保存的是「下一步该执行哪个状态」而非完整调用栈。因此它不能在普通函数调用栈的深层任意挂起——挂起只能发生在 co_await 显式点上。
#include <coroutine>
// 一个最简单的 generator 示意(无栈协程)
struct Generator {
struct promise_type {
int value;
Generator get_return_object() {
return {std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(int v) { value = v; return {}; }
void return_void() {}
void unhandled_exception() {}
};
std::coroutine_handle<promise_type> h;
};
Generator numbers() {
for (int i = 0; i < 3; ++i)
co_yield i; // 只能在这种显式点挂起
}有栈协程(独立栈)
有栈协程拥有自己的栈,可以在任意嵌套函数调用中挂起/恢复(把整个寄存器上下文切走),能力更强但切换开销和内存开销都更大。C++ 标准库不提供,需第三方库(如 boost.coroutine2)。
抓住三点:① 有栈协程有独立栈、任意点可挂起、切换开销大;② 无栈协程复用调用者栈、只能在 co_await 处挂起、切换开销小;③ C++20 用的是无栈协程,编译器把它编译成状态机。能说出「无栈协程靠状态机、挂起只发生在 co_await」即达要点。
C++ 什么场景用线程?什么场景用协程?
核心判断
| 线程 | 协程 | |
|---|---|---|
| 切换开销 | 高(内核态、上下文切换) | 低(用户态、状态机跳转) |
| 并发模型 | 抢占式,由 OS 调度 | 协作式,显式让出(co_await) |
| 适合任务 | CPU 密集(计算) | IO 密集(网络/文件等待) |
| 数量规模 | 数百~数千 | 数万~百万 |
| 内存开销 | 大(每线程独立栈,默认 ~1MB+) | 小(仅状态帧) |
选择原则
- CPU 密集型(大量计算、不阻塞):用线程,真正并行利用多核。
- IO 密集型(大量网络/文件/等待,线程大量时间在等):用协程,一个线程可承载海量协程,切换开销极低。
- 任务量巨大、连接多(如百万连接服务器):协程(或异步事件循环)明显优于每连接一线程。
示例对比
#include <thread>
// 线程:每个任务一个线程,适合 CPU 计算
std::thread t(computeHeavy);
t.join();
// 协程:IO 等待时挂起,让出线程
Task fetchUrl() {
auto resp = co_await httpGet("https://example.com"); // 等待期间线程可干别的
co_return parse(resp);
}本质
线程解决的是「并行」——多核同时跑;协程解决的是「并发」——在等待 IO 时让出 CPU、提高吞吐,但单个协程本身并不并行计算。
一句话:CPU 密集用线程(真正并行),IO 密集用协程(海量并发、低切换开销)。补充:协程是协作式、用户态切换,适合高并发连接;线程是抢占式、适合计算任务。能区分「并行 vs 并发」是加分点。
C++ 多线程开发需要注意些什么?线程同步有哪些手段?
注意事项
- 避免数据竞争(data race):共享可变数据必须用锁或原子变量保护。
- 防死锁:统一加锁顺序、用
std::scoped_lock、避免嵌套锁。 - 注意对象生命周期:线程不能访问已销毁的对象(尤其 detach 后)。
- join/detach:线程对象析构前必须二选一,否则
std::terminate。 - 锁粒度:临界区尽量小,锁内不做耗时/阻塞操作。
- 异常安全:用 RAII 锁,保证异常路径也解锁。
- 变量可见性:跨线程共享变量要注意内存序/同步,避免用
volatile做同步。 - 优先高级抽象:能用
async/线程池就不手写裸thread。
线程同步手段总览
| 手段 | 用途 | 相关章节 |
|---|---|---|
互斥锁 mutex | 保护临界区 | 03-Resources/cpp/C++ 并发编程 |
原子变量 atomic | 无锁保护标量 | 03-Resources/cpp/C++ 并发编程 |
条件变量 condition_variable | 等待条件成立 | 03-Resources/cpp/C++ 并发编程 |
future/promise/async | 异步结果传递 | 03-Resources/cpp/C++ 并发编程 |
读写锁 shared_mutex | 读多写少 | — |
信号量 counting_semaphore(C++20) | 控制并发数量 | — |
屏障 barrier/latch(C++20) | 多线程同步点 | — |
示例:读写锁
#include <shared_mutex>
#include <mutex>
std::shared_mutex rw_mtx;
int data = 0;
int read() {
std::shared_lock<std::shared_mutex> lock(rw_mtx); // 多读并发
return data;
}
void write(int v) {
std::unique_lock<std::shared_mutex> lock(rw_mtx); // 写独占
data = v;
}注意事项按「数据竞争 → 死锁 → 生命周期 → join/detach → 锁粒度」主线答;同步手段分类列举:锁(mutex/shared_mutex)、原子变量、条件变量、future/promise/async、信号量/屏障(C++20),并能说明各自适用场景。
无锁编程是什么?CAS 的原理?ABA 问题如何解决?
无锁编程(lock-free programming)
无锁编程指不用互斥锁、通过原子操作(CAS 等)实现并发数据结构的编程方式。它不是”完全没有锁”,而是不在关键路径上阻塞线程:靠 CPU 的原子指令保证「读-改-写」不可分割,失败就重试。
| 加锁编程 | 无锁编程 | |
|---|---|---|
| 同步方式 | mutex 阻塞 | 原子操作 + 重试循环 |
| 阻塞 | 可能阻塞/睡眠 | 无阻塞(lock-free)或极小自旋 |
| 死锁风险 | 有 | 无(无锁等待) |
| 实现难度 | 简单 | 高,易错 |
| 适用 | 一般场景 | 性能关键路径、实时系统 |
原子变量相关内容见 03-Resources/cpp/C++ 并发编程。
CAS(Compare-And-Swap)原理
CAS 是”比较并交换”:把「判断旧值 + 写入新值」合并为一条原子指令,是无锁编程的基石。C++ 中对应 compare_exchange_weak/strong。
#include <atomic>
std::atomic<int> v{0};
void cas_demo() {
int expected = 0;
// 若 v == expected,则 v = 1 并返回 true;否则把 expected 更新为 v 的当前值
bool ok = v.compare_exchange_strong(expected, 1);
}底层对应 x86 的 lock cmpxchg 指令:硬件保证「比较 + 交换」原子完成,同一时刻只有一个线程能成功。无锁算法通常写成 CAS 循环:
// 无锁自增:CAS 循环(等价于 fetch_add 的思路)
void lockfree_inc(std::atomic<int>& x) {
int old = x.load(std::memory_order_relaxed);
while (!x.compare_exchange_weak(old, old + 1)) {
// 失败说明 x 被别的线程改了,old 已被更新,重试
}
}compare_exchange_weak 允许偶发地”伪失败”(即使值相等也返回 false),但性能更好,适合放在循环里;compare_exchange_strong 保证”值相等就成功”,适合单次判断。CAS 循环里通常用 weak。
ABA 问题
ABA:线程 A 读到值 A,期间别的线程把它从 A 改成 B 又改回 A,A 再 CAS 时看到仍是 A、误以为”没变”,实际中间状态已经变了,导致错误(典型如无锁栈丢失节点)。
线程1: 读 top == A
线程2: 弹出 A(top → B → 改回 A?) // 中间经历 B
线程1: CAS(top, A, ...) 成功,但栈结构已破坏解决方案:带版本号的 tagged pointer
给指针附加一个版本号/计数器,每次修改都递增,CAS 时同时比较「指针 + 版本号」——即使值回到 A,版本号也变了,从而识别中间变化。
#include <atomic>
#include <cstdint>
// tagged pointer:把版本号打包进指针的高位(或结构体)
struct TaggedPtr {
void* ptr;
uint64_t tag; // 每次修改 +1
};
// 用 128 位 CAS(需 is_always_lock_free 支持)同时比较 ptr + tag
// 简化示意:把 tag 和 ptr 合并成一个原子值再 compare_exchange
先讲无锁 = 用原子操作 + 重试代替锁(不阻塞、无死锁、难度高);再讲 CAS = 比较并交换的原子指令(compare_exchange,底层 lock cmpxchg);最后点出 ABA = 值被改走又改回导致误判,解法是 tagged pointer 加版本号。
std::shared_mutex(读写锁)的原理与适用场景?
定位
std::shared_mutex(C++17,读写锁)把读与写分开:多个线程可同时读(共享锁),但写必须独占(独占锁)。读多写少的场景下,比 std::mutex 吞吐更高。
| 锁类型 | 允许并发 | 适用 |
|---|---|---|
shared_lock(读锁) | 多个读者同时持有 | 只读访问 |
unique_lock(写锁) | 同一时刻只有一个写者 | 修改数据 |
原理:内部用状态计数区分「读锁数量」与「写锁标志」,读锁可叠加计数、写锁互斥——本质仍是 CAS + futex 的封装(见 03-Resources/cpp/C++ 并发编程)。
用法
#include <shared_mutex>
#include <mutex>
#include <vector>
class Cache {
public:
int read(int key) {
std::shared_lock<std::shared_mutex> lock(mtx_); // 共享锁:多读并发
return data_.at(key);
}
void write(int key, int val) {
std::unique_lock<std::shared_mutex> lock(mtx_); // 独占锁:写互斥
data_[key] = val;
}
private:
std::shared_mutex mtx_;
std::vector<int> data_;
};适用场景与注意
- 读多写少(缓存、配置、字典查找)收益明显;写多读少或临界区极短时,读写锁的额外状态开销反而可能不如普通 mutex。
- 要防写者饥饿:读者源源不断时写者可能一直拿不到锁(实现有公平策略,但标准未强制)。
核心:读锁共享、写锁独占,用 shared_lock 读、unique_lock 写,适合读多写少。补充:写多读少时收益不大、可能写者饥饿;C++17 才进标准。
如何实现线程安全的单例?双检锁(DCLP)为什么需要内存序配合?
单例实现方式
| 写法 | 线程安全 | 说明 |
|---|---|---|
| 饿汉式(静态成员) | ✅ 天然 | 启动即建,可能浪费 |
| 懒汉式(裸 new) | ❌ | 需手动加锁 |
| 双检锁 DCLP | ✅(需内存序配合) | 减少加锁次数 |
| Meyers 单例 | ✅ C++11 保证 | 推荐:局部 static 的 magic static |
单例基础写法见 C++ 基础-中级。
双检锁(DCLP)与内存序
DCLP 想做到「只在第一次加锁」:先无锁检查,为 null 才加锁再检查。但 C++11 之前是未定义行为——普通指针的读写无原子性,编译器/CPU 可能重排 new(先赋指针、后构造),另一个线程读到「非空但未构造完」的指针。
#include <atomic>
#include <mutex>
class Singleton {
public:
static Singleton* instance() {
Singleton* p = ptr_.load(std::memory_order_acquire); // 1. acquire 读
if (p == nullptr) { // 第一次检查
std::lock_guard<std::mutex> lock(mtx_);
p = ptr_.load(std::memory_order_relaxed); // 2. 锁内再查
if (p == nullptr) { // 第二次检查
p = new Singleton();
ptr_.store(p, std::memory_order_release); // 3. release 写
}
}
return p;
}
private:
Singleton() = default;
static std::atomic<Singleton*> ptr_;
static std::mutex mtx_;
};
std::atomic<Singleton*> Singleton::ptr_{nullptr};
std::mutex Singleton::mtx_;- release 写:保证「构造完成」发生在「发布指针」之前;其他线程看到指针非空时,构造一定已完成。
- acquire 读:保证读到指针后,能看到 release 之前的所有写(即完整构造)。
这正是 acquire/release 配对建立同步关系的应用,详见 03-Resources/cpp/C++ 并发编程。
为什么更推荐 Meyers 单例
C++11 起局部 static 变量初始化由编译器自动保证线程安全(magic static),代码极简、无 DCLP 的微妙内存序问题:
class Singleton {
public:
static Singleton& instance() {
static Singleton inst; // C++11 magic static:线程安全初始化
return inst;
}
private:
Singleton() = default;
};
普通指针(非 atomic)的 DCLP 即使加锁也不安全:new 的「分配内存、构造、赋指针」可能被重排,别的线程可能读到未构造完成的对象。必须用 atomic + acquire/release(或直接用 Meyers 单例)。
串起主线:懒汉要线程安全 → DCLP 用两次检查减少加锁,但必须 atomic + acquire/release 防止 new 重排 → C++11 后直接 Meyers 单例最省心。能讲清「release 发布、acquire 获取,防止读到未构造完对象」是核心得分点。
C++ 内存模型的 happens-before 关系?memory_order 如何保证可见性?
什么是 happens-before
happens-before 是 C++ 内存模型定义跨线程操作可见性顺序的关系:若操作 A happens-before 操作 B,则 A 的结果对 B 可见,且 A 排在 B 之前。它是判断「是否存在数据竞争」的准绳——两个操作若无 happens-before 关系且访问同一变量(至少一写),就是数据竞争。
建立 happens-before 的途径
| 途径 | 说明 |
|---|---|
| 单线程程序顺序 | 同线程内,前面的语句 happens-before 后面的 |
mutex 加解锁 | unlock happens-before 后续 lock |
| 原子操作 | 同步关系(release → acquire)传递出 happens-before |
| 线程创建/join | 线程启动 happens-before 线程体;线程体 happens-before join 返回 |
同步关系与可见性(release/acquire)
release 写与 acquire 读配对形成同步(synchronizes-with)关系,进而通过传递性推导 happens-before,保证数据可见:
#include <atomic>
#include <thread>
#include <string>
#include <cassert>
std::atomic<bool> ready{false};
std::string payload;
void producer() {
payload = "done"; // (1) 普通写
ready.store(true, std::memory_order_release); // (2) release 发布
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {} // (3) acquire 获取
assert(payload == "done"); // (4) 一定能看到 (1)
}推导:(1) happens-before (2)(程序顺序)→ (2) synchronizes-with (3)(release/acquire 配对)→ (3) happens-before (4)。由传递性 (1) happens-before (4),故 (4) 能看到 payload 的写。
memory_order 如何保证可见性
内存序约束编译器和 CPU 的重排,从而保证「该看到的值不会被提前/滞后读」:
| 内存序 | 保证 |
|---|---|
relaxed | 只保证原子性,不保证顺序/可见性 |
acquire | 之后的读写不重排到它之前(读侧屏障) |
release | 之前的读写不重排到它之后(写侧屏障) |
acq_rel | 两者兼具(RMW 操作) |
seq_cst | 全局单一顺序,最强(默认) |
六种内存序的逐一语义见 03-Resources/cpp/C++ 并发编程。
抓住三个词:happens-before(可见性顺序)→ synchronizes-with(release/acquire 配对)→ 传递性。再说明内存序的作用是阻止编译器/CPU 重排、保证跨线程可见。能现场画出上面四条语句的 happens-before 推导即得高分。
什么是伪共享(false sharing)?如何用 cache line 对齐避免?
定义
CPU 以 cache line(缓存行,通常 64 字节) 为单位加载内存。若两个不同线程各自频繁修改的两个变量落在同一缓存行,就会互相无效化对方的缓存,导致频繁回主存——虽然逻辑上无共享,却产生类似共享的竞争,叫伪共享(false sharing)。
struct Bad {
int a; // 线程 1 频繁写 a
int b; // 线程 2 频繁写 b —— a、b 大概率在同一缓存行
};
// 两个线程写 a、b 会互相失效缓存行,性能暴跌解决:cache line 对齐 + padding
用 alignas 把变量对齐到独立的缓存行,或加填充字节把两个变量隔开:
#include <atomic>
#include <cstddef>
// 方案 1:alignas 对齐到 64 字节缓存行
struct alignas(64) Aligned {
std::atomic<int> value{0};
// 一个缓存行只放这一个变量
};
// 方案 2:手动 padding 填满一个缓存行
struct Padded {
std::atomic<int> value{0};
char pad[64 - sizeof(int)]; // 补齐到 64 字节,隔开相邻变量
};
// 方案 3(C++17):硬件破坏性大小,跨平台更稳
struct alignas(std::hardware_destructive_interference_size) Opt {
std::atomic<int> value{0};
};alignas(64) 是常见做法;更可移植的是用 std::hardware_destructive_interference_size(C++17,通常也是 64)。
影响与适用
- 典型场景:无锁计数、线程池统计、生产者/消费者环形队列中,多个线程各写各的计数器却相邻存放。
- 性能差异可达数倍,是高性能并发优化的重要一环;普通业务代码不必过度对齐(会浪费内存)。
讲清「缓存行 64 字节、两个线程各改相邻变量会互相失效缓存」这一本质,再给解法:alignas(64) 或 padding 把变量隔到不同缓存行,进阶可提 std::hardware_destructive_interference_size。
自旋锁和互斥锁的取舍?什么场景用自旋锁?
核心区别
| 自旋锁(spinlock) | 互斥锁(mutex) | |
|---|---|---|
| 拿不到锁时 | 忙等(循环重试,不释放 CPU) | 睡眠(挂起,释放 CPU) |
| 上下文切换 | 无 | 有(用户态↔内核态) |
| 适合临界区 | 极短(纳秒/微秒级) | 较长或不确定 |
| CPU 占用 | 高(空转) | 低(让出 CPU) |
| 实现 | atomic_flag + test_and_set | CAS + futex 多级降级 |
底层机制见 03-Resources/cpp/C++ 并发编程。
自旋锁实现
#include <atomic>
class SpinLock {
public:
void lock() {
while (flag_.test_and_set(std::memory_order_acquire)) {
// 忙等:持有时间极短时,空转比睡眠切换更划算
}
}
void unlock() {
flag_.clear(std::memory_order_release);
}
private:
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};取舍原则
- 自旋锁:临界区极短(几条指令、无系统调用、无 IO),且锁竞争激烈时——避免「睡眠 + 唤醒」的上下文切换开销。典型:内核锁、无锁数据结构的短等待、实时系统。
- 互斥锁:临界区长或含阻塞操作(IO、内存分配),睡眠让出 CPU 给别的线程更划算。典型:大部分业务代码。
- 现代 mutex 已做折中:先自旋一小段时间,拿不到再睡(自适应),所以多数场景直接用 mutex 即可,无需手写自旋锁。
① 单核 CPU 上自旋锁无意义(自旋线程占着唯一核心,持锁线程无法执行);② 临界区稍长就会严重浪费 CPU;③ 持有自旋锁时不能睡眠/阻塞。
一句话:短临界区、多核、竞争时间短 → 自旋锁(免上下文切换);长临界区或可能阻塞 → 互斥锁(睡眠让出 CPU)。补充「现代 mutex 先自旋后睡眠」和「单核自旋无意义」是加分点。
线程安全的队列(加锁+条件变量)如何实现?
定位
线程安全队列 = 多个线程并发 push/pop 不出错 + 队列空时 pop 阻塞等待。它是条件变量最经典的应用,也是线程池任务队列、消息队列的底层组件。相比后面的 03-Resources/cpp/C++ 并发编程,加锁队列实现简单、绝大多数场景够用,是面试常考的「手写并发容器」题。
完整实现
#include <queue>
#include <mutex>
#include <condition_variable>
#include <optional>
template <typename T>
class ThreadSafeQueue {
public:
// 入队:加锁入队,再通知一个等待者
void push(T value) {
{
std::lock_guard<std::mutex> lk(mtx_);
q_.push(std::move(value));
}
cv_.notify_one();
}
// 出队:队列空则阻塞,谓词 + 循环防虚假唤醒
T pop() {
std::unique_lock<std::mutex> lk(mtx_);
cv_.wait(lk, [this] { return !q_.empty(); });
T value = std::move(q_.front());
q_.pop();
return value;
}
// 非阻塞出队:空则返回 nullopt
std::optional<T> try_pop() {
std::lock_guard<std::mutex> lk(mtx_);
if (q_.empty()) return std::nullopt;
T value = std::move(q_.front());
q_.pop();
return value;
}
bool empty() const {
std::lock_guard<std::mutex> lk(mtx_);
return q_.empty();
}
private:
mutable std::mutex mtx_;
std::condition_variable cv_;
std::queue<T> q_;
};关键点
pop用unique_lock、push用lock_guard:condition_variable::wait需要能unlock/lock的unique_lock;push只是加锁改数据,lock_guard更轻。- 谓词防虚假唤醒:
cv_.wait(lk, pred)等价于「!q_.empty()才往下走」,虚假唤醒会被谓词拦下(对应 03-Resources/cpp/C++ 并发编程)。 - 通知放锁外:
notify_one()放到lock_guard作用域之外,避免等待线程刚被唤醒又去抢锁(先通知也正确,锁外通知更高效)。 - 非阻塞用
std::optional:try_pop返回optional<T>表达「可能没值」,比哨兵值 /bool + 引用传出更安全、语义清晰。 - 析构:默认析构即可;若要「关闭队列并唤醒所有等待者」,需加
closed标志 +notify_all()。
进阶:有界队列(限制容量)
上面是无界队列。若要在「队列满时阻塞生产者」,需两个条件变量 not_full / not_empty,push 等 not_full、pop 等 not_empty,是「限流」场景的经典写法。
先给「mutex 保护 queue + condition_variable 阻塞空读」骨架,再点出三个细节:① wait 谓词形式防虚假唤醒;② pop 用 unique_lock、push 用 lock_guard;③ notify_one 放锁外。能说出 std::optional 做 try_pop、以及「有界队列用两个条件变量」属于高阶加分。
无锁队列(MPMC)如何实现?典型难点?
定位
MPMC = 多生产者多消费者队列。加锁队列简单但吞吐受限;无锁队列用 CAS 实现并发入队/出队,是高性能并发组件(如并发框架、日志系统)的核心。最经典的思路是 Michael-Scott 队列(基于单向链表)。
Michael-Scott 队列思路
用「头指针 head、尾指针 tail、空哨兵节点」三个关键结构,入队改 tail、出队改 head,各自用 CAS:
#include <atomic>
#include <memory>
template <typename T>
class MSQueue {
struct Node {
T data;
std::atomic<Node*> next{nullptr};
};
std::atomic<Node*> head_;
std::atomic<Node*> tail_;
// head_ 始终指向哨兵节点,tail_ 指向最后一个节点(或倒数第二个,允许临时滞后)
public:
void push(T val) {
Node* n = new Node{val, nullptr};
Node* t = tail_.load();
// CAS 循环把 n 挂到 tail
while (!t->next.compare_exchange_weak(t->next.load(), n)) { /* 重试 */ }
tail_.compare_exchange_weak(t, n); // 移动 tail(允许失败,别的线程会补)
}
};典型难点
- 内存回收(最难的):出队弹出的节点不能立刻 delete——可能还有别的线程在访问它。需用 Hazard Pointer(风险指针)、epoch-based reclamation 或引用计数。
- ABA 问题:节点被弹出又复用,指针值相同但语义已变,需 tagged pointer 版本号(见 03-Resources/cpp/C++ 并发编程)。
- tail 指针一致性与帮助机制:多个生产者并发移动 tail,需要「帮助落后线程」避免尾部失联。
- 顺序与可见性:CAS 要配正确的 acquire/release 内存序,保证数据先于指针发布。
先讲思路:链表 + head/tail + 哨兵节点 + CAS 移动指针(Michael-Scott 队列);再讲三大难点:内存回收(hazard pointer/epoch)、ABA(版本号)、tail 帮助机制。能说出”节点不能立刻释放”基本就证明真懂无锁。
C++20 的 std::latch、std::barrier、std::counting_semaphore 的作用?
三者定位
| 组件 | 头文件 | 作用 | 计数方向 | 是否可复用 |
|---|---|---|---|---|
std::latch | <latch> | 一次性倒计数闸门,等 N 个线程到齐 | 递减 | ❌ 一次性 |
std::barrier | <barrier> | 可复用的分阶段同步点,等 N 个线程到齐后放行并进入下一阶段 | 循环 | ✅ 可复用 |
std::counting_semaphore | <semaphore> | 计数信号量,控制最多 N 个线程访问资源 | 增减 | ✅(acquire/release) |
三者都解决”让一组线程在某个点汇合 / 限制并发数”,比手写 mutex + condition_variable 更简洁。
std::latch:一次性倒计数
#include <latch>
#include <thread>
#include <vector>
void demo_latch() {
const int N = 3;
std::latch ready(N); // 计数 N
std::vector<std::thread> ts;
for (int i = 0; i < N; ++i)
ts.emplace_back([&] { ready.count_down(); });
ready.wait(); // 阻塞直到计数归零
for (auto& t : ts) t.join();
}std::barrier:可复用阶段同步
#include <barrier>
#include <thread>
#include <vector>
void demo_barrier() {
const int N = 3;
int phase = 0;
std::barrier sync(N, [&]() noexcept { ++phase; }); // 每轮到齐执行完成函数
auto worker = [&](int id) {
for (int r = 0; r < 2; ++r) {
sync.arrive_and_wait(); // 每轮都在这等所有线程到齐
}
};
std::vector<std::thread> ts;
for (int i = 0; i < N; ++i) ts.emplace_back(worker, i);
for (auto& t : ts) t.join();
// phase == 2
}std::counting_semaphore:限制并发数
#include <semaphore>
#include <thread>
void demo_semaphore() {
std::counting_semaphore<3> sem(3); // 最多 3 个并发
auto worker = [&] {
sem.acquire(); // 计数 -1;计数为 0 则阻塞
// 临界区(最多 3 个线程同时进入)
sem.release(); // 计数 +1
};
}std::binary_semaphore(计数上限 1)即信号量版的互斥锁。
记三句话:latch 一次性倒计数闸门、barrier 可复用分阶段同步点、counting_semaphore 计数信号量限并发。三者都是 C++20 引入、用来替代手写 mutex+cv 的同步原语。
C++20 协程的底层机制(对称/非对称转移)?
协程的三大关键字
C++20 协程是无栈协程:编译器把含 co_await / co_yield / co_return 的函数体转成一个状态机(coroutine frame,堆上分配的帧),挂起时只保存”下一步执行哪段代码”。
| 关键字 | 作用 |
|---|---|
co_await | 挂起,等待一个 awaitable(可能恢复) |
co_yield | 挂起并产出一个值 |
co_return | 协程结束,返回最终值 |
有栈/无栈的区别见 03-Resources/cpp/C++ 并发编程。
无栈协程的堆帧(coroutine frame)
协程被调用时,编译器在堆上分配一块帧,存三类东西:promise 对象、协程参数、局部变量 + 状态位(resume 点)。挂起=保存”下一步状态”并返回调用者;恢复=coroutine_handle::resume() 跳到保存的状态继续。
#include <coroutine>
#include <iostream>
struct Task {
struct promise_type {
Task get_return_object() {
return {std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; } // 启动即挂起
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
std::coroutine_handle<promise_type> h;
void resume() { h.resume(); }
};
Task coro() {
std::cout << "A\n";
co_await std::suspend_always{}; // 显式挂起点
std::cout << "B\n";
co_return;
}对称 vs 非对称转移
| 非对称(asymmetric) | 对称(symmetric) | |
|---|---|---|
| 语义 | 挂起后控制权交还给调用者/调度者 | 挂起后直接切换到另一个协程 |
| 转移目标 | 固定(回到 caller) | 可任意指定 |
| 代表 | C++20 协程、Python asyncio | 有栈协程(boost.coroutine)中的协程间切换 |
| 关系 | co_await 把控制权交给 awaitable,再由调度器决定 | transfer_to(other) 直接切走 |
C++20 是无栈 + 非对称:co_await 挂起后返回调用者(或由 awaitable 的 await_resume 决定下一步),调度由外层循环负责;而对称转移是协程之间直接互相切(更像有栈协程的能力)。
讲清三条:① C++20 协程是无栈的,编译器把它转成状态机 + 堆上 coroutine frame;② 挂起只发生在 co_await/co_yield 显式点,恢复靠 coroutine_handle::resume();③ 它是非对称转移——控制权交还调用者,对称转移是协程间直接互切(有栈协程更常见)。