性能主题
单独写原则Single Writer Principle
当我们试图建立一个高度可扩展的系统,最大的一个限制是:对同一个资源会有多个竞争写操作。 计算机科学归结为2个基本的方法:一是提供资源的互斥锁,另一种是采取乐观的策略。
Mutual Exclusion互斥
相互排斥是某个时间对同一个受保护资源保证只有一个写操作,这是通过锁策略实施的。锁策略需要一名仲裁员,通常这个仲裁员是来自操作系统内核,当发生争夺时它决定谁能获得锁,以及以什么顺序获得。这是一个非常昂贵的过程,通常我们需要更多的CPU循环实现我们的业务逻辑,而不是做这些“家务事”。对于那些等待进入临界区,提前进行Mutual的还必须排队,排队效果(Little’s Law)导致延迟变得难以预测,最终制约了吞吐量。这是一种堵塞Blocking做法。
Optimistic Concurrency Control乐观并发锁
比如Hibernate或JPA都有乐观锁策略,见:JPA/Hibernate:基于版本的乐观锁并发控制,包括MongoDB的乐观锁。
乐观的策略是:保留有数据的一个副本,当修改数据时,如果在此期间原本尚未发生变化,那么复制数据的变化覆盖原本。如果在此期间原本发生的变化,你重复这个过程,直到成功为止。
这种重复过程中增加争夺可能性,会造成排队的效果,就如同相互排斥锁一样。现实中源代码控制系统如Subversion或CVS都在使用这种算法。乐观的策略能够在争夺数据情况下使用,但在争夺一些硬件资源下却工作得不怎么好,因为你不能获得一个硬件资源的副本! 也许只有通过CAS指导硬件完成执行原子数据的变化。
CPU管理争夺
CPU每个循环周期会处理一个或多个指令。例如,现代英特尔CPU内核,每个内核有6个执行单元,可以做并行算法、分支逻辑、字操作和内存加载/存储组合等指令。如果一边做工作时, CPU核未命中高速缓存,它就会到主内存去拿取,它会循环数百次等待直到该内存请求返回的结果。现代 CPU为此提升了预测算法进行预测,对一个内存请求将返回继续处理这种情况做一些预测。如果第二个未命中再次发生时, CPU将不再预测,只需等待内存请求返回,因为它通常不能保持状态进行预测直至超过两个未命中高速缓存。管理高速缓存的未命中,是扩展我们目前这一代处理器性能的最大限制。
当系统花费更多时间实现争夺管理而不是做实际业务工作时,高争夺反而更容易发生,下表显示管理争夺的时间,程序状态很小,很容易从L2/L3重新加载,不用考虑主内存:
Method | Time (ms) |
---|---|
One Thread | 300 |
One Thread with Memory Barrier | 4,700 |
One Thread with CAS | 5,700 |
Two Threads with CAS | 18,000 |
One Thread with Lock | 10,000 |
Two Threads with Lock | 118,000 |
单写操作设计
如果只有一个线程对资源进行写操作,它实际上是比你想象的更容易,这个方案是可行的。当然,如果有多个线程读取相同的数据。 CPU可以通过高速缓存一致性的子系统广播只读数据的拷贝到其他核。这虽然有成本的,但它的尺度非常好。
单个线程写操作无需CPU浪费管理资源争夺或上下文切换。目前 Node.js, Erlang, Actor 模式, SEDA 都采取了单写解决方案,但是他们大多数使用基于队列的下实现的,它打破单一作家的原则,而Disruptor分离了关注,真正实现单写原则。(Disruptor的特点是将多线程生产者通过Ringbuffer变成单线程消费者,通过单线程消费者对共享资源进行写操作)
而Disruptor提供了比锁和基于状态的竞争策略更好地吞吐量,也提供了更多可预见的延迟,而不是传统的J-curve型曲线延迟轮廓保持不变,直到硬件饱和。
这一原则也适用于各级规模,比如原来认为在SOA架构将数据写入存储库中也许能更好地扩展,在这样情况下,相关数据都是被存储在一个数据库和其他服务中,如果是直接去访问这些数据,而不是向拥有数据的服务发送消息,那么数据库就也是需要管理争夺的。当然,这也会阻止了服务从缓存中获取数据以快速响应客户端,降低整体性能,导致数据库IO经常成为吞吐量和性能的瓶颈。
总结如下:
多个线程如果同时写同一个资源,必有争夺,就需要用锁或乐观锁等堵塞方法,而非堵塞的单线程写比多线程写要快,能获得高吞吐量和低延迟,特别是多核情况,一个线程一个CPU核,大大增加其他CPU核并行运行其他线程的概率。LMAX架构使用Disruptor每秒处理600万订单。