十月, 2022
让我们先来了解一些基本的问题。Arm在Arm v8.2-A架构中引入了大型系统扩展(Large System Extensions, LSE),它用单个原子指令取代了锁操作的指令序列。这里(https://dev.to/aws-builders/large-system-extensions-for-aws-graviton-processors-3eci)是一个非常不错的总结。虽然旧的Arm版本在功能上可以很好地工作,但随着核心数量的增加和锁的争用更加频繁,预计性能会受到影响。Ampere Altra 和 Ampere Altra Max支持LSE,并配备了可扩展的锁性能。
为了说明使用的指令之间的差异,让我们看看gcc的处理方式
__atomic_fetch_add(&lockptr->lockval, -1, __ATOMIC_ACQ_REL);
使用* -march =armv8.2-a*选项编译,编译器生成带有原子指令的代码:
998: f8f60280 ldaddal x22, x0, [x20]
另一方面,设置* -march =armv8-a*(不支持LSE),生成一个不同的序列:
9a4: c85ffe60 ldaxr x0, [x19]
9a8: d1000400 sub x0, x0, #0x1
9ac: c801fe60 stlxr w1, x0, [x19]
9b0: 35ffffa1 cbnz w1, 9a4 <main+0x104>
为了使序列具有原子性,需要一个单独的监视器。ldaxr获得一个地址标记,在本例中为[x19]。然后执行减法,然后存储回内存位置。但是,只有当存储(store)时的标记与加载(Load)中的标记匹配时,存储才会成功。stlxr之后的条件分支cbnz检查存储是否成功,这意味着load和store中的标记匹配。如果不是,则跳回序列的开头,在本例中是地址0x9a4。
这里值得注意的是,如果没有LSE指令,这个指令序列可能要执行几次才能被认为成功。使用LSE, ldaddal指令可以保证以一条指令完成,不需要循环。
图1显示了当线程数从1增加到80时,使用LSE和不使用LSE时每秒获得排他锁的性能差异。
通常,Compare和Exchange硬件指令用于在软件中实现锁。需要注意的是,这些指令必须是原子指令。
原子在这里是什么意思呢?这些指令首先获得包含锁的缓存行(Cache Line)的所有权,并将其加载到CPU的本地缓存中。然后将当前值与随指令提交的比较值进行比较。如果相等,作为指令一部分提交的新值将替换当前值。如果不相等,则保持当前值。这方面的原子性意味着整个序列由一个线程执行,而没有其他线程访问缓存行,由硬件保证。
在软件中可以实现不同类型的锁,如互斥锁(mutexes)、票据锁(ticket)和自旋锁(spinlocks)。如前所述,不同的锁类型在软件中实现,硬件提供类似cmpxchg或fetchadd的指令。相同的锁类型在不同的硬件上运行,只有使用的指令不同。
这是一个非常重要的问题。让我们把它分解成两个选项:1)使用可用的库和2)使用原子指令来实现专有的锁定算法。
选项1有几个优点。库已经存在,不需要自定义实现,而且经过了充分测试,通常将会在未来的库版本中进行维护。例如pthread_mutex_lock和pthread_rwlock。听起来不错,那么有什么缺点呢? 缺乏统计数据可能是一个问题。没有向应用程序返回任何信息,报告旋转(spins)或线程被调度出多少次。此外,库实现可能不太适合某些应用程序,因为库更通用。
选项2更复杂。它需要实现锁定函数并维护它们。但是,它可以获得一些好处,因为它是专门为应用程序设计的。锁定原语和原子指令可以通过内联汇编(inline assembly)或利用编译器的支持来实现。同样,使用内联程序集编写代码需要应用程序维护该段代码。
对于编译器,gcc提供了atomic built-in function,它允许应用程序使用低级函数,这些函数将被编译成Arm原子指令。这些内置函数为应用程序提供了原子指令和内存序指令的不同方法。代码也更易于移植。但是,使用*-mcpu或-march*的正确设置来编译应用程序来生成Arm LSE指令是很重要的。Ampere Altra和Ampere Altra Max使用Neoverse-n1架构,其中就包括LSE。
然而,使用原子指令实现锁需要设计决策。如果锁被持有,旋转(spinning)是否合理?转几圈?线程在旋转一定次数后如果不成功,是否应该放弃?在旋转环中需要后退,还是直线旋转? 这些只是需要解决的问题中的一部分。
锁的数据类型和大小。通常,应用程序使用int或long作为锁。用于原子操作的内置函数(Built-in functions)从内存中读取锁值。如果应用程序也直接读取锁值,锁类型应该有“volatile”前缀,例如volatile long。使用volatile,编译器生成从内存中读取数据的指令。否则,该值可能在寄存器中而没有更新,从而错过对锁位置的更新。
锁的粒度。由于竞争,粗粒度锁有可能成为性能瓶颈。另一方面,如果每个资源都有自己的锁来保护,那么将需要大量内存来存储锁。必须是一种折衷设计,以避免任何不利因素。
锁对齐。编译器对结构进行正确对齐。如果应用程序管理自己的内存,那么锁的位置可能与锁的大小不一致。在最坏的情况下,锁可能跨越两条缓存行。在AArch64上,对未对齐锁的原子操作会导致SIGBUS(硬件向操作系统发出信号,表明CPU不能寻址内存地址的总线错误,在这种情况下是由于未对齐访问)。从积极的方面来看,获得SIGBUS需要固定对齐,而不是隐藏很少被发现的性能问题。
假共享。虚假分享是什么意思?即同一高速缓存行上的独立数据对性能有不良影响,锁数组就属于这一类。这些锁保护不同的关键区域。但是,对同一缓存行上锁的原子操作会影响该缓存行上的所有锁。重要的是,原子性不是针对锁本身,而是针对包含锁的整个缓存行。
在cmpxchg之前做测试。在执行cmpxchg指令之前读取自旋循环(spinloop)中的锁值可能对争用锁有利。Cmpxchg需要缓存行的所有权,而test将以共享模式获取缓存行,从而避免失效。然而,这可能会增加执行的spin数量。
如果可能的话,在无锁时使用fetchadd而不是cmpxchg。释放锁需要返回线程为获取锁而执行的操作。Cmpxchg,特别是对于共享锁或读写锁,需要一个循环,并且由于锁值的变化而可能会重试操作。然而,fetchadd不需要循环,没有比较,因此它会成功。
锁定持有时间。通常指临界区域内的指令数或在临界区域内花费的时间。时间是一个更好的度量标准,因为临界区域可能只有很少的指令。然而,所有的指令都可以从内存中读取。嵌套锁属于同一类别。无法获得内部锁以及spinning或sleeping会影响外部锁的保持时间。减少关键区域的保持时间总是好的,如果数据在本地缓存而不是内存中就更好了。
抢占。不幸的是,线程在持有锁时可能会被重新调度。如果锁处于独占模式,这意味着没有其他线程能够获得锁。在考虑性能问题时,要记住这一点。较短的保持时间将降低抢占的可能性。
如前所述,正确的内存排序指令对于正确性很重要。AArch64遵循一种宽松的内存模型。使用LSE, AArch64指令强制执行特定的内存顺序。例如,cmpxchg指令集有获取(CASA指令)、释放(CASL指令)以及获取和释放(CASAL指令)的版本。硬件保证这些指令遵循其特定指令的内存模型。这取决于软件使用适当的指令。通常,acquire用于锁获取,Release用于锁释放。但是,如果应用程序在无锁之后读取数据(例如,如果该锁有任何等待程序),那么空闲程序的release语义可能会导致问题,因为对等待程序结构的读取可能会提升到空闲程序之上,因此在空闲程序之后,寄存器中就会出现陈旧的数据。在这些情况下,最好使用acquire和release语义。同样,这取决于应用程序实现。gcc编译器直接在内置函数中使用这些指令,如这里所示。
Ampere 系列处理器正以其持续增加的核心数量不断挑战性能极限,并具备使用锁的多线程应用程序可扩展性的所有要素。使用LSE,在硬件中提供原子指令以获得更好的锁性能。正如我们在本文所看到的,应用程序开发人员可以通过锁库或正确实现锁定算法来充分利用这些指令。