定义
所谓原子操作,就是“不可中断的一个或一系列操作”
- 单核CPU:
在单核CPU中,能够在一条指令中完成的操作都可以看作为原子操作。无论是设备中断请求、时间片超时、程序主动schedule放弃CPU,抑或是发生抢占,从而引起线程调度中断当前程序操作,都是发生在指令间的。计算机的每条指令的执行天生就是原子性不可中断的,这是由硬件设计决定的。
对程序而言,一条语句对应几条指令,是由编译器、指令集(或者说硬件架构设计)决定的,即是由软件硬件共同决定。编译器决定了上层语言被翻译成什么指令,而指令决定了操作是否是原子性的。如,对CISC来说,是能够直接操作内存数据的,可以由一条指令完成,这就是原子操作;而在RICS中,数据需要先load到寄存器进行操作,再store回内存,无法在一条指令中完成,这就不是原子操作。
当指令集提供了一条指令能完成读写的操作时,该读写操作就成为原子操作。而指令依赖硬件通路,最终原子操作将由硬件保证。
-
多核CPU
在单核CPU中,内存的访问总是只由一个核心来进行的。当存在多个核心时,每个核心上执行的程序同时执行一条指令能完成的操作,同时对同一地址的内存进行访问,这时得到的结果可能不是预期的,此时依然不能说它们是原子操作。
回顾之前的定义:原子操作是“不可中断的一个或一系列操作”。这里的“中断”并不翻译成操作被中断停止执行,更确切地应该指在执行的整个过程中,对所用到的资源是独占的。
多个核心之间产生资源竞争,这就违背了原子操作的定义。想要操作依然是原子操作,就需要对资源进行“锁定”。这便依赖于硬件的功能。
如在x86体系中,CPU提供了HLOCK pin引线,允许CPU在执行某一个指令(仅仅是一个指令)时拉低HLOCK pin引线的电位,直到这个指令执行完毕才放开,从而锁住了总线。如此在同一总线的CPU就暂时无法通过总线访问内存了,这样就保证了多核处理器的原子性。同时可以看到,这种机制是牺牲了性能的。
实现
-
硬件实现
处理器实现原子操作,或者说资源锁定,主要有两种手段。一是总线加锁,二是缓存锁定。
1)总线加锁:所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
缺点:总线缓存阻止了被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能只需要锁住特定的一块内存区域,因此总线锁定开销较大。2)缓存锁定:缓存锁定是某个CPU对缓存数据进行更改时,会通知缓存了该数据的该数据的CPU抛弃缓存的数据或者从内存重新读取。
缺点:操作的数据不能被缓存在处理器内部,或者操作的数据跨多个缓存行,或处理器不支持缓存锁定时,无法使用缓存锁定。 -
软件实现
CAS(compare and swap):比较并交换。 cas(var, val_1, val_2) ----变量var的值与期望值val_1比较,如果等于val_1就将var和val_2的值交换。 ----指令集提供了CAS指令,如x86的CMPXCHG(加上LOCK prefix), 能够完成atomic的compare-and-swap(CAS)。借助硬件实现机制(总线锁、缓存锁), 保证了多核CPU下指令的原子操作性。 操作系统中mutex的实现: 借助CAS指令,对内存的一块地址空间写值。0代表没上锁状态,1代表已上锁状态。 当初始值为0(没上锁状态)时,cas(var, 0, 1)成功改写值为1,上锁成功。 此时另一个线程使用cas(var, 0, 1)获取锁必然失败。 这时,让没有抢到锁的线程不断在while里面循环进行compare-and-swap,直到前面的线程放手(对应的内存被赋值0), 就是spin lock。 如果需要长时间的等待,这样反复CAS轮询就比较浪费资源, 这个时候程序可以向操作系统申请被挂起,然后持锁的线程解锁了以后再通知它。 程序中直接使用CAS: gcc下__sync_val_compare_and_swap函数,提供了对CAS指令封装的接口,供上层语言调用。 类似的如__sync_fetch_and_add,__sync_lock_test_and_set。 C++提供了atomic<T>模板类型,及六种原子序,对原子操作进行进一步的跨平台抽象封装
C++11的六种原子序:
typedef enum memory_order {
memory_order_relaxed,
memory_order_consume,
memory_order_acquire,
memory_order_release,
memory_order_acq_rel,
memory_order_seq_cst
} memory_order;
应用
- 无锁队列
EnQueue(x) //进队列
{
//准备新加入的结点数据
q = new record();
q->value = x;
q->next = NULL;
do {
p = tail; //取链表尾指针的快照
} while( CAS(p->next, NULL, q) != TRUE); //如果没有把结点链在尾指针上,再试
CAS(tail, p, q); //置尾结点
}
问题
-
ABA问题
1)进程P1在共享变量中读到值为A 2)P1被抢占了,进程P2执行 3)P2把共享变量里的值从A改成了B,再改回到A,此时被P1抢占。 4)P1回来看到共享变量里的值没有被改变,于是继续执行。
-
解决
使用double-CAS(双保险的CAS),例如,在32位系统上,我们要检查64位的内容 1)一次用CAS检查双倍长度的值,前半部是目标数据:指针,后半部分是一个计数器。 2)只有这两个都一样,才算通过检查,要把指针赋新的值。并把计数器累加1。
总结
在单核处理器中,单条指令操作本身就是原子性的,单条指令能完成的操作即为原子操作。对于需要多条指令完成的操作,当关闭中断禁止调度时,所有操作都为原子操作。
对于多核CPU而言,原子操作的保证需要依赖硬件机制,主要有总线锁和缓存锁。在此基础上,指令集提供如CAS的指令,执行上锁操作,再访问数据,解决访问冲突,使操作原子化。
借助这种指令,通过while循环可以设计自旋锁,或可睡眠锁等,达到锁住临界区的目的。
操作系统层面提供了锁的封装,同样,编译器也提供了相应的API封装。如gcc的__sync_val_compare_and_swap,__sync_fetch_and_add,__sync_lock_test_and_set。
而C++11标准中,抽象出六种原子序,为原子变量和原子操作提供了跨平台实现方案。