校招C++20并发系列06-手写自旋锁:理解忙等与互斥的本质差异

深入理解 C++ 并发:自旋锁与互斥锁的性能博弈

在并行计算中,序列化对共享资源的访问是核心挑战之一。虽然 std::mutex(互斥锁)是最常见的同步原语,但在特定场景下,自旋锁(Spinlock)能提供更优的性能表现。本期教程将深入剖析两者的本质差异,并通过基准测试对比其实际性能,最后从底层汇编角度揭示自旋锁的实现原理。

互斥锁与自旋锁的核心差异

互斥锁和自旋锁的主要区别在于等待锁释放时的策略不同

当线程尝试获取一个已被占用的锁时,有两种基本处理方式:

休眠唤醒机制:线程进入睡眠状态,让出 CPU 时间片,直到锁被释放后由内核唤醒。这是 std::mutex 的典型行为。

忙等待机制:线程保持活跃,通过循环不断检查锁的状态,直到获取成功。这就是自旋锁的工作原理。

如果预计等待时间极短,使用互斥锁会让线程经历“休眠 -> 上下文切换 -> 唤醒”的昂贵开销。此时,自旋锁通过持续轮询(Busy-waiting)避免这些开销,从而在低延迟场景下表现更佳。然而,若等待时间较长,自旋锁会白白消耗 CPU 周期,导致资源浪费。

易错点:不要盲目认为自旋锁一定比互斥锁快。自旋锁适合“锁持有时间短、竞争不激烈”的场景;若竞争激烈或临界区代码执行时间长,互斥锁通常更优。

实验设计:从 Mutex 到 Spinlock 的迁移

为了直观对比两者性能,我们构建了一个简单的基准测试程序。该程序创建一个包含 个随机整数的列表,并启动 8 个线程并发移除列表末尾的元素。

1. 互斥锁实现 (mutex.cpp)

首先,我们使用标准的 std::mutex 进行保护。代码逻辑如下:

#include <iostream>
#include <list>
#include <mutex>
#include <thread>
#include <vector>

std::list<int> data_list;
std::mutex mtx; // 定义互斥锁

void worker_mutex() {
    while (true) {
        std::lock_guard<std::mutex> lock(mtx); // 自动加锁,作用域结束自动解锁
        
        if (data_list.empty()) {
            break; // 列表为空,退出线程
        }
        
        data_list.pop_back(); // 移除最后一个元素
    }
}

int main() {
    // 初始化数据...
    for (int i = 0; i < (1 << 20); ++i) {
        data_list.push_back(i);
    }

    std::vector<std::jthread> threads;
    for (int i = 0; i < 8; ++i) {
        threads.emplace_back(worker_mutex);
    }
    
    return 0;
}

2. 自旋锁实现 (spinlock.cpp)

接下来,我们将互斥锁替换为 POSIX 线程库中的自旋锁 pthread_spinlock_t。注意,这需要链接 -lpthread 库。

#include <iostream>
#include <list>
#include <pthread.h>
#include <thread>
#include <vector>

std::list<int> data_list;
pthread_spinlock_t spin_lock; // 定义自旋锁

void worker_spinlock() {
    while (true) {
        pthread_spin_lock(&spin_lock); // 手动加锁(忙等待)
        
        if (data_list.empty()) {
            pthread_spin_unlock(&spin_lock); // 必须手动解锁
            break; 
        }
        
        data_list.pop_back();
        pthread_spin_unlock(&spin_lock); // 手动解锁
    }
}

int main() {
    // 初始化数据...
    for (int i = 0; i < (1 << 20); ++i) {
        data_list.push_back(i);
    }

    pthread_spin_init(&spin_lock, PTHREAD_PROCESS_PRIVATE); // 初始化自旋锁

    std::vector<std::jthread> threads;
    for (int i = 0; i < 8; ++i) {
        threads.emplace_back(worker_spinlock);
    }
    
    // 销毁自旋锁
    pthread_spin_destroy(&spin_lock);
    return 0;
}

小结:代码结构几乎完全一致,唯一的区别在于锁的类型及其初始化和加锁/解锁 API。自旋锁需要手动管理生命周期,而 std::lock_guard 提供了 RAII 风格的自动管理。

性能基准测试对比

我们使用相同的编译标志对两个版本进行优化编译,以排除编译器差异带来的干扰。

编译命令:

g++ -O3 -std=c++20 mutex.cpp -o mutex_test -lpthread
g++ -O3 -std=c++20 spinlock.cpp -o spinlock_test -lpthread

测试结果分析:

运行多次基准测试后,观察到的耗时数据如下:

互斥锁版本:每次运行耗时稳定在 0.24s ~ 0.25s 左右。

自旋锁版本:每次运行耗时显著降低,约为 0.12s ~ 0.17s,最快时甚至接近互斥锁的一半。

这一结果验证了自旋锁在短临界区场景下的优势。由于移除单个元素的操作极快,锁的持有时间远小于线程上下文切换的时间。自旋锁避免了内核态与用户态之间的切换以及线程调度开销,从而提升了吞吐量。

关键洞察:性能提升并非来自算法复杂度的降低,而是来自减少同步原语的运行时开销。

底层原理:自旋锁的汇编实现

为了理解自旋锁为何如此轻量,我们可以查看 glibc 中 pthread_spin_lock 的反汇编代码。其核心逻辑非常简洁,主要依赖原子操作和 pause 指令。

  1. 原子尝试获取: 每次调用 lock 函数时,CPU 执行一条原子递减指令(如 dec 配合 lock 前缀)。如果返回值指示锁可用,则直接返回零,表示获取成功。

  2. 忙等待循环: 如果锁不可用,程序跳转到等待逻辑块。这里通常会执行 pause 指令。

pause 的作用是让处理器流水线暂停一小段时间,减少总线争用,并为下一次重试留出缓冲。

随后,线程再次读取锁的状态寄存器(如 RDI),判断是否已解锁。

紧密循环

    ; 伪代码示意
    check_lock:
        atomic_dec_and_test(lock_var)
        jne wait_loop       ; 如果未获取成功,跳转至等待
    
    ; 获取成功,继续执行临界区
    ...
    
    wait_loop:
        pause               ; 提示 CPU 正在忙等待,优化电源管理和流水线
        cmp lock_var, 0     ; 检查锁是否释放
        je check_lock       ; 如果已释放,跳回尝试获取
        jmp wait_loop       ; 否则继续等待

这种实现没有涉及任何系统调用或内核介入,纯粹是在用户态进行的 CPU 指令轮询,因此效率极高。

最佳实践与混合锁策略

尽管自旋锁在特定场景下表现优异,但它并非万能钥匙。

何时使用互斥锁:当临界区代码执行时间较长,或者锁的竞争非常激烈时,自旋锁会导致大量的 CPU 空转,严重拖慢系统整体性能。此时应优先选择 std::mutex

何时使用自旋锁:当临界区极短(如几个原子操作),且预期等待时间极短时,自旋锁能显著降低延迟。

混合锁(Hybrid Lock):现代操作系统和库常采用混合策略。例如,先尝试自旋几次(忙等待),如果仍未获取到锁,则让线程休眠。这种方式结合了自旋锁的低延迟优势和互斥锁的资源友好性。

一般经验法则:如果你不确定该用哪种锁,请从 std::mutex 开始。只有在性能剖析(Profiling)明确指出同步开销成为瓶颈,且确认临界区极短时,再考虑替换为自旋锁。

速查表

特性 std::mutex (互斥锁) pthread_spinlock_t (自旋锁)
等待策略 线程休眠 (Sleep) 忙等待 (Busy-wait / Spin)
CPU 占用 低 (释放时间片) 高 (持续轮询)
适用场景 临界区长、竞争高 临界区极短、竞争低
上下文切换 有 (内核介入) 无 (纯用户态)
API 复杂度 简单 (RAII 支持好) 需手动 init/unlock/destroy
典型开销 较高 (调度 + 唤醒) 极低 (仅指令执行)
全部评论

相关推荐

凌小云:实习生,第一个星期。就是把项目架构搞清楚就行。任务别急。千万不要拿自己的时间去干活。一定要让工作和生活有边界感。不要认为自己拿个人时间干活,他们知道就会觉得你很可以。你作为一个实习生,最应该的就是让他们明白你的能力边界,你拿个人时间干活,只会让他们觉得可以安排更多的任务,自己每天累死累活的,还怕任务完成不了。本身工作都不是很熟悉,千万别展示自己。一定要让自己有余力,去复盘工作,而不是每天都忙着工作里。
实习第一天,你在干什么
点赞 评论 收藏
分享
评论
点赞
1
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务