非阻塞算法和可伸缩的多核编程

探索基于锁的同步的一些替代方案

Samy Al Bahra, AppNexus

为了以一种很划算的方式来满足带有复杂服务质量保证的运营需求,现实世界中的系统可能就需要在吞吐量和延迟这二者之间寻求一个微妙的平衡点。当今商用多核(multicore)和众核(many-core)系统越来越普遍,成本也越来越低,为了满足日益增长的性能需求,这就使得并发和并行越来越成为必要的技术手段。很不幸,正确、高效、可伸缩的并发软件在设计和实现起来,通常都是一项非常艰巨的任务。

为了保证共享可变状态的一致性,商用多核处理器之上的并发系统往往都会依赖于基于锁的同步机制。然而,基于锁的同步具有多个局限,其中包括对任务抢占的敏感性和可能会导致死锁等等问题,这就使得它在某些特定环境下变得或者不适用或者不实用。这并不意味着基于锁的同步是设计和实现高效可伸缩软件的障碍。

非阻塞式同步也许可以用于构建具有可预测性和弹性的系统,而且还能避免基于锁的同步所带来的问题。这类同步机制绝非银弹,特别是在原先定义它的性能属性的抽象模型的范围之外更是如此。同基于锁的同步机制相关的若干性能瓶颈和错误情况依然存在(虽然在形式上更加隐蔽);因此,为了保证正确性就需要更加复杂的验证方法,而且在有些情况下,非阻塞算法需要引入比较复杂的支撑系统。非阻塞式同步所带来的这些复杂性经常使得它成为了各种宣传资料的众矢之的,人们对它感到害怕、觉得不可靠并持有怀疑的态度。本文旨在为读者提供足够的知识,从而使得读者可以找出能够从非阻塞式同步机制中获益的使用场合。

通用原则

本节在详细阐述采用非阻塞数据结构的原因之前,着重说明要理解传统多线程模型可伸缩性需要了解的一些重要原则。不管是基于锁或者非阻塞的同步,其性能都源自这些原则。如果你已经非常熟悉缓存一致的多处理器、乱序执行、基于锁的同步对象的设计与实现,那么你完全可以跳过这一节。不过本节也不可能说的很详细,更进一步的内容可以去看参考资料。Paul E. McKenney介绍了如何去选择合适的基于锁的同步方式19。之前还有一些文章介绍了其他一些通用原则。6,15,21

竞争阻碍可伸缩性

并发程序中,共享对象的竞争是影响可伸缩性和可预测性的主要障碍。不管使用什么高层的同步机制,共享对象的竞争都会带来下层的某种形式的串行化,下层环境可能是编译语言运行时,或者是共享内存的多处理器。

Little定理 (L = λW) 是排队理论的基本原理,可以帮助我们更好的理解在序列化的资源上的竞争所带来的影响。定理指出,系统的平均输出请求数(L)是请求的有效到达率 (λ)和平均竞争时间 (W)的乘积。所谓的竞争,也就是有效到达率与共享资源请求数的乘积,会带来排队,这将直接影响系统的响应。

无论采用哪种同步方案,对共享数据的争用都会对并行应用的可伸缩性和可预测性(在延迟方面,有时也会在吞吐量方面)产生抑制作用。为了获得更高的性能,程序的设计者要负责将系统层的争用减少到最小的程度。系统选用的同步机制,不管是基于锁的还是非阻塞式的,在出现争用时都会出现性能方面的降低。

共享可变状态和争用

理解在多处理器之上如何提供一致性的保证机制是理解这种系统中出现的争用的先决条件。缓存一致性协议是在多缓存一致性处理器之上确保最终一致性最突出、最重要的机制。这些协议在缓存线(cache-line)级别实现了对一致性的保证。缓存线是从主内存中读取数据和向内存中写入数据的缓存单位(至少从一致性机制的角度看是这样的)。

商用处理器上三个最突出最重要的缓存一致性协议—MOESI, MESI, and MESIF—的缩写都来自它们为缓存线定义的各种状态:Modified(已修改), Owned(被占用),Exclusive(独占的), Shared(共享的), Invalid(无效的), and Forward(转发的)。缓存一致性协议在对内存确保最终一致性的内存一致性机制的帮助下对这些状态进行管理。图1所示是MOESI协议1相关的状态机。你可以将图中的各种探测转换(probe transition)理解为由来自其它核的外部内存访问所触发的状态转换。

Nonblocking Algorithms and Scalable Multicore Programming 下面的例子展示的是缓存一致性多处理器中共享读写的生命周期。为了叙述简单起见,这个生命周期的状态机经过了简化处理。这个程序生成了一个线程,该线程要读取经过修改后的变量的值:

volatile int x = 443;

static void * thread(void *unused)
{
     fprintf(stderr, "Read: %d\n", x);
     return NULL;
}

int
main(void)
{
     pthread_t a;

     x = x + 10010;
     if (pthread_create(&a, NULL, thread, NULL) != 0) {
           exit(EXIT_FAILURE);
     }

     pthread_join(a, NULL);
     return 0;
} 

 

该例子假设所采用的一致性机制是总线窥探,它允许任何一个处理器都可以对对共享位置处的任意内存进行监视。系统的初始状态如下图所示。系统中有两个插槽,每个插槽中都有一个核,每个核都有一个拥有64字节的缓存线的256字节L2级直接映射后写缓存(参见图2)。

Nonblocking Algorithms and Scalable Multicore Programming

初始的进程执行于core 0之上。假设变量x的地址是0x20c4。将x的值加10010(x = x + 10010;)可以分解为以下三个步骤:

  • 将x从内存载入到CPU的一个寄存器之中。
  • 该寄存器的值加上10010。
  • 将该寄存器中的值保存到x所在的内存处。

x的地址是0x20c4,它被散列到了缓存线3之中。既然该缓存线的状态为modified,并且包含一个来自不同地址(0x00c0)的数据,所以它必须写回到主内存之中。其它插槽都没有包含0x20c4的缓存线,所以64字节的数据块(起始于0x20c0)就会被从主内存读到缓存线3之中,并将状态置为独占状态(参见图3)。保存到x中的这个动作会将缓存线3的状态转换从exclusive转换为modified,如图4所示。

Nonblocking Algorithms and Scalable Multicore Programming
Nonblocking Algorithms and Scalable Multicore Programming

新线程生成后最终会开始执行该线程的线程函数。假设其执行于core 1之上。线程函数从x的地址处载入数据,并将该数据作为输入参数对fprintf函数进行调用。这个载入动作会发出一个读取探测,要求从midified的状态转换为owned状态(如图5所示)。MOESI协议允许缓存到缓存的数据转移,在缓存的探测延迟比主内存的探测延时延迟低的情况下这是个优势,该操作涉及对一般都具有较高延迟的内存互连的探测(同插槽间缓存访问相比具有较高的延迟)。同该探测相关的延迟也受拓扑结构的影响。在具有非对称互连拓扑结构的比较大规模的机器上,内存访问可能会有比较显著的性能失配;有些插槽一个刷新周期就需要一次跳变(hop),而别的一些插槽可能还需要两次或多次跳变。

Nonblocking Algorithms and Scalable Multicore Programming

善用缓存的程序(能够以最小的一致性流量保留共享状态的程序)在当今缓存一致性多处理器平台之上将具有良好的可伸缩性。对活跃的共享状态进行突变会直接导致争用,这是因为缓存控制器要对缓存线的拥有权进行协调。

一致性的粒度

如前所述,缓存线是缓存一致性多处理器系统之上对一致性进行维护的最小粒度,同时这也是争用的最小单位。如果在逻辑上并不相干的可变对象共享了同一个缓存线,那么在这些对象上的操作就会出现争用,从而可能会成为可伸缩性的瓶颈。这种情况叫做伪共享(false sharing)

请看下面这段C代码。每个线程在counters数组中都有一个对应的唯一的索引(UNIQUE_THREAD_ID),每个线程都要对该数组进行读写。因为这些counter是按顺序平铺到内存和缓存中的,缓存线的拥有权就会在增加这些counter的值的所有处理器之间来回切换。缓存线在这样的invalid/shared/modified/owned状态之间来回切换的恶性循环就是伪共享造成的缓存线状态来回切换的一个例子。

#define N_THR 8

struct counter {
     unsigned long long value;
};

static volatile struct counter counters[N_THR];

void *
thread(void *unused)
{

     while (leave == false) {
           counters[UNIQUE_THREAD_ID].value++;
     }

     return NULL;
}

避免该问题的一个方法是通过将每个counter填充到一个缓存线的大小来保证每个缓存线中最多只保存有一个counter。假设应用所在的主机处理器上的缓存线是64个字节,按以下所示对代码进行修改后就能避免伪共享:

struct counter {
     unsigned long long value;
     char pad[64 - sizeof(unsigned long long)];
};

按此方法,填充应只在必要是才进行。仍然很重要的是,还要考虑访问模式。举例来说,如果要在热点路径(即执行频率最高的代码段)中连续获得两个互斥量,那么缓存线填充就没什么大用了,而只会徒增锁操作的延迟。过度填充还会影响应用的整体内存占用情况,导致内存资源方面的压力增加。此外还有一些探测并减少伪共享的其它方法。16,17,19 在现代的体系结构之上,适用于已经被缓存的内存位置的原子操作的原子性有赖于缓存一致性协议。较早期的系统支依靠总线锁定来提供对这种原子性的保证。在缓存线进行完刷新这个步骤后,便发出总线锁定信号,以此来阻止所有处理器接下来对主内存的读写访问。这种做法会导致全系统级的性能下降。要避免跨缓存线边界的原子操作。即使目标体系结构对这种做法提供了支持,这种支持可能也是通过某种形式的总线锁定或目录锁定实现的。例如,在x86的体系结构中,这样的原子性分离事务(split transaction)的定义就是要发出一个总线锁定信号。

内存访问顺序和可见性保证所要付出的不可避免的代价

某些特定的正确性要求需要实用比原子性数据存取重量级更高的同步指令。为了提供性能,许多现代的处理器会对内存操作进行批量化处理,并/或者提供某种形式的指令集并行执行机制。有了这些优化处理就可能需要实用内存屏障(或称序列化处理)指令来保证内存操作的顺序及其所预期的副作用。(内存屏障(memory barrier) 这个术语同内存栅栏(memory fence)等价,可互换使用。) 对内存屏障指令的需要要依处理器的内存模型而定,在该模型中还会明确给出对内存操作进行重新定序的各种可能性。

请看以下所示的源代码片段:

volatile int w, x, y, z;

w = 0;
x = 1;
z = y;

在顺序型的一致性模型下,指令(包括数据载入和存储指令)的执行是按顺序进行的。将数据存储到x的动作发生在将数据存储到w之后,在将数据从y载入存储到z之前。现代处理器能够同时处理多个数据载入和存储动作,为了提高性能一般都会对这些操作的执行顺序具有更加宽松的约束。有一些现代的体系结构甚至实现了无锁缓存(lockup-free cache),  2 这种缓存可以同时处理多个缓存未中而且仍然允许缓存访问。例如,从y载入数据可能会在将数据存储到x之前得到执行,或者,将数据保存到x可能会在将数据保存到w之前得到执行。即使没有指令重新定序功能,但在某些现代的体系结构中仍然有可能有另一个处理器在将数据存储到w之前先看到了将数据存储到z的指令。这里再给出一个更加无伤大雅的例子:

volatile int ready = 0;

void
produce(void)
{
     message = message_new();
     message->value = 5;
     message_send(message);
     ready = 1;
}

void
consume(void)
{

     while (ready == 0) {
           ; /* Wait for ready to be non-zero. */
     }

     message = message_receive();
     result = operation(&message->value);
}

在这段伪代码中,生产者线程执行于一个专门的处理器之上,它先生成一条消息,然后通过更新ready的值,向执行在另外一个处理器之上的消费者线程发送信号。不使用内存屏障的话,处理器就有可能会在将数据存储到message->value的动作执行完成之前和/或其它处理器能够看到message->value的值之前,执行consume函数对消息进行接收或者执行将数据保存到ready的动作。为了保证produce函数能够在ready = 1 这个操作得到执行前把将数据存储到message->value的操作提交,就有可能需要使用内存屏障了。然后,为了保证在consume中正确接收消息,在消费者已经观察到ready处于非零状态后,还包含有在伴随的ready = 1操作之前应进行的内存更新动作,所以也许还需要再使用一个内存屏障:

volatile int ready = 0;

void
produce(void)
{
     message = message_new();
     message->value = 5;
     message_send(message);

     /*
      * Make sure the above memory operations complete before
      * any following memory operations.
      */
     MEMORY_BARRIER();
     ready = 1;
}

void
consume(void)
{

     while (ready == 0) {<
           ; /* Wait for ready to be non-zero */
     }

     /*
      * Make sure we have an up-to-date view of memory relative
      * to the update of the ready variable.
      */
     MEMORY_BARRIER();
     message = message_receive();
     result = operation(&message->value);
}

有些处理器具有专门的屏障指令,可以用来对仅仅是其中某些内存操作(这样的例子包括只序列化执行那些有相对关系的数据存储操作或者只序列化执行那些有相对关系的数据载入操作)的部分顺序。对内存屏障的需求情况由底层的内存模型决定。现代通用处理器中最常见的内存顺序保证方式有:

TSO (整体存储定序)

  • 数据载入间的执行顺序不可改变。
  • 数据存储间的顺序不可改变。
  • 数据存储同相关的它之前的数据载入间的顺序不可改变。
  • 数据载入同其相关的它之前的数据存储的顺序可以改变。
  • 向同一个地址存储数据具有全局性的执行顺序。
  • 原子操作按顺序执行。
  • 这方面的例子包括x86 TSO26和SPARC TSO.

PSO (部分存储定序)

  • 数据载入间的执行顺序不可改变。
  • 数据存储间的执行顺序可以改变。
  • 数据载入同数据存储间相对顺序可以改变。
  • 向同一个地址存储数据具有全局性的执行顺序。
  • 原子操作同数据存储间的顺序可以改变。
  • 这方面的例子包括SPARC PSO.

RMO (宽松内存定序)

  • 数据载入间的顺序可以改变。
  • 数据载入同数据存储间的顺序可以改变。
  • 数据存储间的顺序可以改变。
  • 向同一个地址存储数据具有全局性的执行顺序。
  • 原子操作同数据存储和数据载入间的顺序可以改变。
  • 这方面的例子包括Power27和ARM.7

还有,Paul McKenney提供了这些内存模型的更多细节。 19

让情况变得更糟(在复杂度方面)的是,有些语言允许其编译器和运行时环境对操作的顺序进行重排。直到最近,即使C和C++也缺少一个并发内存模型(所以在以前,并发访问的语义在很大程度上讲会因编译器和运行环境不同而不同)。许多编程语言可能会通过获取/释放语义来专门的生成内存屏障指令。近期的 ACM Queue杂志将有一篇文章会对内存模型及其含义进行更细致的探讨。

常见的基于锁的同步机制通过隐式地使用重量级的内存屏障,对程序的设计者隐藏了内存屏障的所有细节。这么处理会简化并发编程,因为由开发者来负责考虑可串行性的问题,3 但是,有时这样做却要以降低性能为代价。

内存屏障对于其它的并发算法也很有必要,特别是那些不依赖于锁及其顺序保证的算法。最终,避免重量级同步机制的能力决定于数据结构的正确性和可见性方面的要求。最近,有人对在常见的访问模式中为满足特定正确性约束而使用代价比较高的同步指令的必要条件进行了形式化分析。4

原子性Read-Modify-Write操作代价高

原子read-modify-write操作的代价取决于底层的体系结构以及实际编程语言的实现。TSO正在成为商用处理品上越来越常见的内存模型。除了其它的,这种内存模型将原子性操作定义为具有整体定序特性。即使不出现争用,这种保证的代价也很高昂。表1所示为没有争用的情况下原子性CAS (compare-and-swap,比较并交换)操作 (lock cmpxchg指令)相对于普通的CAS 操作(cmpxchg指令)的总体性能对比情况。原子性CAS可以在多个处理器之间提供整体定序和原子性保证,而普通CAS无法做到。这些测量结果是在运行于2.30 GHz主频的Intel Core i7-3615QM之上进行的,它还包括了寄存器移除的开销。

Nonblocking Algorithms and Scalable Multicore Programming

在TSO型的体系结构中,这些原子性操作隐含着重量级的内存屏障语义。即使在具有比较弱的内存模型的体系结构中,原子性操作可能也是采用会在管线中导致比较长的依赖链的代价的负责指令实现的。在其它情况下,编程语言本身可能会为这些指令定义一个整体定序特性,这可能会导致产生一些不必要的重量级内存栅栏指令。只要在必要的情况下才应该使用原子性操作。在大多数体系结构中,即使不出现争用的情况,原子性read-modify-write指令代价也都比较高(相对于其它指令来讲)。

在决定使用原子性操作前,还应该考虑实际的原子性实现方面的进度保证。例如,有些依赖于LL/SC (load-linked/store-conditional primitives,装载-链接/载入-条件性原语)的体系结构,比如ARM和Power体系结构中,即使在不出现争用的情况下,仍然有可能会使应用程序对象抢占这样的外部干扰(jitter,抖动)非常敏感。

下面讲讲如何利用原子操作的手段提供的fetch语义。如果平台提供了能够返回原值的原子性的fetch-and-increment操作或者原子性的CAS操作,那么就使用这些操作吧。

int shared_flag = 0;

void
worse(void)
{
     int old_value = 0;

     /*
      * Under contention, this may involve a write invalidation
      * followed by another transition to shared state (at least
      * two probes).
      */
     while (atomic_cas(&shared_flag, old_value, 1) == false) {
           old_value = atomic_load(&shared_flag);
     }
}

void
better(void)
{
     int old_value = 0;

     int snapshot;

     while (true) {
           /*
            * We generate a single write cycle to retrieve the
            * value we are comparing against. This can reduce
            * cache coherency traffic by at least 50% compared
            * to the previous worse() usage pattern.
            */
           snapshot = atomic_cas_value(&shared_flag, old_value, 1);

           /* Operation has completed, exit loop. */
           if (old_value == snapshot)
                 break;

           old_value = snapshot;
     }
}

当心拓扑结构

内存访问的延迟和吞吐量属性会根据内存访问的类型不同而不同。例如,访问远端的缓存可能要比访问本地还没有经过缓存的主内存代价来得低一些,访问远端未经缓存的主内存要比访问本地未经缓存的主内存的代价高。表2所示为主频2.27 GHz的两个插槽12核Intel Xeon L5640  的点对点唤醒延迟。其中的延迟以CPU的TSC (timestamp counter,时间戳计数器)的节拍来计量的。在第一种情形下(本地),在一个核上运行的线程向运行于同一个插槽的核之上的线程发送信号。在第二种情形下(远端),在一个插槽上运行的线程向运行于另外一个插槽的线程发送信号。远端的情况包括了跨越内存互连的开销。这种性能的非对称性就是一种不可忽略的NUMA (non-uniform memory access,非一致性内存访问)系数的例子。NUMA系数越高,这种非对称性就越明显。对要在多核间进行共享的对象所存储的位置进行优化可以减小NUMA系数。例如,如果共享状态都是从一个单个的插槽之上的一组线程进行访问的,那么,我们就没有理由非要把该对象保存于在远端的内存中。

Nonblocking Algorithms and Scalable Multicore Programming

在一个系统中,如果对象要在多个插槽间进行共享,一定要根据负荷,确保将该对象存储在让最多的核跨过最小的距离就能够访问到的位置上。有NUMA意识的操作系统内核,,比如Linux和Solaris,缺省配置下具有一种首次访问策略,该策略可以用来决定内存页的安排。内存页的安排由第一个访问它的线程在NUMA层次结构中所处的位置决定。这种行为是可配置的。24,25

不具有NUMA意识的同步对象可能也会受到NUMA效果的影响。这种影响不仅会表现为多核间最快路径性能的失配,而且还会表现为互斥等待(starvation)或者甚至在负荷较高的情况下发生活锁(livelock)的情况。

表3显示了两个线程在同一socket上的自旋锁执行lock-unlock操作的吞吐率(本地的场景),以及对于不同的socket的表现(远程的场景)。单位是a/s (每秒完成数)。远程的场景下可以看到饥饿(译者注:即前面翻译的互斥等待(starvation),指一个线程长时间得不到需要的资源而不能执行)有非常明显的增长。

Nonblocking Algorithms and Scalable Multicore Programming

公平的锁可以保证没有饥饿,代价是增加抢占性的灵敏度。这些锁机制的变种也支持可伸缩的点对点的唤醒机制。最近,开发出了分组加锁的方式,作为加锁方面支持NUMA感知的通用方法,换来的是更高一些的fast-path算法的时延。10 另外还有其他一些NUMA感知的变体,可以支持常用的一些原语,例如读写锁。 5,18

可预测性问题

可预测性在需要苛刻延迟或者吞吐量的高性能并发应用中是一个很重要的特性。同步机制的进程保障定义了竞争行为和未预见得执行延迟(也可以包括抖动)。如果程序仅仅为快速路径而设计,程序执行中微小的干扰和/或负荷会导致不可预测的低性能。

许多同步原语依赖于灵活的机制以等待资源可用。例如,许多信号量的实现使用了有限时间内的了忙等(轮询),然后转为重量级的轮询或者异步异步信号机制(包括linux下的Futex,或者FreeBSD下的有界yield和sleep)。这些语义在多进程编程环境下允许更好的系统范围内的响应、负载的公平性、提升的能耗属性等等。换言之,在特定的执行序列中他们可能会表现出高的操作延迟毛刺。和简单的忙等机制、甚至无竞争的执行相比,他们也会增加固定的延迟开销。 虽然系统的延迟性在低竞争的情况下是可接受的,但在中等程度竞争或者外部延迟的情况下系统延迟会很快变得不稳定

依赖于阻塞式同步机制的系统会对执行延迟特别敏感。持有同步对象的线程的执行出现显著的延迟就会导致等待该同步对象的所有其它线程都出现显著的延迟。这种执行延迟的例子有进程抢占和定时器中断。这些延迟所产生的后果的严重性取决于应用的延迟约束。如果应用要求延迟范围在毫秒级,那么,即使网络中断所产生的后果也会非常严重。在通用处理器和通用操作系统之上很难做到将引起延迟的所有外部事件都隔离掉。如果采用阻塞式同步机制的程序具需要严格地限制延迟时间,应该考虑将外部干扰较小到最小的程度(常用的方法有实时调度策略和中断线路重排)对于所需的延迟时间尺度下每种干扰都不可忍受的应用来讲,现有专用的实时操作系统和硬件可以拿来克服这些问题。

非阻塞式同步

在查看为什么要采用非阻塞式数据结构的原因之前,本小节要先介绍一些术语,并简单分析一下同非阻塞数据结构的实用设计、实现和应用相关联的复杂性。

在文献中,非阻塞式同步机制和非阻塞式操作大致主要分为三大类算法,其中每一种算法对执行进度的保证都互不相同:

• OF (Obstruction Freedom,无阻碍算法). 该算法在不出现冲突性操作的情况下提供单线程式的执行进度保证。

• LF (Lock Freedom,无锁算法). 该算法提供系统级的执行进度保证。它可以保证,至少有一个活动的操作调用会在有限的步骤后完成执行。它并不能保证不会出现互斥等待。

• WF (Wait Freedom,无等待算法). 该算法按操作提供执行进度保证。一个操作的每个活动的调用都将在有限的步骤内完成。在不出现超负荷的情况下,它可以保证绝不会出现互斥等待。

这三类算法之间是全序关系,无等待的算法也是无锁和无障碍算法;任何无锁算法也是无障碍算法。非阻塞式数据结构实现的操作可以满足为某些(或无限高)层次的并发在非阻塞层次结构(下文中对此结构有综述)中提供执行进度的保证。13 对于有限数量的阅读者或写入者,有可能可以实现一种能够提供非阻塞式执行进度保证的数据结构。Michael Scott的双锁队列就是这方面一个传统的例子,该队列可以在不多于一个并发入队操作和不对于一个并发出队操作的情况下,提供无等待的执行进度保证。23

为不同的操作集实现不同的执行进度保证的数据结构也是可能的。这方面的例子有,入队操作是无等待的但出队操作却是多个消费者间阻塞式的, 该例子可见于URCU (userspace read-copy-update,用户空间的读取-拷贝-更新)库(http://lttng.org/urcu)中; 还有一种入队操作是单生产者无等待的有限FIFO (first in first out,先进先出)但其出队操作是多消费者无锁的,该例子可见于Concurrency Kit library(并发工具库) (http://concurrencykit.org/).

状态空间爆炸问题

非阻塞式数据结构依赖于原子性的载入和存储或者多个复杂的原子性read-modify- write操作。其所采用的机制取决于这种数据结构想要支持的并发水平以及它们的正确性要求。4,13 在基于锁的同步中,通常只要在锁的依赖关系以及临界区(对于象读写锁这样的非对称的基于锁的同步来说,还包括读取侧或者写入侧的临界区)进行论证就足够了。3 非阻塞式算法中并没有临界区,在对这种并发算法的正确性进行论证时,涉及同共享变量进行交互的执行历史在数量上可能会相当巨大。这种状态空间增长迅速,很快就会使得只由人来严格基于状态进行所有论证变得很不可行。请看下面这个程序:

int x = 1;
int y = 2;
int z = 3;

void
function(void)
{
     /*
      * This returns a unique integer value
      * for every thread.
      */
     int r = get_thread_id();

     atomic_store(&x, r);
     atomic_store(&y, r);
     atomic_store(&z, r);
}

假设该程序由两个线程在实现为TSO的内存模型下进行执行,如果仅考虑三个存储操作并认为它们是完全一致和确定的话,就会有大约20中可能的执行历史。要是4个线程,就会存在369,600中不同的执行历史。 推导可得,对于N个具有确定性的进程和M种不同的动作,将存在(NM)! / (M!) N个执行历史。 28 再加上编程语言顺序重排的可能性、处理器内存顺序重排的可能性以及乱序执行,状况空间的复杂度增长的速度将会更快。

对非阻塞式算法的正确性进行验证,特别是对无等待和无锁算法,需要对程序所处的底层内存模型有比较好的理解程度。理解内存模型的重要性无论怎么强调都不过分,特别是在模型本身定义不清晰或者模型还允许根据机器不同或编译器(就象C或者C++这类语言)的不同而表现出不同的行为时更为重要。内存模型中某些看似微不足道的细节都可能会让你的出现在正确性 方面出纰漏。让我们详细看看下面这段C代码:

void
producer_thread(void)
{
     message_t *message;

     message = message_create();
     memset(message, 0, sizeof *message);
     send_to_consumer(message);
}

void
consumer_thread(void)
{
     message_t *message;

     message = message_receive();

     /*
      * The consumer thread may act in an erroneous
      * manner if any of the contents of the message
      * object are non-zero.
      */
     assert(message->value == 0);
}

我们假设send_to_consumer中只有载入和存储指令而无序列化指令。 在实现了TSO的x86处理器中,向内存位置中进行存储的动作是按顺序提交的,但也有一些例外。假设memset函数中只有普通的存储指令,那么,consumer_thread中的那个断言就不会失败。实际情况是,某些特定的指令、操作和内存类型不一定保证会遵守x86的TSO模型。 26 在这个例子中,memset函数可能会实现为高性能的非时序性和/或向量化的存储指令,这类指令会同通常的内存定序语义不符。 关于memset,很有必要确定一下,看看编译器或者标准库实现以及目标平台是否对内存存储序列化提供保证。幸运的是,memset的多数(但不是所有)比较流行的实现都会在使用这些指令时发出一条某种类型(具体类型按照默认的约定决定)的内存屏障指令,但这种做法并不是要求必须做的。如果你是用某种低级语言来设计无锁的并发程序的话,一定做好思想准备,对类似这种隐蔽于难以发现之处的由具体实现来定义的行为做比较详细的调查。

正确性

线性一致性是非阻塞式数据结构和操作中最常见的一种正确性保证。线性一致性要求,操作要以原子性的方式在调用开始到调用结束间的某个时间点上完成执行。 一个操作的线性化时间点(linearization point)指的是该操作看上去已经以原子性的方式执行完毕的那个时刻。关于数据结构的顺序性规格说明,可以在线性化时间点方面进行分析。由于前文所述的原因,对算法的线性一致性进行证明往往都不是一件简单的事情。

为某个固定的并发水平而专门设计的非阻塞式数据结构可以大大降低状态空间的复杂度。然而,考虑到非阻塞式并发算法状态空间潜在的大小,为了验证就必须使用更加高级的测试方法和校验方法。本期ACM Queue杂志中,另外一篇由Mathieu Desnoyers撰写的文章"Proving the Correctness of Nonblocking Data Structures"详细介绍了其中的一些方法。

非托管语言之殇

对于象C和C++之类的无自动内存管理机制的语言来讲,要管理动态分配的无锁对象就需要一些专门的技术了。(对于计划就用象Java和C#这样的具有自动内存管理的语言的人来说,他们可能无需了解本小节的内容)

在基于锁的同步中减少争用的一种比较常见的方案就是将对象的活性同其可获得性解藕。例如,在采用基于锁的同步的并发缓存实现中可能会有一个保护缓存的互斥量,但还会有另一个单独的用于保护对缓存中所保存的对象的并发访问的互斥量。这种方案通常采用带内应用计数器(in-band reference counter)来实现:

cache_t cache;
object_t *object;

object_t *
cache_lookup(int key)
{
     object_t *object;

     lock(cache);

     /* Returns object associated with that key. */
     object = cache_find(key);

     /* No object found associated with that key. */
     if (object == NULL) {
           unlock(cache);
           return NULL;
     }

     /*
      * Increment reference counter which was initialized to
      * 1 when inserted into cache.
      *
      * The actual reference counter is contained or is in-band
      * the actual object it is reference counting.
      */
     atomic_increment(&object->ref);
     unlock(cache);
     return object;
}

/* Remove an object from cache. */
void
object_delete(object_t *object)
{

     lock(cache);
     cache_remove(object);
     unlock(cache);

     /* Remove the cache reference. */
     object_delref(object);
}

void
object_delref(object_t *object)
{
     int new_ref;

     /*
      * Atomically decrements the integer pointed to
      * by the argument and returns the newly decremented
      * value.
      */
     new_ref = atomic_decrement(&object->ref);
     if (new_ref == 0) {
           /*
            * If the reference count is 0 then the object
            * is no longer reachable and is not currently
            * being used by other threads, so it is safe to
            * destroy it.
            */
           free(object);
     }
}

这个方案允许并发操作一边访问取自缓存的对象,一边仍然继续获取对象(可是注意这可能对高频,短期交易不具有可伸缩性,因为存在对计数器引用的竞争)。这个方案能运行于现有例子,是因为可获取对象能通过计数器引用被自动的管理。用别的话来说,永远不会有计数器引用为0而对象仍然在缓存中的状态。如果仍然有关于对象的引用,对象就不会被销毁。这种情况下,计数器引用提供了这样一个机制,即允许程序安全回收与并发访问对象相关的内存,而这些对象是否能获取是由缓存状态决定的。

在现代的处理器上,非阻塞数据结构由原子操作实现了,这个原子操作只能一次修改一个目标内存地址。假设前面的缓存例子实际上变成了无锁状态。内存中对象是否能够获取,有可能是由一系列原子操作(操作某个内存位置)的点构成的线决定的。为了使计数器引用方案运行,对象是否能获取必须考虑到正在传入的引用,必须被自动管理。如果没有原子操作(操作不同内存位置),这种常见的带内引用计数方案(in-band reference-counting scheme)将无法提供足够的安全保障。

由于这个原因,动态非阻塞和无锁的数据结构(动态分配内存进行访问的结构,这个内存也可以在运行时被释放)通常必须依赖其他安全的内存回收机制。除了全面的垃圾收集,安全的存储——回收技术包括:

• EBR (基于Epoch的回收技术)。11 这是一种被动式的方案,它是通过仔细监视全局Epoch计数器来进行安全的内存回收的。该方案不适宜用于保护创建和销毁频率都非常高的对象。采用EBR方案试图进行同步(直接)对象销毁的线程在方案探测到安全的对象销毁时间点之前,可能会被置于阻塞状态。该方案实现时进行部分修改还可以在最快路径上将开销减小到最小。

• HP (危险指针)。22 该方案通过对需要安全内存回收的对象引用进行现场探测来实现。为了利用该方案,非阻塞式算法要求使用该方案专用的对对象的修改方法。然而,它更适宜于保护创建和销毁频率都非常高的对象,以及想要销毁受保护对象的线程不要求在无限时长内都处于阻塞状态的场合。该方案在快速路径之上开销会很大,可能会不适宜用于遍历工作量很大的场合。

• QSBR (基于静止状态的回收技术)。8,20 这是一种被动式的方案,,它是通过对程序中的静止状态进行探测来进行安全的内存回收的,在静止状态下不可能存在对具有活动对象的引用。该方案不适宜用于保护创建和销毁频率都非常高的对象。 采用QSBR方案试图进行同步(直接)对象销毁的线程在方案探测到安全的对象销毁时间点之前,可能会被置于阻塞状态。该方案实现时进行部分修改还可以在最快路径上将开销降到0。

• PC (代理集合). 这是一个带外分期注销引用计数方案。

此外还有一些安全内存回收机制。14 Thomas Hart等人对上面的前三种方案在性能方面做了一个对比。12 请注意,同应用计数相比,这些方案在大多数场合下性能都比较很不错,而且它们也绝非只能专用于非阻塞式数据结构之中。在本期ACM Queue中的另外一篇文章"Structured Deferral: Synchronization via Procrastination"中,Paul McKenney对安全内存回收进行更加深入细致的探讨。

适应性

要求具有以下特性的系统可能会从无锁和无等待的算法中获益:

• 摆脱死锁 无需加锁意味着无等待和无锁的数据结构不会发生死锁的情况,但死锁在锁的层次结构大且复杂的情况下是很难避免的。

• 异步信号的安全性 能够摆脱死锁并能够在临界区中的异步中断执行的情况下提供一致性可不是一个小问题。无等待和无锁的同步具有

• 终止执行的安全性 能够满足线性一致性的无等待和无锁的操作可以在任何时刻打断其执行过程。这就意味着,处理器或者线程在执行无等待和无锁操作的过程中终止执行将不会对系统的整体可用性造成致命破坏。

• 对抢占的包容性 避免等待之后,任何操作都可以保证在可用资源充足的情况下能够在有限的步骤中完成执行,即使发生抢占或出现其它的外部延迟也没有关系。避免使用锁就能够保证系统的整体执行进度,因为在某一个线程中出现延迟只能导致在另外一个线程中出现有限时长的延迟(线性一致性可能会要求其它的线程一起辅助完成被延迟后的操作,这可能会要求在快速路径之上加入额外的指令)。

• Priority Inversion Avoidance. 在不出现诸如内存之类的资源紧张的情况下,避免了等待就能够为优先级逆转提供紧约束保证,但这么做经常会给快速路径带来比较高昂的开销。这种额外的开销可以通过为固定水平的并非设计专用的算法来避免。避免了锁就能够在单处理器的系统中以比使用正确的调度策略的基于锁的同步更低的开销避免优先级逆转。2 在有更高级别争用的多处理器的系统中,有限的优先级逆转是很难避免的(其困难程度是底层所采用的一致性机制所提供的公平性和优先化保证,而底层一致性机制往往对这二者几乎都没有提供什么保证)。即使不出现内存资源紧张的情况,避免使用锁之后仍然无法避免出现不受限的优先级逆转,因为较低优先级的线程仍然可能会导致更高优先级线程出现任意时长的延迟。争用避免计数可以用于避免发生这种情况。

将抽象模型拉回现实

有一点很重要,就是要将非阻塞式同步机制的执行进度保证放到真正硬件环境中进行讨论。在现实世界的系统中,无等待的执行进度保证可能会在内存和一致性资源出现比较严重的争用水平时出问题。在这种争用水平下,处理器可能会出现处于工作不饱满状态而操作执行的完成度却低到让人无法接受的程度。此时你的程序的执行进度保证仅能达到它所基于的并发原语以及底层一致性机制所提供的水平。但幸运的是,随着互连带宽不断地增长以及互连延迟地持续下降,这个问题的严重性也随之降低。

当然,基于锁的同步机制也难以避免此问题。在不出现内存紧张而出现抖动的情况下,同基于锁的同步机制相比,无等待算法可以提供更高的有限延迟保证(这事因为它对外部延迟的容忍度更高)该延迟的时长是底层微体系结构、内存资源和争用的一个函数。

在非阻塞式数据结构中写操作的快速路径延迟通常比基于锁的方案的快速路径的延迟高。在为任意水平的并发而设计的非阻塞式对象中这种折中特别常见,因为它们要求在快速路径中使用一个或多个重量级原子性read-modify-write操作。在快速路径延迟中的这种折中是底层平台中锁的实现代价和非阻塞式算法复杂度的函数。

这个概念可以通过将用自旋锁保护的栈和无锁的栈(其实现采用的是来自的concurrencykit.org的ck_stack.h )进行对比来说明。无锁栈在压栈和出栈操作中包含有一个单个的比较并交换指令。在x86体系结构中,基于自旋锁的栈实现的快速路径延迟远远低于无锁的栈实现。在Power 7体系结构中,情况却不然。表4 显示了这些各种不同操作的无竞争延迟(单位为时钟节拍)。

Nonblocking Algorithms and Scalable Multicore Programming

在x86中,自旋锁。可以采用一个代价比无锁栈所采用的原子性操作小不少的原子性操作(该例中所指为xchg指令)来实现。该体系结构的TSO并不要求该算法使用显式的内存屏障指令。相反,无锁栈的实现需要使用更为复杂的cmpxchg(以及cmpxchg16b)指令,这些指令呈现更高的基线延迟。

在Power体系结构中,自旋锁和无锁实现都基于相同的底层LL/SC原语。其中的主要差别在于自旋锁需要在每次调用栈操作时都要使用重量级的内存屏障指令,而非阻塞栈只需使用较为轻量级的载入并存储的内存屏障指令。

不考虑体系结构所带来的在延迟方面的折中,非阻塞式数据结构令人满意的特性会在争用的情况下显露出来。图6所示为在一台x86服务器(Intel Xeon L5640)之上,4个线程同时对一个单个的栈进行入栈操作时延迟的分布情况。其中积极采取了以下措施来避免抖动,所采取的措施包括采用SCHED_FIFO调度类、核亲和性以及IRQ (中断请求)亲和性(Linux 2.6.32-100.0.19.el5).

Nonblocking Algorithms and Scalable Multicore Programming 图6所示的延迟分布情况并 是对单个栈操作采取更加严格的延迟约束所造成的结果,而是无锁算法提供的更加严格的 系统级执行进度保证所产生的副作用。更加特别的是,无锁算法能够在出现抢占的情况下也可以保证执行进度。在阻塞式数据结构中,在堆栈操作的临界区中有一个线程被抢占或阻塞就会影响系统级的执行进度。相反,无锁栈只有在另外一个线程执行了更新栈的操作才会强制进行栈操作的重试。如果在测试系统中完全排除抖动源,基于自旋锁的栈的延迟特性就会胜出。

涉及将只写和只读操作结合起来的非对称负载也能够从非阻塞式同步机制中获益。通常在结合使用前文所述的安全内存回收技术的情况下,就有可能在快速路径中少量或根本不使用重量级同步指令,为读取线程提供比较强的执行进度保证。这是一个让人称心如意的特性:它避免了流行的单方面优先读写锁所呈现的写方/读方许多的公平性问题,同时它还避免了同较为重量级的公平读写锁相关的快速路径性能下降的问题。

结束语

查看了多处理器系统中并行编程的一些通用原则,以及非阻塞式同步机制在现实世界中实际使用时所隐含的问题,本文旨在为读者提供探索基于锁的同步机制的替代方案所需的背景知识。尽管非阻塞式算法的设计、实现和验证都很困难,但是这些算法在标准库和开源软件中越来越流行。本文提供的信息可以帮助大家找出需要非阻塞式同步机制的性能特性和适应性的场合。

要是你已经开始冒险去设计自己的非阻塞式算法了,本期杂志中的其它文章会为你提供许多珍贵的知识(还请记住要利用机会专为特定负载对设计做出简化)。

鸣谢

感谢Mathieu Desnoyers和Paul McKenney在无锁同步机制方面为开源社区所做的贡献,并感谢为本文所做的富有洞察力的反馈意见。感谢Devon H. O'Dell和Gabriel Parmer为本文提供的反馈意见并一直在我写这篇文章的时候陪伴着我。感谢Theo Schlossnagle在本文撰写之初所提供的支持。感谢Silvio Cesare, Wez Furlong, Maxime Henrion, David Joseph, Paul Khuong, Abel Mathew, Shreyas Prasad, Brendon Scheinman, Andrew Sweeney, and John Wittrock,你们的反馈意见对我很有帮助。感谢Jim Maurer、编委以及ACM Queue团队的其他成员对我的支持以及给我建议。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值