无锁队列

先贴上一个大佬的blog,讲的比较好:
无锁队列

CAS

⽐较并交换(compare and swap, CAS),是原⼦操作的⼀种,可⽤于在多线程编程中实现不被打断的数据
交换操作,从⽽避免多线程同时改写某⼀数据时由于执⾏顺序不确定性以及中断的不可预知性产⽣的数据
不⼀致问题。 该操作通过将内存中的值与指定数据进⾏⽐较,当数值⼀样时将内存中的数据替换为新的
值。CAS是所有CPU指令都支持CAS的原子操作(X86中CMPXCHG汇编指令),用于实现实现各种无锁(lock free)数据结构。

bool compare_and_swap ( int *memory_location, int expected_value, int new_value)
{
    if (*memory_location == expected_value)
    {
        *memory_location = new_value;
        return true;
    }
    return false;
}

CAS用于检查一个内存位置是否包含预期值,如果包含,则把新值复赋值到内存位置。成功返回true,失败返回false。

对于gcc、g++编译器来讲,它们提供了⼀组API来做原⼦操作:

 type __sync_fetch_and_add (type *ptr, type value, ...)
 type __sync_fetch_and_sub (type *ptr, type value, ...)
 type __sync_fetch_and_or (type *ptr, type value, ...)
 type __sync_fetch_and_and (type *ptr, type value, ...)
 type __sync_fetch_and_xor (type *ptr, type value, ...)
 type __sync_fetch_and_nand (type *ptr, type value, ...)

bool __sync_bool_compare_and_swap (type *ptr, type oldval type ne
wval, ...)
 type __sync_val_compare_and_swap (type *ptr, type oldval type new
val, ...)

 type __sync_lock_test_and_set (type *ptr, type value, ...)
 void __sync_lock_release (type *ptr, ...)

对于C++11:

#include <atomic>
atomic
atomic_ref
atomic_is_lock_free
atomic_storeatomic_store_explic
it
atomic_loadatomic_load_explicit
atomic_exchangeatomic_exchan
ge_explicit
...

X86的架构
Intel X86指令集提供了指令前缀lock⽤于锁定前端串⾏总线FSB,保证了指令执⾏时不会收到其他处理器
的⼲扰。⽐如:

 static int lxx_atomic_add(int *ptr, int increment)
 {
 int old_value = *ptr;
 __asm__ volatile("lock; xadd %0, %1 \n\t"
 : "=r"(old_value), "=m"(*ptr)
 : "0"(increment), "m"(*ptr)
 : "cc", "memory");
 return *ptr;
 }

使⽤lock指令前缀之后,处理期间对count内存的并发访问(Read/Write)被禁⽌,从⽽保证了指令的原
⼦性。

为什么需要无锁队列

锁会引起问题:
1. Cache损坏
在保存和恢复上下⽂的过程中还隐藏了额外的开销:Cache中的数据会失效,因为它缓存的是将被换出任务
的数据,这些数据对于新换进的任务是没⽤的。处理器的运⾏速度⽐主存快N倍,所以⼤量的处理器时间被浪
费在处理器与主存的数据传输上。这就是在处理器和主存之间引⼊Cache的原因。Cache是⼀种速度更快
但容量更⼩的内存(也更加昂贵),当处理器要访问主存中的数据时,这些数据⾸先被拷⻉到Cache中,因为这
些数据在不久的将来可能⼜会被处理器访问。Cache misses对性能有⾮常⼤的影响,因为处理器访问
Cache中的数据将⽐直接访问主存快得多。
线程被频繁抢占产⽣的Cache损坏将导致应⽤程序性能下降。

2. 在同步机制上的争抢队列
阻塞不是微不⾜道的操作。它导致操作系统暂停当前的任务或使其进⼊睡眠状态(等待,不占⽤任何的处理
器)。直到资源(例如互斥锁)可⽤,被阻塞的任务才可以解除阻塞状态(唤醒)。在⼀个负载较重的应⽤程序
中使⽤这样的阻塞队列来在线程之间传递消息会导致严重的争⽤问题。也就是说,任务将⼤量的时间(睡
眠,等待,唤醒)浪费在获得保护队列数据的互斥锁,⽽不是处理队列中的数据上。
⾮阻塞机制⼤展伸⼿的机会到了。任务之间不争抢任何资源,在队列中预定⼀个位置,然后在这个位置上
插⼊或提取数据。这中机制使⽤了⼀种被称之为CAS(⽐较和交换)的特殊操作,这个特殊操作是⼀种特殊的
指令,它可以原⼦的完成以下操作:它需要3个操作数m,A,B,其中m是⼀个内存地址,操作将m指向的
内存中的内容与A⽐较,如果相等则将B写⼊到m指向的内存中并返回true,如果不相等则什么也不做返回
false。

3. 动态内存分配
在多线程系统中,需要仔细的考虑动态内存分配。当⼀个任务从堆中分配内存时,标准的内存分配机制会阻
塞所有与这个任务共享地址空间的其它任务(进程中的所有线程)。这样做的原因是让处理更简单,且它⼯作
得很好。两个线程不会被分配到⼀块相同的地址的内存,因为它们没办法同时执⾏分配请求。显然线程频
繁分配内存会导致应⽤程序性能下降(必须注意,向标准队列或map插⼊数据的时候都会导致堆上的动态内存
分配)

无锁队列的实现

基于ringbuffer的循环数组的实现
特点:

  1. 作为一种无锁同步机制,它显著降低了任务抢占的频率,因此有效减缓了cache颠簸.
  2. 如所有其它无锁队列一样,线程之间的争抢显著降低,因为它不需要锁去保护任何数据结构:线程所要做的就是索要一块空间,然后将数据写进去.
  3. 队列的操作不会导致动态内存分配
  4. 没有ABA问题,只是在数组处理上需要一些额外的开销.

如何实现的?
在这里插入图片描述
队列的实现使用了一个数组和3个作用不同的下标:

  • **writeIndex:**新元素入列时存放位置在数组中的下标
  • **readIndex:**下一个出列元素在数组中的下标
  • **maximumReadIndex:**最后一个已经完成入列操作的元素在数组中的下标.如果它的值跟writeIndex不一致,表明有写请求尚未完成.这意味着,有写请求成功申请了空间但数据还没完全写进队列.所以如果有线程要读取,必须要等到写线程将数完全据写入到队列之后.

CAS操作
此无锁队列基于CAS指令,CAS操作在GCC4.1.1中被包含进来.因为我使用GCC 4.4进行编译,所以我使用了GCC内置的CAS操作__sync_bool_compare_and_swap.

#define CAS(a_ptr, a_oldVal, a_newVal) __sync_bool_compare_and_swap(a_ptr, a_oldVal, a_newVal)

参数1是要被修改的变量的地址
参数2是要被修改变量的老值
参数3是要被修改成的新值
如果修改成功返回true,否则返回false

类接口和变量

template <typename ELEM_T, QUEUE_INT Q_SIZE = ARRAY_LOCK_FREE_Q_DEFAULT_SIZE>
class ArrayLockFreeQueue
{
public:

	ArrayLockFreeQueue();
	virtual ~ArrayLockFreeQueue();

	QUEUE_INT size();

	bool enqueue(const ELEM_T &a_data);   //入队列

 	bool dequeue(ELEM_T &a_data);    //出队列

    bool try_dequeue(ELEM_T &a_data);  //尝试出队列

private:

	ELEM_T m_thequeue[Q_SIZE];

	volatile QUEUE_INT m_count;  //队列的元素格式
	volatile QUEUE_INT m_writeIndex; //新元素⼊列时存放位置在数组中的下标

	volatile QUEUE_INT m_readIndex; //下⼀个出列元素在数组中的下标

	volatile QUEUE_INT m_maximumReadIndex;  //最后⼀个已经完成⼊列操作的元素在数组中的下标

	inline QUEUE_INT countToIndex(QUEUE_INT a_count);
};

详细解释每一步操作:
enqueue 入队列

template <typename ELEM_T, QUEUE_INT Q_SIZE>
bool ArrayLockFreeQueue<ELEM_T, Q_SIZE>::enqueue(const ELEM_T &a_data)
{
	QUEUE_INT currentWriteIndex;		// 获取写指针的位置
	QUEUE_INT currentReadIndex;
	do
	{
		currentWriteIndex = m_writeIndex;
		currentReadIndex = m_readIndex;
		if(countToIndex(currentWriteIndex + 1) ==
			countToIndex(currentReadIndex))
		{
			return false;	//队列满	
		}
	} while(!CAS(&m_writeIndex, currentWriteIndex, (currentWriteIndex+1)));
	// We know now that this index is reserved for us. Use it to save the data
	m_thequeue[countToIndex(currentWriteIndex)] = a_data;

 	// update the maximum read index after saving the data. It wouldn't fail if there is only one thread 
	// inserting in the queue. It might fail if there are more than 1 producer threads because this
	// operation has to be done in the same order as the previous CAS
	while(!CAS(&m_maximumReadIndex, currentWriteIndex, (currentWriteIndex + 1)))
	{
		 // this is a good place to yield the thread in case there are more
		// software threads than hardware processors and you have more
		// than 1 producer thread
		// have a look at sched_yield (POSIX.1b)
		sched_yield();		// 当线程超过cpu核数的时候如果不让出cpu导致一直循环在此。
	}

	AtomicAdd(&m_count, 1);

	return true;

}

如果一个位置被标记为X,表示这个位置里存放了数据。空白表示位置是空的。对下列的情况,表示数组中存放了两个元素,WriteIndex指示的位置是新元素将会被插⼊的位置。ReadIndex指向的位置中的元素将会在下⼀次pop操作中被弹出。m_maximumReadIndex表示是数组有效数据的最后一个写入位置。
在这里插入图片描述
当⽣产者准备将数据插⼊到队列中,它⾸先通过增加WriteIndex的值来申请空间。MaximumReadIndex指
向最后⼀个存放有效数据的位置(也就是实际的队列尾)。
在这里插入图片描述
⼀旦空间的申请完成,⽣产者就可以将数据拷⻉到刚刚申请到的位置中。完成之后增加
MaximumReadIndex使得它与WriteIndex的⼀致。
在这里插入图片描述
现在队列中有3个元素,接着⼜有⼀个⽣产者尝试向队列中插⼊元素。
在这里插入图片描述
在第⼀个⽣产者完成数据拷⻉之前,⼜有另外⼀个⽣产者申请了⼀个新的空间准备拷⻉数据。现在有两个
⽣产者同时向队列插⼊数据。
在这里插入图片描述
现在⽣产者开始拷⻉数据,在完成拷⻉之后,对MaximumReadIndex的递增操作必须严格遵循⼀个顺序:
第⼀个⽣产者线程⾸先递增MaximumReadIndex,接着才轮到第⼆个⽣产者。这个顺序必须被严格遵守的36
原因是,我们必须保证数据被完全拷⻉到队列之后才允许消费者线程将其出列。
(while(!CAS(&m_maximumReadIndex, currentWriteIndex, (currentWriteIndex + 1)))
{sched_yield(); } 让出cpu的⽬的也是为了让排在最前⾯的⽣产者完成m_maximumReadIndex的更
新)
在这里插入图片描述
第⼀个⽣产者完成了数据拷⻉,并对MaximumReadIndex完成了递增,现在第⼆个⽣产者可以递增
MaximumReadIndex了。
在这里插入图片描述
第⼆个⽣产者完成了对MaximumReadIndex的递增,现在队列中有5个元素。

dequeue出队列

template <typename ELEM_T, QUEUE_INT Q_SIZE>
bool ArrayLockFreeQueue<ELEM_T, Q_SIZE>::dequeue(ELEM_T &a_data)
{
	QUEUE_INT currentMaximumReadIndex;
	QUEUE_INT currentReadIndex;

	do
	{
		 // to ensure thread-safety when there is more than 1 producer thread
       	// a second index is defined (m_maximumReadIndex)
		currentReadIndex = m_readIndex;
		currentMaximumReadIndex = m_maximumReadIndex;

		if(countToIndex(currentReadIndex) ==
			countToIndex(currentMaximumReadIndex))
		{
			// the queue is empty or
			// a producer thread has allocate space in the queue but is 
			// waiting to commit the data into it
			return false;
		}
		// retrieve the data from the queue
		a_data = m_thequeue[countToIndex(currentReadIndex)];

		// try to perfrom now the CAS operation on the read index. If we succeed
		// a_data already contains what m_readIndex pointed to before we 
		// increased it
		if(CAS(&m_readIndex, currentReadIndex, (currentReadIndex + 1)))
		{
			AtomicSub(&m_count, 1);	// 真正读取到了数据
			return true;
		}
	} while(true);

	assert(0);
	 // Add this return statement to avoid compiler warnings
	return false;

}

队列中初始有2个元素。WriteIndex指示的位置是
新元素将会被插⼊的位置。ReadIndex指向的位置中的元素将会在下⼀次pop操作中被弹出
在这里插入图片描述
消费者线程拷⻉数组ReadIndex位置的元素,然后尝试⽤CAS操作将ReadIndex加1。如果操作成功消费者
成功的将数据出列。因为CAS操作是原⼦的,所以只有唯⼀的线程可以在同⼀时刻更新ReadIndex的值。
如果操作失败,读取新的ReadIndex值,以重复以上操作(copy数据,CAS)。
在这里插入图片描述
现在⼜有⼀个消费者将元素出列,队列变成空。
在这里插入图片描述
所有判断队列是否为空的条件是 readindex == maximum_readindex ?

现在有⼀个⽣产者正在向队列中添加元素。它已经成功的申请了空间,但尚未完成数据拷⻉。任何其它企
图从队列中移除元素的消费者都会发现队列⾮空(因为writeIndex不等于readIndex)。但它不能读取
readIndex所指向位置中的数据,因为readIndex与MaximumReadIndex相等。消费者将会在do循环中不
断的反复尝试,直到⽣产者完成数据拷⻉增加MaximumReadIndex的值,或者队列变成空(这在多个消费
者的场景下会发⽣)
在这里插入图片描述

在多于⼀个⽣产者线程的情况下yielding处理器的必要性

读者可能注意到了enqueue函数中使⽤了sched_yield()来主动的让出处理器,对于⼀个声称⽆锁的算法⽽
⾔,这个调⽤看起来有点奇怪。正如⽂章开始的部分解释过的,多线程环境下影响性能的其中⼀个因素就
是Cache损坏。⽽产⽣Cache损坏的⼀种情况就是⼀个线程被抢占,操作系统需要保存被抢占线程的上下
⽂,然后将被选中作为下⼀个调度线程的上下⽂载⼊。此时Cache中缓存的数据都会失效,因为它是被抢
占线程的数据⽽不是新线程的数据。
所以,当此算法调⽤sched_yield()意味着告诉操作系统:“我要把处理器时间让给其它线程,因为我要等
待某件事情的发⽣”。⽆锁算法和通过阻塞机制同步的算法的⼀个主要区别在于⽆锁算法不会阻塞在线程同
步上
,那么为什么在这⾥我们要主动请求操作系统抢占⾃⼰呢?这个问题的答案没那么简单。它与有多少个
⽣产者线程在并发的往队列中存放数据有关:每个⽣产者线程所执⾏的CAS操作都必须严格遵循FIFO次
序,⼀个⽤于申请空间,另⼀个⽤于通知消费者数据已经写⼊完成可以被读取了。
如果我们的应⽤程序只有唯⼀的⽣产者操作这个队列,sche_yield()将永远没有机会被调⽤,第2个CAS操
作永远不会失败。因为在⼀个⽣产者的情况下没有⼈能破坏⽣产者执⾏这两个CAS操作的FIFO顺序。

⽽当多于⼀个⽣产者线程往队列中存放数据的时候,问题就出现了。概括来说,⼀个⽣产者通过第1个CAS
操作申请空间,然后将数据写⼊到申请到的空间中,然后执⾏第2个CAS操作通知消费者数据准备完毕可供
读取了。这第2个CAS操作必须遵循FIFO顺序,也就是说,如果A线程第⾸先执⾏完第⼀个CAS操作,那么
它也要第1个执⾏完第2个CAS操作,如果A线程在执⾏完第⼀个CAS操作之后停⽌,然后B线程执⾏完第1
个CAS操作,那么B线程将⽆法完成第2个CAS操作,因为它要等待A先完成第2个CAS操作。⽽这就是问题
产⽣的根源。让我们考虑如下场景,3个消费者线程和1个消费者线程:

  1. 线程1,2,3按顺序调⽤第1个CAS操作申请了空间。那么它们完成第2个CAS操作的顺序也应该与这个
    顺序⼀致,1,2,3。
  2. 线程2⾸先尝试执⾏第2个CAS,但它会失败,因为线程1还没完成它的第2此CAS操作呢。同样对于线
    程3也是⼀样的。
  3. 线程2和3将会不断的调⽤它们的第2个CAS操作,直到线程1完成它的第2个CAS操作为⽌。
  4. 线程1最终完成了它的第2个CAS,现在线程3必须等线程2先完成它的第2个CAS。
  5. 线程2也完成了,最终线程3也完成。

在上⾯的场景中,⽣产者可能会在第2个CAS操作上⾃旋⼀段时间,⽤于等待先于它执⾏第1个CAS操作的
线程完成它的第2次CAS操作。在⼀个物理处理器数量⼤于操作队列线程数量的系统上,这不会有太严重的
问题:因为每个线程都可以分配在⾃⼰的处理器上执⾏,它们最终都会很快完成各⾃的第2次CAS操作。虽
然算法导致线程处理忙等状态,但这正是我们所期望的,因为这使得操作更快的完成。也就是说在这种情
况下我们是不需要sche_yield()的,它完全可以从代码中删除。
但是,在⼀个物理处理器数量少于线程数量的系统上,sche_yield()就变得⾄关重要了。让我们再次考查上
⾯3个线程的场景,当线程3准备向队列中插⼊数据:如果线程1在执⾏完第1个CAS操作,在执⾏第2个
CAS操作之前被抢占,那么线程2,3就会⼀直在它们的第2个CAS操作上忙等(它们忙等,不让出处理器,
线程1也就没机会执⾏,它们就只能继续忙等),直到线程1重新被唤醒,完成它的第2个CAS操作。这就是
需要sche_yield()的场合了,操作系统应该避免让线程2,3处于忙等状态。它们应该尽快的让出处理器让
线程1执⾏,使得线程1可以把它的第2个CAS操作完成。这样线程2和3才能继续完成它们的操作。

完整代码:
arraylockfreequeue

参考:
https://zhuanlan.zhihu.com/p/33985732

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值