并发编程之JMM&Volatile(二)
并发的优势与风险
优势
速度:同时处理多个请求,响应更快;复杂的操作可以同时分成多个进程或者线程同时进行。
设计:程序设计在某些情况下变得更简单。
资源利用:CPU可以在等待IO的时候做其他的事情。
风险
安全性:多个线程同时读写数据可能会产生于期望不相符的结果。
活跃性:某个操作无法进行下去时,就会发生活跃性问题。比如:死锁、饥饿、活锁等问题。
- 死锁:两个或多个线程在执行时互相持有对方所需要的资源,导致线程处于阻塞状态,无法执行。
-
活锁:有若干线程彼此之间都会互相影响,当一个线程需要某个资源,如果他检测到其他线程也需要这个资源,就退出竞争,将资源让给其他线程,这种情况下容易发生活锁,每个线程都占用CPU时间,但每个线程都检测到自己所需要的资源也被其他线程需要,于是谦让给其他资源,导致没有一个线程真正占用资源并完成执行,浪费宝贵的CPU时间。
- 饥饿:如果一个线程因为处理器时间全部被其他线程抢走而得不到处理器运行时间,这种状态被称之为饥饿,一般是由高优先级线程吞噬所有的低优先级线程的处理器时间引起的。
性能:线程过多时会使得:CPU频繁切换,调度时间增多;同步机制;消耗过多内存。
下面,我们来看下死锁的代码:
public class DeadLockTest { private static String a = "a"; private static String b = "b"; public static void main(String[] args) throws InterruptedException { Thread threadA = new Thread(() -> { synchronized (a) { System.out.println("threadA进入a同步块,执行中..."); try { Thread.sleep(2000); synchronized (b) { System.out.println("threadA进入b同步块,执行中..."); } } catch (InterruptedException e) { e.printStackTrace(); } } }, "threadA"); Thread threadB = new Thread(() -> { synchronized (b) { System.out.println("threadB进入b同步块,执行中..."); synchronized (a) { System.out.println("threadB进入a同步块,执行中..."); } } }, "threadB"); threadA.start(); Thread.sleep(1000); threadB.start(); } }
运行结果:
threadA进入a同步块,执行中... threadB进入b同步块,执行中...
上面是一段很传统的死锁代码,线程A在获得对象a的同步锁后,休眠2000ms,确保线程B已获得对象b的同步锁。线程B还要获取对象a的同步锁时,对象a的同步锁已经被线程A持有,线程B只能等待。等到线程A被唤醒,要获取对象b的同步锁,此时线程B持有对象b的同步锁,且线程B还等待线程A释放对象a的同步锁,两个线程都持有对方完成任务所需的锁,但又不能释放,从而造成死锁。
我们再来看下活锁的案例:假设我们有若干工人(Worker)需要工具(tool)来完成工作,工人最开始被创建出来,会加入到工人集合(workers),只有工人使用工具完成工作后,会从工人集合移除该工人。工具一开始会分配一个工人使用,工人在工作时,如果发现工具所分配的主人不是自己,则通知其他工人来争夺工具,而自己陷入等待,工具的主人抢占到工具后会检查工人集合中是否有其他等待工具的工人,如果有,则随机找一个,将工具转让给那个工人。
import java.util.*; public class LiveLockTest { private static Set<Worker> workers = new HashSet<>();//<1>工人集合 private static Tool tool = new Tool();//<2>工具 static class Tool { private volatile Worker owner; public Worker getOwner() { return owner; } public void setOwner(Worker owner) { this.owner = owner; } } //判断其他处于等待的工人 public static Worker getOtherWaitingWorker(Worker except) { //将工人集合转换成list List<Worker> list = new ArrayList<>(workers); //如果list在移除except之后,长度为0,则代表工人集合里没有处于等待的工人 list.remove(except); if (list.size() == 0) { return null; } //如果有处于等待的工人,则随机选择一个返回 Random random = new Random(); int index = random.nextInt(list.size()); return list.get(index); } static class Worker extends Thread { public Worker(String name) { super(name); } @Override public void run() { try { synchronized (tool) { while (true) { //如果当前工人线程抢到工具锁,则判断自己是否是工具的主人,不是则陷入等待并释放锁 while (tool.getOwner().getName() != this.getName()) { tool.wait(); } //如果当前工人线程为工具的主人,则获取其他处于等待的工人 Worker other = getOtherWaitingWorker(this); //如果处于等待的工人不为空,则重新设置工具的主人为等待的工人,并调用工具的notifyAll()方法,通知其他工人线程竞争工具锁 if (other != null) { System.out.println("我是" + getName() + ",我把工具让给" + other.getName()); tool.setOwner(other); tool.notifyAll(); } else { //如果没有处于等待的工人,则使用工具完成工作,并将自己从工人集合中移除 System.out.println(getName() + "使用完工具,从workers集合中删除" + getName()); workers.remove(this); } Thread.sleep(500); } } } catch (InterruptedException e) { System.out.println(getName() + "中断"); } } } public static void main(String[] args) { for (char c = 'A'; c <= 'C'; c++) { Worker w = new Worker("工人" + c); if (tool.getOwner() == null) { tool.setOwner(w);//如果工具没有主人,则设置一个工人为工具的主人 } workers.add(w);//将工人依次加入工人集合 } for (Worker worker : workers) { worker.start();//工人开始工作 } try { Thread.sleep(10000); //中断每个工人,并等待工人线程执行完成 for (Worker worker : workers) { worker.interrupt(); worker.join(); } } catch (InterruptedException e) { e.printStackTrace(); } } }
运行结果:
我是工人A,我把工具让给工人C 我是工人C,我把工具让给工人B 我是工人B,我把工具让给工人A 我是工人A,我把工具让给工人C 我是工人C,我把工具让给工人A …… 我是工人C,我把工具让给工人B 工人C中断 我是工人B,我把工具让给工人C 工人A中断 工人B中断
如我们上面所看到,每个工人都抢占到这个工具,但每个人又因为谦让给别的工人,也无法使用工具完成工作,浪费CPU时间。
缓存一致性
我们知道,一个CPU有多少个处理器,就可以处理多少个线程,同时这些处理器还会维护自己的缓存。这样看来,可见性问题不单单是Java的烦恼,只要运行在多核架构的程序,不管程序本身是否由Java编写,都会面临可见性的问题,那么当我们有多个线程需要同时修改内存某块数据,操作系统是否有提供额外的手段来保证线程安全和数据的可见性呢?答案是有的,操作系统提供了:总线锁和缓存一致性。
从下面这张图可以看到:总线是计算机各种功能部件之间传送信息的公共通信干线,它是cpu、内存、输入、输出设备传递信息的公用通道。当处理器需要对内存里的一块数据做运算,总线会把数据从内存传输到CPU缓存,等处理器计算完毕后回写缓存,再从缓存传输回内存。
那么,如果多个处理器都要计算内存里同一块数据,我们能否在总线那里加把锁?只允许一个处理器计算完数据后,别的处理器才可以通过总线计算内存中的数据。事实上,总线锁就是这么做的,处理器提供的一个LOCK#信号,当一个处理器向总线传输此信号时,其他处理器的请求将陷入阻塞,于是该处理器就可以独占内存了。但这么做有个弊端,就是其他处理器无法处理别的任务,可能不同的处理器正分别处理不同的程序,因为一个程序的某一资源可能出现的并发问题而把总线锁住,导致其他程序无法执行,这似乎有些得不偿失。因此,CPU又提供了缓存一致性:当内存中某块数据被多个处理器缓存,其中一个处理器要修改缓存中的数据时会先通知其他处理器放弃缓存中的副本,得到其他缓存的回应确定其他缓存已将副本置位失效状态,处理器才会修改数据并在未来的某个时刻将缓存中的数据同步到内存。
缓存一致性协议有多种实现,比较为人熟知的一种实现为MESI协议,MESI协议设定CPU缓存中64个字节为一个缓存行,通过给缓存行定义状态:M(Modify,修改)、E(Exclusive,独占)、S(Share,共享)和I(Invalid,无效)用来描述该缓存行是否被多处理器共享、是否修改。
状态 | 描述 |
M(Modify,修改) | 代表该缓存行中的内容被当前处理器修改了,这个状态下的缓存行与对应内存中的数据不一致,在未来的某个时刻它会被写入到内存中,比如当其他处理器需要读取或修改同一份内存数据的时候。 |
E(Exclusive,独占) | 代表该缓存行对应内存中的内容只被当前CPU缓存,其他CPU没有缓存该缓存行对应内存的数据。这个状态下的缓存行和对应内存中的数据一致。缓存行可以在其他CPU读取同一份内存中的数据时变成S状态、或者本地处理器修改缓存行内容时会变成状态M。 |
S(Share,共享) | 代表该缓存行所对应的内存中的数据数据不止存在当前缓存中,还被其他CPU的缓存所持有,这个状态下缓存行的数据和内存中的数据是一致的,当有一个CPU修改该缓存行对应的内容时会使其他CPU中对应的该缓存行变成状态I 。 |
I(Invalid,无效) | 代表该缓存行中的内容无效,CPU要读取或者修改缓存行,需要重新去内存同步。 |
我们已经知道MESI协议的概念,下图是模拟当多个多核CPU在修改内存同一变量时,如何按照MESI协议来交互:
按照MESI协议,当变量i存在CPU1和CPU2中,如果CPU1要修改缓存中的变量i前,首先要通知CPU2,等到CPU2返回消息告诉CPU1已经将自己缓存中的变量i状态置为I(失效)后,CPU1修改缓存中的变量i,状态改为M。之后CPU1会在一定时间内将缓存中副本i的最新数据同步到内存,比如:当其他CPU需要从内存中同步变量i的数据时,CPU1将缓存中的变量i同步到内存,然后CPU2读取内存中的变量i,CPU1和CPU2缓存中的变量i状态变为S。
我们用local read和local write分别代表本地CPU读写,remote read和remote write分别代表其他CPU读写,针对变量i,再次归纳下MESI状态的变化:
- M(Modify)
- local read:不影响当前缓存行状态。
- local write:不影响当前缓存行状态。
- remote read:先把缓存行数据同步到内存,当其他CPU读取内存中的变量i时,持有最新数据的缓存行状态都变为S。
- remote write:首先经历M状态下remote read的步骤,所有持有变量i最新数据的缓存行状态都为S。假设当前CPU为CPU1,CPU1、CPU2和CPU3都持有最新的变量i的拷贝,状态都为S。当CPU2要修改变量i,先通知CPU1和CPU3,得到回应确定变量i在其他缓存的拷贝已经置位失效(I)后,CPU2修改变量i,并把状态置位M,当前CPU(CPU1)和CPU3对变量i的拷贝的状态都为I。
- E(Exclusive)
- local read:不影响当前缓存行状态。
- local write:当前缓存行状态变为M。
- remote read:缓存行和其他CPU的缓存行状态变为S。
- remote write:先经历E状态下,remote read步骤,和其他CPU的缓存行状态都为S。假设当前CPU为CPU1,CPU1、CPU2和CPU3缓存行中的i状态都为S,CPU2要修改缓存中的变量i,先通知CPU1和CPU3将变量i状态置为失效(I),之后CPU2修改缓存行,CPU2缓存行状态变为M。
- S(Share)
- local read:不影响当前缓存行状态。
- local write:当前CPU通知其他CPU将各自缓存行i置为失效,得到其他CPU回应后,修改当前缓存行i,状态变为M。
- remote read:不影响当前缓存行状态。
- remote write:其他CPU通知当前缓存行将变量i置为失效(I)。
- I(Invalid)
- local read:
- 如果其他处理器中没有变量i的拷贝,当前缓存从内存中取变量i后状态变为E。
- 如果其他处理器中有变量i的拷贝,且缓存行状态为M,则先把缓存行中的数据同步到内存。当前缓存再从内存读取数据,这时两个缓存行的状态都变为S。
- 如果其他缓存行中有变量i的拷贝,其他缓存行的拷贝状态为S或E,当前缓存从内存中取数据,并且这些缓存行状态变为S。
- local write:
- 先从内存中读取变量i,如果其他缓存中有变量i的拷贝且状态为M,则先让持有变量i且状态为M的缓存更新最新值到内存后,当前缓存再读取内存中的变量i。之后两个缓存的变量i状态S,当前处理器要修改i之前,先通知其他处理器放弃缓存中的变量i,得到其他处理器回应后,确定变量i在别的缓存中状态为I后,当前处理器修改i的值,状态改为M。
- 如果其他缓存有变量i,且状态为E或S,那么其他缓存行的状态变为I。
- local read:
- remote read:不影响当前缓存行状态。
- remote write:不影响当前缓存行状态。
现在我们来思考一个问题,MESI协议确实可以保证内存的可见性,但处理器发现缓存数据失效,要去内存加载最新的数据,无论加载得再如何快也会存在性能的损耗,那有没有办法既不需要处理器去内存加载最新数据,又可以让别的处理器获取到最新数据呢?这里就引出了MOESI协议,这个协议相比MESI协议,MOESI引入状态Owned,并且重新定义了S状态,而E、M状态保持不变。
当CPU1修改完缓存的副本i,会把当前数据的状态改为O,这个状态代表当前缓存的副本i与内存的变量i的值不同,且变量i被多个CPU共享,在O这个状态下其他持有副本i的CPU会从CPU1的缓存将最新的副本i的值同步到自己的缓存,之后其他CPU原先副本i的状态为I变为S,如果有的CPU的缓存没有变量i,需要从内存同步,这时CPU1就可以借助状态O,将副本i的值同步到内存。
这里需要要注意一点,按照MEOSI协议,如果缓存中的副本i状态为S,并不代表副本的值与内存的值一致,如果存在副本i状态为O的缓存,则此时缓存中的副本i与内存的变量i的值不一致,如果所有缓存的副本i状态都为S,则代表缓存中的副本i与内存的变量i的值是一致的。
另外,缓存一致性协议只能保证数据的可见性,但不能保证原子性,假设内存里变量i的值为0,CPU1和CPU2同时对变量i做+1的操作,计算结果也可能不是2。
Store Buffer
当一份数据在多个CPU缓存中存在拷贝,其中一个CPU要修改数据,需要先通知其他CPU放弃该数据的拷贝,等到回应后才能修改,这无疑是个同步操作,而CPU时间是非常宝贵的,不应该让CPU陷入等待状态,要解决这个问题也很简单,采用异步即可。原先CPU要等到其他CPU的回应后才可以修改缓存中的数据,现在在CPU和缓存之间增加一个Store Buffer,CPU要修改数据时,一边把最新的数据写进Store Buffer,一边向其他CPU发送消息,然后继续执行别的指令,等到其他CPU发来确认数据失效的回应后,当前CPU在把Store Buffer的数据同步到缓存。
Store Buffer确实解决了CPU陷入等待的问题,但又引入一个新的问题,我们来看下面的代码:
a = 0 b = 0 func execToCPU1(){ a = 1 b = a + 1 }
假设变量a同时存在CPU1和CPU2的缓存,缓存行a和内存的值一样都为0。当CPU1要执行上execToCPU1(),它一边将a=1写入到Store Buffer,一边向CPU2发送消息,当CPU1要执行b=a+1时,由于a最新的值存在Store Buffer,CPU1读取a的值依旧从缓存读,缓存中的a的值依旧是0,所以b的值为1,但按照逻辑b的值应该为2,程序的执行顺序遭到破坏,变成下面这样:
func execToCPU1(){ b = a + 1 a = 1 }
Store Forwarding
Store Buffer可能导致程序顺序遭到破坏,因此在Store Buffer的基础上又引入了Store Forwarding技术,CPU可以从Store Buffer中读取最新的数据,将传递给之后的指令,不再完全从缓存中读取数据。
Store Forwarding解决了单CPU下读写的问题,但如果是多CPU读写数据时还是有问题,我们看下面的代码:
a = 0 b = 0 func execToCPU1(){ a = 1 b = 1 } func execToCPU2(){ while(b == 0) continue if(a == 1){ //do something... } }
假设CPU1执行execToCPU1()方法,CPU2执行execToCPU2()方法。初始状态下,CPU1的缓存持有变量b的拷贝,CPU2的缓存持有变量a的拷贝。
- CPU2要执行while(b == 0),由于CPU2的缓存中没有b,发送read b消息。
- CPU1要执行a=1,由于CPU1的缓存中没有a,它将a=1写到自己的Store Buffer中,并发送read invalid a。
- CPU1执行b=1,由于b的拷贝已在CPU1的缓存中且为独占(E)状态,因此直接将缓存中的b改为1并把状态修改为M。
- CPU1接收到CPU2 read b的消息,将缓存中的b返回给CPU2,再将b同步到内存,并将缓存行的状态改为共享(S)。
- CPU2接收到b的值,结束了while(b == 0)的循环。
- CPU2要判断a是否为1,如果判断为true,则要执行if分支里的逻辑。此时CPU2缓存的a仍旧未失效,值依旧为0,所以就不执行if分支里的逻辑。
- CPU2接收到CPU1发送的read invalid a,将本地缓存行置为失效(I),但为时已晚。
- CPU1接收到CPU2的invalid a失效回应,将Store Buffer里的a同步到缓存。
出现这个问题是因为CPU之间不知道数据之间的依赖关系,CPU1可以在最开始的时候就修改变量a为1并发送消息,CPU2跳出while(b == 0)时本应执行if(a == 1)分支里的代码,但由于发送消息是异步的,此时CPU2还没收到缓存行a已失效的通知,不执行if分支的代码,等收到的时候为时已晚。
写屏障指令
现在看来,要确保CPU修改在修改缓存中的拷贝后,其值对其他CPU是立即可见的,单靠硬件是无法做到的,需要在软件层面支持。于是,CPU提供了写屏障指令(write memory barrier),Linux系统将写屏障指令封装成smp_wmb()函数,而CPU执行smp_wmb()函数的有两种思路是:
- 修改数据同时发送消息,等到返回后,将Store Buffer的数据同步到缓存。
- 对Store Buffer中所有条目打上标记,写屏障之后的写入操作也放到Store Buffer中,CPU继续执行别的指令,当其他CPU返回确认数据失效的返回后,将标记条目和之后的写入操作刷新到缓存。
第一种思路不需要讲解,我们重点讲解第二种思路,来看下面的代码:
func execToCPU1(){ a = 1 smp_wmb() b = 1 } func execToCPU2(){ while(b == 0) continue if(a == 1){ //do something... } }
我们依旧设定CPU1持有变量b的拷贝,CPU2持有变量a的拷贝:
- CPU2要执行while(b == 0),由于CPU2的缓存中没有b,发送read b消息。
- CPU1要执行a=1,由于CPU1的缓存中没有a,它将a=1写到自己的Store Buffer中,并发送read invalid a。
- CPU1遇到smp_wmb(),会对Store Buffer里的所有条目,即a=1被标记。
- CPU1执行b=1,尽管b在缓存中的状态为独占(E),但Store Buffer还存在被标记的条目,所以b=1也会写到Store Buffer中。
- CPU1收到CPU2发送的read b消息,将缓存中b的值(b为0)返回给CPU1,并将状态改为S。
- CPU2收到read b的回应,b的值为0,继续while(b ==0)的循环。
- CPU2收到CPU1的read invalid a消息,将本地变量a的拷贝置为失效(I),并发送确认a失效回应。
- CPU1收到CPU2的确认失效回应后,将Store Buffer中的a(值为1)刷新到缓存,并将缓存行状态置为M。
- CPU1所有被标记的条目已被刷回缓存,开始尝试将b=1同步回缓存,由于缓存中b的状态从原先的独占(E)改为共享(S),因此CPU1要先发送invalid b消息。
- CPU2收到CPU1发送的invalid b消息,将本地缓存中的b置为失效后,再发送确认b失效回应。
- CPU2继续执行while(b == 0),由于本地缓存中的b已经失效,CPU2发送read b消息。
- CPU1收到CPU1发送的invalid b返回后,将Store Buffer中b=1写到缓存。
- CPU1收到CPU2的read b消息,将缓存中的b(b为1)返回给CPU2,修改状态为S,并同步回内存。
- CPU2收到CPU1发送的read b返回后,更新本地缓存中的b为S,执行while(b == 0)跳出循环。
- CPU2执行if(a == 1),由于变量a在CPU2的缓存中状态为失效,CPU2发送read a消息。
- CPU1收到CPU2的read a消息后,将a的值返回给CPU2后,修改a的状态为S,并同步回内存。
- CPU2收到CPU1发送的read a的返回后,修改本地缓存中的变量a状态为S,判断if(a == 1)条件为true,执行if分支里的代码。
Invalid Queue
在Store Buffer、Store Forwarding的基础上,我们再在软件层面引入写屏障,确实是解决了多个CPU不知道变量之间的依赖关系。但我们要知道,Store Buffer是有大小限制的,如果CPU遇到一个写屏障,后续的写入操作都会堆积在Store Buffer,直到Store Buffer中屏障之前的条目都处理完才能同步到缓存,这非常容易造成Store Buffer被写满,当Store Buffer被写满之后,CPU还是要等待其他CPU返回的invalid回应以处理Store Buffer中被标记的条目,而invalid回应的主要耗时原因是CPU把invalid消息对应的缓存行状态置为失效后再发送invalid回应。如果一个CPU特别的繁忙,那么会导致别的CPU一直在等待它的invalid回应。而解决方案还是化同步为异步,CPU收到invalid消息后不必立即将对应的缓存行置为失效,可以把invalid消息放到Invalid Queue就立即发送invalid回应,当CPU要处理某个缓存行的MESI状态前,先检查Invliad Queue是否有对应缓存行的消息。
但引入Invliad Queue又会出现新的问题,来看下面的代码:
func execToCPU1(){ a = 1 smp_wmb() b = 1 } func execToCPU2(){ while(b == 0) continue if(a == 1){ //do something... } }
假设a和b在内存的初始值都为0,变量a的拷贝同时存在CPU1和CPU2的缓存中,状态为S,变量b的拷贝存在于CPU1的缓存中,状态为E。现在,我们开始模拟CPU1执行execToCPU1()方法,CPU2执行execToCPU2()方法:
- CPU1执行a=1,由于CPU1缓存已经存在变量a对应的缓存行了,将a=1写入Store Buffer,同时发送invalid a消息。
- CPU2执行while(b==0),由于CPU2缓存中没有变量b对应的缓存行,发出read b消息。
- CPU2收到CPU1发送来的invalid a消息,存放到Invalid Queue并发送确认a失效回应。
- CPU1收到CPU2返回的确认a失效回应,将Store Buffer中的a=1同步到缓存并修改状态为M。
- CPU1看到smp_wmb()写屏障语句,由于Store Buffer为空,因此它跳过该语句。
- CPU1执行b=1,因为CPU1独占b,所以直接将变量b对应的缓存行状态由E改为M。
- CPU1接收到CPU2的read b消息,将缓存行b的值返回给CPU2后,同步回内存,并修改状态为S。
- CPU2收到read b的响应后,执行while(b==0)跳出循环。
- CPU2执行if(a==1),由于缓存行a存的是旧值,所以if条件判断失败,无法执行分支里的逻辑。
- CPU2处理Invalid Queue中的消息,将缓存行a的状态置为失效。
问题出在第9步,CPU2应该先处理Invalid Queue中的消息,将本地缓存行a置为失效后,重新读取a的值,之后再执行if(a==1)分支判断。对此,CPU提供了读屏障指令,Linux将其封装为smp_rmb()函数,只要执行读屏障指令,就能确保CPU会把当前Invalid Queue中的消息处理掉。于是,我们可以把execToCPU2()方法改成如下:
func execToCPU2(){ while(b == 0) continue smp_rmb() if(a == 1){ //do something... } }
这样就能确保在CPU2在第8步跳出while循环时,执行读屏障指令处理完Invalid Queue消息后,将本地缓存行a置为失效,当要执行if(a==1)重新发起对变量a的读取消息。
内存屏障
迄今为止,我们已经介绍了写屏障和读屏障,写屏障可以标记Store Buffer中的条目,将写屏障之后的写入操作都写进Store Buffer,等到接收到标记条目的失效回应,再将Store Buffer的内容同步回缓存。而读屏障可以让CPU先处理Invalid Queue里的消息,更新本地缓存行的最新状态。除了这两种屏障,还有一种全屏障,它具有读、写屏障的功能。另外,内存屏障还能保证屏障两边的指令不会发生重排序。