也談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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM