1. 为什么要用到并发

多核的CPU的背景下,催生了并发编程的趋势,通过并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分 。正是因为这些优点,使得多线程技术能够得到重视,也是一名CS学习者应该掌握的:

  • 充分利用多核CPU的计算能力;
  • 方便进行业务拆分,提升应用性能

image.png

如果把 cpu 一个时钟周期按1秒算内存访问就是6分钟 固态硬盘是2-6天 传统硬盘是1-12个月 网络访问就是几年了

正因为 CPU 的速度超级快,不能老是让它闲着,要充分地压榨它!

这里有两个强劲的理由:

  1. 人类需要多个程序「同时」运行。我们要把 CPU 的时间进行分片,让各个程序在 CPU 上轮转,造成一种多个程序同时在运行的假象,即并发
  2. 当 CPU 遇到IO操作(硬盘,网络)时,在等待的时候,一定要切换,去执行别的程序。程序的切换需要保存程序执行的现场,以便以后恢复执行,于是需要一个数据结构来表示,这就是进程了。
  3. 如果一个进程只有一个「执行流」, 如果进程去等待硬盘的操作,那这个程序就会被阻塞,无法响应用户的输入了,所以必须得有多个「执行流」,即线程

2. 并发编程的缺点

2.1 频繁的上下文切换

时间片是 CPU 分给各个线程的时间,因为非常短,所以 CPU 不断切换线程,让我们觉得多个线程是同时执行的。时间片一般是几十毫秒。而每次切换时,需要保存当前的状态起来,以便能够进行恢复先前状态,而这个切换时非常损耗性能,过于频繁反而无法发挥出多线程编程的优势。通常减少上下文切换可以采用无锁并发编程,CAS算法,使用最少的线程和使用协程。

  • 无锁并发编程:可以参照 concurrentHashMap 锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。
  • CAS 算法,利用 Atomic 下使用 CAS 算法来更新数据,使用了乐观锁,可以有效的减少一部分不必要的锁竞争带来的上下文切换
  • 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多的线程,这样会造成大量的线程都处于等待状态
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换

由于上下文切换也是个相对比较耗时的操作,所以在《java并发编程的艺术》一书中有过一个实验,并发累加未必会比串行累加速度要快。 可以使用 Lmbench3 测量上下文切换的时长 vmstat 测量上下文切换次数。

2.2 线程安全

一个死锁 Demo

  1. public class DeadLockDemo {
  2. private static String resource_a = "A";
  3. private static String resource_b = "B";
  4. public static void main(String[] args) {
  5. deadLock();
  6. }
  7. public static void deadLock() {
  8. Thread threadA = new Thread(new Runnable() {
  9. @Override
  10. public void run() {
  11. synchronized (resource_a) {
  12. System.out.println("get resource a");
  13. try {
  14. Thread.sleep(3000);
  15. synchronized (resource_b) {
  16. System.out.println("get resource b");
  17. }
  18. } catch (InterruptedException e) {
  19. e.printStackTrace();
  20. }
  21. }
  22. }
  23. });
  24. Thread threadB = new Thread(new Runnable() {
  25. @Override
  26. public void run() {
  27. synchronized (resource_b) {
  28. System.out.println("get resource b");
  29. synchronized (resource_a) {
  30. System.out.println("get resource a");
  31. }
  32. }
  33. }
  34. });
  35. threadA.start();
  36. threadB.start();
  37. }
  38. }

上边的程序,threadA 占用了 resource_a, 并等待被 threadB 释放的 resource _b。threadB 占用了 resource _b 正在等待被 threadA 释放的 resource _a。因此 threadA、threadB 出现线程安全的问题,形成死锁。

那么,通常可以用如下方式避免死锁的情况:

  1. 避免一个线程同时获取多个锁
  2. 避免一个线程在锁内获取多个资源,尽量保证一个锁只占用一个资源
  3. 尝试使用定时锁,使用lock.tryLock(timeOut),当超时等待时,当前线程不会阻塞。
  4. 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况

3. 一些概念

3.1 同步VS异步

同步和异步通常用来形容一次方法调用,关注的是消息通信机制。

  • 同步:同步方法调用一开始,调用者必须等待被调用的方法结束后,调用者后面的代码才能执行。是由调用者主动等待这个调用的结果。
    • eg:你打电话问书店老板有没有《分布式系统》这本书,如果是同步通信机制,书店老板会说,你稍等,”我查一下”,然后开始查啊查,等查好了(可能是5秒,也可能是一天)告诉你结果(返回结果)。
  • 异步:调用者不用管被调用方法是否完成,都会继续执行后面的代码,当被调用的方法完成后会通知调用者
    • eg:书店老板直接告诉你我查一下啊,查好了打电话给你,然后直接挂电话了(不返回结果)。然后查好了,他会主动打电话给你。在这里老板通过“回电”这种方式来回调。

3.2 阻塞和非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态,通常用来形容多线程间的相互影响。

  • 阻塞:比如一个线程占有了临界区资源,那么其他线程需要这个资源就必须进行等待该资源的释放,会导致等待的线程挂起,这种情况就是阻塞。
  • 非阻塞:非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

你打电话问书店老板有没有《分布式系统》这本书,你如果是阻塞式调用,你会一直把自己“挂起”,直到得到这本书有没有的结果,如果是非阻塞式调用,你不管老板有没有告诉你,你自己先一边去玩了, 当然你也要偶尔过几分钟check一下老板有没有返回结果。

知乎上一个完整例子说明同步异步、阻塞和非阻塞

老张爱喝茶,废话不说,煮开水。 出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。 1 老张把水壶放到火上,立等水开。(同步阻塞) 老张觉得自己有点傻 2 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞) 老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。 3 老张把响水壶放到火上,立等水开。(异步阻塞) 老张觉得这样傻等意义不大 4 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞) 老张觉得自己聪明了。

所谓同步异步,只是对于水壶而言。 普通水壶,同步;响水壶,异步。 虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。 同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。 所谓阻塞非阻塞,仅仅对于老张而言。 立等的老张,阻塞;看电视的老张,非阻塞。 情况1 和情况3 中老张就是阻塞的,媳妇喊他都不知道。虽然3 中响水壶是异步的,可对于立等的老张没有太大的意义。所以一般异步是配合非阻塞使用的,这样才能发挥异步的效用。

3.3 并发与并行

并发和并行是十分容易混淆的概念。并发指的是多个任务交替进行,而并行则是指真正意义上的「同时进行」。实际上,如果系统内只有一个CPU,而使用多线程时,那么真实系统环境下不能并行,只能通过切换时间片的方式交替进行,而成为并发执行任务。真正的并行也只能出现在拥有多个CPU的系统中。

3.4 临界区

临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每个线程使用时,一旦临界区资源被一个线程占有,那么其他线程必须等待。