课程目标
- 理解多线程编程中“锁”的性能瓶颈
- 认识锁会导致的各种问题
- 初步了解无锁编程能解决什么问题
- 建立学习无锁编程的动机
一、从一个简单的例子开始
假设你正在开发一个计数器程序,多个线程需要同时增加这个计数器的值:
// 第一版:没有任何保护的代码(错误示例!)
class Counter {
int count = 0;
public:
void increment() {
count++; // 看起来很简单,但在多线程下有问题!
}
int get() { return count; }
};
问题在哪里?
count++ 这一行代码看起来是一个操作,但在 CPU 层面实际上是三个步骤:
// count++ 实际上被拆分成:
1. 从内存读取 count 的值到寄存器 (LOAD)
2. 在寄存器中加 1 (ADD)
3. 把结果写回内存 (STORE)
如果两个线程同时执行,就可能发生这种情况:
时间线:
┌─────────┬──────────────────────┬──────────────────────┐
│ 时刻 │ 线程 1 │ 线程 2 │
├─────────┼──────────────────────┼──────────────────────┤
│ T1 │ LOAD count (得到 0) │ │
│ T2 │ │ LOAD count (也得到 0) │
│ T3 │ ADD (计算出 1) │ │
│ T4 │ │ ADD (计算出 1) │
│ T5 │ STORE 1 到 count │ │
│ T6 │ │ STORE 1 到 count │
└─────────┴──────────────────────┴──────────────────────┘
结果: count = 1 (但期望是 2!)
这就是著名的数据竞争问题,是多线程开发中需要解决的核心挑战之一。
二、传统解决方案:使用互斥锁
为了解决数据竞争,最常见的方法是使用互斥锁:
#include <mutex>
class SafeCounter {
int count = 0;
std::mutex mtx; // 加一把锁
public:
void increment() {
mtx.lock(); // 加锁
count++; // 临界区:只有一个线程能执行
mtx.unlock(); // 解锁
}
int get() {
std::lock_guard<std::mutex> lock(mtx); // RAII 方式自动加锁/解锁
return count;
}
};
互斥锁的好处
- 正确性保证:确保同一时刻只有一个线程修改数据
- 简单易懂:概念清晰,容易使用
- 标准支持:C++11 标准库提供,跨平台
三、互斥锁的性能开销
但是,锁并非没有代价!让我们看看实际的性能数据。
根据多个基准测试研究的结果:
| 操作类型 |
执行时间 |
相对开销 |
| 普通的整数加法 |
~1 纳秒 |
1x (基准) |
| 无竞争时的 mutex 加锁/解锁 |
~25-50 纳秒 |
25-50x |
| 有竞争时的 mutex 加锁/解锁 |
~700+ 纳秒 |
700x+ |
让我们用一个真实的基准测试来验证:
#include <iostream>
#include <chrono>
#include <thread>
#include <mutex>
#include <vector>
// 测试 1:使用 mutex 的计数器
class MutexCounter {
int count = 0;
std::mutex mtx;
public:
void increment() {
std::lock_guard<std::mutex> lock(mtx);
count++;
}
int get() { return count; }
};
// 测试 2:无保护的计数器(仅用于对比性能,实际不安全!)
class UnsafeCounter {
int count = 0;
public:
void increment() {
count++; // 不安全!仅用于测试性能基准
}
int get() { return count; }
};
// Benchmark 函数
template<typename CounterType>
void benchmark(const std::string& name, int iterations) {
CounterType counter;
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < iterations; i++) {
counter.increment();
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start);
std::cout << name << ": "
<< duration.count() / iterations << " ns/op\n";
}
int main() {
const int ITERATIONS = 1000000;
benchmark<UnsafeCounter>("无保护(基准)", ITERATIONS);
benchmark<MutexCounter>("Mutex 加锁", ITERATIONS);
return 0;
}
典型输出结果:
无保护(基准):1 ns/op
Mutex 加锁:30 ns/op
关键洞察
即使在没有竞争的情况下(单线程顺序执行),mutex 也有约几十纳秒的固定开销!这包括:
- 检查锁状态
- 内存屏障操作(确保可见性)
- 函数调用开销
四、锁在高并发下的灾难性表现
当多个线程真正竞争同一个锁时,情况会变得更糟。这种对共享资源的争用是并发编程中需要持续优化的痛点。
#include <thread>
#include <vector>
#include <atomic>
// 多线程竞争测试
void multithread_benchmark() {
const int NUM_THREADS = 8;
const int INCREMENTS_PER_THREAD = 1000000;
MutexCounter counter;
std::vector<std::thread> threads;
auto start = std::chrono::high_resolution_clock::now();
// 启动多个线程,都竞争同一个锁
for (int i = 0; i < NUM_THREADS; i++) {
threads.emplace_back([&counter, INCREMENTS_PER_THREAD]() {
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
counter.increment();
}
});
}
// 等待所有线程完成
for (auto& t : threads) {
t.join();
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "8 个线程竞争锁: " << duration.count() << " ms\n";
}
典型结果:
- 单线程完成 100 万次操作:~30ms
- 8 线程实际使用 mutex:~650ms
慢了 20 多倍!
五、锁导致的四大问题
问题 1:锁竞争
当多个线程频繁竞争同一个锁时,大量时间会浪费在等待上,导致 CPU 利用率降低。想象一下公共厕所的场景:
只有 1 个厕所门(锁)
有 10 个人排队等待
每个人使用厕所需要 1 分钟
但是等待 + 上锁 + 解锁 加起来需要 5 分钟!
→ 真正干活的时间只有 1/5,其余都在等待
问题 2:优先级反转
高优先级任务被低优先级任务间接阻塞,违反了优先级调度模型。
真实案例:1997 年火星探路者号险些失败!
火星探路者号着陆器在收集气象数据时开始经历系统重启和数据丢失,问题被追溯到优先级反转。
场景模拟:
┌────────────────────────────────────────┐
│ 高优先级任务 H: 紧急气象数据收集 │ 被迫等待!
│ (需要访问共享数据库) │
└────────────────────────────────────────┘
⬆ 等待锁
┌────────────────────────────────────────┐
│ 中优先级任务 M: 普通图像处理 │ 正在运行
│ (不需要数据库) │
└────────────────────────────────────────┘
⬆ 抢占了 L 的 CPU
┌────────────────────────────────────────┐
│ 低优先级任务 L: 后台数据整理 │ 持有锁,但被 M 抢占
│ (正持有数据库的锁) │
└────────────────────────────────────────┘
结果:H 虽然优先级最高,却被 M(不需要锁)间接阻塞!
问题 3:死锁
两个或多个线程互相等待对方持有的锁,导致永久阻塞:
// 经典的死锁场景
std::mutex mutex_A;
std::mutex mutex_B;
// 线程 1
void transfer_1_to_2() {
std::lock_guard<std::mutex> lock_A(mutex_A); // 先锁 A
// ... 被抢占 ...
std::lock_guard<std::mutex> lock_B(mutex_B); // 再锁 B
// 转账操作
}
// 线程 2
void transfer_2_to_1() {
std::lock_guard<std::mutex> lock_B(mutex_B); // 先锁 B
// ... 被抢占 ...
std::lock_guard<std::mutex> lock_A(mutex_A); // 再锁 A (死锁!)
// 转账操作
}
时间线:
T1:线程 1 获得 mutex_A
T2:线程 2 获得 mutex_B
T3:线程 1 尝试获得 mutex_B → 等待线程 2 释放
T4:线程 2 尝试获得 mutex_A → 等待线程 1 释放
→ 永久死锁!
问题 4:活锁与护航效应
活锁
简单比喻:两个人在狭窄走廊相遇,都很礼貌地想让对方先过:
- 甲向左让,乙也向左让 → 还是堵着
- 甲向右让,乙也向右让 → 还是堵着
- 不停重复,但永远过不去!
// 活锁场景:两个线程都想“礼貌”地避免冲突
void polite_increment() {
int expected = counter.load();
while (!counter.compare_exchange_weak(expected, expected + 1)) {
// 失败了就“礼貌”地让一下
std::this_thread::yield();
expected = counter.load();
// 但其他线程也在做同样的事...无限循环!
}
}
特点:线程都在“工作”,但没有任何实质进展。
护航效应
高速公路比喻(单车道):
- 正常情况:
快车 1 快车 2 快车 3 (都很快)
- 护航效应:
慢车 A 快车 1 快车 2 快车 3 (慢卡车拖累所有车)
锁的护航效应:
// 线程 A 持有锁,但时间片用完被操作系统暂停
{
std::lock_guard<std::mutex> lock(shared_mutex);
// 线程 A 在这里被暂停了!锁还没释放
heavy_computation();
} // 锁要很久才能释放
// 其他线程都在排队等待
Thread B:等待中...
Thread C:等待中...
Thread D:等待中...
问题:一个慢线程(被暂停的)拖累了所有等待的线程,就像慢卡车拖累整个车队。解决这类性能问题,需要对系统层面(如调度器)和运维层面(如监控)都有所了解。
六、无锁编程的解决思路
无锁编程的核心思想:使用原子操作代替锁,避免线程阻塞。
使用 C++11 原子类型
#include <atomic>
class AtomicCounter {
std::atomic<int> count{0}; // 原子类型
public:
void increment() {
count.fetch_add(1); // 原子操作:不需要锁!
}
int get() {
return count.load();
}
};
性能对比
让我们把两种方案放在一起对比:
void complete_benchmark() {
const int NUM_THREADS = 8;
const int OPS_PER_THREAD = 1000000;
// 测试 1:Mutex
{
MutexCounter counter;
auto start = std::chrono::high_resolution_clock::now();
std::vector<std::thread> threads;
for (int i = 0; i < NUM_THREADS; i++) {
threads.emplace_back([&counter, OPS_PER_THREAD]() {
for (int j = 0; j < OPS_PER_THREAD; j++)
counter.increment();
});
}
for (auto& t : threads) t.join();
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Mutex: " << ms << " ms\n";
}
// 测试 2:Atomic
{
AtomicCounter counter;
auto start = std::chrono::high_resolution_clock::now();
std::vector<std::thread> threads;
for (int i = 0; i < NUM_THREADS; i++) {
threads.emplace_back([&counter, OPS_PER_THREAD]() {
for (int j = 0; j < OPS_PER_THREAD; j++)
counter.increment();
});
}
for (auto& t : threads) t.join();
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Atomic: " << ms << " ms\n";
}
}
典型结果(8 核 CPU):
Mutex: 608 ms
Atomic: 144 ms
4 倍性能提升!
七、无锁编程的分类
无锁编程有三个级别,从弱到强:
1. Obstruction-Free(无阻塞)
通俗理解:“单打独斗时能赢,打群架可能一直输”
- 保证:如果只有你一个人跑(没有其他线程干扰),一定能完成
- 问题:有多人竞争时,可能永远完不成
2. Lock-Free(无锁)⭐ 最常用
通俗理解:“保证有人能赢,但不保证每个人都能赢”
- 保证:整个系统一定有进展(至少一个线程能完成)
- 问题:个别线程可能“饿死”(一直抢不到机会)
- 实际应用:我们讲的大部分都是 Lock-Free 级别,如无锁栈、无锁队列、无锁哈希表。
3. Wait-Free(无等待)最强但最难
通俗理解:“保证每个人都能赢”
- 保证:每个线程都能在有限步骤内完成(公平性最强!)
- 代价:实现极其复杂,性能不一定最好
三者关系图
包含关系(集合论):
┌─────────────────────────────────┐
│ Obstruction-Free (最弱保证) │
│ ┌──────────────────────────┐ │
│ │ Lock-Free (中等保证) │ │
│ │ ┌───────────────────┐ │ │
│ │ │ Wait-Free (最强) │ │ │
│ │ └───────────────────┘ │ │
│ └──────────────────────────┘ │
└─────────────────────────────────┘
- 难度对比:
Wait-Free >>> Lock-Free >> Obstruction-Free
- 实用性对比:Lock-Free ← 我们主要学这个
小结
- 活锁:都在动,但没进展(太客气了)
- 护航效应:一个慢线程拖累所有人(单车道堵车)
- 无锁分类:
- Obstruction-Free:单打独斗能赢
- Lock-Free:保证有人赢 ← 重点掌握
- Wait-Free:保证每人都赢(太难了)
八、无锁编程的典型应用场景
✅ 适合使用无锁的场景
- 高性能计数器/统计:网站访问量统计、消息队列的消息计数。原因:操作简单,竞争激烈。
- 生产者-消费者队列:日志系统、任务调度器。原因:高吞吐量需求。
- 高频交易系统:股票交易撮合引擎。原因:延迟敏感,不能容忍锁的不确定性。
- 游戏服务器:玩家位置同步、技能冷却管理。原因:实时性要求高。
- 内存分配器:jemalloc、tcmalloc。原因:被频繁调用,必须极致优化。
❌ 不适合使用无锁的场景
- 操作复杂,涉及多个数据结构:复杂的事务系统。原因:无锁实现会极其复杂。
- 低频操作:配置文件每小时读取一次。原因:锁的开销可以忽略不计。
- 已有成熟的有锁方案且性能足够:原因:过早优化是万恶之源。
九、课程小结与展望
本节课你学到了:
- 数据竞争:多线程同时修改共享数据导致的问题。
- 互斥锁的代价:有竞争时可能慢 100 倍甚至更多。
- 锁的四大问题:锁竞争(浪费 CPU)、优先级反转、死锁、活锁/护航效应(系统吞吐量崩溃)。
- 无锁编程:用原子操作代替锁,可获得数倍性能提升,是优化算法和数据结构性能的重要手段。
- 三种保证级别:Obstruction-Free < Lock-Free < Wait-Free。
下节课预告
我们将深入学习 CPU 缓存架构与伪共享问题:
- 为什么有时候原子操作也很慢?
- 什么是 cache line?
- 如何避免伪共享带来的 50%-200% 性能损失?
课后练习
编译运行本节的 benchmark 代码,在你的机器上测试 mutex vs atomic 的性能差异。
记住:无锁编程不是银弹,但在正确的场景下,它能带来巨大的性能提升。最重要的是理解何时使用锁、何时使用无锁,这需要对底层原理有深入的理解。