多线程锁的分析

多线程锁的分析

首先我们知道锁主要有两种,悲观锁和乐观锁。对于悲观锁(mutex,spin_lock),它永远会假定最糟糕的情况,就像我们上面说到的互斥机制,每次我们都假定会有其他的线程和我们竞争资源,因此必须要先拿到锁,之后才放心的进行我们的操作,这就使得争夺锁成为了我们每次操作的第一步。乐观锁(CAS,原子操作)则不同,乐观锁假定在很多情况下,资源都不需要竞争,因此可以直接进行读写,但是如果碰巧出现了多线程同时操控数据的情况,那么就多试几次,直到成功(也可以设置重试的次数)

底层调用的是cpu提供的汇编指令,这个可以认为是cpu设计时候提供的。

原子操作:

内联汇编

int fetch_addd(int *value, int add)
{
    __asm__ volatile (
    "lock; xaddl %2, %1;"//asm把cpu锁住,执行一句命令
        :"=a"(old)//output
        :"m"(*value),"a"(add)//input
        :"cc","memory"
    ); 
    return old;
}

原子操作对于我们来说,是非常熟悉的概念。在某些场景下,可以用原子操作来替换重量级的锁同步,从而提高程序性能。原子操作可以保障多个线程或进程在更新某块共享内存区时,可以避免同步原语。

对于原子操作的实现来说,需要分开考虑单处理器单核系统,和多处理器系统,多核系统

对于单处理器单核系统来说,只要保证操作指令序列不被打断即可实现原子操作。对于简单的原子操作,cpu实现上会提供单条指令,比如INC和XCHG。对于复杂的原子操作,需要包含多条指令。执行过程中,出现上下文切换行为,比如任务切换,中断处理等。这里的行为会影响原子操作的原子性。因此需要自旋锁spinlock[1]来保证操作指令序列不会在执行的中途受干扰。

但是如果对于多处理器或者多核的系统,原子操作的实现除了需要spinlock来保证外,还需要保证不会受到同处理器上其他核,或者其他处理器的影响。当其他核上执行的指令访问的内存空间,与当前原子操作需要访问的内存空间存在冲突时,就会破坏原子操作的正确性。

在x86架构中,提供了指令前缀LOCK。LOCK保证了指令不会受其他处理器或cpu核的影响。在PentiumPro之前,LOCK的实现,是通过锁住bus(总线),从而阻止其他cpu核的内存访问。可想而知,这种实现是非常低效的。从PentiumPro开始,LOCK只会阻塞其他cpu核对相关内存的缓存块的访问。

现在,大多数的x86处理器都支持了CAS[2]的硬件实现,保证了多处理器多核系统下的原子操作的正确性。CAS的实现同样无需锁住总线,只会阻塞其他cpu核对相关内存的缓存块的访问。同样的,在MIPS和ARM架构下,还支持了LL/SC的实现[3]。LL/SC不会出现CAS中的ABA问题。

在继续深入以前,需要了解MESI缓存协议[4]。当然,还存在其他的MESI变种,不过这里只会简单解释下MESI。每个cache line存在四种状态,Modified代表该cache line为该cpu核独有,且尚未写回(write back)到内存(对缓存一致性不了解的看这里[5])。Exclusive代表该cache line为该cpu核独有,且与内存一致。Shared代表该cache line为多核共享,且与内存一致。Invalid代表缓存失效。系统中多个核之间通过快速通道直接通信,比如intel家的QPI,amd家的Hypertransport。cpu核之间通信的消息包括读消息,以及读消息的响应消息。使无效消息,以及使无效消息的响应消息。当运行在某个cpu核的线程准备读取某个cache line的内容时,如果状态处于M,E,S,直接读取即可。如果状态处于I,则需要向其他cpu核广播读消息,在接受到其他cpu核的读响应后,更新cache line,并将状态设置为S。而当线程准备写入某个cache line时,如果处于M状态,直接写入。如果处于E状态,写入并将cache line状态改为M。如果处于S,则需要向其他cpu核广播使无效消息,并进入E状态,写入修改,后进入M状态。如果处于I,则需要向其他cpu核广播读消息核使无效消息,在收集到读响应后,更新cache line。在收集到使无效响应后,进入E状态,写入修改,后进入M状态。

从上面的说明可知,LOCK的实现,只需要保持cache line的M状态即可,此时就可以阻止其他cpu核对该块内存的修改,而不用去锁住整个总线。

同理,CAS和LL/SC的实现,您应该可以猜出来了吧?

无锁CAS

并发修改同一记录时,避免更新丢失,需要加锁。要么在应用层加锁,要么在缓存加
锁,要么在数据库层使用乐观锁,使用 version 作为更新依据。
说明:如果每次访问冲突概率小于 20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次
数不得小于 3 次

透过linux内核看无锁编程

CAS 原语负责将某处内存地址的值(1 个字节)与一个期望值进行比较,如果相等,则将该内存地址处的值替换为新值,CAS 操作伪码描述如下 :

Bool CAS(T* addr, T expected, T newValue) 
{ 
     if( *addr == expected ) 
    { 
         *addr =  newValue; 
          return true; 
    } 
    else 
          return false; 
}

实际操作:

do{ 
       备份旧数据;
       基于旧数据构造新数据;
}while(!CAS( 内存地址,备份的旧数据,新数据 ))

就是指当两者进行比较时,如果相等,则证明共享数据没有被修改,替换成新值,然后继续往下运行;如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。容易看出 CAS 操作是基于共享数据不会被修改的假设,采用了类似于数据库的 commit-retry 的模式。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e0laziMn-1573897318867)(D:\A_目标!!!\笔记\网络编程\pic\image001.jpg)]

Linux 内核可能是当今最大最复杂的并行程序之一,它的并行主要来至于中断、内核抢占及 SMP 等。内核设计者们为了不断提高 Linux 内核的效率,从全局着眼,逐步废弃了大内核锁来降低锁的粒度;从细处下手,不断对局部代码进行优化,用无锁编程替代基于锁的方案,如 seqlock 及 RCU 等;不断减少锁冲突程度、降低等待时间,如 Double-checked locking 和原子锁等。

内核中的RCU

互斥锁

首先互斥锁是采用一个mutex变量,这个变量就可以让它成为一个原子变量。

互斥锁需要对临界区进行加锁和解锁,其伪代码就可以写为:

lock代码:
	if(mutex > 0) {
        mutex = 0;
        return 0;
    } else 
        挂起等待;
    goto lock;//被唤醒,再检查一次,避免虚假唤醒
    ....;
	....;
unlock代码:
	mutex = 1;
    唤醒等待mutex的线程:
    return 0;

因为这当中,mutex>0这个操作不是原子的,也依然存在线程安全问题,因此的话,这个部分也需要cpu提供的汇编语言来解决。

大多数体系结构都提供了swap和exchange指令,该指令是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。即使是多处理平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。(以x86的xchg指令为例)

代码修改为:

lock代码:
    mov $0, %al
    xchg %al, mutex
	if(al > 0) {
        mutex = 0;
        return 0;
    } else 
        挂起等待;
    goto lock;//被唤醒,再检查一次,避免虚假唤醒
    ....;
	....;
unlock代码:
	movb $1, mutex;
    唤醒等待mutex的线程:
    return 0;

写程序时应该尽量避免同时获得多个锁,如果一定有必要这么做,则有一个原则:如果所有线程在

需要多个锁时都按相同的先后顺序(常见的是按Mutex变量的地址顺序)获得锁,则不会出现死

锁。比如一个程序中用到锁1、锁2、锁3,它们所对应的Mutex变量的地址是锁1<锁2<锁3,那么

所有线程在需要同时获得2个或3个锁时都应该按锁1、锁2、锁3的顺序获得。如果要为所有的锁确

定一个先后顺序比较困难,则应该尽量使用pthread_mutex_trylock调用代替pthread_mutex_lock调

用,以免死锁。

spin_lock

Spin Lock 是一种轻量级的同步方法,一种非阻塞锁。当 lock 操作被阻塞时,并不是把自己挂到一个等待队列,而是死循环 CPU 空转等待其他线程释放锁。 Spin lock 锁实现代码如下 :

static inline void __preempt_spin_lock(spinlock_t *lock) 
{ 
          ……
 do { 
   preempt_enable(); 
   while (spin_is_locked(lock)) 
     cpu_relax(); 
   preempt_disable(); 
 } while (!_raw_spin_trylock(lock)); 
} 
 
static inline int _raw_spin_trylock(spinlock_t *lock) 
{ 
 char oldval; 
 __asm__ __volatile__( 
   "xchgb %b0,%1"
   :"=q" (oldval), "=m" (lock->lock) 
   :"0" (0) : "memory"); 
 return oldval > 0; 
}

汇编语言指令 xchgb 原子性的交换 8 位 oldval( 存 0) 和 lock->lock 的值,如果 oldval 为 1(lock 初始值为 1),则获取锁成功,反之,则继续循环,接着 relax 休息一会儿,然后继续周而复始,直到成功。

对于应用程序来说,希望任何时候都能获取到锁,也就是期望 lock->lock 为 1,那么用 CAS 原语来描述 _raw_spin_trylock(lock) 就是 CAS(lock->lock,1,0);

如果同步操作总是能在数条指令内完成,那么使用 Spin Lock 会比传统的 mutex lock 快一个数量级。Spin Lock 多用于多核系统中,适合于锁持有时间小于将一个线程阻塞和唤醒所需时间的场合

pthread 库已经提供了对 spin lock 的支持,所以用户态程序也能很方便的使用 spin lock 了,需要包含 pthread.h 。在某些场景下,pthread_spin_lock 效率是 pthread_mutex_lock 效率的一倍多。美中不足的是,内核实现了读写 spin lock 锁,但 pthread 未能实现。

递归锁

volatile

<< linux c 一站式编程 >>

单片机中的寄存器相当于一个全局变量,拥有一个固定的内存地址。这里用recv和send模拟串口的发送接收寄存器。

unsigned char recv;
unsigned char send;
/* memory buffer */
unsigned char buf[3];
int main(void)
{
 buf[0] = recv;
 buf[1] = recv;
 buf[2] = recv;
 send = ~buf[0];
 send = ~buf[1];
 send = ~buf[2];
 return 0;
}

将上述代码反汇编

 buf[0] = recv;
 80483a2: 0f b6 05 19 a0 04 08 movzbl 0x804a019,%eax
 80483a9: a2 1a a0 04 08 mov %al,0x804a01a
 buf[1] = recv;
 80483ae: 0f b6 05 19 a0 04 08 movzbl 0x804a019,%eax
 80483b5: a2 1b a0 04 08 mov %al,0x804a01b
 buf[2] = recv;
 80483ba: 0f b6 05 19 a0 04 08 movzbl 0x804a019,%eax
 80483c1: a2 1c a0 04 08 mov %al,0x804a01c
 send = ~buf[0];
 80483c6: 0f b6 05 1a a0 04 08 movzbl 0x804a01a,%eax
 80483cd: f7 d0 not %eax
 80483cf: a2 18 a0 04 08 mov %al,0x804a018
 send = ~buf[1];
 80483d4: 0f b6 05 1b a0 04 08 movzbl 0x804a01b,%eax
 80483dd: a2 18 a0 04 08 mov %al,0x804a018
 80483db: f7 d0 not %eax
 send = ~buf[2];
 80483e2: 0f b6 05 1c a0 04 08 movzbl 0x804a01c,%eax
 80483e9: f7 d0 not %eax
 80483eb: a2 18 a0 04 08 mov %al,0x804a018

接收时,每次都会先将recv内存中的值送给eax,再从eax赋值给buf的内存地址。

发送时,每次也都会先将buf内存中的值送到eax,再取反,送到send 的内存地址中

但如果指定优化选项-O编译,反汇编的结果就不一样了

		buf[0] = recv;
80483ae: 0f b6 05 19 a0 04 08 movzbl 0x804a019,%eax
80483b5: a2 1a a0 04 08 mov %al,0x804a01a
		buf[1] = recv;
80483ba: a2 1b a0 04 08 mov %al,0x804a01b
		buf[2] = recv;
80483bf: a2 1c a0 04 08 mov %al,0x804a01c
 		send = ~buf[0];
		send = ~buf[1];
 		send = ~buf[2];
80483c4: f7 d0 not %eax
80483c6: a2 18 a0 04 08 mov %al,0x804a018

只有第一条语句从内 存地址0x804a019读一个字节到寄存器eax中,然后从寄存器al保存到buf[0],后两条语句就不再 从内存地址0x804a019读取,而是直接把寄存器al的值保存到buf[1]和buf[2]。后三条语句 把buf中的三个字节取反再发送到串口,编译生成的指令也不符合我们的意图:只有最后一条语句 把eax的值取反写到内存地址0x804a018了,前两条语句形同虚设,根本不生成指令.

为什么编译器优化的结果会错呢?因为编译器并不知道0x804a018和0x804a019是设备寄存器的地

址,把它们当成普通的内存单元了如果是普通的内存单元,只要程序不去改写它,它就不会变,

可以先把内存单元里的值读到寄存器缓存起来,以后每次用到这个值就直接从寄存器读取,这样效

率更高我们知道读寄存器远比读内存要快。另一方面,如果对一个普通的内存单元连续做三次写322

操作,只有最后一次的值会保存到内存单元中,所以前两次写操作是多余的,可以优化掉。访问设

备寄存器的代码这样优化就错了,因为设备寄存器往往具有以下特性:

  • 设备寄存器中的数据不需要改写就可以自己发生变化,每次读上来的值都可能不一样。

  • 连续多次向设备寄存器中写数据并不是在做无用功,而是有特殊意义的。

用优化选项编译生成的指令明显效率更高,但使用不当会出错,为了避免编译器自作聪明,把不该

优化的也优化了,程序员应该明确告诉编译器哪些内存单元的访问是不能优化的,在C语言中可以

volatile限定符修饰变量,就是告诉编译器,即使在编译时指定了优化选项,每次读这个变量仍

然要老老实实从内存读取,每次写这个变量也仍然要老老实实写回内存,不能省略任何步骤。我们

把代码的开头几行改成:

volatile unsigned char recv;
volatile unsigned char send;

然后指定优化选项-O编译,查看反汇编的结果:

 buf[0] = recv;
 80483a2: 0f b6 05 19 a0 04 08 movzbl 0x804a019,%eax
 80483a9: a2 1a a0 04 08 mov %al,0x804a01a
 buf[1] = recv;
 80483ae: 0f b6 05 19 a0 04 08 movzbl 0x804a019,%eax
 80483b5: a2 1b a0 04 08 mov %al,0x804a01b
 buf[2] = recv;
 80483ba: 0f b6 05 19 a0 04 08 movzbl 0x804a019,%eax
 80483c1: a2 1c a0 04 08 mov %al,0x804a01c
 send = ~buf[0];
 80483c6: 0f b6 05 1a a0 04 08 movzbl 0x804a01a,%eax
 80483cd: f7 d0 not %eax
 80483cf: a2 18 a0 04 08 mov %al,0x804a018
 send = ~buf[1];
 80483d4: 0f b6 05 1b a0 04 08 movzbl 0x804a01b,%eax
 80483dd: a2 18 a0 04 08 mov %al,0x804a018
 80483db: f7 d0 not %eax
 send = ~buf[2];
 80483e2: 0f b6 05 1c a0 04 08 movzbl 0x804a01c,%eax
 80483e9: f7 d0 not %eax
 80483eb: a2 18 a0 04 08 mov %al,0x804a018

除了设备寄存器需要用volatile限定之外,当一个全局变量被同一进程中的多个控制流程访问时也要用volatile限定,比如信号处理函数和多线程

多核时代多线程慎用volatile

C/C++多线程编程中不要使用volatile。
(注:这里的意思指的是指望volatile解决多线程竞争问题是有很大风险的,除非所用的环境系统不可靠才会为了保险加上volatile,或者是从极限效率考虑来实现很底层的接口。这要求编写者对程序逻辑走向很清楚才行,不然就会出错)

C++11标准中明确指出解决多线程的数据竞争问题应该使用原子操作或者互斥锁。
C和C++中的volatile并不是用来解决多线程竞争问题的,而是用来修饰一些因为程序不可控因素导致变化的变量,比如访问底层硬件设备的变量,以提醒编译器不要对该变量的访问擅自进行优化。

多线程场景下可以参考《Programming with POSIX threads》的作者Dave Butenhof对
Why don’t I need to declare shared variables VOLATILE?
这个问题的解释:
comp.programming.threads FAQ

简单的来说,对访问共享数据的代码块加锁,已经足够保证数据访问的同步性,再加volatile完全是多此一举。

死锁

简介[编辑]

例如,一个进程 p1占用了显示器,同时又必须使用打印机,而打印机被进程p2占用,p2又必须使用显示器,这样就形成了死锁。 因为p1必须等待p2释出打印机才能够完成工作并释出萤幕,同时p2也必须等待p1释出显示器才能完成工作并释出打印机,形成循环等待的死结。

死锁的预防[编辑]

如果系统中只有一个进程,当然不会产生死锁。如果每个进程仅需求一种系统资源,也不会产生死锁。不过这只是理想状态,在现实中是可遇不可求的。

死锁的四个条件是:

  • 禁止抢占 (no preemption) - 系统资源不能被强制从一个进程中退出

  • 持有和等待 (hold and wait) - 一个进程可以在等待时持有系统资源

  • 互斥 (mutual exclusion) - 只有一个进程能持有一个资源

  • 循环等待 (circular waiting) - 一系列进程互相持有其他进程所需要的资源

死锁只有在这四个条件同时满足时出现。预防死锁就是至少破坏这四个条件其中一项,即破坏“禁止抢占”、破坏“持有等待”、破坏“资源互斥”和破坏“循环等待”。

死锁的避免[编辑]

我们也可以尝试回避死锁。因为在理论上,死锁总是可能产生的,所以操作系统尝试监视所有进程,使其没有死锁。

  1. 以确定的顺序获得锁

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TYY5Ysut-1573897318871)(D:\A_目标!!!\笔记\网络编程\pic\1.jpg)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HFpoRRem-1573897318872)(D:\A_目标!!!\笔记\网络编程\pic\2.jpg)]

锁比较多的时候用银行家算法:有点复杂,再说。主要是计算资源当前能申请的最大数量,然后进程尝试申请,看现在能不能申请上。

  1. 超时放弃

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jrICNbrP-1573897318874)(D:\A_目标!!!\笔记\网络编程\pic\超时放弃.jpg)]

死锁的消除[编辑]

最简单的消除死锁的办法是重启系统。更好的办法是终止一个进程的运行。

同样也可以把一个或多个进程回滚到先前的某个状态。如果一个进程被多次回滚,迟迟不能占用必需的系统资源,可能会导致资源匮乏(英语:Starvation (computer science))。

参考文献:[Java面试必问-死锁终极篇]( https://juejin.im/post/5aaf6ee76fb9a028d3753534 J)

你可能感兴趣的:(c++,网络编程,锁,多线程)