并发数据结构:Stack
位卑未敢忘忧国。在此先呐喊一声,强烈谴责藏独活动!一切反动派都是纸老虎!
本文假设您已经阅读过《CLR 2.0 Memory Model》,《谈谈volatile变量》,《迷人的原子》三篇文章或者具有足够的数据结构和并发编程经验。
在叙述并发Stack前,我们先来了解下非线程安全的Stack。
Stack是一种线性数据结构,只能访问它的一端来存储或读取数据。Stack很像餐厅中的一叠盘子:将新盘子堆在最上面,并从最上面取走盘子。最后一个堆在上面的盘子第一个被取走。因此Stack也被称为后进先出结构(LIFO)。
Stack有两种实现方式:数组和列表。下面我们分别用这两种方式来实现一个简单的Stack。采用数组实现代码如下:
using System; using System.Collections.Generic; namespace Lucifer.DataStructure { public class ArrayStack<T> { private T[] array; private int topOfStack; private const int defaultCapacity = 16; public ArrayStack() : this(defaultCapacity) { } public ArrayStack(int initialCapacity) { if (initialCapacity < 0) throw new ArgumentOutOfRangeException(); array = new T[initialCapacity]; topOfStack = -1; } /// <summary> /// 查看Stack是否为空 /// </summary> public bool IsEmpty() { return topOfStack == -1; } /// <summary> /// 进栈 /// </summary> public void Push(T item) { if (this.Count == array.Length) { T[] newArray = new T[this.Count == 0 ? defaultCapacity : 2 * array.Length]; Array.Copy(array, 0, newArray, 0, this.Count); array = newArray; } this.array[++topOfStack] = item; } /// <summary> /// 出栈 /// </summary> public T Pop() { if (this.IsEmpty()) throw new InvalidOperationException("The stack is empty."); T popped = array[topOfStack]; array[topOfStack] = default(T); topOfStack--; return popped; } /// <summary> /// 返回栈顶,但不删除栈顶的值。 /// </summary> public T Peek() { if (this.IsEmpty()) throw new InvalidOperationException("The stack is empty."); return array[topOfStack]; } /// <summary> /// Stack内元素的数量 /// </summary> public int Count { get { return this.topOfStack + 1; } } } }
如上面的代码所示,ArrayStack<T>定义了两个数据成员: array用来存储栈的数据项并在需要时扩展;topOfStack则定位当前栈顶的索引。如果是空栈,该索引值为-1。唯一值得一提的是Push操作。栈的每个操作时间复杂度都是O(1)。但是Push操作在数组满载的时候会引起一个数组加倍的操作,这将花费O(N)的时间。如果这个操作经常发生的话,我们需要考虑改进。然而,实际上这个操作很少发生,因为包含N个元素的数组加倍只有在至少N/2次不包含数组加倍的Push后才会发生一次。因此,我们可以把加倍的O(N)代价均谈到N/2次简单的Push操作上,这样平均每个Push操作的代价只增加了一小点。此外,我们没有让它继承IEnumerable<T>和ICollection<T>接口,这样做的目的是避免其他的细节实现淹没了我们的主题。.NET的Stack<T>采用的就是我们上述的方法,但继承的是IEnumerable<T>,ICollection,IEnumerable。而且它的默认容量是4,而我们这里的ArrayStack<T>的默认容量是16。我认为在大内存的现在,16应该是比较合理的数字。D语言的实现请看http://code.google.com/p/d-phoenix/source/browse/trunk/source/system/collections/Stack.d。
除了使用数组实现以外,我们还可以使用链表实现。链表的优势在于额外的空间仅仅是一个项的引用。而数组实现所用的额外空间则等于空余的数组项的个数。
为了使链表实现可以与数组实现有竞争力,我们必须能够以常量时间执行链表的基本操作。要做到这点很容易,因为对链表的改变仅仅在于链表两端的数据项。具体实现代码如下:
public class ListStack<T> { private ListNode<T> topOfStack; public bool IsEmpty() { return topOfStack == null; } public void Push(T item) { topOfStack = new ListNode<T>(item, topOfStack); } public T Pop() { if (this.IsEmpty()) throw new InvalidOperationException("The stack is empty."); T popped = topOfStack.item; topOfStack = topOfStack.next; return popped; } public T Peek() { if (IsEmpty()) throw new InvalidOperationException("The stack is empty."); return topOfStack.item; } class ListNode<T> { internal T item; internal ListNode<T> next; public ListNode(T initItem, ListNode<T> initNext) { this.item = initItem; this.next = initNext; } public ListNode(T initItem) : this(initItem, null) { } } }
这两种实现的时间复杂度都是O(1)。因此,它们都相当快速,不会成为任何算法的瓶颈。从这点上来看,使用哪种方式实现都无所谓。
使用数组实现可能比使用链表实现稍快一些,尤其是在能够准确估评估所需要的容量时。如果估计正确,就不会有数组加倍操作。此外,数组提供的顺序访问通常比由动态内存分配的非顺序访问要快。但是数组实现也存在着浪费额外内存空间的缺点。这是一个时间-空间取舍的问题。
接下来我们将使用我们在《并发数据结构:迷人的原子》中学习到的CAS原语来构造一个Lock-Free堆栈。因为CAS原语最多只能交换64Bit,如果采取数组实现方式,几乎很难实现。因此我们采取链表的实现方式。这只要在需要进行同步的地方采用CAS原语交换就可以了。具体实现代码如下:
public class LockFreeStack<T> { private ListNode<T> topOfStack; public bool IsEmpty() { return topOfStack == null; } public void Push(T item) { ListNode<T> newTopOfStack = new ListNode<T>(item); ListNode<T> oldTopOfStack; do { oldTopOfStack = topOfStack; newTopOfStack.next = oldTopOfStack; } while (Interlocked.CompareExchange<ListNode<T>>(ref topOfStack, newTopOfStack, oldTopOfStack) != oldTopOfStack); } /// <summary> /// 考虑到在多线程环境中,这里不抛出异常。我们需要人为判断其是否为空,即 !TryPop() or result != null /// </summary> /// <returns></returns> public bool TryPop(out T result) { ListNode<T> oldTopOfStack; ListNode<T> newTopOfStack; do { oldTopOfStack = topOfStack; if (oldTopOfStack == null) { result = default(T); return false; } newTopOfStack = topOfStack.next; } while(Interlocked.CompareExchange<ListNode<T>>(ref topOfStack, newTopOfStack, oldTopOfStack) != oldTopOfStack); result = oldTopOfStack.item; return true; } public bool TryPeek(out T result) { ListNode<T> head = topOfStack; if (head == null) { result = default(T); return false; } result = head.item; return true; } /* ***************************************** * 简单的单向链表实现 * *****************************************/ class ListNode<T> { internal T item; internal ListNode<T> next; public ListNode(T initItem, ListNode<T> initNext) { this.item = initItem; this.next = initNext; } public ListNode(T initItem) : this(initItem, null) { } } }
我们现在已经知道CAS原语有个ABA问题(具体请参考并发数据结构:迷人的原子)。那么我们上面的Lock-free代码有没有这个问题?这需要我们了解它的本质。CAS比较的其实是一个内存地址,这跟内存回收机制有着莫大的关联。C/C++的内存回收策略使得某些时候内存会被重复使用。比方,我们刚刚删除了某个类型的实例,如果在此时又有该类型的实例被创建。那么很有可能这个实例的内存地址就是我们刚刚被删除的类型实例的地址。这样ABA问题就出现了。凡是牵涉到显式内存管理的地方,我们都要考虑会不会导致ABA问题。所以用C/C++编写Lock-free代码相当的麻烦,我们可能会用CAS2原语或者Hazard Pointers来解决此类问题。但是.NET是有GC的。GC在这里很好的帮助了我们。关于GC的详细描述,请参考《CLR via C#》第20章。这里简单描写下。.NET的托管堆上维护着一个指针,我们称之为NextObjPtr,它表示下一个新建对象分配时在托管堆中所处的位置。在.NET中,我们只要new一个对象,NextObjPtr就会返回对象的内存地址,并且会再次指示下一个新建对象分配时在托管堆中所处的位置。内存回收时,GC有Mark/Clear以及压缩阶段。此外,.NET的GC还有分代机制。
通过上面的描述,我们就可以知道TryPop()不会有ABA问题。因为它压根就没有内存分配和回收。而只是已经在内存中的对象位置变换而已(这些对象的内存地址肯定不同)。唯一需要考虑的是Push()操作。它有ABA问题吗?答案是否定的。因为我们在每次Push操作中只分配新对象,而不删除老对象。所有等待回收的对象全部交给GC处理。那么假如TryPop和Push操作前后交替进行,会发生ABA问题吗?我的看法是有可能,但极难发生,发生了也极难重现。但我在http://msdn2.microsoft.com/zh-cn/magazine/cc163427.aspx里看到的说法是不会发生该问题,而在http://www.research.ibm.com/people/m/michael/ieeetpds-2004.pdf里讲的是有可能发生。我比较倾向于后者,因为MSDN的那篇Paper没有讲出个所以然来,但后者也语焉不详。不过.NET的并行库中的ConcurrentStack<T>和Java中还是这样实现了。因为Lock-Free编码很难证明其正确性,我们权且相信它是安全的。如果哪位达人了解的话,在下虚心请教。还望不吝赐教。
在轻度到中度的争用情况下,上面的代码比基于锁的代码性能高出很多,大概会有3~4倍。因为 CAS 的多数时间都在第一次尝试时就成功,而发生争用时的开销也不涉及线程挂起和上下文切换,只多了几个循环迭代。没有争用的 CAS 要比没有争用的锁便宜得多,而争用的 CAS 比争用的锁获取涉及更短的延迟。
在高度争用的情况下(即有多个线程不断争用一个内存位置的时候),基于锁的算法开始提供比非阻塞算法更好的吞吐率,因为当线程阻塞时,它就会停止争用,耐心地等候轮到自己,从而避免了进一步争用。但是,这么高的争用程度并不常见,因为多数时候,线程会把线程本地的计算与争用共享数据的操作分开,从而给其他线程使用共享数据的机会。(这么高的争用程度也表明需要重新检查算法,朝着更少共享数据的方向努力。)此外,CAS涉及的内存分配和回收也是阻碍性能的一大因素。
请注意,上述代码是理想的并发形式:无需阻止其他线程存取数据,只需抱着会在争用中“胜出”的信念即可。如果事实证明无法“胜出”,我们将会遇到一些变化不定的问题,例如活锁。
所以我们将在下一集引入一个新的并发数据结构:SpinLock来构造更好的并发堆栈。