📋 摘要

什么是进程?什么是线程?有什么区别?

定义

  • 进程(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_guardunique_lock
灵活性低(只加锁/解锁)高(可延迟加锁、提前解锁)
能否手动解锁❌✅ unlock()
能否延迟加锁❌✅ defer_lock
能否转移所有权❌ 不可移动✅ 可移动(std::move)
开销更低略高(多状态标志)
配合条件变量❌✅(condition_variable 需要)

lock_guard —— 简单自动锁

C++
std::mutex mtx;
void f() {
    std::lock_guard<std::mutex> lock(mtx);  // 构造即加锁
    // 临界区
    // 作用域结束(包括异常)自动解锁
}

unique_lock —— 灵活锁

C++
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 后线程资源回收线程结束自行回收,但主线程无法控制
后续 joinablefalsefalse

join —— 等待线程结束

C++
#include <thread>

void task() { /* 耗时工作 */ }

std::thread t(task);
// ... 主线程做其他事
t.join();   // 阻塞,直到 t 执行完
// join 后主线程才能继续,保证 t 完成

用途:主线程需要等待子线程结果,保证线程执行完再继续(如计算任务汇总)。

detach —— 分离线程

C++
std::thread t(task);
t.detach();   // t 与线程脱离,主线程不等待
// 主线程继续执行,t 独立在后台运行
// 线程结束后系统自动回收资源

用途:后台任务,主线程不关心其结束(如守护线程、日志线程)。

⚠️ detach 的风险

detach 后主线程无法控制子线程,且子线程不能访问主线程已销毁的局部变量(否则悬挂引用/指针):

C++
void bad() {
    int local = 42;
    std::thread t([&local] { std::cout << local; });  // 捕获局部变量引用
    t.detach();   // ❌ 危险!local 可能已销毁,子线程访问悬挂引用
}

joinable 检查

线程对象析构前,必须 join 或 detach,否则析构时 std::terminate:

C++
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::threadstd::jthread
引入版本C++11C++20
析构行为未 join/detach 时 std::terminate自动 join
协作式取消❌✅ 支持(stop_token)
头文件<thread><thread>

1. 自动 join(最重要的改进)

std::thread 析构时如果还没 join/detach,会调用 std::terminate(崩溃):

C++
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 请求线程协作式停止:

C++
#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懒加载单例保证只初始化一次

基本原则

  1. 用锁封装内部状态,不在类外部暴露可被并发修改的内部数据。
  2. 不返回内部指针/引用(否则调用方可能绕过锁修改)。
  3. 临界区尽量小,锁内不做重操作、不调用未知代码(避免死锁)。
  4. 成员函数整体加锁时,const 方法里的锁要声明为 mutable。
  5. 简单计数/标志优先用 atomic,性能更好。

示例:线程安全计数器

C++
#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};
};

常见坑

C++
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计数同步原语用于互斥/同步,非数据传递

示例:匿名管道

C++
#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。

示例

C++
#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 若被丢弃,其析构会阻塞直到任务完成——等于退化为同步执行:

C++
std::async(std::launch::async, doWork);   // ❌ 临时 future 立即析构 → 阻塞等任务完成
// 相当于同步调用,失去异步意义

auto f = std::async(std::launch::async, doWork);  // ✅ 保存 future

2. launch 策略

策略行为
std::launch::async必须新线程异步执行
std::launch::deferred延迟执行,直到调用 get()/wait() 时在当前线程执行
两者都传 / 默认由实现决定(可能异步或延迟)
C++
auto f = std::async(std::launch::deferred, add, 1, 2);
// 此时 add 尚未执行
std::cout << f.get() << "\n";   // 这里才在当前线程执行 add

3. 异常会延迟到 get() 时抛出

任务内部抛出的异常会被捕获存入 future,直到调用 get() 才重新抛出:

C++
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() 检测当前类型是否真正无锁。

核心操作与内存序

C++
#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 是无锁编程的核心原语:比较当前值是否为期望值,是则替换为新值,否则更新期望值。可用于实现无锁栈、无锁队列、自旋锁等。

C++
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 无阻塞、性能最好。
  • 必须用锁:当需要保证多条操作作为一个整体原子时(如「先检查再修改」、同时更新两个变量、修改容器),原子变量无能为力。

示例

C++
#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 指令:

C++
// 伪代码:自旋锁
#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 系统调用进入内核让线程睡眠,从而**「无竞争零系统调用,有竞争才进内核」**。

C++
// 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 和 releaseRMW 操作(CAS、fetch_add)
memory_order_seq_cst顺序一致全局单一顺序,最强、默认需要强一致性的场景

acquire / release 配对(核心)

release 写入「发布」数据,acquire 读取「获取」数据,二者配对建立同步关系,常用于无锁的「生产者发布、消费者读取」:

C++
#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:只保证单个变量自身原子,不保证与其他操作的相对顺序,性能最好、最难用对。
C++
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)——即「检查条件」和「进入等待」必须原子地完成。

问题:不加锁会发生丢失唤醒

C++
// ❌ 错误示范:检查条件与等待之间存在窗口期
// 线程 A 检查 ready == false,准备 sleep
// —— 就在此时,线程 B 把 ready 置 true 并 notify ——
// 但 A 还没进入 sleep,错过了这次通知 → 永远睡下去
while (!ready) {
    // sleep 之前 ready 被改 + notify,通知丢失
}

正确用法:锁保护谓词 + 原子解锁等待

wait 的语义是:在进入等待前,原子地「解锁互斥量并挂起」;被唤醒后重新加锁再返回。这样「检查条件 → 解锁 → 挂起」不会被插入其他写操作:

C++
#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 修饰的变量每个线程各有一份独立副本,互不干扰;线程首次访问时构造,线程结束时析构。用于存放「线程私有」的状态,避免共享数据加锁。

用法

C++
#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 的区别

staticthread_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 循环能同时解决两个问题:唤醒后重新检查条件,条件不满足则继续等待——既防止虚假唤醒(条件没变就接着睡),又配合锁避免信号丢失:

C++
#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) 等价于:

C++
while (!pred()) {
    cv.wait(lock);
}
  • 用 while 而不是 if:被唤醒后必须重新判断条件,这是抵御虚假唤醒的关键。
  • 锁内改条件、锁内等:生产者在锁内修改条件、消费者在锁内等待,杜绝「改条件发生在等待之前」造成的信号丢失。
💡 面试加分项

核心答案:用带谓词的 wait(本质是 while 循环)——唤醒后重查条件,同时解决「虚假唤醒」和「信号丢失」。要点:① 用 while 不用 if;② 条件必须在锁内读写。


什么情况下会出现死锁?如何避免死锁?

定义与四个必要条件

死锁:多个线程互相等待对方持有的资源,导致都无法继续。发生需同时满足四个条件:

  1. 互斥:资源同一时刻只能被一个线程占用。
  2. 占有且等待:持有资源的同时等待别的资源。
  3. 不可剥夺:已持有的资源不能被强行抢走。
  4. 循环等待:线程间形成「A 等 B、B 等 A」的环。

破坏任意一个即可避免死锁,最常见的是破坏「循环等待」(统一加锁顺序)。

常见场景

C++
#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);   // 可能死锁
}

避免手段

  1. 统一加锁顺序:所有线程按相同顺序获取多把锁(破坏循环等待)。
  2. 一次性加锁:用 std::lock 或 std::scoped_lock(C++17)同时锁多把,内部避免死锁:
C++
#include <mutex>

std::mutex a, b;
void safe() {
    std::scoped_lock lock(a, b);   // 一次性锁住,内部处理顺序,异常安全
    // 同时持有 a、b
}
  1. try_lock 超时:拿不到锁就不阻塞、回退或重试(破坏占有且等待):
C++
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 { /* 至少一把没拿到,不强行等待 */ }
  1. 避免嵌套锁 / 锁内不调用未知代码:缩小持锁范围、持锁时不回调可能再拿锁的函数。
  2. RAII:确保异常路径也解锁,避免「持锁死等」。
💡 面试加分项

先答四个必要条件(互斥、占有且等待、不可剥夺、循环等待),再给解法:统一加锁顺序、std::scoped_lock 一次性加锁、try_lock 超时回退、避免嵌套锁。能用 std::scoped_lock 替代两把 lock_guard 是加分项。


C++ 如何实现线程池?给出大体思路?

核心组成

线程池 = 任务队列 + 一组工作线程 + 条件变量 + 停止标志。工作线程从队列取任务执行,避免频繁创建/销毁线程。

组件作用
任务队列 std::queue存放待执行任务(可用 std::function 包装)
工作线程组循环从队列取任务并执行
mutex + condition_variable保护队列 + 空队列时挂起/唤醒
停止标志析构时通知线程退出
future(可选)让 submit 返回结果

骨架代码

C++
#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_;
};

关键点

  1. submit 返回 future:用 packaged_task 包装任务,让调用方异步拿结果。
  2. 析构顺序:先置 stop_ 标志并 notify_all,再 join 所有线程。
  3. 空队列挂起:cv_.wait 带谓词,避免虚假唤醒和空转。
  4. 可扩展:任务优先级、动态扩缩容、饱和策略(拒绝/阻塞)。
💡 面试加分项

讲清「任务队列 + 工作线程 + 条件变量 + 停止标志」四要素,重点说 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 显式点上。

C++
#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 密集型(大量网络/文件/等待,线程大量时间在等):用协程,一个线程可承载海量协程,切换开销极低。
  • 任务量巨大、连接多(如百万连接服务器):协程(或异步事件循环)明显优于每连接一线程。

示例对比

C++
#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++ 多线程开发需要注意些什么?线程同步有哪些手段?

注意事项

  1. 避免数据竞争(data race):共享可变数据必须用锁或原子变量保护。
  2. 防死锁:统一加锁顺序、用 std::scoped_lock、避免嵌套锁。
  3. 注意对象生命周期:线程不能访问已销毁的对象(尤其 detach 后)。
  4. join/detach:线程对象析构前必须二选一,否则 std::terminate。
  5. 锁粒度:临界区尽量小,锁内不做耗时/阻塞操作。
  6. 异常安全:用 RAII 锁,保证异常路径也解锁。
  7. 变量可见性:跨线程共享变量要注意内存序/同步,避免用 volatile 做同步。
  8. 优先高级抽象:能用 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)多线程同步点—

示例:读写锁

C++
#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。

C++
#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 循环:

C++
// 无锁自增: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 已被更新,重试
    }
}
ℹ️ weak 与 strong 的区别

compare_exchange_weak 允许偶发地”伪失败”(即使值相等也返回 false),但性能更好,适合放在循环里;compare_exchange_strong 保证”值相等就成功”,适合单次判断。CAS 循环里通常用 weak。

ABA 问题

ABA:线程 A 读到值 A,期间别的线程把它从 A 改成 B 又改回 A,A 再 CAS 时看到仍是 A、误以为”没变”,实际中间状态已经变了,导致错误(典型如无锁栈丢失节点)。

Text
线程1: 读 top == A
线程2: 弹出 A(top → B → 改回 A?)   // 中间经历 B
线程1: CAS(top, A, ...) 成功,但栈结构已破坏

解决方案:带版本号的 tagged pointer

给指针附加一个版本号/计数器,每次修改都递增,CAS 时同时比较「指针 + 版本号」——即使值回到 A,版本号也变了,从而识别中间变化。

C++
#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++ 并发编程)。

用法

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(先赋指针、后构造),另一个线程读到「非空但未构造完」的指针。

C++
#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 的微妙内存序问题:

C++
class Singleton {
public:
    static Singleton& instance() {
        static Singleton inst;   // C++11 magic static:线程安全初始化
        return inst;
    }
private:
    Singleton() = default;
};
⚠️ DCLP 的坑

普通指针(非 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,保证数据可见:

C++
#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)。

C++
struct Bad {
    int a;   // 线程 1 频繁写 a
    int b;   // 线程 2 频繁写 b —— a、b 大概率在同一缓存行
};
// 两个线程写 a、b 会互相失效缓存行,性能暴跌

解决:cache line 对齐 + padding

用 alignas 把变量对齐到独立的缓存行,或加填充字节把两个变量隔开:

C++
#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_setCAS + futex 多级降级

底层机制见 03-Resources/cpp/C++ 并发编程。

自旋锁实现

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++ 并发编程,加锁队列实现简单、绝大多数场景够用,是面试常考的「手写并发容器」题。

完整实现

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_;
};

关键点

  1. pop 用 unique_lock、push 用 lock_guard:condition_variable::wait 需要能 unlock/lock 的 unique_lock;push 只是加锁改数据,lock_guard 更轻。
  2. 谓词防虚假唤醒:cv_.wait(lk, pred) 等价于「!q_.empty() 才往下走」,虚假唤醒会被谓词拦下(对应 03-Resources/cpp/C++ 并发编程)。
  3. 通知放锁外:notify_one() 放到 lock_guard 作用域之外,避免等待线程刚被唤醒又去抢锁(先通知也正确,锁外通知更高效)。
  4. 非阻塞用 std::optional:try_pop 返回 optional<T> 表达「可能没值」,比哨兵值 / bool + 引用传出 更安全、语义清晰。
  5. 析构:默认析构即可;若要「关闭队列并唤醒所有等待者」,需加 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:

C++
#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(允许失败,别的线程会补)
    }
};

典型难点

  1. 内存回收(最难的):出队弹出的节点不能立刻 delete——可能还有别的线程在访问它。需用 Hazard Pointer(风险指针)、epoch-based reclamation 或引用计数。
  2. ABA 问题:节点被弹出又复用,指针值相同但语义已变,需 tagged pointer 版本号(见 03-Resources/cpp/C++ 并发编程)。
  3. tail 指针一致性与帮助机制:多个生产者并发移动 tail,需要「帮助落后线程」避免尾部失联。
  4. 顺序与可见性: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:一次性倒计数

C++
#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:可复用阶段同步

C++
#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:限制并发数

C++
#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() 跳到保存的状态继续。

C++
#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();③ 它是非对称转移——控制权交还调用者,对称转移是协程间直接互切(有栈协程更常见)。