第一章 走入并行世界
一、必知的概念
1、同步和异步
同步:串行化的执行,前面A执行完成才能执行后面B的。(需要前面的执行返回结果才开始后面的操作)
异步:执行操作A的同时,去执行操作B。不需要等A操作完返回结果再去操作B。此时利用多线程执行多个操作。
2、并发和并行
并发:多个任务交替执行,利用多线程实现(多个线程间互相切换执行),非真正意义上的同步进行,而是因为线程切换快执行快从而体现的"同时执行"的效果。(一个进程内其实只能有一个线程在运行)
并行:真正的同时执行,多个进程同时执行各自的操作。(多进程需要多核cpu的环境)
3、临界区
即多线程下竞争的公共资源(共享数据),但每次只能有一个线程使用占用它。
当该资源被占用后,其他线程要使用则需要进行等待。
在并发程序中,临界区资源是需要保护的对象。
4、阻塞和非阻塞
临界区资源被占用,此时其他线程要使用该资源就需要在该临界区中等待,此时将线程挂起,这就是阻塞。
非阻塞的意思反之理解。
5、死锁(Deadlock)、饥饿(Starvation)和活锁(Livelock)
死锁、饥饿、活锁都属于多线程的活跃性问题,如果发生这几种情况,那其他线程就不再活跃(很难继续往下执行)。
死锁:线程A占用了a,线程B占用了b。此时B需要用到a就先挂起等待,而A此时需要用到b也挂起等待资源释放。就这样两个线程都一直阻塞等待另一个线程释放临界区资源。
饥饿:一个或多个线程因为某种原因无法获取所需的资源,导致一直无法执行。这种情况称为饥饿。
(例如线程优先级别太低、某个线程长时间占用资源不放等情况)
活锁:两个线程互相的礼让资源,导致资源在两个线程间跳动,而没有一个线程可以真正拿到资源正常执行。
6、并发级别
1、阻塞
一个线程如果是阻塞的,那么在其相应资源被释放前该线程无法继续执行。使用synchorized和重入锁时就能得到阻塞的线程。
2、无饥饿
对于同一个资源的分配可以是不公平的,可以实现公平锁和非公平锁。对于非公平锁来说,系统允许高优先级的线程插队,这样就可能导致低优先级线程产生饥饿。如果此时锁是公平的,此时就会根据先来后到的规则,那么就不会有因线程优先级而产生的饥饿现象了。
3、无障碍
无障碍是一种最弱的非阻塞调度。多个线程可以一起修改临界区,当数据被改坏时,对于无障碍的线程检测到这种情况,就会对自己所做的修改进行回滚,以确保数据安全。
而当多个无障碍线程同时去使用这个临界区时,都发现了冲突这个线程,此时要是所有线程都进行回滚操作,那么不就是会出现没有一个线程使用到临界区资源且正常退出。所以此时无障碍实现可以依赖一个“一致性标记”,线程操作前先读取并保留这个标记,再操作后再次读取看这个标记是否被更改过。如果被更改了则可进行重试操作。(在修改数据前都需要更新这个一致性标记,表示数据不再安全)(是不是有点像乐观锁的实现)
4、无锁
多线程对临界区进行访问时,总有一个线程能成功获取资源。其他获取失败的线程会继续重试获取直到获取成功,如果多次尝试不成功这个时候就会出现类似于饥饿的现象,从而导致线程会停止。(类似自旋锁)
5、无等待
无锁是可以允许获取到资源的线程在有限步内完成,而其他线程可以不断去重试。
而无等待即对等待重试的线程也做了限制,限制其在有限步内完成。
例如经典的一种无等待结构RCU:对读不加控制,对于要进行数据修改的线程,此时给每个线程一个数据副本,然后线程在副本上修改数据,修改完后在合适的时机回写数据。
7、有关并行的两个重要定律
1、Amdahl定律
定义了串行系统并行化后的加速比的计算公式和理论上限。
加速比定义: 加速比 = 优化前系统耗时 / 优化后系统耗时 = 1/(F +(1-F)/n)
比例越高说明优化效果更好。(其中F表示程序中只能串行执行的代码的比例;n代表处理器的核数)
由公式可得要提高系统的速度,不仅仅是增加CPU处理器的数量就可以,也要从根本上修改程序的串行行为。这样才能获取到更大的加速比。
2、Gustafson定律
Gustafson定律也试图在说明处理器个数、串行化比例和加速比之间的关系。
加速比公式: 加速比S(n)= n - F(n-1)
3、两个定律的异同
虽然两个定律推导出来的公式不同,但是这两个定律都是对同一个客观事实从不同角度去审视后的结果。(侧重点不同而已)
Amdahl强调的是当串行化比例一定时,加速比是有上限的,不管你堆积多少个cpu参与计算都不能突破这个上限。而Gustafson关心的是如果可被串行化的代码所占比例足够大,那么加速比就会随着cpu的数量线性增长。
所以这两个定律并不矛盾。
8、JMM(Java的内存模型)
JMM的关键技术点都是围绕着多线程的原子性、可见性、有序性来建立的。因此我们需要先来了解这些概念。
1、原子性
表示操作是不可断的。即多线程中一个操作一旦开始了就不会受其他线程干扰。
2、可见性
一个线程修改了某个共享变量的值时,其他线程能立刻知道这个修改。
其中有一个ABA问题,就是一个线程1去读取一个值为A,此时去做其他操作,在做其他操作的时候,这个值被其他线程修改为B值,然后再改为A值,此时线程1再去读取这个值发现还是A值,但其实这个值已经经过了从A到B再到A的过程了。(ABA问题可以用时间戳或版本号来唯一标识,通过这个标识判断该值是否被修改过了)
3、有序性
对于一个线程来说,程序应该是从前往后依次执行的,但是由于JVM会对程序指令进行优化,从而将不影响程序结果的指令进行重排从而达到优化的效果。而在多线程的情况下,这种指令重排可能导致线程不安全。
而有序性则是防止指令重排。
那为什么JVM、操作系统会进行指令重排?
其实一条指令的执行可以分为很多步,简单可以分为以下几步:
- 取指IF
- 译码和取寄存器操作数ID
- 执行或者有效地址计算EX
- 存储器访问MEM
- 写回WB
在cpu中一条汇编指令不是一步执行完的,而是需要分多个步骤依次执行的,
而且每个步骤所涉及的硬件也可能不同。例如取指时会用到PC寄存器和存储器,译码时会用到指令寄存器组,执行时会用到ALU,写回时要用到寄存器组。(ALU指算术逻辑单位。他是cpu的执行单元,是cpu核心的组成部分,主要功能是进行二进制算术运算)
由于每个步骤都可能使用不同的硬件完成,因此则发明了流水线技术来执行指令。例如:
可以看到,流水线技术即指某个硬件进行执行操作时,可以看成一条流水线,分别执行多个指令的相应的操作。例如IF操作,此时如果指令1、2需要执行,指令1先执行,此时PC寄存器和存储器先进行指令1的IF操作,然后再执行指令2的IF操作。这样依次多个指令的相应IF操作就会形成一条流水线。这样可以使得效率提高。
而流水线存在一个问题就是流水线中断:
一旦流水线中断,此时所有的硬件设备会受影响进入一个停顿期,此时要再进入流水线满载损失的性能会比较大,而指令重排1就是为了尽量的减少流水线得中断。当然指令重排只是减少中断得一种技术。例如:
此时因为后面得指令中有一个大叉表示中断,此时因为R2得数据还没有准备好(需要R2得数据结果),导致add操作必须进行一次等待。从而导致后面得指令都慢一拍。我们再看一个例子:
此时add和sub都需要等待上一条指令得结果,所以需要插入不少得停顿。此时就可以利用指令重排尽量得消除停顿。我们此时只需要将lw re,e 和 lw rf,f 移动到前面执行即可。此时先加载e和f对程序是没有影响得。
重排后,最终结果如上,可以看到所有的停顿都消除了,此时流水线就能顺畅的执行了。(指令重排就是在执行结果不变的情况下,利用各个硬件的空闲时机去执行后面的操作。从而提高利用率和效率)
4、哪些指令不能重排?(happen-before原则)
遵循下面的原则的指令是不能重排的:
- 程序顺序原则:一个线程内保证语义的串行性
- volatile原则:volatile变量的写先于读发生,这保证了volatile变量的可见性(所以volatile具有可见性和有序性)
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
- 传递性:A先于B,B先于C,那么A必然先于C
- 线程的start()方法先于它的每一个动作
- 线程的所有操作先于线程的终结(Thread.join())
- 线程的中断(interrupt())先于被中断线程的代码
- 对象的构造函数的执行、结束先于finalize()方法