文档目录

欢迎进入第三阶段:用户态并发!这是区分"普通程序员"和"底层系统工程师"的分水岭。你现在的目标很明确:彻底摆脱内核态的重量级锁,在用户态用自旋逻辑实现极速并发。

我们先解剖悲观锁 vs 乐观锁的硬件成本和软件代价,让你亲眼看到一次Mutex::lock()背后有多痛。


1. 悲观锁(Mutex):内核态的"断崖式"切换

执行流程(完整链路)

[ 线程A尝试获取锁 ]

1. 用户态执行 mutex.lock()
   ↓
2. 尝试原子CAS抢锁 (快速路径)
   ├─ 成功 → 进入临界区 (约 20-50 ns)
   └─ 失败 → 进入慢速路径
       ↓
3. 系统调用 (syscall) → 陷入内核态 (约 50-100 ns)
   ↓
4. 内核将线程A放入等待队列,状态改为 TASK_UNINTERRUPTIBLE
   ↓
5. 触发调度器 (Schedule) → 保存当前线程上下文 (寄存器、PC、栈指针)
   ↓ (约 100-200 ns)
6. 内核选择下一个线程B执行
   ↓
7. 恢复线程B的上下文 → 切换CR3寄存器 (刷新TLB)
   ↓ (约 200-500 ns)
8. 返回用户态执行线程B
   ↓
总耗时: 约 1-5 微秒 (μs) 
如果锁持有者执行时间短 (如 1μs),内核切换开销占比 > 80%!

上下文切换到底在"切"什么?

; 内核保存现场 (简化版)
push rax, rbx, rcx, rdx, rsi, rdi, rbp, r8-r15  ; 保存通用寄存器 (16个×8字节)
push rip, rsp, rflags                             ; 保存指令指针、栈指针、状态寄存器
mov cr3, [new_thread_page_table]                 ; 切换页表 → 清空TLB (性能杀手!)
; 恢复新线程现场
pop ...

关键代价:

  • TLB全部失效:切换CR3寄存器会冲刷所有TLB条目,导致后续内存访问大量Cache Miss。
  • Cache污染:新线程的数据挤占L1/L2缓存,旧线程的热数据被驱逐。
  • 系统调用开销:用户态↔内核态切换需要切换特权级(Ring 3 → Ring 0)。

实测:Mutex vs 原子自旋

#include <mutex>
#include <atomic>
#include <thread>
#include <chrono>
#include <iostream>

const int ITERATIONS = 1000000;
std::mutex mtx;
std::atomic<int> atomic_counter{0};
int shared_data = 0;

// 测试 Mutex
void mutex_worker() {
    for (int i = 0; i < ITERATIONS; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        shared_data++; // 临界区极短
    }
}

// 测试原子自旋 (乐观锁)
void atomic_worker() {
    int expected, desired;
    for (int i = 0; i < ITERATIONS; ++i) {
        do {
            expected = atomic_counter.load(std::memory_order_relaxed);
            desired = expected + 1;
        } while (!atomic_counter.compare_exchange_weak(expected, desired,
                 std::memory_order_release, std::memory_order_relaxed));
        // 临界区:没有额外开销
    }
}

// 运行时间对比 (4线程)
// Mutex:   ~450 ms  (大量内核切换)
// Atomic:  ~80 ms   (纯用户态CAS循环)
// 性能差距: 5.6倍!

2. 乐观锁(CAS自旋):用户态的"轻功"

核心逻辑:永不放弃,直到成功

// 自旋锁 (SpinLock) 的极简实现
class SpinLock {
    std::atomic<bool> flag{false};
public:
    void lock() {
        // 1. 乐观尝试:期望false,设为true
        // 2. 如果失败,立即重试 (CPU pause指令优化)
        while (flag.exchange(true, std::memory_order_acquire)) {
            // 等待期间:CPU执行PAUSE指令 (让出流水线,降低功耗)
            asm volatile("pause" ::: "memory");
        }
    }
    void unlock() {
        flag.store(false, std::memory_order_release);
    }
};

为什么自旋比Mutex快?

维度 Mutex (悲观锁) SpinLock (乐观锁)
状态切换 用户态 ↔ 内核态切换 全程用户态
线程调度 阻塞 → 挂起 → 唤醒 永不挂起 (一直占用CPU)
上下文切换 保存/恢复大量寄存器、TLB刷新 无
等待时间 微秒级 (1-10μs) 纳秒级 (每次CAS 20-50ns)
适用场景 临界区长 (毫秒级) 临界区极短 (纳秒级)
CPU占用 等待时释放CPU (给其他线程) 等待时忙等 (占用CPU)

自旋的致命弱点:忙等(Busy-Waiting)

[ 线程A持有锁 ]           [ 线程B自旋等待 ]
                           while(flag) { 
                               pause; // 连续执行CAS,占用CPU流水线
                           }
                           ↑
                           核心B的L1缓存一直在读flag地址
                           → 导致缓存行不断在核心A和B间"乒乓"
                           → 功耗飙升 (CPU温度 +10°C!)

解决方案:自适应自旋(Adaptive Spinning)

  • Linux内核的 mutex 实现:先自旋几次(如5次),失败后再挂起。
  • 用户态最佳实践:自旋阈值 (Spin Count)。
class AdaptiveSpinLock {
    std::atomic<bool> flag{false};
    const int SPIN_LIMIT = 1000; // 自旋上限
public:
    void lock() {
        int spin_count = 0;
        while (flag.exchange(true, std::memory_order_acquire)) {
            if (++spin_count < SPIN_LIMIT) {
                asm volatile("pause" ::: "memory");
            } else {
                // 自旋太久,让出CPU (yield)
                std::this_thread::yield(); // 触发上下文切换
                spin_count = 0;
            }
        }
    }
};

3. 硬核实验:直观感受"上下文切换"的代价

实验1:纯用户态自旋 vs 系统调用yield

#include <thread>
#include <chrono>
#include <iostream>

void spin_wait() {
    volatile int x = 0;
    for (int i = 0; i < 100000000; ++i) {
        x++; // 纯CPU计算,无系统调用
    }
}

void yield_wait() {
    volatile int x = 0;
    for (int i = 0; i < 100000000; ++i) {
        x++;
        if (i % 1000 == 0) std::this_thread::yield(); // 每1000次让出CPU
    }
}

// 运行时间:
// spin_wait:  80 ms  (一直占CPU)
// yield_wait: 320 ms (频繁上下文切换,慢4倍!)

实验2:测量一次Mutex锁开销

#include <mutex>
#include <chrono>
#include <iostream>

std::mutex mtx;
void measure_mutex_cost() {
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000000; ++i) {
        mtx.lock();
        mtx.unlock(); // 立即解锁(无竞争)
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count();
    std::cout << "Avg mutex lock/unlock: " << duration / 1000000 << " ns" << std::endl;
    // 典型结果: 50-100 ns (快速路径,无竞争)
    // 如果有竞争: 飙升到 1000-5000 ns (进入内核态)
}

4. 核心决策树:什么时候用自旋锁?

临界区执行时间 (T_critical)
    ↓
    ├─ T_critical < 1 μs → 用 SpinLock (纯用户态CAS)
    ├─ 1 μs < T_critical < 100 μs → 用 AdaptiveSpinLock (自旋+退避)
    └─ T_critical > 100 μs → 用 Mutex (避免浪费CPU)

黄金法则:

  • 读多写少的数据结构(如哈希表查找):用读写自旋锁(std::shared_mutex + 自旋实现)。
  • 极高竞争的场景:用队列+无锁(后面几课会讲),而不是自旋(会严重性能崩塌)。

5. 用户态自旋的终极优化:std::atomic + pause

x86的 pause 指令(硬件优化)

; 自旋循环中
spin_loop:
    lock cmpxchg [flag], rcx  ; 尝试获取锁
    jnz spin_loop              ; 失败则跳转
    
    ; 问题:连续执行lock指令会耗尽流水线
    ; 优化:插入pause
spin_loop_with_pause:
    pause                       ; 告诉CPU:我在自旋,可以降低功耗
    lock cmpxchg [flag], rcx
    jnz spin_loop_with_pause

pause 的作用:

  1. 降低功耗:让CPU退出高速执行模式。
  2. 提高超线程性能:让同一核心的另一个线程获得更多资源。
  3. 避免内存顺序冲突:减少Store Buffer压力。

C++封装最优自旋锁

#include <atomic>
#include <thread>

class OptimizedSpinLock {
    std::atomic<int> flag{0};
public:
    void lock() {
        // 先用宽松读快速检查,避免触发RFO
        if (flag.load(std::memory_order_relaxed) == 0) {
            // 再尝试acquire交换
            if (!flag.exchange(1, std::memory_order_acquire)) {
                return; // 快速路径成功
            }
        }
        // 慢速路径:自旋 + pause
        while (flag.exchange(1, std::memory_order_acquire)) {
            for (int i = 0; i < 100; ++i) {
                asm volatile("pause" ::: "memory");
                // 每次pause后重新检查 (避免一直发RFO)
                if (flag.load(std::memory_order_relaxed) == 0) {
                    // 可能已释放,跳出内部循环重新CAS
                    break;
                }
            }
        }
    }
    void unlock() {
        flag.store(0, std::memory_order_release);
    }
};

6. 硬件层面的"终极透视":CAS vs Mutex的缓存行行为

操作 缓存行状态变化 总线流量
Mutex锁定 (无竞争) L1命中 → 快速路径 无
Mutex锁定 (有竞争) 线程挂起 → 唤醒 → 重新加载缓存行 大量RFO + 上下文切换
CAS自旋 (低竞争) L1缓存行在E/S状态摇摆 少量总线消息
CAS自旋 (高竞争) 缓存行在核心间乒乓 极高RFO流量 (性能崩塌)

7. 给你的"并发编程心法"

  1. 默认用Mutex? 如果你写业务代码,可以。但你是底层开发者,请默认用自旋锁,除非临界区真的很长。
  2. 自旋不是万能:CPU核心数 > 8 且竞争激烈时,自旋会导致缓存颠簸和功耗飙升。这时考虑用无锁队列(生产者-消费者分离)。
  3. 永远测量:不要靠猜测!用 perf 观测 cache-misses 和 context-switches。
### 测量上下文切换频率
perf stat -e context-switches,cs ./your_program

### 测量自旋锁的缓存未命中
perf stat -e cache-misses,L1-dcache-load-misses ./your_spinlock