前往小程序,Get更优阅读体验!
立即前往
发布
社区首页 >专栏 >用CompletableFuture,品怨种码生,写线上BUG,拿C+绩效

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

原创
作者头像
不惑
修改2024-12-05 09:24:30
修改2024-12-05 09:24:30
5052
举报
文章被收录于专栏:Goboy

Hey小伙伴们,今天要给大家安利一篇操作实践的文章,相信大家通过仔细阅读,一定会有所收货!

🔗【文章链接】:「最佳实践」通过IPsec VPN+CCN多路由表+私网NAT解决IDC与云上资源网段冲突

💖 推荐理由:这篇文章是由 RokasYang 撰写的,如果你在使用腾讯云时遇到云上与IDC之间的网段冲突问题,这篇文章将为你提供详细的解决方案。通过腾讯云的VPN网关、私网NAT和CCN多路由表功能,可以轻松实现不同网段之间的互通。文章从VPN隧道建立、私网NAT配置到CCN路由管理,详细描述了每个步骤,帮助你优化云上与云下网络架构,确保跨网段的安全通信和数据传输。


引言

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

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

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

我们的理想设计

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

代码语言:java
复制
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 设备,而主线程在异步线程执行期间退出了:

代码语言:javascript
复制
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 会自动结束所有守护线程。守护线程的生命周期是依赖于非守护线程的,主线程退出时不会影响守护线程的执行。

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

2.2 使用线程池(ExecutorService)

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

代码语言:java
复制
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() 方法上,直到异步任务执行完成后才会退出。这种方式比较简单,但可能会降低程序的并发性,因此在需要高效并发的场景下不建议使用。

代码语言:java
复制
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 和其他异步线程默认启动时,它们也是用户线程(User Thread)。而用户线程的生命周期是由 JVM 管理的,主线程退出时,JVM 会检查是否还有活跃的用户线程,如果存在活跃的用户线程,它就会阻止进程退出,直到这些用户线程执行完成。

答:在没有线程池的情况下,CompletableFuture.runAsync() 启动的异步任务默认会使用 ForkJoinPool.commonPool() 这个全局共享的线程池。这个线程池中的线程是普通的用户线程,并且它们的生命周期与 JVM 进程密切相关。问题的关键在于: 线程池的线程(例如通过 runAsync 启动的线程)会受到主线程生命周期的影响。如果主线程退出时,JVM 会检查是否还有活跃的用户线程。如果没有(即主线程退出时唯一活跃的用户线程是异步线程),JVM 就会认为进程可以结束,导致异步线程也被中断,尽管它们没有完成任务。 ForkJoinPool 作为线程池的实现,它会在没有任务可做时退出。即使有线程正在执行任务,如果没有外部的调用(例如 join()),这些线程有可能会在主线程退出时停止或中断。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
  • 我们的理想设计
  • 万象更新,悲剧上演
  • 不幸的时刻终于到来了。
  • 事情的根本原因
  • 1. 异步线程与主线程的生命周期关系
    • 1.1 Java 中的线程类型
      • 1.2 主线程退出时,异步线程的行为
        • 1.3 问题展示:主线程退出,异步线程也退出
          • 1.4 原因分析
          • 2. 如何确保异步线程在主线程退出后继续执行
            • 2.1 使用守护线程
              • 2.2 使用线程池(ExecutorService)
                • 2.3 显式阻塞主线程
                • 教训与反思
                • 补充说明:
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档