顺序性:
乱序执行·逻辑正确性
现代体系结构的每一个核的指令流水是乱序执行的,但是他能够保证其执行效果正确,即等同于顺序执行。
不过这带来的问题是对于一个核在主观上它的执行状态最终保证正确,但是对于别的核,如果在某一个中间时间点需要观察它呢?看到的是一个不正确的中间状态对应的数据:
乱序中间态:
core1:
asm write a=x(没提交)
asm write b=y(已提交)
core2:
asm if(b==y)
assert(a==x) // 出错了,因为core1乱序提交!
一般情况下,我们可以容忍这类问题发生。
但是当 write b=y 是一个非常重要的多核控制原语的时候,这类问题就无法容忍了。
局部顺序性与局部正确中间态:
杜绝这种问题的关键是让 write b=y操作满足局部顺序性,从而在该操作上得到局部正确中间态:即该操作一旦执行成功,则前面的操作都执行成功。该操作如果没有执行,那么后面的操作也都没有执行。 也即任何时刻只要b==y,那么a==x。
一致性:
各核及线程缓存·volatile
各核缓存及线程缓存不一致是影响并发并行计算正确性的一大问题。
如果上层编程逻辑需要使他们可见的值保持一致,则可以引入volatile。
原子性:
1 多核体系结构与多核原子操作
一. 何谓"原子操作":
原子操作就是: 不可中断的一个或者一系列操作, 也就是不会被线程调度机制打断的操作, 运行期间不会有任何的上下文切换(context switch).
多核原子操作:不可打断(原子),不可干扰(互斥=》串行=》最高隔离)。在原子操作基础上,不被其他核上运行的指令干扰的指令操作。如何不被其他核指令干扰?内存是多核共用的,所以当本核访问内存的时候,其他核都不能访问。下文会讲到总线锁。
二. 为什么关注原子操作?
1. 如果确定某个操作是原子的, 就不用为了去保护这个操作而加上会耗费昂贵性能开销的锁. - (巧妙的利用原子操作和实现无锁编程)
2. 借助原子操作可以实现互斥锁(mutex). (linux中的mutex_lock_t)
3. 借助互斥锁, 可以实现让更多的操作变成原子操作.
三. 单核CPU的原子操作:
在单核CPU中, 能够在一个指令中完成的操作都可以看作为原子操作, 因为中断只发生在指令间.
四. 多核CPU的原子操作:
在多核CPU的时代(确实moore定律有些过时了,我们需要更多的CPU,而不是更快的CPU,无法处理快速CPU中的热量散发问题), 体系中运行着多个独立的CPU, 即使是可以在单个指令中完成的操作也可能会被干扰. 典型的例子就是decl指令(递减指令),
它细分为三个过程: "读->改->写", 涉及两次内存操作. 如果多个CPU运行的多个进程在同时对同一块内存执行这个指令, 那情况是无法预测的.
五. 硬件支持 & 多核原子操作:
软件级别的原子操作是依赖于硬件支持的. 在x86体系中, CPU提供了HLOCK pin引线, 允许CPU在执行某一个指令(仅仅是一个指令)时拉低HLOCK
pin引线的电位, 直到这个指令执行完毕才放开. 从而锁住了总线, 如此在同一总线的CPU就暂时无法通过总线访问内存了, 这样就保证了多核处理器的原子性(个人理解:另外使得cpu强制串行性,该条指令不能和任何其他指令之间发生乱序提交). (想想这机制对性能影响挺大的).
关于为什么本文所讲的“多核原子操作”要锁总线
内存屏障的结果,是在操作原子性基础上实现核间高度隔离以及局部顺序性。
1. 核间高度隔离:锁对其他核上的内存操作(不管R/W)互斥,从而为本操作提供最高级别隔离性。
2. 局部顺序性:如上文,加总线锁之后还会使得本核的指令流水在此串行化,防止本指令相对之前和之后的其他指令发生乱序提交,提供局部状态顺序性。
http://blog.codingnow.com/2007/12/fence_in_multi_core.html
六. 哪些操作可以确定为原子操作了?
对于非long和double基本数据类型的"简单操作"都可以看作是原子的. 例如: 赋值和返回. 大多数体系中long和double都占据8个字节, 操作系统或者JVM很可能会将写入和读取操作分离为两个单独的32位的操作来执行, 这就产生了在一个读取和写入过程中一个上下文切换(context switch), 从而导致了不同任务线程看到不正确结果的的可能性.
递增, 递减不是原子操作: i++反汇编的汇编指令: (需要三条指令操作, 和两个内存访问, 一次寄存器修改)
1
2
3
|
movl i, %eax //内存访问, 读取i变量的值到cpu的eax寄存器 addl $1, %eax //增加寄存器中的值 movl %eax, i //写入寄存器中的值到内存 |
七. 如何实现++i和i++的原子性:
1. 单CPU, 使用锁或则禁止多线程调度, 因为本身单核CPU的并发就是伪并发. (在单核CPU中, 在没有阻塞的程序中使用多线程是没必要的).
2. 多核CPU, 就需要借助上面说道的CPU提供的Lock, 锁住总线. 防止在"读取, 修改, 写入"整个过程期间其他CPU访问内存. (那么“读写,修改,写入”这个操作会不会在在单核中发生线程的切换呢?)
八. Linux提供的两个原子操作接口:
1. 原子整数操作
针对整数的原子操作只能对atomic_t类型的数据处理。这里没有使用C语言的int类型,主要是因为:
1) 让原子函数只接受atomic_t类型操作数,可以确保原子操作只与这种特殊类型数据一起使用.
2) 使用atomic_t类型确保编译器不对相应的值进行访问优化. (原理为: 变量被volatile修饰了)
3) 使用atomic_t类型可以屏蔽不同体系结构上的数据类型的差异。尽管Linux支持的所有机器上的整型数据都是32位,但是使用atomic_t的代码只能将该类型的数据当作24位来使用。这个限制完全是因为在SPARC体系结构上,原子操作的实现不同于其它体系结构:32位int类型的低8位嵌入了一个锁,因为SPARC体系结构对原子操作缺乏指令级的支持,所以只能利用该锁来避免对原子类型数据的并发访问。
原子整数操作最常见的用途就是实现计数器。原子整数操作列表在中定义。原子操作通常是内敛函数,往往通过内嵌汇编指令来实现。如果某个函数本来就是原子的,那么它往往会被定义成一个宏。
在编写内核时,操作demo如下:
1
2
3
4
|
atomic_t cnt; atomic_set(&cnt, 2); atomic_add(4, &cnt); atomic_inc(cnt); |
2. 原子位操作:
原子位操作定义在文件中。令人感到奇怪的是位操作函数是对普通的内存地址进行操作的。原子位操作在多数情况下是对一个字节长的内存(注1)访问,因而位号该位于0-31之间(在64位机器上是0-63之间),但是对位号的范围没有限制。
注1:操作系统可以确保,在同一时刻,只有一个CPU的一个进程访问特定的某个字节,再加上单核中的原子性(基本数据类型的简单操作),所以单字节内存的简单操作是具有天生的多核原子性的。
编写内核代码,把要操作的数据的指针给操作函数,就可以进行位操作了:
1
2
3
4
5
|
unsigned long var = 0; set_bit(0, &var); /*set the 0th bit*/ set_bit(1, &var); /*set the 1th bit*/ clear_bit(1, &var); /*clear the 1th bit*/ change_bit(0, &var); /*change the 1th bit*/ |
九. spinlock CPU同步:
spin lock必须基于CPU的数据总线锁定, 它通过读取一个内存单元(spinlock_t)来判断这个spinlock是否已经被别的CPU锁住. 如果否, 它写进一个特定值, 表示锁定了总线, 然后返回. 如果是, 它会重复以上操作直到成功,
或者spin次数超过一个设定值. 记住上面提及到的: 锁定数据总线的指令只能保证一个指令操作期间CPU独占数据总线. (spinlock在锁定的时侯, 不会睡眠而是会持续的尝试).
2. 原子操作CAS与自旋锁
spinlock与mutex对比
优缺点比较
spinlock不会使线程状态发生切换,mutex在获取不到锁的时候会选择sleep。mutex获取锁分为两阶段,第一阶段在用户态采用spinlock锁总线的方式获取一次锁,如果成功立即返回;否则进入第二阶段,调用系统的futex锁去sleep,当锁可用后被唤醒,继续竞争锁。
Spinlock优点:没有昂贵的系统调用,一直处于用户态,执行速度快。
Spinlock缺点:一直占用cpu,而且在执行过程中还会锁bus总线,锁总线时其他处理器不能使用总线。
Mutex优点:不会忙等,得不到锁会sleep。
Mutex缺点:sleep时会陷入到内核态,需要昂贵的系统调用。
Spinlock使用准则:临界区尽量简短,控制在100行代码以内,不要有显式或者隐式的系统调用,调用的函数也尽量简短。例如,不要在临界区中调用read,write,open等会产生系统调用的函数,也不要去sleep;strcpy,memcpy等函数慎用,依赖于数据的大小。
spinlock系统实现
spinlock的实现方式有多种,但是思想都是差不多的,glibc-2.9中的实现方法:int pthread_spin_lock (lock) pthread_spinlock_t *lock;
{
asm ("\n"
"1:\t" LOCK_PREFIX "decl %0\n\t"
"jne 2f\n\t"
".subsection 1\n\t"
".align 16\n"
"2:\trep; nop\n\t"
"cmpl $0, %0\n\t"
"jg 1b\n\t"
"jmp 2b\n\t"
".previous"
: "=m" (*lock)
: "m" (*lock));
return 0;
}
执行过程:
1,lock_prefix 即 lock。lock decl %0,锁总线将%0(即lock变量)减一。Lock可以保证接下来一条指令的原子性。
2, 如果lock=1,decl的执行结果为lock=0,ZF标志位为1,直接跳到return 0;否则跳到标签2。也许要问,为啥能直接跳到return 0呢?因为subsection和previous之间的代码被编译到别的段中,因此jne之后紧接着的代码就是 return 0 (leaveq;retq)。Rep nop在经过编译器编译之后被编译成 pause。
3, 如果跳到标签2,说明获取锁不成功,循环等待lock重新变成1,如果lock为1跳到标签1重新竞争锁。
该实现采用的是AT&T的汇编语法,更详细的执行流程解释可以参考“五竹”大牛的文档。
3.2,系统自带(glibc-2.3.4)spinlock反汇编代码:
系统环境:
2.6.9-89.ELsmp #1 SMP x86_64 x86_64 x86_64 GNU/Linux
(gdb) disas pthread_spin_lock
Dump of assembler code for function pthread_spin_lock:
//eax寄存器清零,做返回值
0x0000003056a092f0 <pthread_spin_lock+0>: xor %eax,%eax
//rdi存的是lock锁地址,原子减一
0x0000003056a092f2 <pthread_spin_lock+2>: lock decl (%rdi)
//杯了个催的,加锁不成功,跳转,开始busy wait
0x0000003056a092f5 <pthread_spin_lock+5>: jne 0x3056a09300 <pthread_spin_lock+16>
//终于夹上了…加锁成功,返回
0x0000003056a092f7 <pthread_spin_lock+7>: retq
……………………………………….省略若干nop……………………………………….
0x0000003056a092ff <pthread_spin_lock+15>: nop
//pause指令降低CPU功耗
0x0000003056a09300 <pthread_spin_lock+16>: pause
//检查锁是否可用
0x0000003056a09302 <pthread_spin_lock+18>: cmpl $0×0,(%rdi)
//回跳,重新锁总线获取锁
0x0000003056a09305 <pthread_spin_lock+21>: jg 0x3056a092f2 <pthread_spin_lock+2>
//长夜漫漫,爱上一个不回家的人,继续等~
0x0000003056a09307 <pthread_spin_lock+23>: jmp 0x3056a09300 <pthread_spin_lock+16>
0x0000003056a09309 <pthread_spin_lock+25>: nop
……………………………………….省略若干nop……………………………………….
End of assembler dump.
Glibc的汇编代码还是很简洁的,没有多余的代码。
总结
SpinLock:
//https://github.com/wh5a/jos/commit/8223e70a9e8c9942f2fd02b6d4e046c7e6da34ed
+spinlock_acquire(struct spinlock *lk)
+{
+ if(spinlock_holding(lk))
+ panic("recursive spinlock_acquire");
+
+ // The xchg is atomic.
+ // It also serializes,
+ // so that reads after acquire are not reordered before it.
+ while(xchg(&lk->locked, 1) != 0)
+ pause(); // let CPU know we're in a spin loop
+
+ // Record info about lock acquisition for debugging.
+ lk->cpu = cpu_cur();
+ debug_trace(read_ebp(), lk->eips);
+}
// Atomically set *addr to newval and return the old value of *addr.
+static inline uint32_t
+xchg(volatile uint32_t *addr, uint32_t newval)
+{
+<span style="white-space:pre"> </span>uint32_t result;
+
+<span style="white-space:pre"> </span>// The + in "+m" denotes a read-modify-write operand.
+<span style="white-space:pre"> </span>asm volatile("lock; xchgl %0, %1" :
+<span style="white-space:pre"> </span> "+m" (*addr), "=a" (result) :
+<span style="white-space:pre"> </span> "1" (newval) :
+<span style="white-space:pre"> </span> "cc");
+<span style="white-space:pre"> </span>return result;
+}
+
+/* While a spinlock will work if you just do nothing in the loop,
+ Intel has defined a special instruction called PAUSE that notifies
+ the processor that a spin loop is in progress and can improve
+ system performance in such cases, especially on "hyper-threaded"
+ processors that multiplex a single execution unit among multiple
+ virtual CPUs.
+*/
+static inline void
+pause(void)
+{
+<span style="white-space:pre"> </span>asm volatile("pause" : : : "memory");
+}
SpinUnlock:
3 自旋锁与并发编程原语
4 语句原子性和编程逻辑的原子性
5 锁与数据库事务原子性
数据库事务:
1. 原子性(Atomic)
一个事务包含多个操作,这些操作要么全部执行,要么全都不执行。实现事务的原子性,要支持回滚操作,在某个操作失败后,回滚到事务执行之前的状态。回滚实际上是一个比较高层抽象的概念,大多数DB在实现事务时,是在事务操作的数据快照上进行的(比如,MVCC),并不修改实际的数据,如果有错并不会提交,所以很自然的支持回滚。
而在其他支持简单事务的系统中,不会在快照上更新,而直接操作实际数据。可以先预演一边所有要执行的操作,如果失败则这些操作不会被执行,通过这种方式很简单的实现了原子性。
2.隔离性(Isolation)
并发事务之间互相影响的程度,比如一个事务会不会读取到另一个未提交的事务修改的数据。在事务并发操作时,可能出现的问题有:脏读:事务A修改了一个数据,但未提交,事务B读到了事务A未提交的更新结果,如果事务A提交失败,事务B读到的就是脏数据。
不可重复读:在同一个事务中,对于同一份数据读取到的结果不一致。比如,事务B在事务A提交前读到的结果,和提交后读到的结果可能不同。不可重复读出现的原因就是事务并发修改记录,要避免这种情况,最简单的方法就是对要修改的记录加锁,这回导致锁竞争加剧,影响性能。另一种方法是通过MVCC可以在无锁的情况下,避免不可重复读。
幻读:在同一个事务中,同一个查询多次返回的结果不一致。事务A新增了一条记录,事务B在事务A提交前后各执行了一次查询操作,发现后一次比前一次多了一条记录。幻读是由于并发事务增加记录导致的,这个不能像不可重复读通过记录加锁解决,因为对于新增的记录根本无法加锁。需要将事务串行化,才能避免幻读。
事务的隔离级别从低到高有:
Read Uncommitted:最低的隔离级别,什么都不需要做,一个事务可以读到另一个事务未提交的结果。所有的并发事务问题都会发生。
Read Committed:只有在事务提交后,其更新结果才会被其他事务看见。可以解决脏读问题。
Repeated Read:在一个事务中,对于同一份数据的读取结果总是相同的,无论是否有其他事务对这份数据进行操作,以及这个事务是否提交。可以解决脏读、不可重复读。
Serialization:事务串行化执行,隔离级别最高,牺牲了系统的并发性。可以解决并发事务的所有问题。
通常,在工程实践中,为了性能的考虑会对隔离性进行折中。
3. 一致性(Consistency)
一致性是指事务使得系统从一个一致的状态转换到另一个一致状态。事务的一致性决定了一个系统设计和实现的复杂度。事务可以不同程度的一致性:强一致性:读操作可以立即读到提交的更新操作。
弱一致性:提交的更新操作,不一定立即会被读操作读到,此种情况会存在一个不一致窗口,指的是读操作可以读到最新值的一段时间。
最终一致性:是弱一致性的特例。事务更新一份数据,最终一致性保证在没有其他事务更新同样的值的话,最终所有的事务都会读到之前事务更新的最新值。如果没有错误发生,不一致窗口的大小依赖于:通信延迟,系统负载等。
其他一致性变体还有:
单调一致性:如果一个进程已经读到一个值,那么后续不会读到更早的值。
会话一致性:保证客户端和服务器交互的会话过程中,读操作可以读到更新操作后的最新值。