/*该篇文章是笔者查找Disruptor资料的时候,偶然间发现的,笔者认为写的不错,就翻译一下供大家参考,笔者能力有限,翻译的纰漏不足的地方还请大家多多批评指教,谢谢*/
马丁福勒(译者注:就是写《重构》的那家伙)写了一篇非常棒文章,该文章不仅对Disruptor进行了解释,同时描述了Disruptor如何应用到他们LMAX架构中,该篇文章对我们进行了一定程度的解惑,但是,我们最常问的问题仍然是:Disruptor是什么?
接下来我会回答这个问题,但是同时我们还要关注另外一个问题:为什么Disruptor如此之快?
这两个问题经常同时被大家问到。但是,如果不对Disruptor是干什么的进行描述我就无法说明为什么它如此之快,但是如果我不说明它为什么需要这么快我就无法描述它是什么。
好吧,我陷入了鸡生蛋、蛋生鸡的循环问题中。
为了打破这个问题怪圈,我先对第一个问题进行简要阐述,如果后面需要的话我会回来继续解答等我先说明这个:Disruptor是一个在两个线程之间传递信息的方式。
作为一个开发人员,提起Thread,我就要事先把我桌子上的警报先关了,免得它听到Thread就报警,因为线程就意味着并发,但是并发又是如此困难。
并发示例101
假设有两个线程尝试同时修改同一个值,那么会出现下面的情况:
Case 1:Thread 1先到
1. value被Thread 1被修改为blah
2. 接着Thread 2到来,将value的值变为blahy
case 2:Thread 2先到
1. value的值被Thread 2修改为fluffy
2. 然后Thread 1到来将value的值修改为blah
case 3:Thread 1在Thread 2执行期间到来
1. Thread 2最先到来,将Thread 2内部的myValue的值置为fluff然后
2. Thread 1到来,将value的值更新为blah
3. Thread 2继续执行,将value的值置为fluffy
这三种情况中,第三种可能是绝对错误的执行顺序,除非你认为那种幼稚的wiki式的编辑方式是合理的(Google Code Wiki,说的就是你呢....),另外两种情况下,结果还都可以解释为某种意图。Thread 2也许不在乎value是什么,其目的可能只是将value后面追加一个y,在这种情况下,case 1和case 2将都是正确的。
但是如果Thread 2仅仅是希望将fluff变为fluffy,那么case 2和case 3都将是不正确的。假设Thread 2希望将value的值置为fluffy,则还有其他的方式可以来解决这个问题。
方式1:悲观锁
(那个禁止进入的标志大伙是不是都知道啥意思?)
术语悲观锁、乐观锁似乎是我们在讨论数据库读写的时候用得更多,这个原则同样适用于对一个对象加锁。
Thread 2一旦获得了Entry的对象锁就将阻止其他程序对其进行更改,然后Thread 2就去做它想做的事情,改变value的值,然后释放锁,然后让其他程序继续。
你可以想象上面这个操作成本有多高,其他尝试想要获得该对象锁的线程都会因为被阻塞而变得无所事事,线程越多,越有可能拖累整个执行速度。
方式2:乐观锁
在这种情况下,Thread 2仅仅会在其要对value进行写的时候,才会对Entry进行加锁。为了让这种想法能够正确运行,Thread 2在写之前对Entry进行检查判断从刚刚读value到现在Entry有没有被改变过。如果在Thread 2读value之后,写value之前,Thread 1到来对value赋值为blah,那么Thread就不能再将Entry的value赋值fluffy。如果Thread 2不在乎它正在修改的value值是什么Thread 2就可以重新尝试(re-try)对Entry的value进行修改(回去重新读value然后在value的值后面追加个y),或者如果它仅仅是想将fluff变为fluffy,那么Thread 2就可以抛出异常或者返回错误标志。第二种情况的一个例子就是如果有两个用户同时试着去更新一个Wiki的页面,你就需要去告诉Thread 2的用户他需要在从Thread 1加载更新然后重新进行自己的修改。
潜在的问题:死锁
锁常常会引起各种顺序问题,例如死锁。想象一下假如同时有两个线程都希望同时获得两个资源来进行下一步操作。
如果你用了独占排他的锁的技术,这两个线程都将等着另外一个线程放弃资源然后自己来获取该资源,那么结果就是,你需要重启你的 Windows 电脑了。
确切的问题:锁非常慢慢慢慢.....
锁的问题是他需要操作系统进行仲裁,线程就像一群为了一个玩具争抢的姐妹们,操作系统内核就像决定结果的父母。就像你跑到你老爸面前告状说你妹妹在你想玩变形金刚的时候她却弄脏了它,但是你老爸现在却有比你们弟妹争吵更重要的事情需要去处理,在解决你俩争端之前他或许需要将脏衣服放到洗衣机里面然后加上洗衣粉打开洗衣机开关。如果你拿自己的情况来类比锁,就会发现不仅仅操作系统仲裁本身需要时间,操作系统或许还觉得他有比你的线程更重要的事情需要cpu去处理,你的线程先坐下来喝杯茶等会儿再说吧。
Disruptor相关的一篇论文讨论了我们做的一个实验,实验是利用一个函数对一个64位长度计数器(就是一个long 类型嘛)自增500 million次。在无锁单线程情况下,测试需要300ms,如果你加了一把锁(这是针对单线程情况,没有争夺,除了锁之外,没有增加额外的复杂度)其耗时为10000ms,这就意味着,慢了两个数量级。更令人震惊的是,如果你增加了一个线程(逻辑上大家觉得这种比单线程加锁快一倍的时间)其耗时为224000ms,对一个计数器自增500 million次在两个线程情况下耗时是单线程不加锁的将近1000倍。
并发比较难而且锁是坏的
我刚刚仅仅是揭示了问题的表面而且很明显我用的也是最简单的例子。重点在于,如果你的代码打算运行于一个多线程环境,作为一个开发者,你的工作就会困难许多。
- 幼稚的代码会导致意料之外的结果:上面的case 3就是当你没有意识到你的多个线程正在获取和修改同一个同一个数据时会产生多么可怕的结果。
- 自私的代码会拖慢系统的运行速度:像上面case 3那样加锁的话会产生死锁问题或者乐观一点会产生拖慢系统速度的问题。
这就是为什么许多公司机构在面试过程中(当然是java面试)会问及到许多并发方面问题。不幸的是在没有真正理解甚至解决这些问题时,你会觉得这些问题很容易可以学到如何去解答。
Disruptor如何去解决这些问题?
最开始呢,他们不用锁,根本不用!
然而,我们也需要确保操作系统是线程安全的(特别是,在多线程情况下更新序列中下一个可用号码),我们用的是CAS(Compare And Swap/Set)操作,这是一个CPU级别的指令,在我的印象中,它工作起来就像乐观锁,CPU过来更新一个值,但是如果它要更改的元素的值与预期的不一样,操作系统就将放弃这次更改因为很明显其他的程序先对该元素做了更改。
注意这可能是两个不同的cpu核心而不是两个独立的cpu。
CAS操作的代价比锁要小得多因为他们并没有牵涉到操作系统,它们直接是一个cpu级别的操作,但是它们在我上面提到的测试中显示也不是没有代价的。那个测试中,无锁单线程情况下耗时300ms,单线程加锁耗时10000ms,单线程用CAS耗时5700ms。因此其耗时比用锁要少,但是比单线程不加锁不用担心冲突时要多。
再回到Disruptor,我讨论了ClaimStrategy(获取策略)当我检查producers(生产者)的时候,在代码中,你将看到两种策略,单线程策略(SingleThreadedStrategy )和多线程策略(MultiThreadedStrategy),你会好奇,为什么不仅仅运用多线程策略然后将生产者线程数目置为1?Disruptor能处理这种情况吗?当然可以!但是单线程策略时我们仅仅用一个long类型字段没有锁也没用CAS,这意味着,考虑到仅仅有一个生产者因此序列中就没有冲突,单线程获取策略差不多达到速度的极限了。
我知道你在想什么:仅仅将一个数据类型更改为AtomicLong类型肯定不是Disruptor取得如此速度的唯一原因!当然,它不是!否则的话,这篇文章的名字就不是:Disruptor为什么如此快?(第一部分)了。
但是,这是一个很重要的一点,这是代码中唯一一处多线程可能会同时对同一个值进行更改的地方(访问冲突的地方),在这么复杂的数据结构框架中仅仅只有一个地方会被并发访问,这就是秘密。记得每一个元素都有自己的序列号(sequence number)么?如果你只有一个生产者的话,那么这个系统里面的每个序列号都仅仅被这一个线程写入,这就意味着没有写入冲突,就没必要加锁,不需要CAS。唯一的序列号在被需要多个线程写入的情况是在有多个生产者情况下采用的ClaimStrategy。
这也是为什么Entry中的变量仅仅能被一个消费者写的原因,它确保了这种情况下没有冲突,不需要锁以及CAS。
讨论下为什么队列(quenes)不能满足这种需求
接下来你开始看到为什么可以被封装为RingBuffer的队列仍然不能符合Disruptor的性能表现,队列以及基本的RingBuffer,仅仅有两个指针,一个指向队列头,一个指向尾。
如果不止有一个生产者希望向队列中添加元素,就会造成因为有不止一个线程希望对其写入,而让尾指针成为一个有写入冲突的点。如果有超过一个的消费者,那么头指针也将是一个有冲突的点因为这不仅仅是一个读操作的地方,也会有写操作,当队列中元素被消费之后头指针需要被更新。
但是,等等,我似乎听到了你的怒吼!因为你早就知道这些了!!因此队列经常是被设计为单生产者和单消费者(或者不信的话拿他们那些多生产者和多消费者所在队列的效率对比我们的测试表现)。
但是我心里还有一件关于队列/缓冲区的事情不吐不快,不管是头指针还是尾指针,都是被当做生产者或者消费者的阻塞点,都是为了帮助队列这个缓冲区消费从生产者来到消费者去消息。这意味着队列/缓冲区经常是满的(生产者生产速度超过消费者消费速度时),或者队列/缓冲区经常是空的(消费者消费速度超过生产者生产速度)。生产者和消费者速度相同然后队列/缓冲区中一直保持一定的元素的情况是极少见的。
最终造成的结果就像下面展示的,空队列如下:
队列满呢?如下图所示:
队列需要一个size字段来判断队列到底是满的还是空的情况。或者,如果不要该字段的话,那这种情况的区分就需要基于队列中元素(entry)的内容,这种情况读一个元素(entry)就需要一个写操作来擦除它或者标示该元素(entry)已经被消费了。
无论采用哪种方式来实现,在尾指针、头指针以及size字段的地方,都会有一些读写冲突,或者采用第二种方法时元素(entry)本身一个消费操作之后也需要一个写标识操作删除(remove)操作来删除它。
除此之外,这三个变量(head,tail,size)经常在同一个cache缓存行(cache line)中,这样就会造成伪共享问题。因此,你不仅需要担心生产者和消费者同时对size变量或者队列中同一个元素(entry)同时写入造成的写冲突,在对头指针(head)进行更新之后再对尾指针(tail)更新更新也会造成缓存未命中(cache-miss)因为他们在同一个缓存行中。在本篇文章中我将避免对该问题描述太多因为本文已经够长的了。
这就是我说的“搞清楚问题”(Teasing Apart the Concerns)或者队列的“结合问题”(conflated concerns),通过给队列中每个元素一个序列号并且只允许一个消费者对队列中元素(entry)进行写,Disruptor需要处理的唯一冲突就是超过一个生产者来对环形缓冲区进行写的情况。
总结:
Disruptor与传统方式相比具有很大优势。
1. 没有冲突=没有锁=快速
2. 让每个元素都有自己的序列号就可以让多个生产者和多个消费者使用相同的数据结构。
3. 最终每个每个地方独立的序列号(环形缓冲区、索取策略(claim Strategy)、生产者和消费者),外加上神奇的缓冲行填充(cache line padding),就意味着没有伪共享以及没有不希望的冲突。
From http://mechanitis.blogspot.com/2011/07/dissecting-disruptor-why-its-so-fast.html