也谈ARM内存一致性

上周同事讨论ARM内存序问题, 正好是感兴趣的方面于是就研究了一下, 可惜电脑爆炸了拖到今天才恢复正常.

内存序问题的由来

内存序(memory ordering)是指处理器访问内存的顺序, 在传统的in order处理器上对内存的访问顺序由compiler在编译期间决定, 处理器顺序执行指令流, 因此通常不存在内存一致性的问题.
然而现代处理器往往都支持乱序执行(处理器内部会对指令流进行重排, 优先发射已经ready的指令, 尽可能提升处理器利用率), 乱序执行带来的一个问题是可能导致的对load / store指令的重排, 导致程序执行顺序与程序员预期的结果不一致.
以下用一个简单的例子说明:

producer() {
  x = 1;
  y++;
  x = 0;
}

consumer() {
  while (x);
  return y;
}

这里我们首先假设

  1. x与y的初值均为0, 对x与y的访问/修改都是原子的
  2. 有两个线程分别执行两个函数(即最简单的生产者-消费者模型)

在in order处理器上程序执行的结果与程序员预期的一致: 生产者加锁(x = 1)并修改y再解锁, 消费者阻塞直到获取锁(x == 0)再获取y, 终值y = 1
但在out of order处理器上结果却是无法预计的: 由于不存在数据依赖/控制依赖, 在消费者线程中y++可能被重排至x = 1之前或x = 0之后, 对前者而言资源(y)的修改不在原子锁(x)的保护范围内, 对后者而言消费者获取的终值更是错误的0.

内存一致性模型

memory consistency model分类可以见这里或者这里.
简单来说一般可以分为:

  1. Sequential consistency
    all reads and all writes are in-order
  2. Relaxed consistency
    some types of reordering are allowed:
    Loads can be reordered after loads (for better working of cache coherency, better scaling)
    Loads can be reordered after stores
    Stores can be reordered after stores
    Stores can be reordered after loads
  3. Weak consistency
    reads and writes are arbitrarily reordered, limited only by explicit memory barrier
    从上往下限制依次放宽, 更宽松的限制自然允许更高效的利用硬件, 相对应的程序员越需要小心谨慎编码.
    以下是部分架构支持的内存reorder:

其中
RISCV一列:
WMO - Weak memory order (default)
TSO - Total store order (only supported with the Ztso extension)
SPARC一列:
TSO - Total store order (default)
RMO - Relaxed-memory order (not supported on recent CPUs)
PSO - Partial store order (not supported on recent CPUs)

解决内存一致性问题

由于reorder的存在, 仅仅依靠锁已经不能保证程序的正确性(上文例子). 为解决内存一致性问题需要在锁中引入内存屏障(memory barrier)来维护内存顺序.
下面以musl(一个轻量级标准c库)为例看看如何实现安全的同步机制, 这里可以找到非官方镜像.
musl支持nptl标准接口, 其代码见:

[01:54:02] hansy@hansy:~/source/5.musl (master)$ cat src/thread/pthread_mutex_lock.c 
#include "pthread_impl.h"

int __pthread_mutex_lock(pthread_mutex_t *m)
{
	if ((m->_m_type&15) == PTHREAD_MUTEX_NORMAL
	    && !a_cas(&m->_m_lock, 0, EBUSY))
		return 0;

	return __pthread_mutex_timedlock(m, 0);
}

weak_alias(__pthread_mutex_lock, pthread_mutex_lock);

注意到__pthread_mutex_lock()首先尝试原子锁操作(a_cas()), 如果失败退化为悲观锁(__pthread_mutex_timedlock()). 我们关注的重点即原子锁的实现, 其定义见架构相关目录arch/aarch64/atomic_arch.h

[01:59:00] hansy@hansy:~/source/5.musl (master)$ cat arch/aarch64/atomic_arch.h 
#define a_ll a_ll
static inline int a_ll(volatile int *p)
{
	int v;
	__asm__ __volatile__ ("ldaxr %w0,%1" : "=r"(v) : "Q"(*p));
	return v;
}

#define a_sc a_sc
static inline int a_sc(volatile int *p, int v)
{
	int r;
	__asm__ __volatile__ ("stlxr %w0,%w2,%1" : "=&r"(r), "=Q"(*p) : "r"(v) : "memory");
	return !r;
}

#define a_barrier a_barrier
static inline void a_barrier()
{
	__asm__ __volatile__ ("dmb ish" : : : "memory");
}

#define a_cas a_cas
static inline int a_cas(volatile int *p, int t, int s)
{
	int old;
	do {
		old = a_ll(p);
		if (old != t) {
			a_barrier();
			break;
		}
	} while (!a_sc(p, s));
	return old;
}

其中
a_ll()实际调用了aarch64的ldaxr(load acquire exclusive)指令, 返回load给定地址的值.
a_sc()实际调用了aarch64的stlxr(store release exclusive)指令, 返回1即成功更新给定地址的值.
a_barrier()实际调用了aarch64的dmb(data memory barrier)指令, 制造内存屏障.
a_cas首先调用a_ll()读取给定地址的值, 若结果与给定输入(t)不同即代表锁被占用, 直接返回失败. 否则尝试调用a_sc()改写该地址, 若改写成功退出循环, 否则重新循环.
为何会改写失败? 由于是独占访问硬件会设置monitor, 若load-store期间别的程序/硬件访问该地址则检测失败. 伪代码如下:

// ldaxr
if ConditionPassed() then
  EncodingSpecificOperations();
  address = R[n];
  SetExclusiveMonitors(address, 2);
  R[t] = ZeroExtend(MemO[address, 2], 32);

// stlxr
if ConditionPassed() then
  EncodingSpecificOperations();
  address = R[n];
  if ExclusiveMonitorsPass(address,4) then
    MemO[address, 4] = R[t];
    R[d] = ZeroExtend('0');
  else
    R[d] = ZeroExtend('1');

另一个问题是为何在load失败时需要做barrier? 在解释这个问题前我们先回退代码看看旧版的实现.

[02:27:26] hansy@hansy:~/source/5.musl (master)$ git checkout aa0db4b5d08ff6ac180a93678d8fd1799569a530
#define a_ll a_ll
static inline int a_ll(volatile int *p)
{
	int v;
	__asm__ __volatile__ ("ldxr %0, %1" : "=r"(v) : "Q"(*p));
	return v;
}

#define a_sc a_sc
static inline int a_sc(volatile int *p, int v)
{
	int r;
	__asm__ __volatile__ ("stxr %w0,%1,%2" : "=&r"(r) : "r"(v), "Q"(*p) : "memory");
	return !r;
}

#define a_barrier a_barrier
static inline void a_barrier()
{
	__asm__ __volatile__ ("dmb ish" : : : "memory");
}

#define a_pre_llsc a_barrier
#define a_post_llsc a_barrier

#define a_cas_p a_cas_p
static inline void *a_cas_p(volatile void *p, void *t, void *s)
{
	void *old;
	__asm__ __volatile__(
		"	dmb ish\n"
		"1:	ldxr %0,%3\n"
		"	cmp %0,%1\n"
		"	b.ne 1f\n"
		"	stxr %w0,%2,%3\n"
		"	cbnz %w0,1b\n"
		"	mov %0,%1\n"
		"1:	dmb ish\n"
		: "=&r"(old)
		: "r"(t), "r"(s), "Q"(*(void *volatile *)p)
		: "memory", "cc");
	return old;
}

首先注意到旧版使用的是ldxr / stxr指令(即未包含acquire-release语义), 因此仅仅保证了对锁的原子操作. 为防止原子操作前后的load / store被重排, 还需要在加锁前后分别执行一次barrier, 保证加锁前后的代码不会进入临界区.
而aarch64引入了带acquire-release语义的独占访问:

注意到

  1. 对load-acquire指令而言, 保证了lda后的指令不会被reorder到其之前(lda前的指令顺序不能由lda保证).
  2. 对store-release指令而言, 保证了stl前的指令不会被reorder到其之后(stl后的指令顺序不能由stl保证).
    即它们分别实现了一半的barrier功能, 两者组合起来形成一个临界区, 临界区前后的指令不能跨越临界区调度. 而在lda失败时需要做barrier的原因也找到了: 仅仅acquire语义不能保证一个完整的临界区.
    然而当我们进一步思考这个问题时, 会发现仅仅这样的临界区代码并不能完全保证full barrier. 思考如果有这样的指令序列: load->lda->stl->store, 其中普通load与store访问同一地址. 那么,
    load本身不能被调度到stl后, 但可以调度到lda->stl之间, 同理store也一样. 那么在临界区内load与store的顺序没有必然的保证! 这是可能发生的吗? 还真有, 参考以下kernel patch:
    http://lists.infradead.org/pipermail/linux-arm-kernel/2014-February/229588.html
    这里提到了这种情况, 鉴于内核对full barrier的需求, 因此还需要在stl后做barrier, 保证barrier后的指令不会进入临界区.

小结:

  1. 以后再补.
  2. 补充说明: 参考Barrier Litmus Tests and Cookbook 7.2 Acquiring and Releasing a Lock
posted @ 2020-11-03 03:07  Five100Miles  阅读(1658)  评论(0编辑  收藏  举报