IT笔记与心得基础科学

操作系统原子性与锁,synchronized小结

2021-02-20  本文已影响0人  会跳的八爪鱼

1. 锁概念

多个进程之间共享数据,如果多个进程同时对共享数据进行修改就会出现并发问题,那么如何解决并发问题,这里可能需要用到锁,那么锁是如何实现的?

1.1 原子操作

在操作系统中,锁可以看成对内存中一个共享变量的修改,多个进程竞争锁,可以理解为多个线程竞争一个变量的修改。那么如何保证多线程环境下共享变量只能被一个进程修改。以下3种是硬件提供修改数据的原子操作。

mesi协议的过程
mesi协议中cpu收到读写变量以及收到消息的状态变化
note:操作系统中的原子操作
首先处理器会保证基本的内存操作的原子性,比如从内存读取或者写入一个字节是原子的,但对于(cas指令,tsl指令)读-改-写、或者是其它复杂的内存操作是不能保证其原子性的,又比如跨总线宽度、跨多个缓存行和夸页表的访问,这时候需要处理器提供总线锁和缓存锁(CPU的LOCK前缀)来保证复杂的内存操作原子性。

1.2 MESI引入的问题以及优化

失效队列与存储缓存带来的问题:多个变量在进行修改时,其顺序可能是不确定的。即重排序

value = 3;
void exeToCPUA(){
  value = 10; isFinsh = true;
}
void exeToCPUB(){
  if(isFinsh){
      assert value == 10;//value一定等于10?!
  }
}

试想一下开始执行时,CPU A保存着finished在E(独享)状态,而value并没有保存在它的缓存中(例如,Invalid)。在这种情况下,value会比finished更迟地抛弃存储缓存。完全有可能CPU B读取finished的值为true,而value的值不等于10,即isFinsh的赋值在value赋值之前。这种在可识别的行为中发生的变化称为重排序(reordings)。

注意,这不意味着你的指令的位置被恶意(或者好意)地更改。
它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。


缓存一致性优化所带来的问题
public class Main {
    static int a = 0;
    public static void main(String[] args)  throws Exception {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                while (a == 0) {
                    b++;
                }
                System.out.println("T1得知a = 1");
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                    a = 1;
                    System.out.println("T2修改a = 1");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t1.start();
        t2.start();
    }
}

上述代码中"T1得知a = 1"可能不会立刻打印出来,因为t1线程一直在做b++操作,导致线程繁忙而不能同步store buffer中的数据到缓存中(cpu同步store buffer中的数据时会有时延),如果把b++改成Thread.sleep(1)则线程t1就可以退出循环了。

1.3 操作系统的锁

void producer(void){ 
 int item;  
 while(TRUE){                  /* TRUE是常量1 */
   item = producer_item();     /* 产生放在缓冲区中的一些数据 */
   down(&empty);               /* 将空槽数目-1  */
   down(&mutex);               /* 进入临界区  */
   insert_item(item);          /* 将新数据放入缓冲区中 */
   up(&mutex);                 /* 离开临界区 */
   up(&full);                  /* 将满槽数目+1 */
 }
}
void consumer(void){
 int item;
 while(TRUE){                  /* 无限循环 */
   down(&full);                /* 将满槽数目-1 */
   down(&mutex);               /* 进入临界区 */
   item = remove_item();       /* 从缓冲区取出数据项 */
   up(&mutex);                 /* 离开临界区 */
   up(&empty);                 /* 将空槽数目+1 */
   consume_item(item);         /* 处理数据项 */
 }
}

②不能表示同步关系,例如需要线程a,b顺序执行,当线程b先执行时会阻塞等待线程a执行完成。如果使用互斥量,则当线程a先执行完时,释放锁并唤醒被阻塞线程,可是此时没有被阻塞的线程,这是一步空操作。接着线程b在执行还是会被阻塞(因为a已经执行完了,没有线程去执行唤醒方法了),如果想解决这个问题可以使用唤醒等待位。如果线程数比较多就需要多个唤醒等待位。

这种情况可以使用信号量,它会累计唤醒次数供以后使用。

  • mutex(互斥锁-互斥锁(mutex)的底层原理):如果获取成功则进入临界区,如果获取失败需要阻塞该线程(阻塞线程需要系统调用,从用户态转为内核态),释放锁后唤醒阻塞的线程。互斥锁会造成用户态内核态切换,如果临界区的执行时间过长或者锁竞争比较激烈,则阻塞进程会提升性能。反之应该使用自旋锁。互斥锁的底层使用的是原子交换的指令,简单来说就是将寄存器中值与锁变量在的内存地址交换,如果寄存器返回0表示加锁成功,如果返回1表示加锁失败,如果两个同时进行,则会进行总线仲裁。其中锁变量0表示锁空闲,1表示锁被使用
    • lock与trylock:互斥锁提供了两种获取加锁的方法,但是这两种方法有一些区别,lock加锁失败会阻塞,等待锁释放;trylock加锁失败直接返回错误号(如EBUSY),不阻塞,线程可以去执行其他事情。
  • spinlock(自旋锁):获取锁失败不阻塞线程cpu继续执行获取锁操作,但是会浪费cpu资源,如果再次竞争获取锁的时间小于线程阻塞唤醒的时间,则使用自旋锁。
  • futex:互斥锁的变量是由内核进行管理的,所以加锁和释放锁都需要从用户态转到内核态,这样即使没有用户竞争锁,也需要一次系统调用。futex是一种用户态和内核态混合的同步机制,同步的进程间通过mmap共享一段内存,futex变量就位于用户态的共享内存中且操作是原子的,当进程尝试加锁和解锁时,先去查看共享内存中的futex变量,如果没有竞争发生,则只修改futex,而不用再执行系统调用了。当通过访问futex变量告诉进程有竞争发生,则还是得执行系统调用去完成相应的处理(wait或者wake up)。

2. synchronized锁分析

synchronized分为代码块加锁与方法加锁
①代码块加锁会生成monitorenter(获取锁)和 monitorexit(释放锁)指令,两个指令之间的便是加锁区域②方法加锁会根据ACC_SYNCHRONIZED标志判断然后再获取monitor锁。详情Synchronized解析——如果你愿意一层一层剥开我的心 (juejin.cn)

代码加锁与解锁的说明
方法加锁说明

旧版本的synchronized使用的是重量级锁,即互斥锁,这种锁的优点是当线程竞争比较大时可以提升性能,但线程竞争比较小或者临界区执行时间较短时会降低性能,所以java后续版本针对synchronized做了优化。

  • 锁升级:根据线程竞争情况和数量分为,可偏向锁,轻量级锁,重量级锁。根据竞争激烈程度依次向后升级。这些锁都与对象头的markword有关。
  • 锁消除:在编译去除不会竞争资源的同步方法,例如StringBuffer的append是一个同步方法,但是不会竞争资源,所以去除锁标记。
  • 锁粗化:将多个出现同步的代码块合并成一个临界区,避免假锁和解锁的内核切换。
  • 可偏向锁:当只有一个线程竞争时,通过原子操作设置markword中的偏向线程id为此线程id,即可获得锁,取消了同步。递归进入时根据线程id判断是否可加锁


    markword结构
    可偏向锁
  • 轻量级锁:当有不太多线程竞争时,且竞争程度不激烈,则升级为轻量级锁。线程将对象的markword复制到线程栈中,并修改对象markword的数据为指向线程栈帧的指针。如果加锁失败不阻塞,使用自旋锁继续执行。


    轻量级锁
  • 重量级锁:使用互斥锁同步。
  • note:java原子类中的compareAndSet()方法底层使用的就是cas指令,原子类中的value使用volatile修饰,并且禁止缓存优化,确保原子操作。属于无锁机制。详细说明可以查找参考中的链接。

参考:
原子操作
cpu缓存一致性与mesi
缓存一致性与优化,内存屏障
进程 mutex与spinlock
futex
可偏向锁,轻量级锁与重量级锁
https://zhuanlan.zhihu.com/p/109971253(同步与锁解析)
synchronized原理
compareAndSet
https://blog.csdn.net/ls5718/article/details/52563959(volatile)
进程通信
Synchronized解析——如果你愿意一层一层剥开我的心 (juejin.cn)
Java 锁机制_寒泉-CSDN博客
CPU缓存一致性协议MESi与内存屏障- 博客园 (cnblogs.com)
CAS(Compare and Swap)无锁算法,使用非阻塞同步算法构造堆栈和链表

上一篇 下一篇

猜你喜欢

热点阅读