Lock-Free?还是多入口?

原文链接: http://blog.sina.com.cn/s/blog_d3bf72ff0101qr21.html

  最近一段时间,感觉大家对于Lock-Free的兴趣又高涨了起来,Lock-Free大有包治百病、一统江湖之势,特写下此文,希望对围观者有所帮助。

    让我们先从一个简单的场景开始:考虑一个需要频繁并发访问的Freelist,这应该是许多应用程序中最常见的结构了,如果我们使用基本设计,用一个简单的Mutex量来保护这个Freelist,那么在高并发环境下它很容易成为性能的瓶颈。然后该如何优化呢?一种是多入口,将Freelist拆分成多个,每个用不同的Mutex来保护;另一种是不使用Mutex,直接使用Lock-Free。本文不想过多地讨论实现细节,关于Lock-Free FIFO的设计,网上已经有太多的资源了(但令我惊奇的是,直到今天,LINUX并没有提供封装好的API);至于多入口,有兴趣的同学可以去看一下ORACLE的LOG BUFFER里关于多COPY LATCH的设计,这是一个经典的实现。

    我们的问题是:那种设计更好?多入口?还是Lock-Free?答案是:多入口完胜Lock-Free。原因很简单:Lock-Free只是将数据操作原子化,避免了Mutex设计中的Wait/Sleep之类的设计,但它并没有改变单点冲突的本质,从延展性的角度上看,Lock-Free相对于Mutex,只能算是“五十步笑百步”;而多入口,则是真正地将单点冲突变为多点冲突。

    将这两者放在一起比较是不公平的,因为它们并不是一个层面的东西,事实上,你可以在把Freelist改造为多入口之后,再将每个入口改造为Lock-Free。引入这个问题的目的,是希望引起关于并发优化的问题全景的思考。所以回到一个更一般性的问题:并发优化需要解决哪几个层面的问题?它们各自的重要性是怎么样的?以下是我个人的理解,按照重要性排列,仅供参考:

    1)减少调用次数

    没有任何一种临界区优化手段的效果能够和减少调用次数相比,如果你能够减少调用次数,那么意味着优化工作的意义已经不限于目标临界区本身。这种优化更多地来自于协议本身,这也是一个优秀的并发系统最有价值的部分。比如,如果你采用MVCC,那么你可以消除数据读写之间的冲突,同时也意味着你不需要维护行锁,而后者在传统数据库的实现中是最主要的几个并发瓶颈之一。再比如索引的设计,你的索引支持并发的SMO操作吗?支持一个线程在做SMO的同时,另一个线程可以继续访问这些数据吗?优秀的索引并发协议设计消除了譬如全局锁之类的显而易见的并发瓶颈,也决定了你的系统的写扩展能力。

    2)多入口改造

    无论何时,无论何地,当你需要优化一个临界区的时候,你都应该首先考虑如何多入口化,如果你的临界区能够被改造成多入口,那么它能够带来的延展性提升效果是后面将要提到的这些技术无法到达的。很多时候,多入口意味着你需要提供更加精细的访问粒度,在数据库的设计中,所有重要的数据结构,比如SGA、字典缓存、锁表、...等,无一例外,都是多入口的。

    更细的访问粒度看上去象是一种静态的资源切分。而在前面的例子中,Freelist的多入口改造看上去像是一种动态的资源切分,因为你并不在乎获得哪个资源,此时多入口访问的重点在于平均应用对这些入口的访问,并采用非绑定的模式(比如使用NOWAIT方式逐一尝试各入口),基本上都是一些非常简单、但是很实用的实现级优化细节。

    3)优化临界区本身

    哪怕只是减少一次内存访问,对于减少临界区的长度都是有意义的,代码级别的优化的重要性无须赘述。更多时候,优化临界区需要进行临界区拆分,临界区拆分和多入口改造有些类似,差别在于前者是面向数据的,而后者是面向代码路径的。临界区拆分需要考虑几个问题:一、要保证拆分后的正确性,这是一切有用功的基础;二、拆分后的临界区通常需要引入更多的同步量(比如Mutex),所以需要仔细考虑如何避免引入死锁;三、同步量的实现是有代价的,这种代价随着机器性能的进步会相对地越来越大,所以,过于精细的临界区拆分可能适得其反。

    私有化改造是一种比较高级的临界区拆分技术,它的本质是区分对临界区访问的不同路径,将其中90%的访问路径定义为读者(如果它们是彼此相容的),将另外10%的访问路径定义为写者(如果它们和其它所有人都冲突);私有化改造将读者的访问私有化(使用本地的同步量),而将冲突交由写者来处理,写者访问全局的同步量,并逐一同步读者的本地同步量来解决冲突。简单地说,就是“牺牲写者,让读者的处理效率最快化”。了解RCU吗?这是一种经典的私有化改造的设计。

    私有化改造在数据库中可以用来解决一些非常顽固的冲突,这些冲突所涉及的资源无法或难以再被细分。还记得私有日志缓存吗?(参看我前面的微博“关于私有日志缓存”),它是一种典型的私有化改造,读者是谁?是每个事务各自产生日志;写者是谁?是当发生页面更新冲突时,由后来者负责将读者的私有日志同步。通过私有日志缓存,我们极大地降低了公有日志缓存上的并发冲突。在神通数据库中,私有化改造还被用于其它的并发结构,以后有机会的话,我可以为大家介绍一下,如何将数据库的锁表(用于实现字典锁、表级锁)私有化。

    4)优化同步机制本身

    最后,是优化同步机制本身,我们把它称为“最后一公里”的优化,它的效果仍然是不可忽视的。这一工作通常可以分为两个层面:首先,是为每个临界区选择合适的同步机制,是选择互斥锁,还是读写锁?是选择基于SPIN+SLEEP的Mutex,还是选择基于SPIN+WAIT的Latch?是选择锁,还是直接使用Lock-Free?

    其次,是对每个临界区的同步机制进行Tuning,比如临界区A上的Mutex的SLEEP时间多少毫秒比较合适?临界区B上的Latch的SPIN次数多少更优?这看上去更像是面向特定应用场景的优化。所以对于这两个层面来说,前者适合于产品设计阶段,后者则更多发生于系统运维阶段。

    以上就是我理解的并发优化你需要关注的四个层面的问题。然后回到Lock-Free本身,本文前面所举的例子反映了早期对Lock-Free的研究成果,它们基于一些特定的数据结构(比如FIFO),来实现数据访问逻辑的原子化。在实际实现中,通过灵活地应用Memory-Barrier,Lock-Free可以应用于更多的场景。比如如果对于一个对象的访问是读多写少的,我们可以采取一种简单的逻辑:为这个对象增加一个时间戳;更新对象的前后对时间戳原子加1;读取对象的前后获取时间戳的当前值,如果这两个值相等,那么表示读取操作是原子性的,否则就重试。

    这看上去更像是名副其实的Lock-Free,我们没有使用昂贵的CAS操作,我们所要做的只是在代码中加入一些Memory-Barrier,以保证上述逻辑会以正确的顺序执行。问题在于:读者可能在写者更新对象的同时进行访问,所以你需要保证读者不会在访问的过程中崩溃,这意味着你的临界区通常只能访问固定地址的数据,而对于一些稍微复杂的结构(比如指针、数据库中的行目录间址),你可能需要引入足够强劲的容错逻辑来保证程序的鲁棒性,很多时候,你为此所付出的代价并不能被使用Lock-Free而得到的好处所覆盖。

    最后是一个简单的关于Lock-Free的总结:客观地说,Lock-Free是一种非常有效的并发优化手段,尽管它们并不能支持更加复杂的临界区;大多数时候,Lock-Free被用于所谓的“最后一公里”的性能优化,它们不应,也不能用于为应用糟糕的并发设计而免单。

猜你喜欢

转载自blog.csdn.net/duxingxia356/article/details/100766814