写一个正确的锁无关

来源:互联网 发布:淘宝助理怎么下载安装 编辑:程序博客网 时间:2024/04/29 12:23

    从没写过博客,现在试水一下,呵呵。这两天写了个锁无关的对象池,就以此和大家分享吧,大年三十,给大家拜个早年, J

    锁无关(lock-free) 大家应该都知道,网上搜搜也有很多相关的代码,但是 我个人以为,其中很多是错误的, J ,多线程编程确实复杂,也许比我们以为的复杂还要复杂些。本来,有操作系统给我们提供的各种锁机制,我们已经很大程度上从多线程的这种复杂性中解脱出来了,但牛人一出,提出个锁无关,我们一下子又回到了黑暗的旧社会, J

    不管怎么说,希望我这篇小文章能对大家有些帮助,呵呵。

 

、一、锁无关和 CAS

    简单来说,锁无关是一种基于比较 - 交换原子操作的线程同步技巧,通过细致的设计,可以在避免加锁开销的同时在多线程之间共享数据。用例子来说明会比较清楚:假设我们要写一个基于stack的对象池,需要新对象T时就从这个池中分配一个, 用完后又放回池中。如果这个池只会被单线程使用,则代码可能大致如下:

template <typename T , unsigned int POOL_SIZE =10>

class DsPool

{

     struct QueueInfo {

         QueueInfo ():m_head (NULL) {};

    TPoolNode <T>*  m_head ;

     };

       。。。

public :

     T* GetEle ()

     {

         TPoolNode<T>* pTmpNode = m_Queue.m_head ;

         if ( NULL == pTmpNode )

         {

              return NULL;

         }

         m_Queue.m_head = pTmpNode->pNext ;

         pTmpNode->pNext = NULL ;

         pTmpNode->pVal ->PoolInit();

         return pTmpNode->pVal ;

     }

 

     void ReleaseEle(T* pEle )

     {

         if ( NULL == pEle )

         {

              return ;

         }

         TPoolNode<T>* pTmpNode = pEle->GetOwnerNode();  

         pTmpNode->pNext = m_Queue.m_head ;

         m_Queue.m_head = pTmpNode;

         return;

     }

     。。。

     QueueInfo m_Queue ;

};

    代码很简单,就是将可分配的对象组成了一个单向链表,而GetRelease 时会对链表指针作相应修改。为什么叫DsPool,呵呵,因为本人姓氏拼音的首字母是D DsPool的意思就是本人写的pool 显然,这个池不能用于多线程,因为一个元素可能会同时被多个线程取到并使用,不同线程里的释放操作也会互相覆盖 ,错误的可能有千千万万种。我们写个测试看看它的错先, J,我个人比较喜欢先错后对,这样我觉得比较踏实,对了以后会感觉自己确实写了有用的代码,呵呵。

class TestType : public TPoolVal<TestType>

{

public:

     TestType():m_val(0){}

public:

     bool PoolInit ()

    {

         ++m_val;

         return true;

     }

public:

     int GetVal() volatile { return m_val; }

private:

     int m_val;

};

DsPool<TestType > g_testPool;

void ThreadRun (PVOID inParam)

{

     const unsigned int loopTimes = 1000000;

     do {

         unsigned int stTime = GetTickCount();

         TestType* test1 = NULL;

         for ( int i =0; i <loopTimes; ++i )

         {

              test1 = g_testPool.GetEle ();

              if ( NULL == test1 )

              {

                   --i;

                   continue;

              }

              g_testPool.ReleaseEle( test1 );

         }

         unsigned int passedTime = GetTickCount() - stTime;

     } while ( !( GetStopFlag ()) );

}//void ThreadRun(PVOID inParam)

    我将g_testPool 定义为存放TestType元素的对象池,而线程函数则简单地反复从池中取元素然后释放回池,程序中的passedTime是为了将来比较有锁和无锁的性能差异加的。预期的错误有两类,一种是崩溃,第二种是内部数据乱,多线程下肯定是会错的,但是会怎么表现出来? 我现在还不知道,分析起来也挺麻烦的。如果程序直接崩溃的话,那是最好了,错误会很直观,但为了在程序不崩的时候也能看到发生的错误,我还是多写了一点点校验的代码。

    主要校验两个地方:1 、每次从池中取到新元素时都在PoolInit++m_val,如果元素没有错误地被多线程操作,那各线程执行完毕后,池中元素m_val的总值应该==线程数*每线程的循环数;2 、与链表相关的操作大部分与node 之间的联系有关,如果这些联系在过程中正确维护了的话,各线程执行完毕后的池中元素数量应该与初始池中的元素数量一样( 不会有node 丢失,也不会在node 间形成循环,等等) 。因此,校验代码大致如下:

         TestType * test1 = g_testPool.GetEle();

         unsigned int alloctimes = 0;

         unsigned int nodenum = 0;

         while ( NULL != test1 )

         {

              ++nodenum;

             alloctimes += test1->GetVal();

              --alloctimes;// 去除刚刚那次GetEle() 的影响;

              test1 = g_testPool.GetEle();

         }

         Assert( nodenum == 10/*默认的池大小*/ );

         Assert( alloctimes == 10/*线程数*/ * 1000000/*每线程循环次数*/   );

 

    先起单线程跑跑看,一切正常!那么,让它在多线程下错给我们看看? J 。开10 个线程, ,一点意外都没有,还没等到断言呢,程序就崩溃了,毕竟循环的次数多,10个一百万次的分配,要想侥幸一下是很难的,呵呵。

既然有错,那就改。先从没创意但简明的方法开始,加锁吧。

     T * GetEle()

     {

         NewLock tmpLock(m_normalLock);

                  …

     void ReleaseEle(T* pEle)

     {

         NewLock tmpLock(m_normalLock);

        

    其中m_normalLock是个互斥锁,tmpLock在构造中获取锁,析构中释放锁。非常简明,不需要去分析多线程之间的执行顺序,反正有可能竞争的地方都锁上了。

    重编,再测,一切都很好,没有任何异常,最后的校验结果也正确,再多跑个上亿次也一样正确,我们安全了,呵呵。但是等等 ,等测试结果的过程感觉比较漫长,明显比单线程不加锁的时候慢了不止一点点。理想情况下,10个线程分配释放的次数是单线程的10 倍,因此需要的时间大概也应该是单线程情形下的十几倍。但在我的机器上,实际结果是10线程的用时是单线程用时的两到三百倍甚至更多(无论debug 还是release ),也就是说,多线程加锁情况下的分配要比不考虑加锁的分配慢几十倍。不加锁时跑下测试只要几十毫秒,跑10 个线程以为等个最多几秒钟吧,没想到等了两分钟,没有比这更郁闷的了, J

    简明是有代价的!没办法,考虑试试不简明的锁无关了, J

    先看第一次的锁无关实现代码,注意,下面的代码有错误,稍后我会逐步地改正代码中的错误,大家先通过这代码了解下锁无关原理,千万不要直接拷贝使用这代码,因为它是错误的!

T * GetEle(){

         TPoolNode<T>* pTmpNode = m_Queue.m_head ;

         if ( NULL == pTmpNode )

         {

              return NULL ;

         }

         while ( !CAS( (LONG volatile *)&m_Queue, (LONG volatile)(pTmpNode ->pNext), (LONG volatile)pTmpNode) )

         {

              pTmpNode = m_Queue.m_head ;

              if ( NULL == pTmpNode )

              {

                   return NULL ;

              }

         }

       

    相对于之前的加锁代码,上面这段锁无关代码有两处不同,一是多了一个循环判断,二是对m_Queue.m_head 的赋值不见了, J 。当然,m_head 是必须要被赋成新值的。所以,解释下CAS 函数的意思, J CASpTmpNode 的值与&m_Queue 所指向地址的LONG( 在这里就是m_Queue.m_head) 进行比较,如果两个值一样,则将&m_Queue 所指的LONG(m_Queue.m_head) 改成输入的第二个参数值(pTmpNode->pNext) 。所以,这段代码有点象是while(m_Queue.m_head = pTmpNode->pNext&& false ) ,准确地说,在单线程时它的确就退化成了这样,因为pTmpNode 刚刚被赋成了m_Queue.m_head ,马上又比较这两个值是否相等,肯定是一样的啊。

    那多线程时又会怎样?设想如下情形:

threadA : pTmpNodeA = m_Queue.m_head

threadB : pTmpNodeB = m_Queue.m_head ( 此时,pTmpNodeA==pTmpNodeB)

threadB : m_Queue.m_head = pTmpNodeB->pNext 然后threadB 开始使用pTmpNodeB

    接着又轮到threadA 执行了,但经过这么一阵沧海桑田,threadA已经不能使用pTmpNodeA,因为它已被threadB使用了。不加锁,结果自然就是这样。那在这种竞争的情况下,threadA 是否有办法检查自己刚刚取到的pTmpNodeA,看它有没有被其它人抢走呢?有,CAS就可以帮助threadA做这个事。如果这一节点中途被其它线程抢去了,则m_Queue.m_head 会被设成一个新的值(pTmpNodeB->pNext) ,因此m_head 就不再==pTmpNodeA了。在上述代码中while(!CAS( ))不断重复( 测试)过程,一旦发现m_Queue.m_head仍旧==pTmpNodeA,则threadA就推断,在过程中没有其它线程抢走自己想要的东西,于是可以放心地将pTmpNodeA 拿走,并将m_Queue.m_head 设成pTmpNodeA->pNext 这就是锁无关的基本原理!!!

上述代码中,锁无关之所以可行,其关键点在于,取值操作和测试操作之间允许被其它操作打断,但测试(m_Queue.m_head==pTmpNodeA) 和赋值(m_Queue.m_head=pTmpNodeA->pNext) 必须是连在一起不被打断的原子操作,否则仍旧会在这两个操作的打断间隙发生许许多多的事情。幸好, CAS 可以是一个原子操作,在windows 下它对应的就是_InterlockedCompareExchange ,如果不是windows ,则在x86 架构下使用内联汇编 cmpxchg 实现同样的功能,其它架构也有相应的汇编指令,我没有详细去看, J :

#ifdef WIN32

bool CAS ( LONG volatile * pDestPos , LONG inNew , LONG inCmp )

{

     if ( inNew == inCmp )

     {

         assert ( false );

         return false;

     }

     return ( inCmp == _InterlockedCompareExchange ( pDestPos , inNew , inCmp ) );

}... 

    锁无关的原理大致就是这样,使用循环测试来检查冲突,冲突时进行避让。锁无关为什么会比加锁快?当然首先是因为加锁操作的效率低,除此之外,我个人认为这与真正冲突的概率低有很大关系。打个比方,加锁方法就像是去一个只有一个人能使用的房间,每次去都开锁然后将房门反锁好,不让别人进来。而锁无关,有点像不管三七二十一,打开门再说,如果房里有人,那我先关上门,然后再拉开门看看那人有没有离开,直到房间没人了,我再进去(当然看到没人和进去要连着,否则一小时前看过没人,过一小时后再来进房间肯定是不行的)。由于绝大部分情况下不会有人和你抢着进房间(互斥段的代码在总代码中永远只占极少数,因此执行时间是极短的),所以每次进出都开锁上锁无疑显得比较笨拙和浪费, J,锁无关是个更加简单聪明直接的进门方法,一点不浪费,所以,锁无关应该会更快。

    好了,理论分析完毕,让实践检验检验。检验之前,将release 用同样方式改好:

     void ReleaseEle ( volatile T * pEle ) volatile

     {

         

         TPoolNode <T >* pTmpNode = pEle ->GetOwnerNode ();

         pTmpNode ->pNext = m_Queue .m_head ;

         while ( !CAS ( (LONG *)&m_Queue .m_head , (LONG )pTmpNode , (LONG )(pTmpNode ->pNext ) ) )

         {

              pTmpNode ->pNext = m_Queue .m_head ;

         }

        

    重编,测试。好象还不错,速度快多了,如预期的,用时基本随着分配次数线性增长,而与线程的多少没有明显的相关性,太理想了!多跑几次,享受一点点成就感吧,呵呵。但是 ,虽然很不情原,问题还是出来了。没有崩溃,但偶尔有那么几次,池中节点数目与分配次数的校验会失败,最后池中会只剩下少量几个节点!!!虽然概率极低 估计几千万次分配才会导致一次校验错,但错误的就是错误的,和正确之间有着本质的区别。我们不得不理理多线程这堆乱麻了。

 

ABACAS2

    上面的代码哪里有错? 嗯,实在是看不出来。那就不断地跑测试,希望总结出一些错误规律来吧。

    功夫不负有心人,跑过几十次之后,发现一点点端倪:不管将池初始大小设为多少,只要出错,那剩在池内的节点数都只有一点点,不会超过运行的线程数量。为了确认这一判断,将线程数改为2 再跑,果然,只要出错,那剩下的节点数永远是1 ,永远不会是空也永远不会大于1 。也就是说,运行结果要么一点错没有,剩下的节点数== 初始节点数,要么出错,剩下的节点数是1 。进一步测试表明,如果将池大小设为1 的话,则不会出错,当然,由于任一时刻只能有一个线程的分配请求获得满足,因此各个线程的一百万次请求都满足会需要更长的时间,这也是很正常的。

    什么错误会导致这种奇怪的现象呢?回头再看前述代码的分配和释放函数,推断出一种可能性:即某次取元素时,将池的m_head 错误地改成了NULL 。这样,除了当前已分配出去的节点,其余所有可用节点都丢失了,所以最后池内所余节点数才不会超过运行的线程数量。而在池内初始只有一个节点的情况下,由于没有可被丢失的节点,因此也就始终不会出错了。

这个推断非常完美, J ,很好地解释了所观察到的错误。那么为什么会将m_head 错误地设成NULL 呢?既然CAS 是原子操作?

为了解释这错误,我们先来看多线程编程中的ABA 问题, J

    ABA 听起来很高级,其实是很简单的概念。ABA 不是单词首字母的简写,而是对现象的一种形象描述,也许更准确地应该是ABa 。形象的比方就是物是人非,你早年有个熟悉的人一直在熟悉的地方,多年后你回到熟悉的地方又看到了熟悉的人,你以为她还是当年的她,以为这么些年来她一直呆在那没有离开过,但实际上呢,和你一样,她其实也刚从外面回来呢,人还是那个人,但身上带的心里想的早就不是当年的东西了,呵呵。Aba中的A就是那熟人,前面你熟悉的是大写的A,后面你看到的是小写的a,看起来一样,但其实是不同的东西,中间那个B呢,B没什么关系,只是中间不相关的某个其它人,:)。在我们的代码中,A就是欲取的队首元素,A-->a的过程如下:

threadA: pTmpNodeA = m_head

threadB: pTmpNodeB = m_head

threadB: 经过CAS比较交换得到pTmpNodeB,使用pTmpNodeB,然后又将pTmpNodeB放回了队列

      threadA: 进行CAS检查准备取pTmpNodeA,这时比较会成功,因为m_head还是pTmpNodeA,它刚刚被线程B放回去,:)。看起来应该没什么问题,虽然别的线程将它抢走用了一回,但它还回来了,不影响我的使用。是这样的吗?当然不是。因为线程用的不仅仅是m_head,线程还要用到m_head->pNext,因此会有下述出错情形:

(1)threadA: pTmpNodeA = m_head;

(2)threadB: pTmpNodeB = m_head; pTmpNextB = pTmpNodeB->Next;

(3)threadB: CAS获得pTmpNodeB,使用pTmpNodeB的过程中将pTmpNodeB->Next置成了NULL;

(4)threadA: pTmpNextA = pTmpNodeA->Next; (由于pTmpNodeA就是pTmpNodeB,所以pTmpNextA将是NULL!!!)

(5)threadB: 归还pTmpNodeB,归还后,pTmpNodeB->pNext又会指向队列中正确位置,且pTmpNodeB再次成为m_head

(6)threadA: 进行CAS检查准备取pTmpNodeA,CAS比较成功,并在原子操作中将之前取到的pTmpNextA(NULL)置成m_head!!!

      至此,错误形成,队列空了,可分配元素丢失!这就是ABA的全过程,第(1)步看到的m_head和第(4)步用于取next的pTmpNodeA看起来似乎一样,但实际不是同一个元素。

      问题知道了,怎么解决这个问题?怎么识别区分A和a?简单的回答是,CAS不足以应付这个问题,需要新人CAS2出马了。

      CAS2是who? 嗯,说实话,他们其实是同一员大将,装单手武器他叫CAS,拿双手武器后他就改名叫CAS2了,或者更准确的说法是,CAS2装备的是一把两倍于CAS长度的武器,他把这长武器用得很是熟练,上阵杀敌舞得飞快,眼神不好的敌人一直以为他拿的是两把武器呢,呵呵。

      前述windows下的CAS实现用的是_InterlockedCompareExchange,它所比较和操作的是32位的数据(LONG),windows下还有一个_InterlockedCompareExchange64,它操作的是64位的数据(__int64),这就是CAS2,它将一个64位的数和目标地址64位的数据进行比较,如果相等,则将另一个新的64位数放到目标地址中去。

如此而已?CAS2怎么应对前述的ABA问题呢?

    回顾ABA,我们的问题主要出在CAS比较时用的A和之前取pNext的A不一样,元素虽然是同一个元素,但其新旧程度是不同的,如果可以识别元素的新旧程度,问题就解决了。仍以人来作比较,如果只看名字,那个熟人当然还是那个熟人,如果考虑年龄的话,那实际上就不是同一个人了,所以如果node也有年龄,那我们就解决了ABA问题。node的年龄是什么,就是它的分配次数,在node身上加个计数器就行了,每次分配node时将node的计数器++,进行CAS比较时,我们同时比较node的名字(地址)和node的年龄(计数器),这样就可以确保A仍是A,当然这个过程仍然必须是原子的,即CAS2。

    同时进行名字和年龄检测交换的相关代码如下: 

struct QueueInfo {

     QueueInfo ():m_head (NULL ),m_age(0) {};

TPoolNode <T >*  m_head;

__int32          m_age;

 };  

 

bool CAS2 ( __int64 volatile * pDestPos , __int32 inNew1 , __int32 inNew2 , __int32 inCmp1 , __int32 inCmp2 )

{

     __int64 inNew = (((__int64 ) inNew1 )<<32) | inNew2 ;

     __int64 inCmp = (((__int64 ) inCmp1 )<<32) | inCmp2 ;

     if ( inNew == inCmp )

     {

         assert ( false );

          return false ;

     }

     return ( inCmp == _InterlockedCompareExchange64 ( pDestPos , inNew , inCmp ) );

}

     volatile T * GetEle ()

     {

             ...

         volatile TPoolNode <T >* pTmpNode = m_Queue.m_head ;

         volatile unsigned int curAge = m_Queue.m_age;

         if ( NULL == pTmpNode )

         {

              return NULL ;

         }

         volatile TPoolNode <T >* pNextNode = pTmpNode ->pNext ;

         while ( !CAS2 ( (volatile __int64 *)&m_Queue , (__int32 )(pNextNode ), (__int32 )cuAge +1, (__int32 )pTmpNode , (__int32 )curAge ) )

         {

              pTmpNode = m_Queue.m_head ;

              curAge = m_Queue.m_age;

              if ( NULL == pTmpNode )

              {

                   return NULL ;

              }

              pNextNode = pTmpNode ->pNext ;

         }

             ...

     }

      准确地说,上面的代码中age并不是每个元素真正的分配次数,而是所有元素的总分配次数,因此实际上元素的年龄是记大了点,不过没有关系,因为我们其实并不关心元素真正的年龄,我们只要知道元素的年龄是否发生过变化就行了。

      理论上说,这就是完整的锁无关实现,再没有其它更高深的技术值得废话的,我看到网上的其它一些代码大概也就到此为止。如果大家只是准备在过年和朋友聊天吹牛时拿锁无关作为谈资之一,那走到这里也够远了,可以休息了。但如果想要在自己的代码中真正用上锁无关的话,请耐心再看看吧,因为就此停步是错误的,到目前为止的代码是错误的。

 

、测试可行的锁无关

    到底错没错,只有测试拥有发言权。开测!

    碰到第一个问题:

    程序跑起来不但没有如预期的进行快速分配释放,而且连修改之前CAS方法的表现也达不到,程序死了!汗,赶紧下断点看,原来是while(!CAS2(..))死循环,为什么?与字节序有关,大家想起来了吧,对,前面我们的结构定义是

struct QueueInfo {

     QueueInfo ():m_head (NULL ),m_age(0) {};

TPoolNode <T >*  m_head;

__int32          m_age;

 };

    CAS2比较的时候,比较的不是两个32位值,而是一个64位的值,而在intel-windows下,这个64位值实际存放的顺序是m_age在前m_head在后,所以我们的比较永远失败,所以陷入了死循环,修改方法很简单,将结构改为

struct QueueInfo {

     QueueInfo ():m_age(0),m_head (NULL ){};  

__int32          m_age; 

TPoolNode <T >*  m_head;

 };

    第一个问题应该解决了,继续测,碰到第二个问题:

    死循环没有了,但老问题像个幽灵一样又出来了,有时仍然会在最后只剩了少数几个节点,虽然感觉出错概率更小了,如果CAS取元素时出错的概率是几千万分之一的话,现在CAS2取元素的出错概率大概是几亿分之一吧,还是老话,错的就是错的,与对的有本质的不同。由于故障依旧,因此应该还是错误地将m_head设成了空,为什么用了CAS2还会这么错?对着下面这段相关代码瞑思苦想一会,:) 

         volatile TPoolNode <T >* pTmpNode = m_Queue.m_head ;

         volatile unsigned int curAge = m_Queue.m_age;

         if ( NULL == pTmpNode )

         {

              return NULL ;

         }

         volatile TPoolNode <T >* pNextNode = pTmpNode ->pNext ;

         while ( !CAS2 ( (volatile __int64 *)&m_Queue , (__int32 )(pNextNode ), (__int32 )cuAge +1, (__int32 )pTmpNode , (__int32 )curAge ) )

         {

              pTmpNode = m_Queue.m_head ;

              curAge = m_Queue.m_age;

      看出问题来了吗?对!!!现在的代码是先取pTmpNode然后再取curAge,现在设想以下情形:

threadA: pTmpNodeA = m_Queue.m_head

threadB: pTmpNodeB = m_Queue.m_head; CAS2; ++m_age; pTmpNodeB = NULL;

threadA: curAgeA = m_age; pTmpNextA = pTmpNodeB(NULL!!!)

threadB: CAS归还pTmpNodeB,所以m_head仍然==pTmpNodeA

threadA: 进行CAS2比较,这时,curAgeA == m_age, m_head == pTmpNodeA,CAS2的两个比较都相符,但准备换进去的pTmpNextA呢?是NULL!!!

      问题在哪?多线程真麻烦,在pTmpNodeA取值以及curAgeA取值之间又发生了沧海桑田的变化。汗如雨下啊,感觉有点无语,难道是个死循环的问题吗?其实不必慌张,这不是一个ABABA问题,:),简单处理处理就行了,将代码改成这样:

         volatile unsigned int curAge = m_Queue.m_age; 

          volatile TPoolNode <T >* pTmpNode = m_Queue.m_head ;

         if ( NULL == pTmpNode )

         {

              return NULL ;

         }

         volatile TPoolNode <T >* pNextNode = pTmpNode ->pNext ;

         while ( !CAS2 ( (volatile __int64 *)&m_Queue , (__int32 )(pNextNode ), (__int32 )cuAge +1, (__int32 )pTmpNode , (__int32 )curAge ) )

         {

              curAge = m_Queue.m_age;              

              pTmpNode = m_Queue.m_head ;

 

 

    这样就解决了,为什么?因为之前代码的问题在于pTmpNodeA和m_age赋值之间插入了别一个线程的CAS2和++m_age,通过调换这两个元素的赋值顺序,这种打断就成为不可能,问题也就没有了。也许您会想,它们之间还是会被别线程的CAS2和++m_age打断,但是,这时的打断是可以被检测出来的,要么CAS2中的m_age比较通不过,要么pTmpNode的比较通不过,呵呵!

    解决了第二个问题,再测准备解决第三个问题...,嗯?没有问题了,:)。好象可以跑喽!一点错都没有了,跑了几十亿次一两天都再没有碰到问题,速度呢,毫不奇怪,非常地快,加锁的方法简直是没法和它比,大功告成!!!

 

、其它问题

    都大功告成了为什么还有问题,:)。嗯,随便您了,愿意的话就看看吧。

    1)在最早CAS的方法中代码是这样的:

 

 

 

      while ( !CAS ( (LONG volatile *)&m_Queue , (LONG volatile )(pTmpNode ->pNext ), (LONG volatile )pTmpNode ) )

      这中间并没有将pTmpNode->pNext赋到一个临时变量中,为什么还需要CAS2,毕竟无论这其间头节点被其它线程用了多少次,只要pTmpNode被重新赋成了m_head的话,那pTmpNode->pNext就肯定还是指向链表中的下一节点,而不会是NULL吧?:),初看的确是这样,但再仔细看看就知道不是这么回事。因为CAS的执行可以分成两步,第一步是参数取值;第二步才是原子的比较和交换,上述代码虽然看起来没有显式的pTmpNode->pNext赋值,其实内部是有一个临时的变量赋值的。CAS2不能少!!!

      2)有心人肯定会注意到最后一个问题的解决似乎不是特别完美,:),这里其实牵涉到一点点稍为'高级'的东东,即所谓的读超越、写超越和读写相互超越。简单说,我上面的解决方法是依赖于对curAge和pTmpNode的赋值顺序,但代码中的这个赋值顺序是不保险的!编译器可能会做静态的优化,cpu执行指令流时也有可能按自己认为好的方式对顺序作调整。它们这样做理由是完全充分的,谁知道你的代码逻辑竟然依赖于这种顺序呢,在它们看来先取哪个值先赋哪个值最后结果是一样的。如果我们想要确保在任何架构,任何编译器下都保证前述代码的正确性,那这里需要一个内存栅栏mfence,我在这里就不再多说了。不过,如果代码只在特定的目标机器上跑,那只要经过充分测试就够了,不必再多费心思一一分析了,这也是我将本文标题为测试可行的锁无关,而不是理论可行的锁无关的原因。

      3)锁无关任何时候都会好吗?当然不是这样,最起码它写起来比加锁麻烦多了,越麻烦的东西越容易错。我个人的话,建议将简明性放在效率之前考虑,因为如果代码难维护,不能确定其安全性,那性能再高又有什么用呢?此外,现实情况总是比我们能掌握的复杂的多,锁无关也不一定就会比加锁方法快,例如cpu高速缓存行就是一个会带来问题的地方,如果多个核不加锁地访问同一片内存,可能会导致乒乓问题,不能充分利用cpu的缓存架构的话,实际效率可能反而更低。

      4)我在前面的代码中用了许多的volatile,哪些地方要用volatile哪些地方不要用?这个也比较麻烦,理论上说,只要你在用的这个变量可能随时被别人改动,那就加上volatile,让机器自作聪明的优化少一点,具体哪里要用?我的建议是分析不清的时候就多用,说实话,我没有太感觉出来用了volatile后的效率有降多少,所以保险起见就多用用它吧。

      总之,没有测试就没有发言权,只有测试有发言权,所以就让测试说话吧。最起码,我在本文中举这个例子在我自己的机器上跑得还是挺好的,呵呵!

      谢谢大家!

      正是:辞旧岁往事如浮云,迎新春神马都给力!

      祝大家新年快乐,身体健康!

 

原创粉丝点击