SpringBoot如何实现异步编程?大师们就是这样做的!

445 阅读6分钟

SpringBoot如何实现异步编程?大师们就是这样做的!

今天,我们来讨论一下如何在SpringBoot项目中实现异步编程。首先,让我们看看为什么在Spring中使用异步编程以及它可以解决什么问题。

为什么使用异步框架,它解决了什么问题?

在SpringBoot的日常开发中,通常会使用同步调用。然而,在现实中,有许多场景非常适合异步处理。

例如,当注册新用户时,将发送电子邮件提醒。之后,当你升级为会员时,你将获得1000点积分和其他场景。

以注册新用户的用例为例,为什么需要异步处理?主要有两个方面:

  1. 容错性和鲁棒性:如果发送邮件时发生异常,用户注册不会因为邮件发送失败而失败。因为用户注册是主要功能,发送电子邮件是次要功能。当用户无法接收电子邮件但可以查看并登录系统时,他们不会关心电子邮件。
  2. 提高界面性能:例如,注册用户需要20毫秒,发送电子邮件需要2000毫秒。如果使用同步模式,总时间消耗约为2020毫秒,用户可以明显感觉到迟缓。但如果使用异步模式,注册可以在短短几十毫秒内成功。这是一个即时的事情。

SpringBoot如何实现异步调用?

在了解了为什么需要异步调用之后,让我们看看SpringBoot如何实现这样的代码。

其实很简单。从Spring 3开始,提供了@Async annotation。我们只需要在方法上标记这个注释,这个方法就可以实现异步调用。

当然,我们还需要一个配置类,并将注释@EnableAsync添加到配置类中以启用异步函数。

第一步:创建配置类并启用异步函数支持

使用@EnableAsync启用异步任务支持。@EnableAsync annotation可以直接放在Spring Boot startup类上,也可以单独放在其他配置类上。这里我们选择使用一个单独的配置类SyncConfiguration

@Configuration
@EnableAsync
public class AsyncConfiguration {
   // do nothing 
}

第二步:将方法标记为异步调用

为业务处理添加一个Component类。同时,添加@Async注释,表示此方法是异步处理。

@Component
public class AsyncTask {

    @Async
    public void sendEmail() {
        long t1 = System.currentTimeMillis();
        Thread.sleep(2000);
        long t2 = System.currentTimeMillis();
        System.out.println("Sending an email took " + (t2-t1) + " ms");
    }
}

第三步:在Controller中调用异步方法。

@RestController
@RequestMapping("/user")
public class AsyncController {

    @Autowired
    private AsyncTask asyncTask;

    @RequestMapping("/register")
    public void register() throws InterruptedException {
        long t1 = System.currentTimeMillis();
        // Simulate the time required for user registration.
        Thread.sleep(20);
        // Registration is successful. Send an email.
        asyncTask.sendEmail();
        long t2 = System.currentTimeMillis();
        System.out.println("Registering a user took " + (t2-t1) + " ms");
    }
}

访问http://localhost:8080/user/register后,查看控制台日志。

Registering a user took 29 ms
Sending an email took 2006 ms

从日志中可以看出,主线程不需要等待邮件发送方法的执行完成后才返回,有效减少了响应时间,提高了界面性能。

通过以上三个步骤,我们可以轻松地使用Spring Boot中的异步方法来提高我们的接口性能。不是很简单吗?

但是,如果你真的在公司项目中这样实现它,在代码审查时肯定会被拒绝,甚至可能会被训斥。😫

因为上面的代码忽略了最大的问题,那就是没有为@Async异步框架提供自定义线程池。

为什么要为@Async定制线程池?

在使用@Async注释时,默认情况下使用SimpleAsyncTaskExecutor线程池。此线程池不是真正的线程池。

使用此线程池无法实现线程重用。每次调用时都会创建一个新线程。如果在系统中不断创建线程,最终将导致系统过度使用内存,并导致OutOfMemoryError

关键代码如下:

public void execute(Runnable task, long startTimeout) {
      Assert.notNull(task, "Runnable must not be null");
      Runnable taskToUse = this.taskDecorator != null ? this.taskDecorator.decorate(task) : task;
      // Determine whether rate limiting is enabled. By default, it is not enabled.
      if (this.isThrottleActive() && startTimeout > 0L) {
            // Perform pre-operations and implement rate limiting.
            this.concurrencyThrottle.beforeAccess();
            this.doExecute(new SimpleAsyncTaskExecutor.ConcurrencyThrottlingRunnable(taskToUse));
      } else {
            // In the case of no rate limiting, execute thread tasks.
            this.doExecute(taskToUse);
      }
}

protected void doExecute(Runnable task) {
      // Continuously create threads.
      Thread thread = this.threadFactory != null ? this.threadFactory.newThread(task) : this.createThread(task);
      thread.start();
}

public Thread createThread(Runnable runnable) {
     //Specify the thread name, task-1, task-2, task-3...
      Thread thread = new Thread(this.getThreadGroup(), runnable, this.nextThreadName());
      thread.setPriority(this.getThreadPriority());
      thread.setDaemon(this.isDaemon());
      return thread;
}

如果再次输出线程名,很容易发现,每次打印的线程名都是以[task-1]、[task-2]、[task-3]、[task-4]的形式,后面的序号是不断增加的。

因此,当在Spring中使用@Async异步框架时,我们必须自定义一个线程池来替换默认的SimpleAsyncTaskExecutor

Spring提供了多种线程池可供选择:

  • SimpleAsyncTaskExecutor:不是一个真实的线程池。此类不重用线程,每次调用时都会创建一个新线程。
  • SyncTaskExecutor:这个类不实现异步调用,只是一个同步操作。仅适用于不需要多线程的场景。
  • ConcurrentTaskExecutor:Executor的适配类。不推荐.仅当ThreadPoolTaskExecutor不满足要求时才考虑使用此类。
  • ThreadPoolTaskScheduler:可以使用cron表达式。
  • ThreadPoolTaskExecutor最常用和推荐的。本质上,它是java.util.concurrent.ThreadPoolExecutor的包装器。

SimpleAsyncTaskExecutorThreadPoolTaskExecutor的实现路径如下:

实现自定义线程池

让我们直接看看代码实现。这里,实现了一个名为asyncPoolTaskExecutor的线程池:

@Configuration
@EnableAsync
public class SyncConfiguration {
    @Bean(name = "asyncPoolTaskExecutor")
    public ThreadPoolTaskExecutor executor() {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        // Core thread count.
        taskExecutor.setCorePoolSize(10);
        // The maximum number of threads maintained in the thread pool. Only when the buffer queue is full will threads exceeding the core thread count be requested.
        taskExecutor.setMaxPoolSize(100);
        // Cache queue.
        taskExecutor.setQueueCapacity(50);
        // Allowed idle time. Threads other than core threads will be destroyed after the idle time arrives.
        taskExecutor.setKeepAliveSeconds(200);
        // Thread name prefix for asynchronous methods.
        taskExecutor.setThreadNamePrefix("async-");
        /**
         * When the task cache queue of the thread pool is full and the number of threads in the thread pool reaches maximumPoolSize, if there are still tasks coming, a task rejection policy will be adopted.
         * There are usually four policies:
         * ThreadPoolExecutor.AbortPolicy: Discard the task and throw RejectedExecutionException.
         * ThreadPoolExecutor.DiscardPolicy: Also discard the task, but do not throw an exception.
         * ThreadPoolExecutor.DiscardOldestPolicy: Discard the task at the front of the queue and then try to execute the task again (repeat this process).
         * ThreadPoolExecutor.CallerRunsPolicy: Retry adding the current task and automatically call the execute() method repeatedly until it succeeds.
         */
        taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        taskExecutor.initialize();
        return taskExecutor;
    }
}

恭喜你!定制线程池后,我们可以大胆地使用@Async提供的异步处理能力。😊

配置多个线程池

在真实的Internet项目的开发中,对于高并发请求,一般的方法是用单独的线程池隔离高并发接口进行处理。

假设当前有两个高并发接口。通常,根据接口特性定义两个线程池。此时,当我们使用@Async时,我们需要通过指定不同的线程池名称进行区分。

为@Async指定特定的线程池

@Async("myAsyncPoolTaskExecutor")
public void sendEmail() {
    long t1 = System.currentTimeMillis();
    Thread.sleep(2000);
    long t2 = System.currentTimeMillis();
    System.out.println("Sending an email took " + (t2-t1) + " ms");
}

当系统中有多个线程池时,我们也可以配置一个默认线程池。对于非默认异步任务,我们可以通过@Async("otherTaskExecutor")指定线程池名称。

配置默认线程池

可以修改配置类来实现AsyncConfigurer并覆盖getAsyncExecutor()方法来指定默认线程池:

@Configuration
@EnableAsync
@Slf4j
public class AsyncConfiguration implements AsyncConfigurer {

    @Bean(name = "myAsyncPoolTaskExecutor")
    public ThreadPoolTaskExecutor executor() {
        // Initialization code for thread pool configuration as above.
    }

    @Bean(name = "otherTaskExecutor")
    public ThreadPoolTaskExecutor otherExecutor() {
        // Initialization code for thread pool configuration as above.
    }

    /**
     * Specify the default thread pool.
     */
    @Override
    public Executor getAsyncExecutor() {
        return executor();
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
                log.error("An unknown error occurred while executing tasks in the thread pool. Executing method: {}", method.getName(), ex);
    }
}

如下所示,sendEmail()方法使用默认的线程池myAsyncPoolTaskExecutor,而otherTask()方法使用线程池otherTaskExecutor,这是非常灵活的。

@Async("myAsyncPoolTaskExecutor")
public void sendEmail() {
    long t1 = System.currentTimeMillis();
    Thread.sleep(2000);
    long t2 = System.currentTimeMillis();
    System.out.println("Sending an email took " + (t2-t1) + " ms");
}

@Async("otherTaskExecutor")
public void otherTask() {
    //...
}