主要讨论 锁的分类,锁的特点,锁的应用场景
为什么要有锁?为了解决多线程环境下的数据竞争问题,保证共享资源在同一时刻只有一个线程访问。
常见的锁
| 锁类型 | 特点 | 适用场景 | 优点 | 缺点 |
| 互斥锁 (Mutex) | 一次只允许一个线程访问 | 最常见的线程同步 | 简单、可靠 | 线程阻塞、切换开销大 |
| 自旋锁 (Spinlock) | 线程忙等(不停循环检查锁) | 锁持有时间非常短 | 上下文切换开销小 | CPU空转浪费资源 |
| 读写锁 (RWLock) | 多读单写 | 读多写少的情况 | 读操作并发高效 | 写时仍需阻塞全部读 |
| 递归锁 (Recursive Mutex) | 同一线程可多次加锁 | 避免死锁的特定场景 | 编码方便 | 滥用易隐藏设计问题 |
| 信号量 (Semaphore) | 允许多个线程同时访问有限资源 | 限流、连接池 | 可控制并发量 | 复杂度较高 |
| 条件变量 (Condition Variable) | 等待某条件满足才继续 | 线程间事件通知 | 高效等待 | 配合锁使用,设计复杂 |
| 读复制更新锁 (RCU) | 读几乎无锁,写延迟更新 | 高并发读多写少系统 | 极高读性能 | 实现复杂,适合内核/底层 |
| 乐观锁 (Optimistic Lock) | 假设冲突少,冲突再重试 | 数据库、CAS场景 | 无锁开销低 | 冲突多会频繁重试 |
| 悲观锁 (Pessimistic Lock) | 假设冲突多,先锁再操作 | 银行转账、库存扣减 | 简单安全 | 性能可能差 |
互斥锁和自旋锁
上下文切换(Context Switch):
- 是指操作系统内核把 CPU 从一个线程/进程切换到另一个线程/进程时,保存和恢复寄存器、栈指针等上下文的过程。
- 上下文切换是昂贵的(开销在微秒级)。
互斥锁(mutex):
- 如果加锁时锁已被占用,线程会主动让出 CPU,进入阻塞状态,等待被唤醒。
- 涉及内核态调度,必然会有上下文切换。
自旋锁(spinlock):
- 如果锁被占用,线程会忙等(自旋),不会立刻让出 CPU。
- 无阻塞、无系统调度,所以正常情况下不会有上下文切换(除非内核抢占或者线程用尽时间片)。
加解锁时的上下文切换分析
| 锁类型 | 情况 | 上锁(lock)时上下文切换次数 | 解锁(unlock)时上下文切换次数 | 说明 |
| 互斥锁 | 锁空闲 | 0 次 | 0 次 | 用户态快速获取和释放锁,无需调度。 |
| 锁被占用 | 2 次(当前线程阻塞) | 0~1 次(唤醒等待线程) | 当前线程睡眠时: 1. 线程切换到内核,阻塞当前线程 → 1 次 2. 切换到其他可运行线程 → 1 次 解锁时,可能需要唤醒等待线程,调度器决定是否切换 → 0 或 1 次。 | |
| 自旋锁 | 锁空闲 | 0 次 | 0 次 | 自旋锁只用原子操作。 |
| 锁被占用 | 0 次(自旋等待) | 0 次 | 自旋锁不会主动让出 CPU,线程会一直忙等,不发生上下文切换(除非时间片耗尽或内核抢占)。 |
总结规律
- 互斥锁:
- 成功获取锁时几乎没开销。
- 如果锁被占用,线程会进入内核阻塞,至少发生 2 次上下文切换(阻塞自己、调度别人(若绑定的cpu亲和则另说);等锁释放再唤醒自己)。
- 最通用且最基础的锁。
- 自旋锁:
- 不会因为锁被占用而上下文切换,线程会忙等。
- 但会浪费 CPU 时间片。
- 常用于高性能编程中预期等待时间较少的竞争场景。
代码验证
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>
#include <fstream>
#include <chrono>
#include <string>
// 读取当前进程的上下文切换次数
void print_context_switches(const std::string& tag) {
std::ifstream status("/proc/self/status");
std::string line;
std::string voluntary, nonvoluntary;
while (std::getline(status, line)) {
if (line.find("voluntary_ctxt_switches") != std::string::npos) {
voluntary = line;
} else if (line.find("nonvoluntary_ctxt_switches") != std::string::npos) {
nonvoluntary = line;
}
}
std::cout << "[" << tag << "] " << voluntary << ", " << nonvoluntary << std::endl;
}
// 互斥锁测试
void test_mutex() {
std::mutex mtx;
bool ready = false;
auto worker = [&]() {
while (!ready) std::this_thread::yield();
auto start = std::chrono::high_resolution_clock::now();
std::lock_guard<std::mutex> lock(mtx);
auto end = std::chrono::high_resolution_clock::now();
std::cout << "[Mutex] Lock acquired after "
<< std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()
<< " us\n";
print_context_switches("Mutex thread");
};
std::thread t(worker);
mtx.lock();
ready = true;
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 持锁100ms
mtx.unlock();
t.join();
}
// 自旋锁测试
void test_spinlock() {
std::atomic_flag spin = ATOMIC_FLAG_INIT;
bool ready = false;
auto worker = [&]() {
while (!ready) std::this_thread::yield();
auto start = std::chrono::high_resolution_clock::now();
while (spin.test_and_set(std::memory_order_acquire)) {
// busy wait
}
auto end = std::chrono::high_resolution_clock::now();
std::cout << "[Spinlock] Lock acquired after "
<< std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()
<< " us\n";
spin.clear(std::memory_order_release);
print_context_switches("Spinlock thread");
};
std::thread t(worker);
spin.test_and_set(std::memory_order_acquire);
ready = true;
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 持锁100ms
spin.clear(std::memory_order_release);
t.join();
}
int main() {
std::cout << "=== Mutex test ===" << std::endl;
test_mutex();
std::cout << "\n=== Spinlock test ===" << std::endl;
test_spinlock();
return 0;
}
g++ -std=c++17 -O2 -pthread lock_test.cpp -o lock_test
./lock_test
我们可以通过getrusage方法获取当前线程的调度信息:
voluntary: 线程上下文主动切换计数nonvoluntary: 由内核调度的线程上下文切换计数
在互斥锁的测试中,我们期望其是主调调度进内核,而测试自旋锁时,我们希望它只会被内核调度出。

测试结果与预期相符
在多线程自旋锁的测试中,其确实会消耗大量的cpu时间
taskset -c 6 ./lock_test_mt
perf top -C 6 -g

读写锁和RCU
读写锁(RWLock) 和 RCU(read-copy-update), 都适用于读多写少的场景,其主要的区别在于
RWLock: 允许同时读,但是写者与任何读者都互斥,写时会阻塞RCU: 读时几乎无锁,写者复制新版本——原子发布——等待宽限期回收旧版本,读者可能读到稍旧的快照。
维度对比
| 维度 | RWLock | RCU |
| 读路径 | 获取共享锁(有锁操作) | 进入读段(轻量标记/屏障,无锁或近似无锁) |
| 写路径 | 独占锁,等待所有读者退出后写 | 复制新数据并原子切换指针;不阻塞在读者上 |
| 读可见性 | 读到最新且一致的数据 | 读到一致但可能过期的快照 |
| 延迟特性 | 写会阻塞新读者;大量读者时写者可能饥饿 | 写的发布很快,但回收要等宽限期;读基本恒定低延迟 |
| 扩展性(多核多读) | 锁元数据争用显著,扩展性一般 | 极佳,读路径几乎不触碰共享状态 |
| 实现复杂度 | 低(库现成,易用) | 高(版本管理、内存回收、内存屏障) |
| 内存开销 | 基本无额外副本 | 写时需要副本/多版本,并有延迟回收 |
| 适用数据结构 | 通用 | 指针/引用为主的结构(表、链表、树、map 等)更自然 |
| 一致性需求 | 强一致(写后读立即可见) | 最终一致(允许短暂陈旧) |
| 写比例较高时 | 容易退化(写阻塞读) | 复制成本高、回收压力大,收益变小 |
适用场景
RWLock 当你需要:
- 读必须看到最新结果(强一致)
- 写不频繁且可接受写期间暂停读
- 代码要简单可维护,团队对并发技巧不熟
RCU 当你需要:
- 极高读吞吐与低读延迟(热点读、多核扩展)
- 读能接受**“稍旧也行”**的快照一致性
- 数据结构以指针/引用为主,更新可以复制新版本再切换
- 系统允许延迟回收与适度的内存放大
RWLock
读优先和写优先
读写锁可以设置读优先和写优先
| 策略 | 行为特点 | 优点 | 缺点 | 适用场景 |
| 读优先 | 有写者等待时仍允许新读者进入 | 读延迟低,读吞吐量极高 | 写者可能长时间饥饿 | 读极多写极少的场景 |
| 写优先 | 有写者等待时阻塞新读者 | 避免写饥饿,写延迟可控 | 读者延迟变高,读吞吐下降 | 写操作时效性要求高 |
| 公平策略 | 按到达顺序排队 | 无饥饿,性能均衡 | 吞吐量略低于偏向策略 | 需要整体公平调度的系统 |
总结选择建议
- 读极多写极少:读优先,最大化读吞吐。
- 写需要及时生效:写优先,避免写者饥饿。
- 公平性重要或读写比例均衡:公平策略(例如 Java 的
ReentrantReadWriteLock(true)构造公平锁)。 - 实际应用:很多标准库(如
std::shared_mutex)是读优先,但数据库和高实时系统往往倾向写优先或公平策略。
递归锁
递归锁的特点是:同一个线程可以多次获得同一把锁,每次获得都会增加一个计数;只有当相同线程调用对应次数的 unlock 后,锁才真正释放给其他线程。 换句话说:递归锁允许重入——当持锁线程在临界区内调用会再次尝试加同一把锁的函数时,不会死锁,而是增加计数并继续执行。
比较罕见的锁类型
为什么需要(典型场景)
- A 函数加锁后调用 B,B 也可能间接调用到 A(或其他也加同一把锁的函数),如果使用普通互斥锁,会自死锁;递归锁能避免这种“自己给自己锁住”的情况。
- 常见于面向对象的实例方法相互调用,且每个方法都保护同一份状态时(e.g. 对象方法用锁保护成员变量)。
代码验证
#include <iostream>
#include <thread>
#include <mutex>
//std::mutex mtx; // 普通互斥锁,替换为 std::recursive_mutex 可解决重入
std::recursive_mutex mtx;
void inner(int n) {
std::lock_guard<std::recursive_mutex> lk(mtx); // 若 mtx 是 std::mutex,第二次 lock 将死锁
std::cout << "inner " << n << " locked by thread " << std::this_thread::get_id() << "\n";
if (n > 0) inner(n - 1);
}
int main() {
std::thread t([]{
// 如果 mtx 是 std::mutex,这里第一次 lock 后,inner 再次 lock 会死锁。
// 将上方 mtx 改成 std::recursive_mutex 并把 lock_guard 类型也改为 std::lock_guard<std::recursive_mutex>
inner(2);
});
t.join();
std::cout << "done\n";
}
互斥锁:

递归锁:

信号量和条件变量
这个我认为严格意义上不算锁吧,一者是资源的限制,一者是事件的通知机制。暂且不表。
乐观锁和悲观锁
乐观锁:假设冲突很少,要操作数据时先不上锁。由于乐观锁全程不加锁,也可叫做无锁编程悲观锁: 假设冲突很多,操作前先上锁
| 维度 | 悲观锁 | 乐观锁 |
| 成本(读路径) | 读可能被阻塞,锁开销 | 读无锁,低延迟 |
| 成本(写路径) | 写时独占,开销直观 | 写要做冲突检测与重试 |
| 并发吞吐 | 写多/冲突多时更稳健 | 写少时吞吐高,写多时退化 |
| 一致性 | 强(写期间无并发干扰) | 最终一致/基于版本校验 |
| 实现复杂度 | 简单直接 | 逻辑上需要重试/回退和版本管理 |
| 死锁风险 | 存在(要注意锁顺序) | 无锁或少锁,死锁风险低 |
| 资源占用 | 可能长时间持有锁阻塞他人 | 可能多次复制/重试导致 CPU/内存开销 |
相关场景
悲观锁:适合并发冲突多,写多读少的场景
START TRANSACTION;
-- 锁定行,直到事务提交/回滚
SELECT balance FROM account WHERE id = 123 FOR UPDATE;
-- 进行更新
UPDATE account SET balance = balance - 100 WHERE id = 123;
COMMIT;
乐观锁:数据库,在线文档,svn,git
-- 读
SELECT balance, version FROM account WHERE id = 123;
-- 假设读到 version = 4,尝试更新时带版本条件
UPDATE account
SET balance = balance - 100, version = version + 1
WHERE id = 123 AND version = 4;
-- 若受影响行数 == 0 => 有并发写,检测到冲突,需要重试或报错
总结规律
- 冲突少,就用乐观锁. 不加锁,用版本号或者时间戳校验,性能高。
- 冲突多,就用悲观锁. 直接上锁,不会失败,就是性能低。
stl中有哪些锁
| 名称 | 头文件 | 特点 | 用途 |
| std::mutex | <mutex> | 普通互斥锁,不可递归 | 基本线程同步,保护共享资源 |
| std::recursive_mutex | <mutex> | 允许同一线程多次加锁 | 避免递归调用时死锁 |
| std::timed_mutex | <mutex> | 支持 try_lock_for 和 try_lock_until | 限时尝试加锁 |
| std::recursive_timed_mutex | <mutex> | 递归 + 限时加锁 | 灵活超时控制 |
| std::shared_mutex (C++17) | <shared_mutex> | 多读单写锁 | 读多写少场景 |
| std::shared_timed_mutex (C++14) | <shared_mutex> | 共享锁+限时锁 | C++17前的过渡版本 |
| std::lock_guard | <mutex> | 简单的RAII加锁器 | 作用域锁,自动解锁 |
| std::unique_lock | <mutex> | 更灵活的RAII锁 | 可延迟上锁、解锁、转移 |
| std::scoped_lock (C++17) | <mutex> | 同时锁多个互斥量 | 死锁安全的多锁方案 |
| std::condition_variable | <condition_variable> | 线程等待/通知 | 结合 std::unique_lock 用 |
| std::condition_variable_any | <condition_variable> | 支持任意锁类型 | 比 condition_variable 灵活 |
| std::once_flag + std::call_once | <mutex> | 保证只执行一次 | 单例模式初始化 |
| std::future / std::promise | <future> | 异步结果同步 | 任务结果传递 |
| std::atomic | <atomic> | 原子操作 | 无锁并发 |










