《性能优化》并发与并行

Posted 程序员囧辉

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《性能优化》并发与并行相关的知识,希望对你有一定的参考价值。

前言

性能优化系列第一篇主要给大家科普了一些性能相关的数字,为大家建立性能的初步概念。第二篇给大家介绍了支撑淘宝双十一这种达到百万QPS项目所需的相关核心技术。

本文带来的是性能优化中的第一利器:并发与并行。

除了核心原理介绍外,我将结合我自身的过去的实战经验,给出一些自己在使用上的建议,希望对大家有帮助。

不多废话,直接开怼。

正文

1、并发和并行?

并发和并行最关键的区别是:并行是同时执行,而并发不是同时。

这边使用 Joe Armstrong 排队使用咖啡机的例子来看并行和并发的区别,如下图所示:

上半部分为并发:两个队伍交替使用咖啡机

下半部分为并行:两个队伍同时使用咖啡机

从和我们更相关的CPU的角度来看两者的区别。

并发是这样的:同一时刻只有一个任务执行。

并行是这样的:同一时刻有多个任务执行。

并发和并行结合起来是这样的:

2、并发一定能提升性能吗?

并行能提升性能大家不会有太多的疑问,但是并发是否一定能提升性能,估计还是有不少同学会有疑问。

答案是否定的,并发不一定能提升性能,但是在绝大多数场景都能提升性能。

什么场景下并发不能提升性能?

我们举个简单的例子:假设我们的服务器配置为单核CPU,要执行10个任务,10个任务都是CPU计算密集型任务,此时单线程执行效率理论上要比开10个线程执行要快。

在执行的整个过程中,基本都是CPU在运行,但是开十个线程会涉及到线程上下文切换,需要花费一些时间,导致反而更慢。

再举个更形象的例子:囧辉上语文开小差,被老师罚抄10篇课文,此时囧辉脑子里想到了两种方法。

方法1:先抄完第一篇,再抄第二篇,再抄第三篇,直到抄完第十篇。

方法2:先抄第一篇的第一段,再抄第二篇的第一段,...,再抄第十篇的第一段,再抄第一篇的第二段,直到抄完全部。

方法1为串行执行,方法2为并发执行,相信大家都能很容易看出方法二反而会更慢,因为我们在从切换不同文章时,需要先放好原来的文章,然后找新文章抄到哪个位置了,这个过程需要花费一些时间,这个过程就类似于线程上下文切换。

那什么场景下并发会提升性能了?

再举个例子:囧辉要烧10壶水,一壶水烧开的时间为1分钟。

串行执行:囧辉先烧第一壶,第一壶烧开了后接着烧第二壶,直到烧完第十壶,这个方法烧完十壶水大概需要10分钟。

并发执行:囧辉先烧第一壶,没等第一壶烧开,接着烧第二壶,就这样,囧辉一下子将十壶水都放到灶台上同时烧,这个方法烧完十壶水大概需要1分钟。

在这个场景里,并发执行就体现了很大的优化,性能提升了接近10倍。

在我们实际项目中,大部分应用场景都是第二类,因此并发大多时候能提升性能,而哪些动作是“烧开水”了,这个其实在性能优化第一篇里提到了,最常见的“烧开水”操作就是I/O操作,最常见的如:调用其他服务的RPC接口查询数据、查询mysql数据库获取数据等等。

3、实现方式

并发/并行的实现方式通常有两种,如下。

1)开线程直接怼,每循环一次都会新建一个线程来执行,例如下面代码,

public static void test() throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(10);
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            // 烧水
            boilingWater();
            countDownLatch.countDown();
        }).start();
    }
    // 等待处理结束
    countDownLatch.await();
}

2)使用线程池,例如下面代码。

public static void test() {
    List<Future> futureList = new ArrayList<>();
    for (int i = 0; i < 10; i++) {
        futureList.add(THREAD_POOL_EXECUTOR.submit(() -> {
            // 烧开水
            boilingWater();
        }));
    }
    for (Future future : futureList) {
        try {
            // 等待处理结束
            future.get();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

1是反例,实际项目中不要使用,就算只开1个线程,也要用线程池,因为每次创建和回收线程都是需要开销的。

下面用一个简单的demo来模拟“烧开水”的例子

public class BoilingWaterTest {
​
    /**
     * CPU的核数
     */
    private static final int NCPUS = Runtime.getRuntime().availableProcessors();
​
    /**
     * 创建线程池
     */
    private static final ThreadPoolExecutor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(
            NCPUS,
            NCPUS * 2,
            30,
            TimeUnit.MINUTES,
            new LinkedBlockingDeque<>(1000),
            Executors.defaultThreadFactory(),
            new ThreadPoolExecutor.AbortPolicy());
​
    public static void main(String[] args) throws Exception {
        serial();
        concurrent();
    }
​
    public static void serial() {
        // 串行执行
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10; i++) {
            boilingWater();
        }
        System.out.println("serial cost:" + (System.currentTimeMillis() - start));
    }
​
    public static void concurrent() throws InterruptedException {
        // 并发执行
        CountDownLatch countDownLatch = new CountDownLatch(10);
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10; i++) {
            THREAD_POOL_EXECUTOR.execute(() -> {
                boilingWater();
                countDownLatch.countDown();
            });
        }
        // 等待任务全部执行完毕
        countDownLatch.await();
        System.out.println("concurrent cost:" + (System.currentTimeMillis() - start));
    }
​
    public static void boilingWater() {
        try {
            // 烧开一壶水需要1秒
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

执行该方法输出如下,符合我们的预期。

serial cost:10091
concurrent cost:1048

此时并发执行的流程就如下图,从一个task拆出多个task,然后由每个CPU负责处理1个,因此处理时间接近于1个任务的处理时间。

4、线程池的参数设置

1)线程数

之前的线程池面试文章里有介绍过线程数的设置,这边直接复制过来:

要想合理的配置线程池大小,首先我们需要区分任务是计算密集型还是I/O密集型。

对于计算密集型,设置 线程数 = CPU数 + 1,通常能实现最优的利用率。

对于I/O密集型,网上常见的说法是设置 线程数 = CPU数 * 2 ,这个做法是可以的,但个人觉得不是最优的。

在我们日常的开发中,我们的任务几乎是离不开I/O的,常见的网络I/O(RPC调用)、磁盘I/O(数据库操作),并且I/O的等待时间通常会占整个任务处理时间的很大一部分,在这种情况下,开启更多的线程可以让 CPU 得到更充分的使用,一个较合理的计算公式如下:

线程数 = CPU数 * CPU利用率 * (任务等待时间 / 任务计算时间 + 1)

例如我们有个定时任务,部署在4核的服务器上,该任务有100ms在计算,900ms在I/O等待,则线程数约为:4 * 1 * (1 + 900 / 100) = 40个。

当然,具体我们还要结合实际的使用场景来考虑。如果要求比较精确,可以通过压测来获取一个合理的值。

上述是比较理想的线程数计算方式,在实际项目使用中,如果无法很准确的计算,那么可以先用我上面的线程池配置,也就是:

corePoolSize = CPU核数

maximumPoolSize = CPU核数 * 2

这个参数设置可能不是最理想的,但在大多数情况下都是一个还不错的选择,比较合适。

2)keepAliveTime、TimeUnit

这两个参数一起决定了非核心线程空闲后的存活时间。

这两个参数说实话并不是非常重要,实际使用过程中不要设置太离谱的值一般问题不大,我个人一般使用5分钟或30分钟。

3)workQueue

工作队列,当核心线程处理不过来时,任务会堆积在队列里。

常见的队列有 ArrayBlockingQueue 和 LinkedBlockingQueue,两者的主要区别在于 ArrayBlockingQueue 占用空间会更小,而 LinkedBlockingQueue 在生产者和消费者使用了不同的锁性能会好一点。

通常情况下,两者的区别微乎其微,除非你要处理的任务量非常非常大,此时你需要仔细考虑使用哪个更合适,否则通常情况下两个随便选都可以。

常见的坑:使用 LinkedBlockingQueue 时没设置队列大小,也就是使用了无界队列(Integer.MAX_VALUE),任务处理不过来,不断积压在队列里,最终造成内存溢出。

线程池使用不当导致内存溢出的case我已经见过很多次了,这个经验大家一定要铭记在心:使用 LinkedBlockingQueue 一定要设置队列大小。

另外,这边给大家介绍下另一个我常用的工作队列:SynchronousQueue。

SynchronousQueue 不是一个真正的队列,而是一种在线程之间移交的机制。要将一个元素放入 SynchronousQueue 中,必须有另一个线程正在等待接受这个元素。如果没有线程等待,并且线程池的当前大小小于 maximumPoolSize,那么线程池将创建一个线程,否则根据拒绝策略,这个任务将被拒绝。使用直接移交将更高效,因为任务会直接移交给执行它的线程,而不是被放在队列中,然后由工作线程从队列中提取任务。只有当线程池是无界的或者可以拒绝任务时,该队列才有实际价值,Executors.newCachedThreadPool使用了该队列。

上述内容里提到了:当线程池是无界的或者可以拒绝任务时,该队列才有实际价值。

使用无界的线程池说实话挺危险的,我强烈建议不要使用,特别是经验不太丰富的新人。因此我们在使用 SynchronousQueue 的时候可以理解为一定会出现任务被拒绝的情况,因此要选择好合适的拒绝策略。

SynchronousQueue 我一般会搭配 CallerRunsPolicy 使用,个人觉得这2个是个绝佳组合,这个组合起到的效果是:当线程池处理不过来时,直接交由调用者线程(往线程池里添加任务的主线程)来执行,此时任务不会被积压在队列里,同时调用者线程无法继续提交任务。

简单来说:任务处理非常高效,没有任务积压的概念不会有内存溢出的风险,同时在线程池处理不过来时具有控制任务提交速度的效果。

4)ThreadFactory

线程工厂,这个没啥好说的,通常使用默认的就行。

常见的改动场景是:给线程设置个自定义的名字,方便区分。

这种场景下,可以使用一些工具类提供的现有方法,也可以将 DefaultThreadFactory 拷贝出来自己修改一下。

5)RejectedExecutionHandler

拒绝策略,线程池处理不过来时的策略。默认有4种策略,其中3种我个人比较常用到。

AbortPolicy:默认的策略,直接抛出异常,没有特殊需求直接使用该策略即可。

 

CallerRunsPolicy:调用者线程执行策略,该策略上面提到了,我一般是配合 SynchronousQueue 使用,起到一个控制任务提交速度的效果。

DiscardPolicy:抛弃策略,直接丢掉要提交的任务,这个策略一般在线程池执行的是不太重要的任务时使用。

5、并发并行适用于哪种场景

典型的适合使用并发并行的场景通常有以下特点:

1)存在I/O操作,并且I/O操作有多次,最典型的就是RPC调用和查询数据库

2)I/O操作比较耗时,越耗时越有优化价值

3)多次I/O操作之间没有依赖关系,可以同时调用

总结

并发和并行是性能优化中非常常用的手段,使用起来非常简单,并且带来的性能提升通常非常明显,很容易就有几倍几倍的提升,快在自己的项目中用起来吧。

最后

我是囧辉,一个坚持分享原创技术干货的程序员,如果觉得本文对你有帮助,欢迎一键三连。

推荐阅读

Java 基础高频面试题(2021年最新版)

Java 集合框架高频面试题(2021年最新版)

面试必问的 Spring,你懂了吗?

面试必问的 MySQL,你懂了吗?

以上是关于《性能优化》并发与并行的主要内容,如果未能解决你的问题,请参考以下文章

《性能优化》并发与并行

《性能优化》并发与并行

goroutine简介

高性能并行编程与优化 | 第04讲回家作业

高性能并行编程与优化 | 第04讲回家作业

高性能并行编程与优化 | 第01讲回家作业