同步机制

同步机制让多个线程以正确的顺序访问共享状态。选择原则很简单:一个独立的简单状态可考虑原子对象;要维护多个数据或多个步骤之间的不变量时,优先使用互斥锁保护临界区。

选择同步原语

工具 解决的问题 不能替代什么
std::mutex + RAII 锁 互斥访问复杂共享状态 事件通知与任务结果传递
std::condition_variable 等待受互斥锁保护的条件变化 对条件本身的互斥保护
std::atomic<T> 对单个原子状态进行读改写与同步 多对象事务或复杂容器保护
std::scoped_lock 一次获取多把锁,降低死锁风险 设计良好的锁层级

原子操作不一定是无锁(可用 is_lock_free() 查询),无锁也不等于无等待。不要仅因“可能更快”就把清晰的锁方案改为 CAS 循环。

互斥锁与 RAII

std::mutex 的 lock()/unlock() 成功配对可建立同步关系。手写 unlock() 容易在提前返回或异常时遗漏,因此应让 RAII 对象持有锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <mutex>
#include <thread>

std::mutex counter_mutex;
int counter = 0;

void add() {
for (int i = 0; i < 100'000; ++i) {
std::lock_guard<std::mutex> lock(counter_mutex);
++counter;
} // 即使临界区抛异常,lock 析构时也会解锁
}

int main() {
std::thread first(add);
std::thread second(add);
first.join();
second.join();
std::cout << counter << '\n';
}

lock_guard 适合“进入作用域即加锁、离开即解锁”。需要延迟加锁、临时解锁或交给条件变量等待时,使用 std::unique_lock<std::mutex>。

1
2
3
4
5
std::unique_lock<std::mutex> lock(counter_mutex, std::defer_lock);
// 先完成不需要锁的准备工作
lock.lock();
// 访问临界区
lock.unlock();

unique_lock 的灵活性有额外状态和误用空间;不需要它的功能时选 lock_guard。

条件变量:等待“谓词”,不是等待通知

条件变量适合“队列非空”“已停止”等状态发生改变的场景。通知可能发生在等待前,线程也可能虚假唤醒;所以条件状态必须由同一把互斥锁保护,并且总是用谓词判断。

1
2
3
生产者:持锁修改队列/停止标志 → 解锁 → notify_one 或 notify_all
消费者:持锁检查谓词 → 不满足则 wait(原子地解锁并睡眠)
被唤醒后重新加锁并再次检查谓词

有关闭协议的生产者消费者示例

下面是可独立编译的 C++17 示例。队列容量上限提供背压;closed 让消费者在生产结束且队列清空后退出。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <condition_variable>
#include <cstddef>
#include <iostream>
#include <mutex>
#include <optional>
#include <queue>
#include <thread>

class BlockingQueue {
public:
explicit BlockingQueue(std::size_t capacity) : capacity_(capacity) {}

bool push(int value) {
std::unique_lock<std::mutex> lock(mutex_);
not_full_.wait(lock, [this] { return closed_ || queue_.size() < capacity_; });
if (closed_) {
return false;
}
queue_.push(value);
lock.unlock();
not_empty_.notify_one();
return true;
}

std::optional<int> pop() {
std::unique_lock<std::mutex> lock(mutex_);
not_empty_.wait(lock, [this] { return closed_ || !queue_.empty(); });
if (queue_.empty()) { // 仅当 closed_ 且已消费完时成立
return std::nullopt;
}
const int value = queue_.front();
queue_.pop();
lock.unlock();
not_full_.notify_one();
return value;
}

void close() {
{
std::lock_guard<std::mutex> lock(mutex_);
closed_ = true;
}
not_empty_.notify_all();
not_full_.notify_all();
}

private:
const std::size_t capacity_;
std::mutex mutex_;
std::condition_variable not_empty_;
std::condition_variable not_full_;
std::queue<int> queue_;
bool closed_ = false;
};

int main() {
BlockingQueue queue(2);
std::thread producer([&queue] {
for (int value = 1; value <= 5; ++value) {
if (!queue.push(value)) return;
}
queue.close();
});
std::thread consumer([&queue] {
while (const auto value = queue.pop()) {
std::cout << "消费: " << *value << '\n';
}
});
producer.join();
consumer.join();
}

wait(lock, predicate) 等价于在循环中检查谓词、条件不满足则等待。notify 不携带数据,也不“记住”事件;真正决定能否继续的是锁保护的状态。

原子对象

std::atomic<T> 支持对 T 的原子读、写和部分读改写。下例只有一个计数器不变量,可用 fetch_add;默认的顺序一致性适合先保证正确性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> counter{0};

void add() {
for (int i = 0; i < 100'000; ++i) {
counter.fetch_add(1); // 原子读-改-写
}
}

int main() {
std::thread first(add), second(add);
first.join();
second.join();
std::cout << counter.load() << '\n';
}

memory_order_relaxed 只适用于只需原子性、无需借此发布其他数据的场景,例如独立统计计数。发布数据或实现协议前,应先理解 acquire/release;详见CAS:内存序。

死锁与多把锁

死锁通常需要互斥、持有并等待、不可抢占与循环等待四个条件同时存在。不要通过超时“掩盖”锁设计问题;优先减少嵌套锁,并为必须同时获取的锁建立统一策略。

1
2
3
4
5
6
7
8
9
#include <mutex>

std::mutex account_a_mutex;
std::mutex account_b_mutex;

void transfer() {
std::scoped_lock lock(account_a_mutex, account_b_mutex); // C++17
// 在同一临界区检查余额并更新两个账户
}

std::scoped_lock 会采用避免死锁的获取策略,并在析构时释放所有锁。它不能保护其他地方以不同规则持锁的设计;整个代码库仍应有明确锁的所有权与顺序。

std::recursive_mutex 允许同一线程重复加锁,但会隐藏可重入设计和锁粒度问题,通常应重构为“不在持锁状态调用可能回调自身的函数”。std::timed_mutex 的超时只表示本次未获锁,不等于消除了死锁或饥饿。

常见误区与实践建议

  • 不要在持锁状态执行 I/O、耗时计算或未知回调;必要时复制最小数据后解锁再处理。
  • 条件变量的停止标志与队列状态必须同锁保护,停止时要唤醒所有可能等待的线程。
  • volatile 不是线程同步工具,不能替代 atomic 或 mutex。
  • 先使用默认内存序和清晰的锁;只在测量并证明需要时优化内存序或无锁算法。
  • C++17 编译示例:c++ -std=c++17 -Wall -Wextra -Wpedantic -pthread file.cpp。