多线程、数据竞争与同步原语
多线程的目标不是“线程越多越快”,而是把能够并行的工作安全地分配到多个执行上下文,同时保持数据依赖、生命周期和退出顺序正确。
一、进程与线程
进程通常拥有独立的虚拟地址空间和系统资源;同一进程中的线程共享代码、堆、全局数据与大部分进程资源,但每个线程拥有自己的栈和寄存器状态。
Process
├── Shared:代码、Heap、全局数据、文件与系统资源
├── Thread A:寄存器 + Stack A
├── Thread B:寄存器 + Stack B
└── Thread C:寄存器 + Stack C共享地址空间让线程间交换数据很方便,也意味着错误同步可以直接破坏整个进程。
二、并发、并行与上下文切换
- 并发:多个任务在时间上交错推进;
- 并行:多个任务在不同 CPU 核心上同时执行。
线程多于可用核心时,操作系统需要进行上下文切换:保存当前线程寄存器和调度状态,再恢复另一个线程。切换本身、缓存扰动和调度延迟都会产生成本。
因此把一个小任务拆成过多线程,可能比单线程更慢。
三、线程生命周期
std::thread worker([] {
doWork();
});
worker.join();join() 等待线程结束。detach() 让线程脱离当前对象独立运行,但会显著增加对象生命周期、错误报告和进程退出管理的难度,通常不应作为默认选择。
C++20 的 std::jthread 在析构时自动请求停止并 join:
std::jthread worker([](std::stop_token token) {
while (!token.stop_requested()) {
processOneBatch();
}
});停止令牌是协作式取消:任务必须在合适位置主动检查并收敛状态。
四、什么是数据竞争
如果两个线程并发访问同一内存位置:
- 至少一个访问是写;
- 访问不是受支持的原子操作;
- 两者之间没有建立正确同步;
就会发生 Data Race。C++ 中的数据竞争属于未定义行为。
int counter = 0;
// 线程 A
++counter;
// 线程 B
++counter;++counter 不是不可分割的单一步骤,可以概念性地拆为:
读取 counter
加一
写回 counter两个线程可能都读取旧值 0,最后都写回 1,丢失一次更新。编译器还会基于“程序不存在数据竞争”的语言前提进行优化,因此实际结果不只是不准确计数,还可能出现更难预测的行为。
五、可见性与 happens-before
int data = 0;
bool ready = false;线程 A:
data = 42;
ready = true;线程 B:
if (ready) {
use(data);
}没有同步时,线程 B 不能依赖看到 ready == true 就一定看到 data == 42。编译器、CPU 和缓存系统都可能改变普通内存操作对其他线程可见的顺序。
同步原语的核心作用是建立 happens-before 关系,让一个线程在同步点之前的写入,对另一个线程在对应同步点之后的读取可见。
volatile 主要用于特定硬件和可观察访问语义,不提供普通 C++ 线程间同步,也不能替代 mutex 或 atomic。
六、Mutex:保护共享不变量
class ScoreBoard {
public:
void addScore(int value)
{
std::lock_guard lock(mutex_);
score_ += value;
}
int score() const
{
std::lock_guard lock(mutex_);
return score_;
}
private:
mutable std::mutex mutex_;
int score_ = 0;
};mutex 不只是保护一个变量,更重要的是保护一组必须保持一致的不变量。例如队列的 head、tail 和 size 应在同一个协议下更新。
RAII 加锁
{
std::lock_guard lock(mutex);
updateSharedState();
} // 自动解锁不要手写容易遗漏的:
mutex.lock();
doWork();
mutex.unlock();提前返回或异常会跳过 unlock()。lock_guard、unique_lock 和 scoped_lock 把锁生命周期绑定到作用域。
七、临界区应该多大
锁范围过大:
- 并行度下降;
- 等待时间增加;
- 长操作阻塞其他线程;
- 容易把 I/O、回调或 GPU 操作放进锁内。
锁范围过小:
- 同一不变量被拆成多个不安全步骤;
- 加锁次数增加;
- 代码难以推断;
- 可能暴露中间状态。
常见做法是先在锁外准备独立数据,再在短临界区内提交状态:
Result result = performExpensiveWork();
{
std::lock_guard lock(mutex_);
results_.push_back(std::move(result));
}不要在持锁期间调用无法控制的外部回调,因为回调可能再次获取同一把锁或等待其他资源。
八、锁住容器不等于保护对象生命周期
下面的资源管理器看起来已经为查找和删除都加了锁:
class TextureManager {
public:
Texture* find(int id)
{
std::lock_guard lock(mutex_);
auto it = textures_.find(id);
return it == textures_.end() ? nullptr : it->second.get();
}
void remove(int id)
{
std::lock_guard lock(mutex_);
textures_.erase(id);
}
private:
std::mutex mutex_;
std::unordered_map<int, std::unique_ptr<Texture>> textures_;
};mutex 确实保护了容器内部结构,但 find() 返回的裸指针在解锁后逃出了临界区:
渲染线程 资源线程
find()
加锁并取得 Texture*
解锁
remove()
erase 并销毁 Texture
继续使用 Texture* ← 悬空指针这里的根因不是“裸指针必然错误”,而是一个不拥有对象的引用离开了临界区,并且没有其他协议保证对象继续存活。如果资源保证存活到程序结束、只在固定安全阶段销毁,或者外层已有稳定所有者,裸指针仍然可以安全使用。
用 shared_ptr 延长 CPU 对象生命周期
一种直接做法是在锁内复制 shared_ptr:
std::shared_ptr<Texture> find(int id)
{
std::lock_guard lock(mutex_);
auto it = textures_.find(id);
return it == textures_.end() ? nullptr : it->second;
}调用方持有返回值期间,即使管理器从容器中删除资源,Texture 对象也不会立即析构。两种机制解决不同问题:
- mutex 保护容器查找、插入和删除的一致性;
shared_ptr保护对象离开容器后的 CPU 生命周期。
这不意味着资源管理器必须普遍使用 shared_ptr。共享所有权会让最终销毁位置和时间更难控制,热点路径上的引用计数也可能带来额外同步成本。
Handle 仍然需要回收协议
带代数的 Handle 可以在解析时识别已经失效或被复用的槽位:
struct TextureHandle {
std::uint32_t index;
std::uint32_t generation;
};但 Handle 本身通常不拥有资源。代码如果先把 Handle 解析成裸指针,再允许另一个线程立刻销毁资源,仍可能发生 use-after-free。因此集中资源管理还需要配合一种明确机制:
- 解析后取得临时强引用或 pin;
- 销毁前等待所有使用者离开安全区;
- 按帧或 Epoch 延迟回收;
- 只在拥有资源的固定线程销毁;
- GPU 资源等待对应 Fence 后再释放。
锁、所有权和 Handle 分别回答不同问题:能否并发修改、谁保证对象存活、如何稳定标识并验证资源。不能只加入其中一层,就假设另外两层自动成立。
九、死锁
线程 A:
持有 Lock A
等待 Lock B线程 B:
持有 Lock B
等待 Lock A双方永久等待形成死锁。
经典四个必要条件是:
- 互斥使用资源;
- 持有一个资源时继续等待其他资源;
- 资源不能被外部强制抢占;
- 存在循环等待。
破坏任意一个条件都可以避免死锁。
固定加锁顺序
所有代码统一先锁 A,再锁 B:
Lock A → Lock B → Lock C同时获取多把锁
std::scoped_lock lock(mutexA, mutexB);标准库会使用避免简单锁序死锁的策略获取多把锁。
不持锁等待跨系统操作
线程等待 I/O、任务、Fence 或另一个线程时,应检查是否仍持有对方完成工作所需的锁。
十、unique_lock
lock_guard 简单轻量;unique_lock 支持延迟加锁、提前解锁、转移锁所有权,并且是条件变量等待所需的锁类型。
std::unique_lock lock(mutex_, std::defer_lock);
prepare();
lock.lock();
commit();
lock.unlock();灵活性也增加了状态复杂度,因此不需要时优先 lock_guard。
十一、读写锁 shared_mutex
读多写少的数据可以允许多个读者并发访问:
std::shared_mutex mutex_;
Value read(Key key)
{
std::shared_lock lock(mutex_);
return table_.at(key);
}
void write(Key key, Value value)
{
std::unique_lock lock(mutex_);
table_.insert_or_assign(key, std::move(value));
}shared_mutex 不一定比普通 mutex 快。它的内部状态更复杂,读锁也有同步成本,还可能出现读者或写者饥饿。临界区很短、竞争不高时,普通 mutex 往往更简单。
选择应基于读写比例、临界区长度和实际采样。
十二、条件变量
条件变量用于等待“某个受 mutex 保护的条件成立”,而不是用来保存事件本身。
std::unique_lock lock(mutex_);
condition_.wait(lock, [this] {
return stopped_ || !queue_.empty();
});等待过程会原子性地:
- 释放 mutex;
- 阻塞当前线程;
- 被通知后重新获取 mutex;
- 检查谓词。
为什么必须有谓词
条件变量允许虚假唤醒;通知也可能发生在等待之前。真正的状态是 stopped_ 和 queue_,通知只提示等待者重新检查状态。
错误思维:
notify = 永久保存的一次事件正确思维:
共享状态由 mutex 保护
notify 促使等待线程重新检查状态十三、有关闭语义的阻塞队列
template <typename T>
class BlockingQueue {
public:
bool push(T value)
{
{
std::lock_guard lock(mutex_);
if (stopped_) {
return false;
}
queue_.push_back(std::move(value));
}
condition_.notify_one();
return true;
}
std::optional<T> pop()
{
std::unique_lock lock(mutex_);
condition_.wait(lock, [this] {
return stopped_ || !queue_.empty();
});
if (queue_.empty()) {
return std::nullopt;
}
T value = std::move(queue_.front());
queue_.pop_front();
return value;
}
void stop()
{
{
std::lock_guard lock(mutex_);
stopped_ = true;
}
condition_.notify_all();
}
private:
std::mutex mutex_;
std::condition_variable condition_;
std::deque<T> queue_;
bool stopped_ = false;
};这里最重要的是完整协议:停止后拒绝新任务、唤醒所有消费者、消费者在队列排空后退出。
十四、Semaphore
信号量维护可用许可数量:
初始许可 = 3
acquire → 2
acquire → 1
release → 2适合:
- 限制并发 I/O 数量;
- 管理固定数量资源;
- 生产者与消费者计数;
- 控制同时运行的昂贵任务。
C++20 示例:
std::counting_semaphore<8> slots(4);
slots.acquire();
performLimitedWork();
slots.release();mutex 主要表达独占临界区,semaphore 主要表达数量许可。信号量本身不自动保护复杂共享不变量。
十五、Latch 与 Barrier
latch 是一次性倒计时同步点:多个任务完成后,等待者继续执行。
barrier 可以重复使用:一组线程都到达某一阶段后,再共同进入下一阶段。
Phase 1:所有工作线程完成动画采样
↓ barrier
Phase 2:开始骨骼矩阵计算如果任务之间只有局部依赖,完整 barrier 可能让快速线程等待最慢线程。任务图通常能表达更细粒度依赖。
十六、线程安全不等于可扩展
给所有操作加一把全局锁,可以获得正确性,却可能把系统重新串行化:
8 个工作线程
↓
争用同一 Global Mutex
↓
实际一次只能有 1 个线程工作优化前先区分:
- 正确性问题:是否有数据竞争、死锁、遗漏唤醒;
- 可扩展性问题:锁竞争、缓存抖动、负载不均;
- 任务粒度问题:调度成本是否超过工作量。
本章结论
- 同一进程的线程共享地址空间,但各自拥有栈和寄存器状态。
- 数据竞争是未定义行为,不只是“少算一次”。
- mutex 应保护共享不变量,锁生命周期应使用 RAII。
- 死锁常来自不一致锁序和持锁等待跨系统操作。
shared_mutex适用于经测量确实读多写少的场景,不是默认优化。- 条件变量必须围绕受 mutex 保护的谓词使用。
- semaphore 表达有限许可,不自动维护复杂状态一致性。
- 线程安全只保证正确性,不保证系统能够随核心数扩展。
- 锁住资源容器不等于保护逃逸指针的生命周期;锁、所有权和 Handle 解决的是不同问题。
