各位技术同仁,下午好!
今天,我们将深入探讨 C++ 并发编程中的一个极小、却极为强大的原语:std::atomic_flag。我们将围绕它,构建极致轻量级的“自旋锁”(Spinlock),并剖析其内部机制、性能特点以及在实际应用中的考量。作为一名编程专家,我希望通过这次讲座,不仅让大家理解 std::atomic_flag 的用法,更能掌握其背后的并发哲学和性能优化策略。
一、并发编程的挑战与轻量级同步的需求
在现代多核处理器架构下,并发编程已成为构建高性能系统的基石。然而,共享资源的访问冲突(即数据竞争)是并发编程永恒的痛点。为了避免数据竞争,我们必须引入同步机制。
C++ 标准库提供了多种同步原语,例如 std::mutex、std::shared_mutex、std::condition_variable 等。它们功能强大,能够处理复杂的并发场景。然而,这些高级同步机制通常依赖于操作系统内核,其工作原理往往涉及:
- 系统调用(System Call):从用户态切换到内核态,再从内核态切换回用户态,这一过程本身就存在开销。
- 上下文切换(Context Switch):当一个线程无法获取锁时,它会被操作系统调度器挂起,让出 CPU 给其他线程。这需要保存当前线程的状态,加载新线程的状态,开销显著。
- 调度开销:操作系统需要维护等待队列,并决定哪个线程下次获得 CPU。
这些开销在锁竞争激烈、临界区(critical section)非常短的场景下,可能会变得不可接受。举例来说,如果临界区只包含一两次原子操作,那么获取和释放一个 std::mutex 的开销可能远大于执行临界区代码本身的开销。
为了应对这种极端场景,我们引入了“自旋锁”(Spinlock)。自旋锁的哲学是:当一个线程尝试获取锁但发现它已被占用时,它不会立即挂起,而是会在一个紧凑的循环中“忙等待”(busy-wait),反复检查锁是否可用。它“自旋”在那里,直到锁被释放。
自旋锁的优势在于:
- 极低延迟:不涉及内核调用和上下文切换,一旦锁被释放,等待线程几乎可以立即获取。
- 适用于短临界区:如果预期锁的持有时间非常短,自旋等待比上下文切换更高效。
自旋锁的劣势在于:
- 浪费 CPU 周期:在等待期间,线程会持续占用 CPU,执行空循环,消耗电力。
- 不适用于长临界区或高竞争:如果锁持有时间较长或竞争激烈,自旋锁会导致大量 CPU 资源被浪费,甚至可能降低系统整体性能。
- 单核系统下的问题:在单核系统中,自旋锁是灾难性的,因为它会阻止持有锁的线程运行并释放锁,导致死锁。
理解了自旋锁的定位,我们就可以引入 C++ 中实现极致轻量级自旋锁的核心原语:std::atomic_flag。
二、揭秘 std::atomic_flag:最简单的原子类型
std::atomic_flag 是 C++11 引入的最简单的原子类型。它本质上是一个布尔标志,但其操作是原子性的,这意味着它们不会被其他线程的操作中断,从而避免数据竞争。
与 std::atomic<bool> 相比,std::atomic_flag 提供了更少的功能,但通常保证是无锁(lock-free)的。这意味着它的操作可以直接映射到 CPU 提供的原子指令(如 XCHG, CMPXCHG 等),而不需要任何内部锁机制。这使其成为构建高性能、低延迟同步原语的理想选择。
2.1 std::atomic_flag 的核心操作
std::atomic_flag 仅提供两个核心成员函数:
-
test_and_set(std::memory_order order = std::memory_order_seq_cst)- 功能:原子地将
std::atomic_flag设置为true,并返回它之前的值。 - 返回值:如果
atomic_flag在调用前是true,则返回true;如果调用前是false,则返回false。 - 用途:这是实现自旋锁的关键。通过检查返回值,我们可以判断是否成功获取了锁。
- 功能:原子地将
-
clear(std::memory_order order = std::memory_order_seq_cst)- 功能:原子地将
std::atomic_flag设置为false。 - 用途:用于释放锁。
- 功能:原子地将
2.2 std::atomic_flag 的初始化
std::atomic_flag 有一个特殊之处在于其初始化:它不像其他原子类型那样可以直接通过构造函数进行初始化。
- 编译期初始化:必须使用宏
ATOMIC_FLAG_INIT来进行编译期初始化,将其置为false。std::atomic_flag my_flag = ATOMIC_FLAG_INIT; // 初始化为 false - 运行时初始化:如果通过默认构造函数创建
std::atomic_flag,其初始状态是未指定的。因此,在首次使用之前,必须调用clear()方法来将其明确设置为false。std::atomic_flag my_flag; // 初始状态未指定 my_flag.clear(); // 明确设置为 false,以便后续使用为了安全起见,通常推荐使用
ATOMIC_FLAG_INIT或在构造函数中立即调用clear()。
2.3 std::atomic_flag 与 std::atomic<bool> 的区别
| 特性 | std::atomic_flag |
std::atomic<bool> |
|---|---|---|
| 保证无锁 | 是,标准保证其操作是无锁的。 | 否,标准不保证其操作是无锁的,可以通过 is_lock_free() 检查。 |
| 操作集合 | 仅 test_and_set() 和 clear()。 |
load(), store(), exchange(), compare_exchange_weak(), compare_exchange_strong() 等更多操作。 |
| 初始化 | 必须使用 ATOMIC_FLAG_INIT 或在首次使用前 clear()。 |
可以直接通过构造函数初始化。 |
| 语义 | 旨在作为基本的互斥锁构建块。 | 更通用的原子布尔值,可以用于各种原子状态管理。 |
| 性能 | 通常是所有原子类型中最快的,因为其操作直接映射到最简单的 CPU 指令。 | 可能会比 atomic_flag 慢,如果不是无锁的,则可能涉及内部锁。 |
正是 std::atomic_flag 的极简设计和无锁保证,使其成为构建高性能自旋锁的理想基石。
三、构建基础自旋锁:std::atomic_flag 的实践
现在,我们利用 std::atomic_flag 的 test_and_set 和 clear 操作来构建一个基础的自旋锁。
3.1 自旋锁的核心逻辑
自旋锁的逻辑非常直观:
- 获取锁(Lock):在一个循环中不断调用
test_and_set()。如果test_and_set()返回true(表示锁已经被占用),则继续循环;如果返回false(表示成功将锁从false设置为true,即成功获取锁),则退出循环,进入临界区。 - 释放锁(Unlock):调用
clear()将atomic_flag设置为false,从而释放锁。
3.2 内存顺序(Memory Order)的理解
在并发编程中,仅仅保证操作的原子性是不够的,我们还需要保证内存操作的可见性和顺序性,这就是内存顺序(Memory Order)的作用。std::atomic_flag 的 test_and_set 和 clear 方法都接受一个 std::memory_order 参数。
-
std::memory_order_seq_cst(Sequentially Consistent):这是默认且最强的内存顺序。它保证所有seq_cst操作在所有线程中都以相同的总顺序出现,并且它本身具有获取-释放语义。这是最安全的选项,但在某些情况下可能不是最优的。 -
std::memory_order_acquire(Acquire Ordering):用于读操作或获取锁的操作。它确保当前线程中,在该操作之后的所有内存访问,都不能被重排到该操作之前。同时,它保证能看到所有在前一个释放操作(std::memory_order_release或std::memory_order_seq_cst)之前发生的内存写入。简而言之,它获取了其他线程的内存可见性。 -
std::memory_order_release(Release Ordering):用于写操作或释放锁的操作。它确保当前线程中,在该操作之前的所有内存访问,都不能被重排到该操作之后。同时,它保证所有在该操作之前发生的内存写入,都能被后续的获取操作(std::memory_order_acquire或std::memory_order_seq_cst)看到。简而言之,它释放了内存可见性。
为什么自旋锁需要 acquire 和 release?
考虑一个共享变量 data,以及一个自旋锁 lock:
// 线程 A:
lock.lock(); // acquire
data = 42;
lock.unlock(); // release
// 线程 B:
lock.lock(); // acquire
int x = data;
lock.unlock(); // release
- 当线程 A 调用
lock.unlock(std::memory_order_release)时,它确保data = 42这个操作在内存中对其他线程是可见的,并且在释放锁这个操作之前完成。 - 当线程 B 调用
lock.lock(std::memory_order_acquire)成功时,它确保能够看到所有在线程 A 释放锁之前发生的内存写入,包括data = 42。
如果没有正确的内存顺序,编译器或处理器可能会对内存操作进行重排,导致线程 B 在获取锁后,仍然看到旧的 data 值,从而引发数据不一致。
因此,对于自旋锁,test_and_set 应该使用 std::memory_order_acquire,而 clear 应该使用 std::memory_order_release,这样可以确保临界区内的所有操作的可见性。
3.3 基础自旋锁实现
我们将自旋锁封装在一个类中,并提供 lock() 和 unlock() 方法。
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
// Spinlock 类的基本实现
class BasicSpinlock {
private:
std::atomic_flag flag = ATOMIC_FLAG_INIT; // 初始化为 false
public:
void lock() {
// test_and_set 会原子地设置 flag 为 true,并返回其旧值。
// 如果旧值为 true,说明锁已被占用,则继续自旋。
// 如果旧值为 false,说明成功获取锁,退出循环。
// std::memory_order_acquire 确保所有在锁获取后访问的内存操作,
// 都不会被重排到锁获取操作之前,并能看到之前所有 release 操作同步的内存写入。
while (flag.test_and_set(std::memory_order_acquire)) {
// 忙等待,不做任何事情
}
}
void unlock() {
// std::memory_order_release 确保所有在锁释放前访问的内存操作,
// 都不会被重排到锁释放操作之后,并使其对后续的 acquire 操作可见。
flag.clear(std::memory_order_release);
}
};
// 示例:使用 BasicSpinlock 保护共享计数器
BasicSpinlock spinlock;
long long shared_counter = 0;
void increment_counter_basic(int iterations) {
for (int i = 0; i < iterations; ++i) {
spinlock.lock();
shared_counter++;
spinlock.unlock();
}
}
int main() {
const int num_threads = 4;
const int iterations_per_thread = 1000000;
std::vector<std::thread> threads;
std::cout << "Starting basic spinlock test with " << num_threads << " threads and "
<< iterations_per_thread << " iterations per thread." << std::endl;
auto start_time = std::chrono::high_resolution_clock::now();
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(increment_counter_basic, iterations_per_thread);
}
for (std::thread& t : threads) {
t.join();
}
auto end_time = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> duration = end_time - start_time;
std::cout << "Final shared_counter value: " << shared_counter << std::endl;
std::cout << "Expected shared_counter value: " << (long long)num_threads * iterations_per_thread << std::endl;
std::cout << "Time taken: " << duration.count() << " seconds." << std::endl;
return 0;
}
运行上述代码,你会发现 shared_counter 的最终值是正确的,这证明了自旋锁的有效性。然而,这个基础的自旋锁在实际高竞争环境下,性能可能会迅速下降。
四、自旋锁的优化:性能与公平性考量
基础自旋锁的“忙等待”是一个纯粹的 CPU 循环,它会不断地读取 atomic_flag 的状态。在高竞争环境下,这会导致几个问题:
- CPU 浪费:线程持续占用 CPU 核心,但实际上没有执行任何有效计算。
- 缓存一致性协议开销:多个核心频繁地尝试写入同一个缓存行(
atomic_flag所在的内存),会触发大量的缓存一致性协议消息(如 MESI 协议中的 RFO – Request For Ownership),导致总线流量增加,进而降低性能。 - 优先级反转:如果一个高优先级的线程在自旋等待一个低优先级线程持有的锁,而低优先级线程因为某些原因(如被其他线程抢占)无法及时释放锁,高优先级线程就会持续自旋,浪费 CPU,导致整个系统性能下降。
为了缓解这些问题,我们可以对自旋锁进行优化。
4.1 引入 std::this_thread::yield()
std::this_thread::yield() 提示操作系统调度器,当前线程愿意放弃其当前的时间片,允许其他线程运行。这可以稍微减轻忙等待的 CPU 浪费,让出 CPU 给其他可能需要执行的线程(包括可能持有锁并需要释放它的线程)。
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
#include <chrono>
class YieldSpinlock {
private:
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {
std::this_thread::yield(); // 提示调度器,当前线程可以被替换
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
};
// ... (main 函数和 increment_counter_yield 与上面类似,只需替换 Spinlock 类型)
// 示例:使用 YieldSpinlock 保护共享计数器
YieldSpinlock yield_spinlock;
long long shared_counter_yield = 0;
void increment_counter_yield(int iterations) {
for (int i = 0; i < iterations; ++i) {
yield_spinlock.lock();
shared_counter_yield++;
yield_spinlock.unlock();
}
}
int main_yield() {
const int num_threads = 4;
const int iterations_per_thread = 1000000;
std::vector<std::thread> threads;
std::cout << "Starting yield spinlock test with " << num_threads << " threads and "
<< iterations_per_thread << " iterations per thread." << std::endl;
auto start_time = std::chrono::high_resolution_clock::now();
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(increment_counter_yield, iterations_per_thread);
}
for (std::thread& t : threads) {
t.join();
}
auto end_time = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> duration = end_time - start_time;
std::cout << "Final shared_counter_yield value: " << shared_counter_yield << std::endl;
std::cout << "Expected shared_counter_yield value: " << (long long)num_threads * iterations_per_thread << std::endl;
std::cout << "Time taken: " << duration.count() << " seconds." << std::endl;
return 0;
}
yield() 是一种合作式多任务处理的提示,操作系统不一定会立即切换线程,具体行为取决于调度器的实现和当前系统的负载。
4.2 使用处理器指令:_mm_pause() (x86/x64)
对于 Intel 和 AMD 处理器,_mm_pause() 是一个 x86/x64 架构特有的内部函数(intrinsic)。它是一个专门为自旋锁设计的“提示”指令。
当 CPU 执行 pause 指令时,它会:
- 降低功耗:告诉处理器当前处于自旋循环中,可以进入低功耗状态。
- 改善超线程(Hyper-Threading)性能:在超线程处理器上,
pause指令可以避免处理器核心在自旋时过多地消耗资源,从而让另一个逻辑处理器更好地执行其任务。 - 减少内存顺序推测失败:在自旋循环中,处理器可能会尝试预测内存访问模式。
pause指令可以减少这种推测的激进程度,从而减少推测失败带来的惩罚。 - 降低缓存一致性流量:它引入一个短时间的延迟,减少了处理器频繁尝试重新获取锁的缓存行所有权,从而降低了总线上的缓存一致性流量。
_mm_pause() 包含在 <intrin.h> (MSVC) 或 <xmmintrin.h> (GCC/Clang) 中。
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
#include <chrono>
#if defined(__GNUC__) || defined(__clang__)
#include <xmmintrin.h> // For _mm_pause
#elif defined(_MSC_VER)
#include <intrin.h> // For _mm_pause
#endif
class PauseSpinlock {
private:
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {
// 在自旋时调用 _mm_pause()
// 这是一个对CPU的提示,可以降低功耗,改善超线程性能,减少缓存一致性流量
_mm_pause();
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
};
// ... (main 函数和 increment_counter_pause 与上面类似,只需替换 Spinlock 类型)
// 示例:使用 PauseSpinlock 保护共享计数器
PauseSpinlock pause_spinlock;
long long shared_counter_pause = 0;
void increment_counter_pause(int iterations) {
for (int i = 0; i < iterations; ++i) {
pause_spinlock.lock();
shared_counter_pause++;
pause_spinlock.unlock();
}
}
int main_pause() {
const int num_threads = 4;
const int iterations_per_thread = 1000000;
std::vector<std::thread> threads;
std::cout << "Starting pause spinlock test with " << num_threads << " threads and "
<< iterations_per_thread << " iterations per thread." << std::endl;
auto start_time = std::chrono::high_resolution_clock::now();
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(increment_counter_pause, iterations_per_thread);
}
for (std::thread& t : threads) {
t.join();
}
auto end_time = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> duration = end_time - start_time;
std::cout << "Final shared_counter_pause value: " << shared_counter_pause << std::endl;
std::cout << "Expected shared_counter_pause value: " << (long long)num_threads * iterations_per_thread << std::endl;
std::cout << "Time taken: " << duration.count() << " seconds." << std::endl;
return 0;
}
_mm_pause() 通常比 std::this_thread::yield() 更高效,因为它直接与处理器交互,避免了操作系统调度的开销。它是自旋锁的常用优化。
4.3 引入指数退避(Exponential Backoff)
在高竞争环境下,即使使用了 _mm_pause(),多个线程同时自旋并频繁尝试 test_and_set 仍然会产生大量缓存一致性流量。为了进一步缓解这个问题,我们可以引入指数退避策略。
指数退避的思路是:当一个线程尝试获取锁失败时,它不是立即再次尝试,而是等待一段逐渐增加的时间。随着失败次数的增加,等待时间呈指数级增长。这有助于:
- 减少争用:当多个线程同时竞争锁时,指数退避可以使它们在不同时间点尝试获取锁,从而减少冲突。
- 降低总线流量:减少了对锁变量的频繁访问,从而减少了缓存一致性协议消息。
- 改善公平性:虽然不是严格的公平,但它可以帮助避免某些线程“霸占”锁的情况。
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
#include <chrono>
#include <random> // For random delay in backoff
#if defined(__GNUC__) || defined(__clang__)
#include <xmmintrin.h> // For _mm_pause
#elif defined(_MSC_VER)
#include <intrin.h> // For _mm_pause
#endif
class BackoffSpinlock {
private:
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
int backoff_attempts = 0;
const int max_backoff = 1024; // 最大退避循环次数
while (flag.test_and_set(std::memory_order_acquire)) {
// 随着失败次数增加,增加自旋等待时间
// 这里的退避策略是简单的循环 _mm_pause()
for (int i = 0; i < backoff_attempts; ++i) {
_mm_pause();
}
if (backoff_attempts < max_backoff) {
backoff_attempts = backoff_attempts * 2 + 1; // 指数增长
}
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
};
// ... (main 函数和 increment_counter_backoff 与上面类似,只需替换 Spinlock 类型)
// 示例:使用 BackoffSpinlock 保护共享计数器
BackoffSpinlock backoff_spinlock;
long long shared_counter_backoff = 0;
void increment_counter_backoff(int iterations) {
for (int i = 0; i < iterations; ++i) {
backoff_spinlock.lock();
shared_counter_backoff++;
backoff_spinlock.unlock();
}
}
int main_backoff() {
const int num_threads = 4;
const int iterations_per_thread = 1000000;
std::vector<std::thread> threads;
std::cout << "Starting backoff spinlock test with " << num_threads << " threads and "
<< iterations_per_thread << " iterations per thread." << std::endl;
auto start_time = std::chrono::high_resolution_clock::now();
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(increment_counter_backoff, iterations_per_thread);
}
for (std::thread& t : threads) {
t.join();
}
auto end_time = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> duration = end_time - start_time;
std::cout << "Final shared_counter_backoff value: " << shared_counter_backoff << std::endl;
std::cout << "Expected shared_counter_backoff value: " << (long long)num_threads * iterations_per_thread << std::endl;
std::cout << "Time taken: " << duration.count() << " seconds." << std::endl;
return 0;
}
指数退避中的具体延迟策略可以有多种,例如随机化退避时间,或者在达到一定退避次数后切换到 std::this_thread::yield() 甚至阻塞等待(Hybrid Spinlock)。这里我们使用简单的 _mm_pause() 循环来实现延迟。
五、自旋锁与互斥量:何时选择?
理解了自旋锁的机制和优化,关键在于知道何时使用它,何时使用更传统的互斥量(如 std::mutex)。
| 特性 | 自旋锁 (Spinlock) | 互斥量 (Mutex) |
|---|---|---|
| 实现机制 | 用户态忙等待,通常基于原子操作(如 std::atomic_flag)。 |
内核态阻塞等待,涉及系统调用和上下文切换。 |
| 获取/释放延迟 | 极低,通常仅需几条 CPU 指令。 | 较高,涉及系统调用和可能的上下文切换。 |
| CPU 资源消耗 | 锁竞争时,等待线程持续占用 CPU 核心,执行空循环。 | 锁竞争时,等待线程被挂起,不消耗 CPU 资源。 |
| 临界区长度 | 适用于极短的临界区(微秒级)。 | 适用于任意长度的临界区,包括较长的操作。 |
| 竞争程度 | 适用于低竞争或高频率短时竞争。 | 适用于中高竞争,或预期锁持有时间不确定。 |
| 单核系统 | 不适用,可能导致死锁(自旋线程阻止锁持有者运行)。 | 适用,操作系统可以调度其他线程。 |
| 优先级反转 | 容易发生,高优先级线程可能被低优先级线程阻塞。 | 操作系统调度器通常有机制来缓解(如优先级继承)。 |
| 功耗 | 较高,因为 CPU 核心持续活跃。 | 较低,等待线程进入睡眠状态。 |
经验法则:
-
选择自旋锁:
- 临界区代码非常短(通常在几十到几百个 CPU 指令以内)。
- 预期锁的持有时间极短,线程等待时间远小于上下文切换的开销。
- 系统对延迟有极高的要求,即使是微小的上下文切换开销也无法接受。
- 在多核处理器上使用,且已知线程不会长时间阻塞持有锁。
- 在操作系统内核中(用户态下慎用)。
-
选择互斥量:
- 临界区代码较长,或者涉及 I/O 操作、内存分配等可能导致线程阻塞的操作。
- 锁的竞争程度高,或者无法确定锁的持有时间。
- 在单核系统上。
- 对功耗有要求。
- 作为默认选择,除非有明确的性能瓶颈分析表明互斥量是瓶颈。
混合策略:一些高级的自旋锁实现会结合两种策略:先自旋一小段时间,如果仍未获取到锁,则转为阻塞等待(像互斥量一样),从而兼顾低延迟和资源效率。这超出了 std::atomic_flag 的直接范畴,但值得了解。
六、高级考量与最佳实践
6.1 RAII 封装:SpinlockGuard
如同 std::mutex 常与 std::lock_guard 配合使用一样,为了确保自旋锁总是被正确释放(即使在临界区内发生异常),我们应该为其提供一个 RAII(Resource Acquisition Is Initialization)风格的封装。
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
#include <chrono>
#if defined(__GNUC__) || defined(__clang__)
#include <xmmintrin.h>
#elif defined(_MSC_VER)
#include <intrin.h>
#endif
// 完整的 Spinlock 实现,包含 _mm_pause 和简单的指数退避
class Spinlock {
private:
// 为了防止伪共享,将 atomic_flag 放在独立的缓存行中
// 通常通过对齐和填充实现
// C++17 引入了 hardware_destructive_interference_size
alignas(64) std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
int backoff_attempts = 0;
const int max_backoff_loops = 10; // 控制最大退避循环的次数
while (flag.test_and_set(std::memory_order_acquire)) {
// 自旋前先空转一部分时间,减少对 atomic_flag 的频繁读写
for (int i = 0; i < (1 << std::min(backoff_attempts, max_backoff_loops)); ++i) {
_mm_pause();
}
// 增加退避尝试次数,指数增长
if (backoff_attempts < max_backoff_loops) {
backoff_attempts++;
}
// 考虑在更高竞争下,达到一定退避次数后可以 yield()
// if (backoff_attempts > X) std::this_thread::yield();
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
// 允许移动构造和赋值,但通常不建议移动锁对象
Spinlock(const Spinlock&) = delete;
Spinlock& operator=(const Spinlock&) = delete;
Spinlock() = default;
};
// RAII 锁守卫
class SpinlockGuard {
private:
Spinlock& lock_;
public:
explicit SpinlockGuard(Spinlock& lock) : lock_(lock) {
lock_.lock();
}
// 析构函数中释放锁
~SpinlockGuard() {
lock_.unlock();
}
// 不允许拷贝和移动
SpinlockGuard(const SpinlockGuard&) = delete;
SpinlockGuard& operator=(const SpinlockGuard&) = delete;
};
// 示例:使用 Spinlock 和 SpinlockGuard 保护共享计数器
Spinlock global_spinlock;
long long shared_counter_final = 0;
void increment_counter_final(int iterations) {
for (int i = 0; i < iterations; ++i) {
SpinlockGuard guard(global_spinlock); // RAII 自动加锁
shared_counter_final++;
// 临界区结束,guard 析构时自动解锁
}
}
int main() {
const int num_threads = 8; // 增加线程数以模拟更高竞争
const int iterations_per_thread = 5000000; // 增加迭代次数
std::vector<std::thread> threads;
std::cout << "Starting final spinlock test with " << num_threads << " threads and "
<< iterations_per_thread << " iterations per thread." << std::endl;
auto start_time = std::chrono::high_resolution_clock::now();
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(increment_counter_final, iterations_per_thread);
}
for (std::thread& t : threads) {
t.join();
}
auto end_time = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> duration = end_time - start_time;
std::cout << "Final shared_counter_final value: " << shared_counter_final << std::endl;
std::cout << "Expected shared_counter_final value: " << (long long)num_threads * iterations_per_thread << std::endl;
std::cout << "Time taken: " << duration.count() << " seconds." << std::endl;
return 0;
}
在这个更完整的 Spinlock 实现中,我们:
- 使用了
alignas(64)来尝试将atomic_flag放置在独立的缓存行中,以缓解伪共享(False Sharing)问题。 - 优化了指数退避的逻辑,使其在自旋循环中进行
_mm_pause()。 - 提供了
SpinlockGuardRAII 封装,确保锁的正确释放。
6.2 伪共享(False Sharing)
当多个处理器核心访问不同的、逻辑上独立的变量,但这些变量恰好位于同一个缓存行中时,就会发生伪共享。由于缓存一致性协议以缓存行为单位进行管理,即使这些变量是独立的,对其中任何一个变量的写入都会导致整个缓存行在不同核心之间来回“弹跳”,从而产生大量缓存一致性流量,严重降低性能。
std::atomic_flag 通常很小(一个字节),它很可能与其他不相关的变量共享同一个缓存行。如果多个线程频繁访问这些不相关的变量,就会导致伪共享。
缓解伪共享的方法:
- 填充(Padding):在
atomic_flag周围添加足够多的无用字节,使其独占一个缓存行。// 假设缓存行大小是 64 字节 struct PaddedSpinlock { alignas(64) std::atomic_flag flag = ATOMIC_FLAG_INIT; // char padding[64 - sizeof(std::atomic_flag)]; // C++17 alignas 更好 };C++17 引入了
std::hardware_destructive_interference_size和std::hardware_constructive_interference_size,可以帮助我们更好地进行对齐和填充。使用alignas(std::hardware_destructive_interference_size)是更现代和推荐的做法。
6.3 死锁(Deadlock)的风险
自旋锁与其他任何锁一样,都面临死锁的风险。当两个或多个线程互相持有对方所需的锁,并都在等待对方释放时,就会发生死锁。使用自旋锁时,由于其忙等待特性,死锁会更加灾难性,因为它会浪费大量 CPU 资源。
- 避免策略:
- 锁顺序:始终以相同的顺序获取多个锁。
- 锁粒度:尽量减小临界区,避免在持有锁时调用可能获取其他锁的函数。
- 避免嵌套锁:除非万不得已,避免在持有锁 A 的情况下尝试获取锁 B。
6.4 std::atomic_flag 的限制与高级原子操作
虽然 std::atomic_flag 极致轻量,但其功能非常有限,只能实现简单的互斥。如果需要更复杂的原子操作,例如:
- 读取当前值而不改变它:
std::atomic<bool>::load() - 根据当前值条件性地更新:
std::atomic<bool>::compare_exchange_weak/strong() - 实现读写锁或无锁数据结构:需要更高级的原子操作原语。
在这种情况下,std::atomic<bool> 或其他 std::atomic<T> 类型将是更好的选择,它们提供了更丰富的功能,尽管不总是保证无锁。
七、总结与展望
通过本次讲座,我们深入探讨了 std::atomic_flag 这个 C++ 中最基础、最强大的原子类型。我们学习了如何利用它来构建极致轻量级的自旋锁,并逐步引入了 std::this_thread::yield()、_mm_pause() 以及指数退避等优化策略,以应对不同程度的竞争和性能需求。
自旋锁是并发编程工具箱中的一把锋利之刃,它在特定场景下能够提供无与伦比的低延迟性能。然而,它并非万能药,其忙等待的特性决定了它必须在临界区极短、竞争程度可控的环境下才能发挥最大效用。深入理解其工作原理、内存模型以及性能权衡,是每一位追求极致性能的 C++ 开发者必备的知识。正确地选择和使用同步原语,是构建高效、稳定并发系统的关键。