首页 > 其他分享 >线程池最佳实践!这几个坑使用不当直接生产事故!!

线程池最佳实践!这几个坑使用不当直接生产事故!!

时间:2024-01-21 10:32:34浏览次数:24  
标签:使用不当 事故 队列 任务 线程 上下文 CPU threadPoolExecutor

拿来即用!这篇文章我会介绍我使用线程池的时候应该注意的坑以及一些优秀的实践。

1、正确声明线程池

线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用Executors 类创建线程池,会有 OOM 风险。

Executors 返回线程池对象的弊端如下(后文会详细介绍到):

  • FixedThreadPool 和 SingleThreadExecutor :使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPool :使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
  • ScheduledThreadPool 和 SingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

说白了就是:使用有界队列,控制线程创建数量。

除了避免 OOM 的原因之外,不推荐使用 Executors提供的两种快捷的线程池的原因还有:

  • 实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。
  • 我们应该显示地给我们的线程池命名,这样有助于我们定位问题。


2、监测线程池运行状态

你可以通过一些手段来检测线程池的运行状态比如 SpringBoot 中的 Actuator 组件。

除此之外,我们还可以利用 ThreadPoolExecutor 的相关 API 做一个简陋的监控。从下图可以看出, ThreadPoolExecutor提供了获取线程池当前的线程数和活跃线程数、已经执行完成的任务数、正在排队中的任务数等等。

线程池最佳实践!这几个坑使用不当直接生产事故!!_上下文切换

图片

下面是一个简单的 Demo。printThreadPoolStatus()会每隔一秒打印出线程池的线程数、活跃线程数、完成的任务数、以及队列中的任务数。

/**
 * 打印线程池的状态
 *
 * @param threadPool 线程池对象
 */
public static void printThreadPoolStatus(ThreadPoolExecutor threadPool) {
    ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(1, createThreadFactory("print-images/thread-pool-status", false));
    scheduledExecutorService.scheduleAtFixedRate(() -> {
        log.info("=========================");
        log.info("ThreadPool Size: [{}]", threadPool.getPoolSize());
        log.info("Active Threads: {}", threadPool.getActiveCount());
        log.info("Number of Tasks : {}", threadPool.getCompletedTaskCount());
        log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());
        log.info("=========================");
    }, 0, 1, TimeUnit.SECONDS);
}

3、建议不同类别的业务用不同的线程池

很多人在实际项目中都会有类似这样的问题:我的项目中多个业务需要用到线程池,是为每个线程池都定义一个还是说定义一个公共的线程池呢?

一般建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。

我们再来看一个真实的事故案例! (本案例来源自:《线程池运用不当的一次线上事故》 [1] ,很精彩的一个案例)

线程池最佳实践!这几个坑使用不当直接生产事故!!_上下文切换_02

案例代码概览

上面的代码可能会存在锁饥饿的情况,你可以理解为是死锁的一种情况!画个图给大家捋一捋。

假如我们线程池的核心线程数为 n ,父任务(扣费任务)数量为 n ,父任务下面有两个子任务(扣费任务下的子任务),其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 "锁饥饿"

线程池最佳实践!这几个坑使用不当直接生产事故!!_线程池_03

线程池使用不当导致死锁

解决方法也很简单,就是新增加一个用于执行子任务的线程池专门为其服务。

4、别忘记给线程池命名

初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。

默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。

给线程池里的线程命名通常有下面两种方式:

1、利用 guava 的 ThreadFactoryBuilder

ThreadFactory threadFactory = new ThreadFactoryBuilder()
                        .setNameFormat(threadNamePrefix + "-%d")
                        .setDaemon(true).build();
ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory)

2、自己实现 ThreadFactor

import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * 线程工厂,它设置线程名称,有利于我们定位问题。
 */
public final class NamingThreadFactory implements ThreadFactory {

    private final AtomicInteger threadNum = new AtomicInteger();
    private final ThreadFactory delegate;
    private final String name;

    /**
     * 创建一个带名字的线程池生产工厂
     */
    public NamingThreadFactory(ThreadFactory delegate, String name) {
        this.delegate = delegate;
        this.name = name; // TODO consider uniquifying this
    }

    @Override
    public Thread newThread(Runnable r) {
        Thread t = delegate.newThread(r);
        t.setName(name + " [#" + threadNum.incrementAndGet() + "]");
        return t;
    }

}

5、正确配置线程池参数

说到如何给线程池配置参数,美团的骚操作至今让我难忘(后面会提到)!

我们先来看一下各种书籍和博客上一般推荐的配置线程池参数的方式,可以作为参考!

常规操作

很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换 成本。不清楚什么是上下文切换的话,可以看我下面的介绍。


上下文切换:

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

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

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

有一个简单并且适用面比较广的公式:

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

如何判断是 CPU 密集任务还是 IO 密集任务?

CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。


标签:使用不当,事故,队列,任务,线程,上下文,CPU,threadPoolExecutor
From: https://blog.51cto.com/u_16502039/9353720

相关文章

  • C++多线程3
    1利用栈特性自动释放锁RAII1.1什么是RAIIRAII(ResourceAcquisitionIsInitialization),使用局部对象管理资源的技术称为资源获取既初始化,它的生命周期由操作系统管理,无需人工干预。为什么要引入自动释放锁,因为我们有时会因为忘记释放锁,而造成死锁或内存泄漏。我们先来手动实......
  • 多线程与多进程
    多线程和多进程爬虫在很多场景中,爬虫需要抓取大量的数据,而且需要做大量的分析工作。如果只使用单线程的爬虫,效率会非常低。通常有实用价值的爬虫会使用多线程或多进程,这样可以很多工作同时完成,尤其在多CPU的机器上,执行效率更是惊人。一、线程与进程1、进程计算机程序有静态和......
  • 线程同步之条件变量
    目录condition_variable简介成员函数实现线程间的通信condition_variable简介std::condition_variable是C++中用于线程同步的一个类。它通常与std::mutex一起使用,用于在一个或多个线程中阻塞,直到另一个线程修改了共享变量并通知了condition_variable。下面是一个例子,演示了如何......
  • C++11原子变量:线程安全、无锁操作的实例解析
     在C++11中,原子变量(std::atomic)提供了一种线程安全的方式来操作共享变量。下面是一个简单的例子,演示了C++11原子变量的用法。#include<iostream>#include<atomic>#include<thread>std::atomic<int>counter(0);//声明一个原子整数变量voidincrementCounter(int......
  • Java多线程
    Java多线程名词解释程序(program)是为完成特定任务、用某种语言编写的一组指令集合。简单而言:就是自己写的代码进程(Process)进程是指运行中的程序,比如启动迅雷时,就启动了一个进程,操作系统就会为该进程分配内存空间。进程是程序的一次执行过程,或是正在运行的一个程序。是......
  • 线程池
    ExecutorServiceexecutor=Executors.newCachedThreadPool();ExecutorServiceexecutor2=Executors.newFixedThreadPool();ExecutorServiceexecuto3=Executors.newSingleThreadExecutor();SynchronousQueue和LinkedBlockingQueue是Java中两种不同类型的阻塞队列,它们在实现......
  • java线程的基本操作
    1.线程名称的设置和获取在Thread类中可以通过构造器Thread(...)初始化设置线程名称,也可以通过setName(...)实例方法去设置线程名称,取得线程名称可以通过getName()方法完成。关于线程名称有以下几个要点:线程名称一般在启动线程前设置,但也允许为运行的线程设置名称......
  • 线程和进程
    进程和线程是操作系统中的两个基本概念,他们都是用来完成执行任务的,但是有所区别。进程是资源分配的最小单位,它代表CPU所能处理的单个任务。每个进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段。而线程是进程中执......
  • java线程核心原理
    1.线程的调度与时间片1.1java线程与操作系统现代操作系统(如Windows、Linux、Solaris)提供了强大的线程管理能力,Java不需要再进行自己独立的线程管理和调度,而是将线程调度工作委托给操作系统的调度进程去完成。在某些系统(比如Solaris操作系统)上,JVM甚至将每个Java线程一对一......
  • java创建线程的4种方式
    1.Thread类一个线程在Java中使用一个Thread实例来描述。Thread类是Java语言一个重要的基础类,位于java.lang包中。Thread类有不少非常重要的属性和方法,用于存储和操作线程的描述信息。1.1线程ID属性:privatelongtid,此属性用于保存线程的ID。这是一个private类型属性,外......