无锁队列原理
自旋锁与互斥锁
在我们的日常开发当中,为保证线程安全,会经常使用到锁,其中使用到最多的就是自旋锁与互斥锁,这两种本质上是存在区别的,他们的使用场景通常也是不一样的。
互斥锁
锁作为一个临界资源,他也是需要线程安全的,但是我们申请锁就是为了保证线程安全的问题,那么锁的线程安全由谁来进行保证呢?
对于锁来说,其实是自己在保护自己,其实我们只要保证申请锁的过程是原子的,就能保证锁的线程安全问题。在多线程环境下每个线程都拥有自己的寄存器,申请锁的过程其实就是将把寄存器和内存单元的数据相交换,这个操作保证是原子的,我们就能保证锁的线程安全。
我们来看一段伪代码:
互斥锁(伪代码):
typedef struct {
uint32_t state; // 0:未锁定,1:已锁定
} mutex_t;
void mutex_init(mutex_t *m) {
m->state = 0;
}
void mutex_lock(mutex_t *m) {
while (1) {
uint32_t old = __sync_val_compare_and_swap(&m->state, 0, 1);
if (old == 0) break; // 获取成功
// 通过futex系统调用进入内核态休眠
syscall(SYS_futex, &m->state, FUTEX_WAIT, 1, NULL, NULL, 0);
}
}
void mutex_unlock(mutex_t *m) {
m->state = 0;
// 唤醒等待队列中的一个线程
syscall(SYS_futex, &m->state, FUTEX_WAKE, 1, NULL, NULL, 0);
}
首先我们来看一下 __sync_val_compare_and_swap 这个函数:
type __sync_val_compare_and_swap(type *ptr, type oldval, type newval);
__sync_val_compare_and_swap 是 GCC 提供的一种内置原子操作函数,用于实现多线程环境下的原子比较和交换操作。该函数确保在并发场景中,对共享变量的读写操作是原子的,避免数据竞争问题。
功能说明
- 原子性操作:函数会原子地比较 *ptr 和 oldval。
- 条件交换:如果 *ptr == oldval,则将 *ptr 设置为 newval。
- 返回值:始终返回 *ptr 的原始值,可通过返回值判断是否交换成功
了解了这个函数以后,我们可以理解上面伪代码的这段操作了:
- 互斥锁存在两种状态,0 表示未锁住,1 表示锁住,当互斥锁初始化之初,没有线程去获取到它,他的状态就为 0 ;
- 如果当前线程 A 去获取到这个互斥锁了,此时在 lock 的过程中就像上面这段伪代码所示,会进入到一个循环里面,此时就会去条件交换,m->state 最开始为 0 ,oldval 设置为 0 ,newval 为 1 ,此时发生条件交换,m->state 状态被设置为 1,就表示当前已经被锁住了,注意,这整个状态都是原子的,无法被打断;
- 此时线程 A 被切走,线程 B 来获取这个锁了,当前 m->state 状态为 1 ,oldval 设置为 0 ,newval 为 1 ,注意,m->state 是不等于 oldval 的,m->state 的状态就不会被设置,返回 m->state 初始状态 1,直接进入到 syscall 系统调用接口,通过 futex 系统调用进入内核态休眠,等待被唤醒。
- 线程 A 被切回来,回到刚刚执行的位置,线程 A 的返回值是 0 ,此时就会跳出循环,执行他的操作语句去了,直到 unlock ,此时 m->state 设置为 0 ,通过 futex 机制唤醒等待队列中的一个线程,由于 m->state 已经被设置为 0,此时被唤醒的线程 B 再次进入到循环,就会获取到这个锁,将 m->state 状态被设置为 1,就代表线程 B 持有这个锁,同样道理,其他线程也就拿不到这个锁,循环往复,就保证了锁自己也是安全的,整个操作也是安全的。
上面就是互斥锁的实现原理,他就是通过这种方式保证了线程安全的问题。
自旋锁
自旋锁我们可以理解为一种用户态下忙等待的状态,我们也来看一段伪代码:
自旋锁(伪代码):
typedef struct {
atomic_flag flag; // 原子标志位,用于锁状态
} spinlock_t;
void spinlock_init(spinlock_t *lock) {
atomic_flag_clear(&lock->flag); // 初始化为未锁定状态
}
void spinlock_lock(spinlock_t *lock) {
while (atomic_flag_test_and_set(&lock->flag)) { // 尝试原子地设置标志位
// 忙等待:通过编译器优化或硬件指令(如x86的PAUSE)降低功耗
#ifdef __x86_64__
__asm__ __volatile__("pause");
#endif
}
}
void spinlock_unlock(spinlock_t *lock) {
atomic_flag_clear(&lock->flag); // 原子清除标志位
}
同样的道理,没有线程获取这个锁的时候,他设置的是一个原子标志位,初始化为未锁的状态,存在线程去获取这个锁的时候,就会将当前状态从 false 设置为 true,成功获取到锁,然后实行相应处理的逻辑,其他的线程来获取这个锁是,本身原子标记位为 true ,继续设置为 true ,当前线程就会进入循环,不断去检查所的状态,直到他变为可用为止,自旋锁是不会让线程进行等待,线程会一直去检查当前锁是否为可用的,他是一种无阻塞的操作,同样,也就意味着他只能保护短时间的临界资源,因为它是一直占用 CPU 资源的。
通过上面的对比,我们就可以互斥锁与自旋锁的区别:
- 策略不同:自旋锁是让线程一直维持在忙等待的状态下,维持在用户态,而互斥锁未获取到就会陷入内核态被放入到阻塞队列当中,等待被唤醒;
- 上下文切换:自旋锁是不会进行上下文切换,而互斥锁会从用户态陷入到内核态,就会对上下文进行切换;
- 他们的使用场景是有区别的,自旋锁适合于时间短的锁场景,而对于互斥锁来说,适用于时间比较长的场景。
什么是无锁队列
无锁队列通过原子操作来实现队列的线程安全,是一种非阻塞的队列结构。
我们知道,锁是存在局限性的:
- 线程阻塞,会造成上下文切换,也是一种开销;
- 使用不慎,就会存在死锁的风险;
- 性能有瓶颈,高并发的场景下,锁竞争就会加剧,系统的吞吐量就变低了。
而使用无锁队列,就可以有效的去降低这些问题发生的概率,这就是无锁队列出现的原因。
无锁和无等待的区别
在多线程编程中,“lock-free”(无锁)和"wait-free"(无等待)是-两个相关但不完全相同的概念。
lock-free:系统作为一个整体无论如何都向前移动,不能保证每个线程的前进进度(可能出现线程饿死);通常使用 compare_exchange 原语实现。可以有循环,但类似compare_exchange 实现的自旋锁不行。也就是说,无锁至少有一个线程是成功的,其他的可能会重试。
wait-free:考虑到其他线程争用,阻塞等情况下,每个线程向前移动,每个操作在有限步骤中执行通常使用 exchange、fetch_add 等原语实现,并且不包含可能被其他线程影响的循环。无等待就是所有的线程都必须成功,没有重试的。
生产者消费者队列
生产者-消费者队列是并发系统中最基本的组件之一,根据允许的生产者和消费者线程的数量,可以划分为:
- MPMC:多生产者/多消费者队列;
- SPMC:单生产者/多消费者队列;
- MPSC:多生产者/单消费者队列;
- SPSC:单生产者/单消费者队列。
要实现一个无锁队列,我们就需要考虑上面的这些情况,对于每一种情况,无锁队列的实现最终都会存在一些差异的。
volatile、内存屏障、原子操作之间的关系
volatile
volatile 作为一个经常被使用的关键字,它的作用到底是什么呢?
我们所编写的程序,编译器是会去进行优化的,对于一些变量来说,他会被从内存中拷贝到寄存器中进行备份,当存在多个执行流的时候,肯定会出现从寄存器当中去读取值的情况,如果编译器进行优化,那么也就会存在内存中的值被更新了,但是并没有加载带寄存器当中,此时我们读取在寄存器当中进行读取,读取到的就是一个旧的值,就会存在问题,而 volatile 的作用就是让某一个变量保持在内存中的可见性,我们去读取这个值的时候不会去读取寄存器当中备份的那个值,而是从主存中读取,同样,在多线程环境下,他也会让该变量付其他线程是可见的,也就是将其刷新到主存当中,但要切记一点,它并不能保持变量的原子性。
内存屏障(std::atomic_thread_fence)
内存屏障通过限制处理器和编译器对指令的重排序来保证特定内存操作的顺序性,它分为:
- 编译器屏障:阻止编译器将屏障前后的指令进行重排序;
- 硬件屏障:阻止CPU将屏障前后的内存操作进行重排序。
在原子操作中我们已经介绍内存屏障的一个操作原理,这里不做解释。
对于一个无锁队列来说,我们就需要考虑到上面的三种操作,对于内存序来说,他规定了可见性以及顺序性,volatile 保证了可见性,而原子变量保证了顺序性,三者需要结合进行使用。
伪共享问题
CPU 的缓存是以缓存行(cache line)进行数据加载的,cache line 的大小是在64B,由于计算机的局部性原理,CPU 在进行数据加载的过程中总是会将某个数据相近的数据也加载到 cache line 中,比如现在有 A B 两个变量相近,CPU 就会将 A B 两个变量都加载到一个缓存行中,但是 A 变量只需要线程 1 去操作,B 变量只需要线程 B 去操作,如下图所示:
此时就会出现一个问题,线程 1 对 A 变量进行修改或者是线程 2 对 B 变量进行修改,由于缓存一致性的原理,都会导致该 cache line 的标记位被标记为无效,那么后续其他核心访问该缓存行时就会出现缓存未命中的情况,此时就需要重新去主存中进行加载,在刷新到缓存当中,这种操作很明显无意义,如果出现大量的这样的操作,就会造成性能的损失,这就是伪共享问题。
无锁队列实现
对于无锁队列,我们要考虑的问题就在于任务的耗时与生产者和消费者的数量问题,就需要选择一个合适的数据结构,首先我们来看数组,对于数组来说,特点就在于他可以是固定数量,我们开辟一块空间即可,但是这块空间是我们也不知道需要多大,大了就会存在空间浪费的情况,而且他也是存在边界条件的。
那我们再来看链表,链表结构我们可以随意进行扩容,也简单,但是扩容就意味着性能的开销,平凡的开辟空间,就会造成性能的降低。
总和上述的考虑,我们就可以考虑循环队列的结构,他是基于数组的一个结构,他可以开辟固定大小的空间,循环进行使用,我们只需要定义好头尾两个索引即可,可以看一下之前的一篇文章:基于环形队列的生产者消费者模型,接下来我们来实现一个单生产者单消费者的无锁队列结构。
#ifndef __RING_BUFFER__
#define __RING_BUFFER__
#include <atomic>
#include <cstddef>
#include <type_traits>
template <typename T, std::size_t Capicity>
class RingBuffer
{
static_assert(Capacity && !(Capacity & (Capacity - 1)), "Capacity must be power of 2"); // Capacity必须是2的n次幂
// 构造函数
RingBuffer() : write_(0), read_(0) {}
// 析构函数
~RingBuffer();
bool Push();
bool Pop();
std::size_t Size() const;
private:
alignas(64) std::atomic<std::size_t> write_; // 写指针
alignas(64) std::atomic<std::size_t> read_; // 读指针
alignas(64) std::aligned_storage_t<sizeof(T), alignof(T)> buffer_[Capacity]; // 支持 POD 以及非 POD 类型
};
#endif
当前无锁队列的整体架构如上面代码所示:
- 我们在这儿使用了 aligned_storage_t ,std::aligned_storage是 C++ 标准库
<type_traits>
中提供的模板类,用于创建具有特定大小和对齐要求的未初始化存储空间,仅提供原始内存块,不构造或初始化任何对象。用户需通过 placement new 手动构造对象。使用该函数我们就可以支持 POD 类型又可以支持非 POD 类型,同时也得注意使用 aligned_storage_t 以后要保证大小必须是2的n次幂; - 当前我们必须解决伪共享的问题,就要保证 read_ 和 write_ 不能放同一个缓存行,数组 data_ 也不要和其他数据共享缓存行,所以我们就可以指定使用 64 字节对齐,这样他们就不会处于同一个 cache line 上面了。
Push
// && 在模版中表示万能引用,既能传递左值又能传递右值
template <typename U>
bool Push(U &&value)
{
const std::size_t w = write_.load(std::memory_order_relaxed);
// 使用位运算,效率更高
const std::size_t next_w = (w + 1) & (Capicity - 1);
// 为满,就返回false
if (next_w == read_.load(std::memory_order_acquire))
{
return false;
}
// 定位new表达式,在预先分配的内存上构造对象
new (&buffer_[w]) T(std::forward<U>(value));
// 后移一位
write_.store(next_w, std::memory_order_release);
return true;
}
函数解析:
- 对于 push 操作来说,就是往队列中插入一个对象,然后当前 write_ 后移即可,当然在这儿我们需要去判断队列是否满了,满了就不能再插入对象了,因为我们这儿分配了块儿并未初始化的内存区域,所以我们在这儿需要使用定位 new 表达式来初始化这块内存区域;
- 对于 push 操作,我们要求性能比较高,这儿传进来的对象既可能是左值又可能是右值,我们就可以使用到万能引用 && ,对于万能引用来说,他既可以满足左值的传递,又可以满足右值的传递,但是因为右值在传输过程中属性会退化,所以这儿值在传递的过程中需要使用到 std::forward<> 操作。
- 因为使用的是循环队列结构,主要就是让他循环起来,最初我们可能会想到取模运算,但是在这儿使用位运算更优,效率更高。
Pop
bool Pop(T &value)
{
const std::size_t r = read_.load(std::memory_order_relaxed);
// 为空就没有数据
if (r == write_.load(std::memory_order_acquire))
{
return false;
}
// 类型转换,对内存重新进行解释
value = std::move(*reinterpret_cast<T *>(&buffer_[r]));
reinterpret_cast<T *>(&buffer_[r])->~T();
read_.store((r + 1) & (Capicity - 1), std::memory_order_release);
return true;
}
对于 Pop 数据来说:
- 如果对应的队列中没有数据,我们直接退出就可以,如果有数据,我们就需要获取到一个数据,然后将对应的 read_ 指针向后移动一位即可,在这儿使用了 reinterpret_cast 强制类型转换,对内存重新进行解释。
Size
Size 函数其实很简单,就是为了获取当前无锁队列中数据的个数。
// 获取当前插入了多少数据
std::size_t Size() const
{
const std::size_t w = write_.load(std::memory_order_acquire);
const std::size_t r = read_.load(std::memory_order_acquire);
return (w >= r) ? (w - r) : (Capicity - r + w);
}
整体代码如下:
#ifndef __RING_BUFFER__
#define __RING_BUFFER__
#include <atomic>
#include <cstddef>
#include <type_traits>
template <typename T, std::size_t Capicity>
class RingBuffer
{
static_assert(Capacity && !(Capacity & (Capacity - 1)), "Capacity must be power of 2"); // Capacity必须是2的n次幂
// 构造函数
RingBuffer() : write_(0), read_(0) {}
// 析构函数 释放所有资源
~RingBuffer()
{
const std::size_t w = write_.load(std::memory_order_relaxed);
std::size_t r = read_.load(std::memory_order_relaxed);
while( r != w)
{
reinterpret_cast<T*>(&buffer_[r])->~T();
r = (r + 1) & (Capicity - 1);
}
}
// && 在模版中表示万能引用,既能传递左值又能传递右值
template <typename U>
bool Push(U &&value)
{
const std::size_t w = write_.load(std::memory_order_relaxed);
// 使用位运算,效率更高
const std::size_t next_w = (w + 1) & (Capicity - 1);
// 为满,就返回false
if (next_w == read_.load(std::memory_order_acquire))
{
return false;
}
// 定位new表达式,在预先分配的内存上构造对象
new (&buffer_[w]) T(std::forward<U>(value));
// 后移一位
write_.store(next_w, std::memory_order_release);
return true;
}
bool Pop(T &value)
{
const std::size_t r = read_.load(std::memory_order_relaxed);
// 为空就没有数据
if (r == write_.load(std::memory_order_acquire))
{
return false;
}
// 类型转换,对内存重新进行解释
value = std::move(*reinterpret_cast<T *>(&buffer_[r]));
reinterpret_cast<T *>(&buffer_[r])->~T();
read_.store((r + 1) & (Capicity - 1), std::memory_order_release);
return true;
}
// 获取当前插入了多少数据
std::size_t Size() const
{
const std::size_t w = write_.load(std::memory_order_acquire);
const std::size_t r = read_.load(std::memory_order_acquire);
return (w >= r) ? (w - r) : (Capicity - r + w);
}
private:
alignas(64) std::atomic<std::size_t> write_; // 写指针
alignas(64) std::atomic<std::size_t> read_; // 读指针
alignas(64) std::aligned_storage_t<sizeof(T), alignof(T)> buffer_[Capacity]; // 支持 POD 以及非 POD 类型
};
#endif
对于内存序的一个整体解析
// 析构函数 释放所有资源
~RingBuffer()
{
/*
当前是单生产者单消费者的模式,只要线程完成切换
总是能拿到最新值,无需考虑可见性问题,使用
std::memory_order_relaxed最为合适
*/
const std::size_t w = write_.load(std::memory_order_relaxed);
std::size_t r = read_.load(std::memory_order_relaxed);
while( r != w)
{
reinterpret_cast<T*>(&buffer_[r])->~T();
r = (r + 1) & (Capicity - 1);
}
}
// && 在模版中表示万能引用,既能传递左值又能传递右值
template <typename U>
bool Push(U &&value){
/*
对于当前写操作来说,我们虽然需要保证是最新的
但是我们目前是处于一个单生产者单消费者队列里面
只有一个核心,不存在数据不同步的问题,所以使用
std::memory_order_relaxed 最为合适
*/
const std::size_t w = write_.load(std::memory_order_relaxed);
// 使用位运算,效率更高
const std::size_t next_w = (w + 1) & (Capicity - 1);
// 为满,就返回false
/*
我们当前读取 read ,我们要保证我们的 read 是最新的数据
pop 函数中也会对 read 进行写操作,所以我们不允许前面的操作被优化到后面去
我们所获取的是最新的数据,使用std::memory_order_acquire最为合适,那么pop
中的read 写操作就需要使用std::memory_order_release
*/
if (next_w == read_.load(std::memory_order_acquire)){
return false;
}
// 定位new表达式,在预先分配的内存上构造对象
new (&buffer_[w]) T(std::forward<U>(value));
// 后移一位
/*
跟上面的读操作原因是一样的
*/
write_.store(next_w, std::memory_order_release);
return true;
}
bool Pop(T &value) {
/*
内存序同Push原理
*/
const std::size_t r = read_.load(std::memory_order_relaxed);
// 为空就没有数据
if (r == write_.load(std::memory_order_acquire)){
return false;
}
// 类型转换,对内存重新进行解释
value = std::move(*reinterpret_cast<T *>(&buffer_[r]));
reinterpret_cast<T *>(&buffer_[r])->~T();
read_.store((r + 1) & (Capicity - 1), std::memory_order_release);
return true;
}
// 获取当前插入了多少数据
std::size_t Size() const
{
/*
我们有可能在 push 或 pop 的过程中去获取到这个size
那么我们就不能让获取 size 之前的操作被优化到后面去,这样
就会出现数据不一致的问题,所以使用std::memory_order_acquire
最为合适
*/
const std::size_t w = write_.load(std::memory_order_acquire);
const std::size_t r = read_.load(std::memory_order_acquire);
return (w >= r) ? (w - r) : (Capicity - r + w);
}
基于链表的 mpsc 无锁队列
template<typename T>
class MPSCQueueNonIntrusive
{
public:
MPSCQueueNonIntrusive() : _head(new Node()), _tail(_head.load(std::memory_order_relaxed))
{
Node* front = _head.load(std::memory_order_relaxed);
front->Next.store(nullptr, std::memory_order_relaxed);
}
~MPSCQueueNonIntrusive()
{
T* output;
while (Dequeue(output))
delete output;
Node* front = _head.load(std::memory_order_relaxed);
delete front;
}
void Enqueue(T* input)
{
Node* node = new Node(input);
Node* prevHead = _head.exchange(node, std::memory_order_acq_rel);
prevHead->Next.store(node, std::memory_order_release);
}
bool Dequeue(T*& result)
{
Node* tail = _tail.load(std::memory_order_relaxed);
Node* next = tail->Next.load(std::memory_order_acquire);
if (!next)
return false;
result = next->Data;
_tail.store(next, std::memory_order_release);
delete tail;
return true;
}
private:
struct Node
{
Node() = default;
explicit Node(T* data) : Data(data)
{
Next.store(nullptr, std::memory_order_relaxed);
}
T* Data;
std::atomic<Node*> Next;
};
std::atomic<Node*> _head;
std::atomic<Node*> _tail;
MPSCQueueNonIntrusive(MPSCQueueNonIntrusive const&) = delete;
MPSCQueueNonIntrusive& operator=(MPSCQueueNonIntrusive const&) = delete;
};
针对于 MPSC (多生产者多消费者)的无锁队列是基于链表去进行实现的,我们在这儿就需要去理解对应的原子操作是如何去实现的:
- 每一个 Node 节点中会保存对应的节点当中的数据以及一个 Node* 指针,可以保证指向下一个结点;
- 对于 Enqueue 来说,多个生产者会进行操作,我们就需要保证整个过程的可见性与顺序性,我们不止要将结点插入进去,还要保证其被其他的核心所知道,这样才可以保证线程安全的问题,正如下图所示,我们保证整个过程的原子性,注意 exchange 返回的是原结点。
- 对于 dequeue 来说,单消费者,其实就更加简单了,就像我们上面,原子性就更加容易实现,只要保证有数据结点就可以。