计算机体系结构-量化研究方法第五版P288中的一段摘录:
在多处理器中实施同步时所需要的关键功能就是一组能够以原子方式读取和修改存储器位置的硬件原语,没有这一功能,构建基本同步原语的成本就会过高(单核关中断,多核硬件同步,成本都很高),并随着处理器数目的增大而增大,基本硬件原语有许多替代方式,所有这些方式都能够以原子形式读取和修改一个位置,还有某种方法可以判断读取和写入是否以原子形式执行。
原子操作的分类
1. FetchAdd。该请求包含一个操作数(数据地址不算做操作数),即需要累加的值A。该请求对应四步操作:读取目的地址原始值O -> 两数补码相加求和(O补+A补,忽略进位与溢出) -> 把求得的和sum写入目的地址 -> 返回目的地址原始值O。
2. Swap。该请求包含一个操作数(数据地址不算做操作数),即需要交换的值S。该请求对应三步操作:读取目的地址原始值O -> 把值S写入目的地址 -> 返回目的地址原始值O,x86平台上使用xchgl指令实现。
3. CAS(Compare and Swap)。该请求包含两个操作数(数据地址不算做操作数),即需要比较的值C及需要交换的值S。该请求对应四步操作:读取目的地址原始值O -> C与O进行比较 -> 若O=C即把S写入目的地址 -> 返回目的地址原始值O。CAS在X86平台上用cmpxchg指令实现,在ARM平台上有专用的CAS指令,在RISCV平台上,则根据是否支持原子扩展,可以基于基础指令集LR/SC指令实现,也可以根据指令amoswap实现。
此锁是通过原子交换指令实现的,原子交换在不同的架构上有不同的名字,在X86上,是通过指令xchgl实现的,而在ARM平台上,则提供了swp指令,无论哪种方式,原理类似,都是通过引入一种原子操作,让第一次读(发现锁空闲)和下一次写(写入数值1)操作成为一个完整的整体。期间不允许其它的核访问打断。那么便可以保证一次只能有一个核上锁成功。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
typedef struct
{
volatile unsigned long count;
} atomic_t;
void atomic_inc(unsigned long *ptr)
{
__asm__("incl %0;\n"
: "+m"(*ptr));
}
static inline int atomic_xchg(atomic_t* v, int i)
{
int ret;
asm volatile(
"lock; xchgl %0, %1"
:"=r"(ret)
:"m"(v->count),"0"(i)
);
return ret;
}
void mutex_lock(atomic_t* v)
{
while (1 == atomic_xchg(v, 1)) {
sched_yield(); //获取锁失败后,放弃CPU
}
}
static atomic_t atomic;
unsigned long counter = 0;
void mutex_unlock(atomic_t* v)
{
v->count = 0;
}
void *read_msg_server(void *p)
{
while(1)
{
mutex_lock(&atomic);
atomic_inc(&counter);
printf("%s line %d. counter %ld.\n", __func__, __LINE__, counter);
mutex_unlock(&atomic);
}
return NULL;
}
void *write_msg_server(void *p)
{
while(1)
{
mutex_lock(&atomic);
atomic_inc(&counter);
printf("%s line %d. counter %ld.\n", __func__, __LINE__, counter);
mutex_unlock(&atomic);
}
return NULL;
}
void *final_msg_server(void *p)
{
while(1)
{
mutex_lock(&atomic);
atomic_inc(&counter);
printf("%s line %d. counter %ld.\n", __func__, __LINE__, counter);
mutex_unlock(&atomic);
}
return NULL;
}
int main(void)
{
atomic.count = 0;
pthread_t pthread1;
pthread_t pthread2;
pthread_t pthread3;
int err = pthread_create(&pthread1, NULL, read_msg_server, NULL);
if(err != 0)
{
perror("create pthread failure.");
return -1;
}
err = pthread_create(&pthread2, NULL, write_msg_server, NULL);
if(err != 0)
{
perror("create pthread failure.");
return -1;
}
err = pthread_create(&pthread2, NULL, final_msg_server, NULL);
if(err != 0)
{
perror("create pthread failure.");
return -1;
}
pthread_join(pthread2, NULL);
pthread_join(pthread1, NULL);
pthread_join(pthread3, NULL);
return 0;
}
总线锁
为了支持原子操作,以ARM指令集架构为例,ARM架构早期引入了原子交换指令SWP,该指令同时将存储器中的值读出至结果寄存器,并将另一个源操作数的值写入存储器中相同的地址,实现通用寄存器中的值和存储器中的值的交换。并且,再第一次读操作之后,硬件便将总线或者目标存储器锁定,直到第二次写操作完成之后才解锁。期间不允许其它的核访问。这便是再AHB总线中开始引入LOCK信号支持总线锁定功能的由来。
有了SWP指令和硬件锁定总线功能的支持,每个核便可以使用SWP指令进行上锁。步骤如下:
1.步骤1,使用SWP指令将锁中的值读出,并向锁中写入数值1,该过程为一个整体性的原子操作,读和写操作之间其它核不会访问到锁。
2.步骤2,对读取的值进行判断,如果发现锁中的值为1,则意味着当前锁正在被其他的核占用,上锁失败,因此继续回到步骤1重复再读,如果发现锁中的值为0,则意味着当前锁已经空闲,同时由于SWP指令也以原子方式向其写入了数值1,则上锁成功,可以进行独占。
如果将步骤2中的判断操作offload给指令完成,这样就相当于进行了CAS操作。
下图展示了一主二从和一主三从的AHB总线结构,HMASTLOCK可选,图中没有列出:
通过互斥操作解决上锁问题:
原子操作存在弊端,它会将总线锁住,导致其它的核无法访问总线,再核数众多且频繁抢锁的情况下,会造成总线长期被锁的情况,影响系统的运行性能。
因此后来ARM架构又引入了一种新的互斥类型的存储器访问指令来替代SWP指令,也就是LDREX和STREX指令。为此,AXI总线引入了互斥属性的信号用于实现此种机制。AXI3的Lock Access类似于X86架构下的LOCK指令,它表示在接下来的操作中锁住总线,保证原子操作。正是因为在整个过程中总线都被锁住,所以无法发挥出总线的最大带宽,影响性能。在AXI4中取消了这种锁类型。
Exclusive Access和Locked Access的区别有点像是临界区用信号量还是关中断实现的区别,信号量的获取和释放对应Exclusive Access的发起Exclusive Read让slave记录ARID和地址,以及发起Exclusive Write结束这次Exclusive Access transaction的过程,在进行Exclusive Access transaction的过程中,不会禁止其它Normal的读写请求使用总线,而Locked Access则更像是软件中通过关闭中断来建立的临界区,除了请求方,任何方面不可以使用总线,严重影响了总线效率。
图中的exclusive monitor有三级,每级负责自己自身范围内的独占独占访问监视,三级保证各个范围内的破坏独占访问的操作都能被监视的到。
用原子操作实现的锁在内核中广泛使用,比如下图中的代码片段来自于AMDGPU DRM设备驱动中断处理函数,在其中就使用了原子锁实现同时只处理一个中断事务的功能需求。
比较交换指令和load-link/store-condition指令
比较交换指令和和store link指令是等价的,可以互相实现,比如内核代码,ARM架构下,就用LDREX/STREX实现了compare and swap
Wiki说明
经典应用场景
一个经典的应用场景是GPU驱动的PAKCET调度器实现,源码参考./drivers/gpu/drm/amd/amdgpu/amdgpu_fence.c:
下面参考代码使用cmpxchg来实现无锁的单向链表,add_node函数把元素添加到链表末尾,del_node将链表末尾的元素删除,该程序创建三个线程来异步执行添加,删除和访问元素,它们之间并未使用锁。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
struct node {
struct node *next;
unsigned long val;
};
int get_tid(void)
{
long tid;
tid = syscall(SYS_gettid);
printf("%ld\n", tid);
return tid;
}
static struct node head;
static inline unsigned long cmpxchg(struct node **p, unsigned long t, unsigned long s)
{
unsigned long ret;
__asm__ __volatile__(
"lock ; cmpxchgq %3, %1"
: "=a"(ret), "+m"(*p) : "0"(t), "r"(s) : "memory");
return ret;
}
static unsigned long cmpxchg_lck(unsigned long *ptr, unsigned long old, unsigned long new)
{
unsigned long ret;
volatile unsigned long *__ptr = ptr;
__asm__ __volatile__(
"lock ; cmpxchgq %2, %1"
: "=a"(ret), "+m"(*ptr) : "r"(new), "0"(old) : "memory");
return ret;
}
void lock(unsigned long *lck)
{
while (cmpxchg_lck(lck, 0, 1));
}
void unlock(unsigned long *lck)
{
*lck = 0;
}
static struct node *new_node(unsigned long val)
{
struct node *node = malloc(sizeof(*node));
if (node == NULL) {
printf("%s line %d, error, alloc node failure.\n",
__func__, __LINE__);
return NULL;
}
node->next = NULL;
node->val = val;
return node;
}
void free_node(struct node *node)
{
free(node);
}
static int add_node(struct node *head, unsigned long val)
{
struct node *new = new_node(val);
struct node *next = head->next;
printf("%s line %d, adding %ld.\n",
__func__, __LINE__, val);
// if link is empty, then insert new directly.
if (cmpxchg(&head->next, (unsigned long)NULL, (unsigned long)new) == (unsigned long)NULL) {
return 0;
}
for (; ;) {
//next->next is null, then next->next = new;
if (next->next == NULL && cmpxchg(&next->next, (unsigned long)NULL,
(unsigned long)new) == (unsigned long)NULL)
break;
next = next->next;
}
return 0;
}
static void print_list(struct node *head)
{
struct node *node = head->next;
printf("=====================print list======================\n");
while (node) {
printf("%s line %d, val = %ld, node = 0x%lx.\n",
__func__, __LINE__, node->val, (unsigned long)node);
node = node->next;
}
}
static int del_node(struct node *head)
{
struct node *next = head->next;
struct node *prev = head;
// if list is empty.
if (next == NULL)
return 0;
for (; ;) {
// if the last element is null and prev is not null. set prev->next=null
// atomically.
if (next->next == NULL && prev &&
cmpxchg(&prev->next, (unsigned long)next,
(unsigned long)NULL) == (unsigned long)next)
break;
prev = next;
next = next->next;
}
printf("%s line %d, del node val = %ld, node = 0x%lx.\n",
__func__, __LINE__, next->val, (unsigned long)next);
return 0;
}
static void *add_list_thread(void *arg)
{
while (1) {
add_node(&head, rand() & 0xffff);
usleep(200 * 1000);
}
return NULL;
}
static void *del_list_thread(void *arg)
{
while (1) {
del_node(&head);
usleep(500 * 10000);
}
return NULL;
}
static void *print_list_thread(void *arg)
{
while (1) {
print_list(&head);
usleep(900 * 10000);
}
return NULL;
}
static void *lock_sync_thread(void *arg)
{
unsigned long *lck = (unsigned long *)arg;
printf("%s line %d, tid %d.\n", __func__, __LINE__, get_tid());
while (1) {
lock(lck);
printf("%s line %d %ld.\n", __func__, __LINE__, pthread_self());
unlock(lck);
sched_yield();
}
return NULL;
}
int main(void)
{
pthread_t t1;
pthread_t t2;
pthread_t t3;
printf("%s line %d, tid %d.\n", __func__, __LINE__, get_tid());
#if 1
head.next = NULL;
pthread_create(&t1, NULL, add_list_thread, NULL);
pthread_create(&t2, NULL, del_list_thread, NULL);
pthread_create(&t3, NULL, print_list_thread, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
#else
unsigned long lock = 0;
pthread_create(&t1, NULL, lock_sync_thread, &lock);
pthread_create(&t2, NULL, lock_sync_thread, &lock);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
#endif
return 0;
}
ARM架构atomic_cmpxchg实现,早期单核架构:
ARM多核上的实现
基于上文的无锁链表,实现的忙等锁
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
struct node {
struct node *next;
unsigned long val;
};
int get_tid(void)
{
long tid;
tid = syscall(SYS_gettid);
printf("%ld\n", tid);
return tid;
}
static struct node head;
static inline unsigned long cmpxchg(struct node **p, unsigned long t, unsigned long s)
{
unsigned long ret;
__asm__ __volatile__(
"lock ; cmpxchgq %3, %1"
: "=a"(ret), "+m"(*p) : "0"(t), "r"(s) : "memory");
return ret;
}
static unsigned long cmpxchg_lck(unsigned long *ptr, unsigned long old, unsigned long new)
{
unsigned long ret;
volatile unsigned long *__ptr = ptr;
__asm__ __volatile__(
"lock ; cmpxchgq %2, %1"
: "=a"(ret), "+m"(*ptr) : "r"(new), "0"(old) : "memory");
return ret;
}
void lock(unsigned long *lck)
{
while (cmpxchg_lck(lck, 0, 1));
}
void unlock(unsigned long *lck)
{
*lck = 0;
}
static struct node *new_node(unsigned long val)
{
struct node *node = malloc(sizeof(*node));
if (node == NULL) {
printf("%s line %d, error, alloc node failure.\n",
__func__, __LINE__);
return NULL;
}
node->next = NULL;
node->val = val;
return node;
}
void free_node(struct node *node)
{
free(node);
}
static int add_node(struct node *head, unsigned long val)
{
struct node *new = new_node(val);
struct node *next = head->next;
printf("%s line %d, adding %ld.\n",
__func__, __LINE__, val);
// if link is empty, then insert new directly.
if (cmpxchg(&head->next, (unsigned long)NULL, (unsigned long)new) == (unsigned long)NULL) {
return 0;
}
for (; ;) {
//next->next is null, then next->next = new;
if (next->next == NULL && cmpxchg(&next->next, (unsigned long)NULL,
(unsigned long)new) == (unsigned long)NULL)
break;
next = next->next;
}
return 0;
}
static void print_list(struct node *head)
{
struct node *node = head->next;
printf("=====================print list======================\n");
while (node) {
printf("%s line %d, val = %ld, node = 0x%lx.\n",
__func__, __LINE__, node->val, (unsigned long)node);
node = node->next;
}
}
static int del_node(struct node *head)
{
struct node *next = head->next;
struct node *prev = head;
// if list is empty.
if (next == NULL)
return 0;
for (; ;) {
// if the last element is null and prev is not null. set prev->next=null
// atomically.
if (next->next == NULL && prev &&
cmpxchg(&prev->next, (unsigned long)next,
(unsigned long)NULL) == (unsigned long)next)
break;
prev = next;
next = next->next;
}
printf("%s line %d, del node val = %ld, node = 0x%lx.\n",
__func__, __LINE__, next->val, (unsigned long)next);
return 0;
}
static void *add_list_thread(void *arg)
{
while (1) {
add_node(&head, rand() & 0xffff);
usleep(200 * 1000);
}
return NULL;
}
static void *del_list_thread(void *arg)
{
while (1) {
del_node(&head);
usleep(500 * 10000);
}
return NULL;
}
static void *print_list_thread(void *arg)
{
while (1) {
print_list(&head);
usleep(900 * 10000);
}
return NULL;
}
static void *lock_sync_thread(void *arg)
{
unsigned long *lck = (unsigned long *)arg;
printf("%s line %d, tid %d.\n", __func__, __LINE__, get_tid());
while (1) {
lock(lck);
printf("%s line %d %ld.\n", __func__, __LINE__, pthread_self());
unlock(lck);
sched_yield();
}
return NULL;
}
int main(void)
{
pthread_t t1;
pthread_t t2;
pthread_t t3;
printf("%s line %d, tid %d.\n", __func__, __LINE__, get_tid());
#if 1
head.next = NULL;
pthread_create(&t1, NULL, add_list_thread, NULL);
pthread_create(&t2, NULL, del_list_thread, NULL);
pthread_create(&t3, NULL, print_list_thread, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
#else
unsigned long lock = 0;
pthread_create(&t1, NULL, lock_sync_thread, &lock);
pthread_create(&t2, NULL, lock_sync_thread, &lock);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
#endif
return 0;
}
原子指令,关中断,IPC Mutex/Semaphore 哪个更基础?
这个问题曾经一度困惑着我,直到亲手基于atomic指令实现了一个MUTEX,对这个问题才总算有了更深刻的认识。
当然原子指令是最基础的原子操作机制,它是实现原子性的最基础的原语,创造了一个只运行一个执行流进入的临界区。
其次,在单核系统上,关中断也能达到创建一个单执行流的临界区的效果,所以,很多RTOS的MUTEX实现仅仅通过关中断保护创建临界区,比如大名鼎鼎的UCOS ENTER_CRITICAL就是通过关中断实现的,但正如本文开始所说,关中断代价比较高,它禁止了全局的并发流。
最后,ipc mutex/semaphore机制是OS API层面的,它是基于更底层的原子操作/关中断实现的。
基于ATOMIC指令实现原子操作的伪代码:
int sema_lock(sema_t *sema)
{
while(atomic_xchg(sema->lock, 1));
if(sema->value > 0) {
sema->value --;
sema->lock = 0;
} else {
add_task_to_wq(current, sema->wait_list);
sema->lock = 0;
schedule();
}
return 0;
}
原子操作指令只能保证一件事的完整性,但是无法连续做多件事并保证原子性,需要基于原子操作实现的锁,信号量等机制保证。
QEMU的原子操作
qemu中模拟了arm架构的ldrex/strex/clrex的实现,env->exclusive_addr会在下面的操作中复位:
1.arm_cpu_reset CPU复位时。
2.异常推出时.
3.执行clrex指令时。
4.strex执行结束时。
5.用户态程序进行系统调用,中断,或者异常处理时。
会在下面操作中设置:
1.执行strex指令时.
原子操作的语意
如果想要搞清楚原子操作的所有语义,参考内核代码中如下实现文件:linux-5.4.240/lib/atomic64.c 其仍然是基于CPU ISA架构上的原子操作指令实现的更高一级C语言层面的原子操作。
而架构层的实现在linux-5.4.240/arch/x86/include/asm/cmpxchg.h文件中。
总结
原子操作是全芯片系统级工程,原子操作的路径可能需要经过多个IP环节,每个环节都需要支持原子操作的硬件基础设施,缺失任何一个环节将无法做到原子操作。比如,GPU设备如果需要原子访问HOST Memory,中间会经过GPU本身的L2->AXI总线->NOC->PCIe->Host Memory等环节,中间每个环节都需要支持原子操作,如果某个环节不支持,比如PCIE不支持发送原子访问包,则无法用HOST 内存实现原子操作。
参考资料:
DDI0406B_arm_architecture_reference_manual.pdf
http://www.gstitt.ece.ufl.edu/courses/fall15/eel4720_5721/labs/refs/AXI4_specification.pdf
AMBA总线协议(六)—— 一文看懂 AXI3 协议原子访问2_51CTO博客_AMBA总线协议
AMBA AXI Exclusive访问的概念解惑和验证测试点 - 知乎
AXI 独占访问(Exclusive)和锁定访问(Locked )机制_axi exclusive-优快云博客
https://en.wikipedia.org/wiki/Compare-and-swap