C++
C++ 常用锁:用法与注意事项
发布于 2026年7月22日
C++ 常用锁:用法与注意事项
C++ 标准库提供了多种互斥与加锁工具。选择锁时,首先考虑正确性和可维护性,再根据实际测量决定是否需要更复杂的锁。
核心原则:优先使用 RAII 管理锁;缩短临界区;固定多锁获取顺序;不要在持锁时执行耗时、阻塞或不可控操作。
1. std::mutex:最常用的互斥锁
std::mutex 同一时间只允许一个线程进入临界区。
#include <mutex>
std::mutex m;
int counter = 0;
void increment()
{
std::lock_guard<std::mutex> lock(m);
++counter;
} // 自动解锁
应优先用 std::lock_guard,避免手动 lock() / unlock():
m.lock();
do_something(); // 如果这里抛异常,unlock 不会执行
m.unlock();
注意事项
std::mutex不可复制、不可移动。- 同一线程重复锁定同一个普通 mutex,通常会死锁。
- 只能由成功加锁的线程解锁,否则行为未定义。
- mutex 保护的是程序约定的数据,不是变量与锁的自动绑定;所有访问路径都必须遵守同一约定。
2. lock_guard:简单作用域加锁
std::lock_guard 构造时加锁,析构时解锁,开销小、语义直接。
void update()
{
std::lock_guard<std::mutex> guard(m);
// 临界区
}
适合“进入作用域就加锁、离开作用域就解锁”的场景。
如果 mutex 已经被当前代码锁定,可使用 std::adopt_lock 接管:
m.lock();
std::lock_guard<std::mutex> guard(m, std::adopt_lock);
使用
adopt_lock的前提是当前线程确实已经持有该锁,否则行为未定义。
3. unique_lock:可延迟、可转移的锁
std::unique_lock 比 lock_guard 灵活,支持延迟加锁、提前解锁、所有权转移,并且是条件变量常用的锁类型。
std::unique_lock<std::mutex> lock(m, std::defer_lock);
// 做不需要锁的工作
lock.lock();
// 临界区
lock.unlock();
配合条件变量:
std::mutex m;
std::condition_variable cv;
bool ready = false;
void consumer()
{
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; });
// 被唤醒且重新持有锁后继续
}
注意事项
unique_lock比lock_guard状态更多,除非确实需要灵活性,否则优先使用 lock_guard。wait必须用谓词版本或放在循环中,以处理虚假唤醒。- 调用
unlock()后要清楚保护对象是否仍可能被访问。 release()只放弃锁对象的所有权,不会解锁底层 mutex,容易误用。
4. scoped_lock:一次安全锁住多个 mutex
C++17 的 std::scoped_lock 可同时锁定多个 mutex,并使用避免死锁的算法:
std::mutex m1;
std::mutex m2;
void transfer(Account& from, Account& to, int amount)
{
std::scoped_lock lock(m1, m2);
from.balance -= amount;
to.balance += amount;
}
不应写成:
std::lock_guard<std::mutex> lock1(m1);
std::lock_guard<std::mutex> lock2(m2);
如果另一个线程按相反顺序获取 m2、m1,就可能死锁。
C++11 的等价写法:
std::lock(m1, m2);
std::lock_guard<std::mutex> lock1(m1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(m2, std::adopt_lock);
5. recursive_mutex:允许同线程重复加锁
std::recursive_mutex 允许同一线程多次加锁,但必须以相同次数解锁。
std::recursive_mutex m;
void outer()
{
std::lock_guard<std::recursive_mutex> lock(m);
inner();
}
void inner()
{
std::lock_guard<std::recursive_mutex> lock(m);
}
注意事项
- recursive_mutex 经常掩盖职责不清或调用层次混乱。
- 它不能解决不同线程之间的死锁。
- 递归加锁次数存在实现限制,超过限制会失败。
- 更推荐重构为“公开加锁函数 + 私有不加锁函数”。
class Example {
public:
void outer()
{
std::lock_guard<std::mutex> lock(m_);
inner_unlocked();
}
private:
void inner_unlocked()
{
// 调用者必须持有 m_
}
std::mutex m_;
};
6. timed_mutex:带超时的互斥锁
std::timed_mutex 支持等待一段时间或等待到指定时刻。
std::timed_mutex m;
void try_work()
{
using namespace std::chrono_literals;
if (m.try_lock_for(100ms)) {
std::lock_guard<std::timed_mutex> lock(m, std::adopt_lock);
// 临界区
} else {
// 超时后的降级、重试或报错
}
}
相关类型还有 std::recursive_timed_mutex。
注意事项
- 超时不是死锁修复方案,只是让系统有机会恢复或报告。
try_lock_for可能受到调度延迟影响,实际等待时间可能更长。- 不要用极短超时配合高频重试,否则可能造成忙等和 CPU 浪费。
- 对“持续时间”优先使用
std::chrono::steady_clock语义。
7. shared_mutex:读写锁
C++17 的 std::shared_mutex 支持:
- 独占锁:写线程使用
std::unique_lock; - 共享锁:读线程使用
std::shared_lock。
#include <shared_mutex>
#include <string>
#include <unordered_map>
class Cache {
public:
std::string get(int key) const
{
std::shared_lock lock(mutex_);
auto it = data_.find(key);
return it == data_.end() ? "" : it->second;
}
void set(int key, std::string value)
{
std::unique_lock lock(mutex_);
data_[key] = std::move(value);
}
private:
mutable std::shared_mutex mutex_;
std::unordered_map<int, std::string> data_;
};
注意事项
- 只有读操作明显多于写操作、临界区有一定工作量时,shared_mutex 才可能更快。
- 实现可能存在读者或写者饥饿,公平性并非标准保证。
- 不要在持有 shared lock 时直接升级为 unique lock;标准 shared_mutex 没有原子升级操作。通常应先释放共享锁,再获取独占锁,并重新检查条件。
- “读取”必须是真正只读;惰性缓存、统计计数等隐藏写操作仍需要同步。
8. 自旋锁:短临界区的特殊工具
标准库没有通用的 std::spinlock,可以基于 std::atomic_flag 实现:
#include <atomic>
class SpinLock {
public:
void lock() noexcept
{
while (flag_.test_and_set(std::memory_order_acquire)) {
#if __cpp_lib_atomic_wait >= 201907L
flag_.wait(true, std::memory_order_relaxed);
#endif
}
}
void unlock() noexcept
{
flag_.clear(std::memory_order_release);
#if __cpp_lib_atomic_wait >= 201907L
flag_.notify_one();
#endif
}
private:
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};
注意事项
- 纯自旋等待会持续占用 CPU,只适合临界区极短且锁竞争很低的情况。
- 持锁线程被操作系统抢占时,自旋线程可能白白消耗整个时间片。
- 单核环境、超额订阅、I/O 或可能阻塞的临界区尤其不适合自旋锁。
- 自制锁需要严谨处理内存序、公平性和退避策略;业务代码通常应优先使用标准 mutex。
- C++20 的
atomic::wait/notify能减少无意义忙等,但这已接近“自适应锁”而非传统纯自旋。
9. try_lock:非阻塞尝试加锁
std::mutex m;
void work()
{
if (m.try_lock()) {
std::lock_guard<std::mutex> lock(m, std::adopt_lock);
// 获得锁
} else {
// 执行其他任务,或稍后重试
}
}
try_lock() 失败不代表发生死锁,只表示此刻没有获得锁。不要无退避地死循环调用它。
10. 常见死锁场景
10.1 多锁顺序不一致
线程 A:锁 m1 → 等 m2
线程 B:锁 m2 → 等 m1
解决方案:
- 使用
std::scoped_lock或std::lock; - 或为全局锁规定固定获取顺序。
10.2 持锁调用外部代码
std::lock_guard lock(m);
callback(); // callback 可能再次获取 m 或等待其他资源
回调、日志、用户代码、网络和磁盘 I/O 都可能扩大锁依赖图。可先在锁内复制所需状态,释放锁后再调用。
10.3 持锁等待线程结束
std::lock_guard lock(m);
worker.join(); // worker 可能正在等待 m
不要在持锁时 join() 一个可能需要该锁的线程。
11. 减小临界区
Result result = expensive_compute(); // 锁外计算
{
std::lock_guard lock(m);
shared_result = std::move(result); // 锁内只做必要更新
}
但不要为了“缩短临界区”破坏操作的原子语义。应先明确哪些状态必须作为整体保持一致。
12. 锁与对象生命周期
锁只能保护仍然存在的对象。常见风险包括:
- mutex 已析构,其他线程仍可能访问它;
- 返回受锁保护对象的引用或指针,离开临界区后继续使用;
- 容器在解锁后被修改,导致此前取得的迭代器失效;
- 对象销毁时工作线程尚未停止。
销毁包含 mutex 的对象前,应确保所有工作线程已停止并完成连接。
13. 锁不能自动消除数据竞争
所有访问共享数据的路径必须使用同一同步协议:
int value = 0;
std::mutex m;
void safe_write()
{
std::lock_guard lock(m);
value = 42;
}
int unsafe_read()
{
return value; // 仍然是数据竞争
}
正确读取也必须加锁:
int safe_read()
{
std::lock_guard lock(m);
return value;
}
14. 性能注意事项
不要凭感觉选择“更快”的锁,应先进行基准测试和性能分析。影响性能的因素包括:
- 锁竞争频率;
- 临界区长度;
- 线程数量与 CPU 核数;
- 缓存行抖动和伪共享;
- 操作系统调度;
- NUMA 拓扑;
- 公平性与尾延迟要求。
常见优化顺序:
- 减少共享可变状态;
- 缩短临界区;
- 分片锁,降低同一把锁的竞争;
- 批量处理,减少加锁次数;
- 使用读写锁;
- 最后才考虑无锁结构。
15. 选型建议
| 需求 | 推荐工具 |
|---|---|
| 普通互斥 | std::mutex + std::lock_guard |
| 需要延迟锁、提前解锁或条件变量 | std::unique_lock |
| 同时锁多个 mutex | std::scoped_lock |
| 读多写少且已测量有收益 | std::shared_mutex |
| 需要超时与降级策略 | std::timed_mutex |
| 同线程确实需要递归加锁 | std::recursive_mutex,但优先考虑重构 |
| 极短临界区的底层组件 | 谨慎评估自旋锁 |
16. 实用检查清单
- 是否使用 RAII 自动解锁?
- 每个共享变量的同步协议是否明确且一致?
- 多把锁是否采用固定顺序或 scoped_lock?
- 临界区内是否调用了回调、I/O、sleep、join 或未知代码?
- 是否把受保护对象的引用泄漏到了锁外?
- 条件变量是否使用谓词处理虚假唤醒?
- shared_mutex 是否真的通过测量优于 mutex?
- 对象销毁前,相关线程是否已停止?
- 是否用线程消毒器(ThreadSanitizer)等工具检查数据竞争?