首页 > 其他分享 >用CompletableFuture,品怨种码生,写线上BUG,拿C+绩效

用CompletableFuture,品怨种码生,写线上BUG,拿C+绩效

时间:2024-12-10 13:36:27浏览次数:5  
标签:异步 主线 品怨种 任务 线程 写线 退出 CompletableFuture

引言

你是不是也曾在开发中,觉得 CompletableFuture 这类异步编程的工具能让你高效、优雅地处理并发任务,从而避免线程阻塞,提升系统响应速度?相信很多开发者都曾有过这种理想主义的想法,认为异步编程不仅能优化性能,还能让代码变得简洁优雅。但在实际项目中,有时我们在过度依赖 CompletableFuture 或类似异步工具时,往往忽略了它们在某些边缘场景下的潜在风险和问题,最终却为此付出了惨重的代价。

今天,我想和大家分享一个故事,一个关于我在生产环境中因为使用 CompletableFuture 而引发线上事故的故事。事故的发生不仅导致了系统的严重崩溃,还让我背上了“C 绩效”,这对我来说,无疑是一次刻骨铭心的教训。

那是一个忙碌的周五下午,夕阳即将西下,我正在望着窗外思考,准备对即将上线的版本进行最后的调试和测试。我们的一款后台服务系统需要处理大量的并发 I/O 请求,这些请求大多是外部系统的 API 调用和文件处理任务。为了提升响应速度,我们决定采用 CompletableFuture 来优化这些异步任务,尤其是在涉及外部接口调用和数据库查询时,让主线程能够并发执行,而不是等待每个操作完成后再继续处理。

我们的理想设计

我们设计了一个异步任务调度系统,通过 CompletableFuture 来分发多个异步操作,并用 thenRunthenAccept 进行任务的链式处理。代码看起来很简洁,所有异步操作都不会阻塞主线程,理论上应该能大大提高系统的处理效率。

请在此添加图片描述

CompletableFuture<Void> task = CompletableFuture.runAsync(() -> {
    // 执行文件读取任务
    readFileAndProcessData();
}).thenRun(() -> {
    // 执行外部接口调用
    callExternalApi();
}).thenAccept(result -> {
    // 处理外部接口返回结果
    saveResultToDatabase(result);
});

万象更新,悲剧上演

那时候,我和我的同事们觉得这个实现极为理想,代码优雅且效率高,异步任务的执行并不会阻塞主线程。系统的响应时间和处理速度都得到了显著的提升,一切似乎都在朝着正确的方向发展。

然而,问题出在了一个细节上:我并没有深入思考 CompletableFuture 在特定场景下的行为,尤其是主线程退出时异步线程的生命周期问题。在当时的设计中,我们没有使用线程池,而是直接通过 CompletableFuture.runAsync() 启动了异步任务。由于没有显式的线程管理,所有异步线程默认是用户线程。

不幸的时刻终于到来了。

上线当天,随着流量的逐渐增加,系统突然出现了无法预料的崩溃,API 响应变得异常缓慢,部分任务卡住,甚至有些请求超时未能返回。这时候,我开始排查问题,发现问题出现在我们的异步任务处理上。

事情的根本原因

问题的根本原因在于,主线程退出时,异步线程被强制中断了。由于我没有对异步任务进行适当的生命周期管理,主线程在完成初步的任务后直接退出,导致与主线程相关联的所有异步线程也被强制终止。此时,尽管异步线程还在进行数据处理和外部 API 调用,但由于主线程的退出,所有异步操作都被中断,导致了未完成的任务丢失,数据处理中断,API 请求未能完成。

我没有充分理解 CompletableFuture 和线程池管理的关键区别,特别是线程池管理下,异步任务的生命周期不受主线程的影响,而直接使用 CompletableFuture.runAsync() 启动的任务依赖于主线程的生命周期,导致了这一严重的设计失误。

1. 异步线程与主线程的生命周期关系

1.1 Java 中的线程类型

在理解主线程与异步线程的关系之前,我们首先需要了解 Java 中线程的基本分类。Java 中的线程主要分为两种类型:用户线程(User Thread)和守护线程(Daemon Thread)。线程的类型直接决定了它的生命周期。如果主线程是用户线程,它会等待所有用户线程执行完毕后才退出。如果主线程退出时仍有活跃的用户线程,JVM 会阻止进程结束,直到这些线程执行完成。

  • 用户线程(User Thread)

用户线程是默认的线程类型,所有普通线程都属于用户线程。用户线程的生命周期由 JVM 管理,只要有一个用户线程在运行,JVM 就不会退出。用户线程的生命周期通常会持续到线程执行完毕或被显式地中断。当所有用户线程完成时,JVM 会终止进程并退出。

  • 守护线程(Daemon Thread)

守护线程是由开发者手动设置的线程类型。与用户线程不同,守护线程的生命周期依赖于非守护线程。当所有非守护线程(即用户线程)执行完毕时,JVM 会自动退出并终止所有守护线程。守护线程的退出并不会影响 JVM 的退出,因此它通常用于执行一些后台任务,如垃圾回收、定时任务等。

1.2 主线程退出时,异步线程的行为

在大多数 Java 程序中,主线程通常会启动一些后台任务,如 I/O 操作、网络请求等。这些任务可能会通过异步线程来执行,避免阻塞主线程。比如,我们可以使用 CompletableFuture 来启动异步任务,让主线程继续执行其他操作,而不被阻塞。

然而,问题随之而来:如果主线程结束时,异步线程是否会继续运行?

默认情况下,在没有线程池管理的情况下,Java 启动的异步线程会被视为用户线程,而不是守护线程。这意味着,如果主线程退出时,JVM 会检查是否还有其他活跃的用户线程。如果没有活跃的用户线程,JVM 会终止进程,强制终止所有用户线程,包括异步线程。

这种行为在某些情况下会导致异步任务的中断或丢失,尤其是在异步线程需要较长时间执行的情况下,主线程退出后,异步线程的生命周期会受到影响,从而导致任务没有被正确完成。

1.3 问题展示:主线程退出,异步线程也退出

例如,下面的代码中使用 CompletableFuture.runAsync() 启动了一个异步线程来监听 USB 设备,而主线程在异步线程执行期间退出了:

import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CompletableFuture;

public class UsbListenerAsyncTest {

    private static final Set<String> knownDevices = new HashSet<>();

    public static void main(String[] args) throws InterruptedException {
        String configValue = "someConfig";
        CompletableFuture<Void> usbDetectionFuture = CompletableFuture.runAsync(() -> startUsbListening(configValue))
                .thenRun(() -> System.out.println("监听任务完成"));
        
        usbDetectionFuture.join();  // 阻塞主线程直到异步任务完成

        System.out.println("主线程退出,但异步任务仍在后台运行...");
    }

    public static void startUsbListening(String configValue) {
        System.out.println("初始化 startUsbListening");
        while (true) {
            System.out.println("获取设备列表中...");
            try {
                Thread.sleep(2000);  // 每 2 秒检测一次设备变化
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}
  • 如果主线程调用了 join(),主线程会显式地等待异步任务完成。在此期间,主线程仍然是活跃的用户线程,JVM 不会触发进程退出,也不会中断异步线程。因此,异步任务会正常执行并完成。

  • 如果主线程未调用 join() 或其他显式阻塞操作,且未使用线程池管理异步线程,主线程很可能会提前退出。此时,若主线程是最后一个活跃的用户线程,JVM 会认为程序可以结束,从而导致异步线程被强制中断。

通过下面的两种截图,我们会发现,如果不进行阻塞操作,当前主线程退出的情况,异步线程也会退出。

请在此添加图片描述

请在此添加图片描述

1.4 原因分析

这是因为 Java 默认将所有线程(包括由主线程启动的异步线程)都视作用户线程。在没有线程池管理的情况下,当主线程退出时,如果没有其他活跃的用户线程,JVM 会检测到这是最后一个活跃的用户线程,因此会自动终止所有其他用户线程,包括异步线程。

2. 如何确保异步线程在主线程退出后继续执行

虽然主线程退出时会导致异步线程的终止,但 Java 提供了多种方法来确保异步线程能够在主线程退出后继续执行。

2.1 使用守护线程

如果你希望异步线程在主线程退出后继续运行,可以将异步线程设置为守护线程。守护线程的生命周期不依赖于主线程,只要有用户线程存在,守护线程就会继续执行。Thread.currentThread().setDaemon(true) 设置该线程为守护线程。当主线程结束时,如果没有其他非守护线程在运行,JVM 会自动结束所有守护线程。守护线程的生命周期是依赖于非守护线程的,主线程退出时不会影响守护线程的执行。

CompletableFuture.runAsync(() -> {
    Thread.currentThread().setDaemon(true); // 设置当前线程为守护线程
    try {
        Thread.sleep(5000);
        System.out.println("异步任务完成");
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
});

2.2 使用线程池(ExecutorService)

使用线程池可以让你更细致地控制线程的生命周期。线程池中的线程不受主线程的控制,它们会根据任务的完成情况独立退出。我们通过 ExecutorService 来执行异步任务,这样异步任务将在独立线程池中执行,并且主线程退出时不会影响异步任务的执行。主线程会直接退出,并且不会调用 join() 等方法阻塞,确保异步任务继续运行。主线程的退出不影响异步线程的生命周期,因为它们是在不同的线程池中执行的。当不再需要执行异步任务时,可以调用 shutdownNow() 来停止线程池中的所有线程。

import java.util.concurrent.*;

public class AsyncTestWithExecutor {
    private static ExecutorService executorService = Executors.newSingleThreadExecutor();  // 使用线程池

    public static void main(String[] args) throws InterruptedException {
        CompletableFuture<Void> usbDetectionFuture = CompletableFuture.runAsync(() -> startUsbListening("someConfig"), executorService)
                .thenRun(() -> System.out.println("USB 监听任务完成"));

        System.out.println("主线程退出,但异步任务仍在后台运行...");
        
        Thread.sleep(5000);  // 模拟主线程退出
        executorService.shutdownNow();  // 停止线程池
    }

    public static void startUsbListening(String configValue) {
        System.out.println("初始化 startUsbListening");
        while (true) {
            System.out.println("获取设备列表中...");
            try {
                Thread.sleep(2000);  // 每 2 秒检测一次设备变化
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

2.3 显式阻塞主线程

除了使用守护线程和线程池之外,另一种简单的方法是显式地阻塞主线程,直到异步任务执行完成。通过 join() 等方法,可以确保主线程等待所有异步任务完成后再退出。在这种情况下,主线程会阻塞在 join() 方法上,直到异步任务执行完成后才会退出。这种方式比较简单,但可能会降低程序的并发性,因此在需要高效并发的场景下不建议使用。

CompletableFuture<Void> usbDetectionFuture = CompletableFuture.runAsync(() -> {
    try {
        Thread.sleep(5000);
        System.out.println("异步任务完成");
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
});

usbDetectionFuture.join();  // 阻塞主线程,等待异步任务完成

教训与反思

这次事故后,我花了大量时间反思。首先,我意识到对于异步编程,尤其是 CompletableFuture 等并发工具的使用,我们必须理解其背后的线程管理机制,特别是在主线程退出时,异步线程的生命周期是如何受到影响的。其次,不管是使用守护线程、线程池,还是显式阻塞主线程,只有确保异步任务能够在主线程退出后继续执行,才是保证系统稳定运行的关键

从此以后,我在使用 CompletableFuture 或其他异步工具时,学会了更加谨慎地管理线程的生命周期,避免直接依赖于主线程的退出。在线上环境中,任何看似简单的并发操作,都需要通过线程池进行细粒度的控制,确保任务的正确执行。

在 Java 中,当主线程退出时,若没有其他活跃的用户线程,JVM 会终止所有非守护线程(包括异步线程)。为了解决这一问题,可以通过以下方法确保异步线程在主线程退出后继续执行:

  1. 将异步线程设置为守护线程;
  2. 使用线程池(ExecutorService)管理异步任务;
  3. 显式阻塞主线程,等待所有异步任务完成。

这些方法各有优缺点,开发者可以根据具体的应用场景选择合适的方案。在现代的高并发系统中,使用线程池通常是最推荐的做法,它能帮助开发者更好地控制线程的生命周期,避免线程资源浪费并提高程序的并发能力。

标签:异步,主线,品怨种,任务,线程,写线,退出,CompletableFuture
From: https://www.cnblogs.com/bu-huo/p/18597044

相关文章

  • CompletableFuture.runAsync使用示例
    CompletableFuture.runAsync()是Java8引入的一个方法,它用于异步执行一个任务,并且该任务没有返回值(即返回void)。该方法会启动一个新的线程来执行给定的任务,而不阻塞主线程或调用线程。作用:异步执行:CompletableFuture.runAsync()会在独立的线程中执行一个Runnable任务,......
  • 【高性能组件(1)】手写线程池
    文章目录前言一、线程池介绍1.1为什么需要线程池?1.2线程池的作用1.3线程池的构成二、手写线程池2.1接口设计2.1.1封装原则2.1.2创建线程池的接口2.2数据结构设计2.3线程池线程数量选择2.3.1维持固定数量线程2.3.2线程数量选择2.4具体编码实现2.4.1外部接......
  • java之使用CompletableFuture入门1
    Java17- 简介JDK中异步执行任务。源码://AFuturethatmaybeexplicitlycompleted(settingitsvalueandstatus),//andmaybeusedasaCompletionStage,supportingdependentfunctions//andactionsthattriggeruponitscompletion.publicclassCo......
  • CompletableFuture优雅处理并发最佳实践
    1、supplyAsync方法需要一个Supplier函数接口,通常用于执行异步计算CompletableFuture<String>future=CompletableFuture.supplyAsync(()->{dosomething("处理事务");return"结果";});2、runAsync接受一个Runnable函数接口,不关心异步任务的结果CompletableF......
  • 一文搞定高并发编程:CompletableFuture的supplyAsync与runAsync
    CompletableFuture是Java8中引入的一个类,用于简化异步编程和并发操作。它提供了一种方便的方式来处理异步任务的结果,以及将多个异步任务组合在一起执行。CompletableFuture支持链式操作,使得异步编程更加直观和灵活。在引入CompletableFuture之前,Java已经有了Future接口来......
  • 【项目实践】CompletableFuture异步编排在多任务并行执行中的使用
    【项目实践】CompletableFuture异步编排在多任务并行执行中的使用一、单次请求处理多任务的场景        在实际项目中,我们经常会遇到一些比较复杂的查询,需要给前端响应一个内容量较大的响应结果。例如在租房系统的app中,点击具体的某个房间查看详情,需要后端将这个房间的......
  • 异步任务组合神器CompletableFuture
    使用Demoimportjava.util.concurrent.*;importjava.util.concurrent.atomic.AtomicInteger;publicclassCompletableFutureDemo{privatestaticAtomicIntegercount=newAtomicInteger(2);publicstaticvoidmain(String[]args)throwsInterrupte......
  • Java异步编程:CompletableFuture与Future的对比
    Java异步编程:CompletableFuture与Future的对比大家好,我是微赚淘客返利系统3.0的小编,是个冬天不穿秋裤,天冷也要风度的程序猿!在Java中,异步编程是一种常见的编程范式,用于提高应用程序的响应性和吞吐量。Java提供了多种异步编程工具,其中Future和CompletableFuture是两个重要的接口。......
  • 通过阅读本篇文章你将了解到:CompletableFuture的使用
    通过阅读本篇文章你将了解到:CompletableFuture的使用CompletableFure异步和同步的性能测试已经有了Future为什么仍需要在JDK1.8中引入CompletableFutureCompletableFuture的应用场景对CompletableFuture的使用优化场景说明查询所有商店某个商品的价格并返回,并且查询商店某......
  • Java-CompletableFuture工具类(续)
    CompletableFuture提供了runAsync和supplyAsync方法来异步执行任务。这两个方法可以帮助你在Java中轻松地实现异步编程。下面是关于这两个方法的详细说明以及如何在CompletableFuture工具类中使用它们的示例。runAsyncrunAsync方法用于异步执行一个Runnable任务......