结合实际谈谈:CPU密集型和IO密集型任务在并发编程中的应用

大家好,我是三叔,很高兴这期又和大家见面了,一个奋斗在互联网的打工人。

在并发编程中,了解任务的性质对于选择合适的并发策略和资源分配至关重要。本篇博客将深入探讨 CPU 密集型和 IO 密集型任务的概念,分析它们在并发环境下的特点,并进行举例说明。

什么是CPU密集型

对于 CPU 密集型任务,由于大部分时间都花费在计算操作上,使用过多的线程反而可能会增加上下文切换开销,导致性能下降。在这种情况下,通常会使用较小的线程池,以充分利用 CPU 核心。选择固定大小的线程池,使线程数等于可用的 CPU 核心数,或者根据实际情况调整线程池大小。同时,还可以考虑使用更高效的算法和并行计算技术来优化计算过程,以提高 CPU 密集型任务的性能。

示例

现在假设我们正在开发一个复杂的图像处理应用,需要对大量图像进行处理和分析。这个任务是CPU密集型的,因为大部分时间都花费在进行图像处理算法上。

在这种情况下,我们不应该使用过多的线程,因为过多的线程可能会导致上下文切换的开销增加,反而影响性能。相反,我们可以使用一个较小的线程池,例如使用固定大小的线程池,使线程数等于可用的CPU核心数。

获取CPU核心线程数的方法:

// ,用于获取当前计算机可用的处理器核心数(CPU核心数)。这个方法返回一个整数值,表示系统中可用的处理器核心数量,通常用于确定在并发编程中需要创建多少个线程来充分利用计算资源。
int cpuSize = Runtime.getRuntime().availableProcessors();

// 创建一个固定线程池
ExecutorService executor = Executors.newFixedThreadPool(cpuSize);

什么是IO密集型

对于IO密集型任务,由于大部分时间都花费在等待IO操作(如文件读写、网络通信、数据库查询等)上,传统上会使用较大的线程池,甚至是无界线程池,以便能够并发地处理多个IO操作。在等待IO的过程中,CPU资源不会被充分利用,因此可以通过增加线程数来提高并发性能。但是需要注意,过多的线程也会导致上下文切换开销增加,因此需要在性能和资源消耗之间进行权衡。

// 例如处理网络请求
ExecutorService executor = Executors.newCachedThreadPool();
for (String url : urls) {
    executor.submit(() -> {
        // 发起网络请求并处理响应
    });
}

如何设置线程数比较好

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。

上下文切换对系统来说意味着消耗大量的 CPU 时间,如果我们设置的线程池数量太小的话,如果同一时间有大量请求需要处理,可能会导致大量的请求在任务队列中排队等待执行,甚至会出现任务队列满了之后请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。

  1. CPU 密集型:N + 1
    将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

  2. IO密集型:2N(大部分开发过程中遇到的都是IO密集型)
    系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。

案例分析:接下来这部分来自转载,非原创:《美图技术团队》,阅读后深有体会

原博客地址:线程池在业务中的实践
https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html

3.1 业务背景
在当今的互联网业界,为了最大程度利用CPU的多核性能,并行运算的能力是不可或缺的。通过线程池管理线程获取并发性是一个非常基础的操作,让我们来看两个典型的使用线程池获取并发性的场景。

场景1:快速响应用户请求

描述:用户发起的实时请求,服务追求响应时间。比如说用户要查看一个商品的信息,那么我们需要将商品维度的一系列信息如商品的价格、优惠、库存、图片等等聚合起来,展示给用户。

分析:从用户体验角度看,这个结果响应的越快越好,如果一个页面半天都刷不出,用户可能就放弃查看这个商品了。而面向用户的功能聚合通常非常复杂,伴随着调用与调用之间的级联、多级级联等情况,业务开发同学往往会选择使用线程池这种简单的方式,将调用封装成任务并行的执行,缩短总体响应时间。另外,使用线程池也是有考量的,这种场景最重要的就是获取最大的响应速度去满足用户,所以应该不设置队列去缓冲并发任务,调高corePoolSize和maxPoolSize去尽可能创造多的线程快速执行任务。
在这里插入图片描述
场景2:快速处理批量任务

描述:离线的大量计算任务,需要快速执行。比如说,统计某个报表,需要计算出全国各个门店中有哪些商品有某种属性,用于后续营销策略的分析,那么我们需要查询全国所有门店中的所有商品,并且记录具有某属性的商品,然后快速生成报表。

分析:这种场景需要执行大量的任务,我们也会希望任务执行的越快越好。这种情况下,也应该使用多线程策略,并行计算。但与响应速度优先的场景区别在于,这类场景任务量巨大,并不需要瞬时的完成,而是关注如何使用有限的资源,尽可能在单位时间内处理更多的任务,也就是吞吐量优先的问题。所以应该设置队列去缓冲并发任务,调整合适的corePoolSize去设置处理任务的线程数。在这里,设置的线程数过多可能还会引发线程上下文切换频繁的问题,也会降低处理任务的速度,降低吞吐量。
在这里插入图片描述
3.2 实际问题及方案思考
线程池使用面临的核心的问题在于:线程池的参数并不好配置。一方面线程池的运行机制不是很好理解,配置合理需要强依赖开发人员的个人经验和知识;另一方面,线程池执行的情况和任务类型相关性较大,IO密集型和CPU密集型的任务运行起来的情况差异非常大,这导致业界并没有一些成熟的经验策略帮助开发人员参考。

关于线程池配置不合理引发的故障,公司内部有较多记录,下面举一些例子:

Case1:2018年XX页面展示接口大量调用降级:

事故描述:XX页面展示接口产生大量调用降级,数量级在几十到上百。

事故原因:该服务展示接口内部逻辑使用线程池做并行计算,由于没有预估好调用的流量,导致最大核心数设置偏小,大量抛出RejectedExecutionException,触发接口降级条件,示意图如下:
在这里插入图片描述
Case2:2018年XX业务服务不可用S2级故障

事故描述:XX业务提供的服务执行时间过长,作为上游服务整体超时,大量下游服务调用失败。

事故原因:该服务处理请求内部逻辑使用线程池做资源隔离,由于队列设置过长,最大线程数设置失效,导致请求数量增加时,大量任务堆积在队列中,任务执行时间过长,最终导致下游服务的大量调用超时失败。示意图如下:
在这里插入图片描述

面对业务中使用线程池遇到的实际问题,我们曾回到支持并发性问题本身来思考有没有取代线程池的方案,也曾尝试着去追求线程池参数设置的合理性,但面对业界方案具体落地的复杂性、可维护性以及真实运行环境的不确定性,我们在前两个方向上可谓“举步维艰”。最终,我们回到线程池参数动态化方向上探索,得出一个且可以解决业务问题的方案,虽然本质上还是没有逃离使用线程池的范畴,但是在成本和收益之间,算是取得了一个很好的平衡。成本在于实现动态化以及监控成本不高,收益在于:在不颠覆原有线程池使用方式的基础之上,从降低线程池参数修改的成本以及多维度监控这两个方面降低了故障发生的概率。希望本文提供的动态化线程池思路能对大家有帮助。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我是三叔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值