浏览知识库目录

C++

C++ 常用锁:用法与注意事项

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_locklock_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_locklock_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);

如果另一个线程按相反顺序获取 m2m1,就可能死锁。

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_lockstd::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 拓扑;
  • 公平性与尾延迟要求。

常见优化顺序:

  1. 减少共享可变状态;
  2. 缩短临界区;
  3. 分片锁,降低同一把锁的竞争;
  4. 批量处理,减少加锁次数;
  5. 使用读写锁;
  6. 最后才考虑无锁结构。

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)等工具检查数据竞争?