联系我

并发编程的优缺点

2020.06.24

前言

一直以来并发编程对于很多小伙伴来说总是觉得高深莫测,于是乎,这里就写点东西记录下,以提升对并发编程的认知。为什么需要用到并发?凡事总有两面,我们需要知道之间的trade-off是什么,也就是说并发编程有哪些缺点?以及在进行并发编程时应该了解和掌握的概念是什么?这篇文章主要以这三个问题来谈一谈。

Why并发

一直以来,硬件的发展速度非常快,遵循一个非常著名的"摩尔定律",有些小伙伴可能会奇怪明明讨论的是并发编程为什么会扯到硬件的发展,这其中的关系其实是多核CPU的发展为并发编程提供了硬件基础。摩尔定律并不是一种自然法则或者是物理定律,它只是基于观测数据,对未来的一种预测。

按照预测的速度,计算机的计算能力理应按照指数级的速度增长,不久以后便会拥有超强的计算能力。2004年,Intel宣布4GHz芯片的研究计划推迟到2005年,然后在2004年秋季,Intel宣布彻底取消4GHz芯片计划,也就是说摩尔定律的有效性证明戛然而止。

但是,硬件工程师们没有停止研发的脚步,为了进一步提升计算速度,不再追求单独的计算单元,而是将多个计算单元整合到了一起,也就是形成了多核CPU。

短短十几年的时间,家用型CPU,比如Intel i7就可以达到4核心甚至8核心。而专业服务器则通常可以达到好几个独立的CPU,每一个CPU甚至拥有多达8个以上的内核。因此,摩尔定律似乎在CPU核心扩展上继续得到体现。

因此,多核的CPU的背景下,便催生了并发编程的需求,通过并发编程可以发挥多核CPU的计算能力,提升性能

顶级计算机科学家Donald Ervin Knuth曾经这样评价这种情况:在我看来,这种现象(并发)或多或少是由于硬件设计者无计可施导致的,他们将摩尔定律的责任推给了软件开发者。

另外,在某些特殊的业务场景下,算法处理流程本身就适合于并发编程。比如:

  • 在图像处理领域,一张1024X768像素的图片,包含多达78万6千多个像素。将所有的像素遍历一边需要很长的时间,面对如此庞大的计算量就需要充分利用多核计算的能力。

  • 当我们在网上购物时,为了提升响应速度,需要拆分减库存,生成订单等等多项操作,就可以利用多线程的技术完成。

面对复杂业务模型,并行程序会比串行程序更能满足业务需求,而并发编程更能吻合这种业务拆分模型 。正是因为这些优点,使得多线程技术能够得到发展应用,也是一名CS学习者应该掌握的开发能力:

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

并发编程有哪些缺点

多线程技术有这么多的好处,难道就没有一点缺点么,在任何场景下就一定适用么?很显然不是的。

问题一:频繁的上下文切换

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

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

由于上下文切换是一个比较耗时的操作,所以在《java并发编程的艺术》一书中有过一个实验,并发累加未必会比串行累加速度要快

问题二:线程安全

多线程编程中最难以把握的就是临界区线程安全问题,稍微不注意就会出现死锁的情况,一旦产生死锁就会造成系统功能不可用。来看一个简单的死锁案例:

public class DeadLockDemo {
    private static String resource_a = "A";
    private static String resource_b = "B";

    public static void main(String[] args) {
        deadLock();
    }

    public static void deadLock() {
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (resource_a) {
                    System.out.println("get resource a");
                    try {
                        Thread.sleep(3000);
                        synchronized (resource_b) {
                            System.out.println("get resource b");
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (resource_b) {
                    System.out.println("get resource b");
                    synchronized (resource_a) {
                        System.out.println("get resource a");
                    }
                }
            }
        });
        threadA.start();
        threadB.start();

    }
}

在上面的这个例子当中,开启了两个线程,分别是threadA和threadB,其中threadA占用了resource_a, 然后等待被threadB占有的resource _b。threadB占用了resource _b,然后等待被threadA占有的resource _a。因此threadA,threadB出现了线程安全的问题,形成死锁。可以通过排查工具jps,jstack证明这种推论:

"Thread-1":
  waiting to lock monitor 0x000000000b695360 (object 0x00000007d5ff53a8, a java.lang.String),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x000000000b697c10 (object 0x00000007d5ff53d8, a java.lang.String),
  which is held by "Thread-1"

Java stack information for the threads listed above:
===================================================
"Thread-1":
        at learn.DeadLockDemo$2.run(DeadLockDemo.java:34)
        - waiting to lock <0x00000007d5ff53a8(a java.lang.String)
        - locked <0x00000007d5ff53d8(a java.lang.String)
        at java.lang.Thread.run(Thread.java:722)
"Thread-0":
        at learn.DeadLockDemo$1.run(DeadLockDemo.java:20)
        - waiting to lock <0x00000007d5ff53d8(a java.lang.String)
        - locked <0x00000007d5ff53a8(a java.lang.String)
        at java.lang.Thread.run(Thread.java:722)

Found 1 deadlock.

如上所述,完全可以看出当前死锁的情况。

所以,如何正确的使用多线程编程技术有很大的学问,比如如何保证线程安全,如何正确理解由于JMM内存模型在原子性,有序性,可见性带来的问题,比如数据脏读,DCL等这些问题。而在学习多线程编程技术的过程中也会让你收获颇丰。

应该了解的概念

并发与并行

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

同步VS异步

同步和异步通常用来形容一次方法调用。同步方法调用一开始,调用者必须等待被调用的方法结束后,调用者后面的代码才能执行。而异步调用,指的是,调用者不用管被调用方法是否完成,都会继续执行后面的代码,当被调用的方法完成后会通知调用者。

比如,在超时购物,如果一件物品没了,你得等仓库人员跟你调货,直到仓库人员跟你把货物送过来,你才能继续去收银台付款,这就类似同步调用。而异步调用了,就像网购,你在网上付款下单后,什么事就不用管了,该干嘛就干嘛去了,当货物到达后你收到通知去取就好。

临界区

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

阻塞和非阻塞

阻塞和非阻塞通常用来形容多线程间的相互影响,比如一个线程占有了临界区资源,那么其他线程需要这个资源就必须等待该资源的释放,会导致等待的线程挂起,这种情况就是阻塞,而非阻塞就恰好相反,它强调没有一个线程可以阻塞其他线程,所有的线程都会尝试地往前运行。