随笔-13  评论-168  文章-0  trackbacks-19

位卑未敢忘忧国。在此先呐喊一声,强烈谴责***活动!一切反动派都是纸老虎!

本文假设您已经阅读过《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来构造更好的并发堆栈。

posted on 2008-04-24 02:52 Angel Lucifer 阅读(2369) 评论(17)  编辑 收藏 所属分类: 并行程序设计数据结构

评论:
#1楼  2008-04-24 08:16 | 李战      
路过,学习!
  回复  引用  查看    
#2楼  2008-04-24 08:41 | 镜涛      
没看楼主提到的书,不过楼主的文章还是不错的!
  回复  引用  查看    
#3楼  2008-04-24 10:25 | zzz [未注册用户]
好多啊,晚上再来看,小强哥哥,向你学习了
  回复  引用    
#4楼  2008-04-24 12:30 | H.Q.Cai [未注册用户]
问一下 .NET的并行库 在什么地方下载的 我去研究一下他的实现方法。
  回复  引用    
#5楼 [楼主] 2008-04-24 16:44 | Angel Lucifer      
@H.Q.Cai
抱歉回复晚了。
你可以从下面这个地址获得技术预览版:
http://www.microsoft.com/downloads/details.aspx?FamilyID=e848dc1d-5be3-4941-8705-024bc7f180ba&displaylang=en
  回复  引用  查看    
#6楼  2008-04-24 22:22 | lbq1221119      
不错不错 呵呵 顶一下再看
  回复  引用  查看    
#7楼  2008-04-24 23:35 | H.Q.Cai [未注册用户]
thx!
  回复  引用    
#8楼  2008-04-25 08:30 | 简单生活      
很好,谢谢博主的精彩文章。
能不能给大家讲解一下 ConcurrentHashMap.java ?比如在.NET中怎么实现这种应用?
  回复  引用  查看    
#9楼  2008-04-25 09:24 | Da Vinci      
代码写的很优雅 不错 学习
  回复  引用  查看    
#10楼  2008-04-25 12:04 | fuadam [未注册用户]
LockFreeStack<T>在TryPop的时候会出现ABA问题。
CAS的想法虽好但是因为只是CAS1,还没能实现CASN,我认为lock-free还不成熟,不能应用到苛刻的环境当中去。
  回复  引用    
#11楼  2008-04-25 15:40 | <Null> [未注册用户]
如果lz写的是.NET的代码,我觉得在高负载实际应用中链表会慢很多。
因为额外增加了许多reference,对GC是个不小的负担。
Array 相对来说比较经济实惠。
  回复  引用    
#12楼  2008-04-25 18:17 | GoGoSonny      
.NET数据结构应该自带Stack类型吧???

不确定,呵呵~ 没看前提文章,呵呵~
  回复  引用  查看    
#13楼 [楼主] 2008-04-26 00:46 | Angel Lucifer      
@简单生活
这个以后的文章会讲到,Java里面的ConcurrentHashMap还有些瑕疵,这个也会讲到,:-)
其实.NET基本上已经把基础架构给打好了,Microsoft也在做并行库。实在等不及的话,可以自己实现。我用D语言写的代码就是参照.NET来设计的。只不过只起了个头,嘿嘿。

@Da Vinci
照着感觉写,呵呵。

@fuadam
如果只Pop,不会有ABA问题。有问题的可能是Push和Pop多线程交互操作,不过我在文章提到的几篇Paper,都有一些说法。但是Java和.NET因为GC的缘故,就这么实现了。ABA说到底还是内存回收机制的问题。比如文中提到的Hazard Pointers,C++使用这项技术完全可以避免ABA问题。而对于有GC的语言,则又是另外一种问题。
对于Lock-Free来说,CAS对应的最理想的原语应该是SC。可惜没有硬件来实现,或者说不好实现。
我个人认为Lock-Free经过近十几年的研究,基本上已经到达成熟应用的地步,以前影响不大,多是因为多核没有成为主流。我看到的比较早的一篇Paper是1996年。举个例子可能更好的来佐证。以前以及现在驱动层的开发人员在开发中就经常用到Lock-Free技术。Windows下有个SList的Lock-Free实现也是很好的示例。


@&lt;Null&gt;
仁兄多虑了。文中的确是.NET代码。链表和数组实现的性能对比,文中已经说过了。Array的确性能高一些,但只有很微小的性能差距。引用对于GC来说也不是问题。这些几乎可以忽略不计。更重要的是采用数组很难用Lock-Free来实现并发堆栈。

@GoGoSonny
.NET在System.Collections.Generic命名空间下有Stack<T>。实际上它就是文中提及的ArrayStack<T>。这篇文章主要的目的在于并发数据结构,而不是那些经典的数据结构,:-)

  回复  引用  查看    
#14楼  2008-04-26 10:25 | fuadam [未注册用户]
你能保证.net的gc确实不会引起ABA问题吗?如果你这个Stack用10个线程连续不断的跑1年不出错我就相信。
而且如果真的没问题ms不早实现了,但没有这说明什么,肯定是有aba问题
  回复  引用    
#15楼 [楼主] 2008-04-26 14:13 | Angel Lucifer      
@fuadam
多线程问题千差万别,尤其是关于Lock-Free,我不敢做这个保证。不过我可以提供几个证据,仅限于.NET领域。
1.Microsoft官方的MSDN杂志有一篇文章讲到了这个问题。这篇文章是由.NET并行领军人物Joe Duffy撰写。里面有一节:Lock-Free LIFO Stack专门讨论了该问题,并且明确说明不会有ABA问题。具体链接:http://msdn2.microsoft.com/en-us/magazine/cc163427.aspx

2.Microsoft官方的Microsoft Parallel Extensions to .NET Framework 3.5, December 2007 Community Technology Preview中已经实现了ConcurrentStack<T>。其实现正如文中所提。

  回复  引用  查看    

标题  
姓名  
主页
Email (博主才能看到) 
验证码 *  看不清,换一张 [登录][注册]
内容(请不要发表任何与政治相关的内容)  
  登录  使用高级评论  新用户注册  返回页首  恢复上次提交      
该文被作者在 2008-06-13 03:18 编辑过


相关链接: