實戰Spring Boot 2.0系列(三) - 使用@Async進行非同步呼叫詳解

零壹技術棧發表於2018-06-18

前言

非同步呼叫 對應的是 同步呼叫同步呼叫 指程式按照 定義順序 依次執行,每一行程式都必須等待上一行程式執行完成之後才能執行;非同步呼叫 指程式在順序執行時,不等待 非同步呼叫的語句 返回結果 就執行後面的程式。

實戰Spring Boot 2.0系列(三) - 使用@Async進行非同步呼叫詳解

本系列文章

  1. 實戰Spring Boot 2.0系列(一) - 使用Gradle構建Docker映象
  2. 實戰Spring Boot 2.0系列(二) - 全域性異常處理和測試
  3. 實戰Spring Boot 2.0系列(三) - 使用@Async進行非同步呼叫詳解
  4. 實戰Spring Boot 2.0系列(四) - 使用WebAsyncTask處理非同步任務
  5. 實戰Spring Boot 2.0系列(五) - Listener, Servlet, Filter和Interceptor
  6. 實戰Spring Boot 2.0系列(六) - 單機定時任務的幾種實現

正文

1. 環境準備

利用 Spring Initializer 建立一個 gradle 專案 spring-boot-async-task,建立時新增相關依賴。得到的初始 build.gradle 如下:

buildscript {
    ext {
        springBootVersion = '2.0.3.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'io.ostenant.springboot.sample'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}
複製程式碼

Spring Boot 入口類上配置 @EnableAsync 註解開啟非同步處理。

@SpringBootApplication
@EnableAsync
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
複製程式碼

建立任務抽象類 AbstractTask,並分別配置三個任務方法 doTaskOne()doTaskTwo()doTaskThree()

public abstract class AbstractTask {
    private static Random random = new Random();

    public void doTaskOne() throws Exception {
        out.println("開始做任務一");
        long start = currentTimeMillis();
        sleep(random.nextInt(10000));
        long end = currentTimeMillis();
        out.println("完成任務一,耗時:" + (end - start) + "毫秒");
    }

    public void doTaskTwo() throws Exception {
        out.println("開始做任務二");
        long start = currentTimeMillis();
        sleep(random.nextInt(10000));
        long end = currentTimeMillis();
        out.println("完成任務二,耗時:" + (end - start) + "毫秒");
    }

    public void doTaskThree() throws Exception {
        out.println("開始做任務三");
        long start = currentTimeMillis();
        sleep(random.nextInt(10000));
        long end = currentTimeMillis();
        out.println("完成任務三,耗時:" + (end - start) + "毫秒");
    }
}
複製程式碼

2. 同步呼叫

下面通過一個簡單示例來直觀的理解什麼是同步呼叫:

  • 定義 Task 類,繼承 AbstractTask,三個處理函式分別模擬三個執行任務的操作,操作消耗時間隨機取(10 秒內)。
@Component
public class Task extends AbstractTask {
}
複製程式碼
  • 單元測試 用例中,注入 Task 物件,並在測試用例中執行 doTaskOne()doTaskTwo()doTaskThree() 三個方法。
@RunWith(SpringRunner.class)
@SpringBootTest
public class TaskTest {
    @Autowired
    private Task task;

    @Test
    public void testSyncTasks() throws Exception {
        task.doTaskOne();
        task.doTaskTwo();
        task.doTaskThree();
    }
}
複製程式碼
  • 執行單元測試,可以看到類似如下輸出:
開始做任務一
完成任務一,耗時:4059毫秒
開始做任務二
完成任務二,耗時:6316毫秒
開始做任務三
完成任務三,耗時:1973毫秒
複製程式碼

任務一、任務二、任務三順序的執行完了,換言之 doTaskOne()doTaskTwo()doTaskThree() 三個方法順序的執行完成。

3. 非同步呼叫

上述的 同步呼叫 雖然順利的執行完了三個任務,但是可以看到 執行時間比較長,若這三個任務本身之間 不存在依賴關係,可以 併發執行 的話,同步呼叫在 執行效率 方面就比較差,可以考慮通過 非同步呼叫 的方式來 併發執行

  • 建立 AsyncTask類,分別在方法上配置 @Async 註解,將原來的 同步方法 變為 非同步方法
@Component
public class AsyncTask extends AbstractTask {
    @Async
    public void doTaskOne() throws Exception {
        super.doTaskOne();
    }

    @Async
    public void doTaskTwo() throws Exception {
        super.doTaskTwo();
    }

    @Async
    public void doTaskThree() throws Exception {
        super.doTaskThree();
    }
}
複製程式碼
  • 單元測試 用例中,注入 AsyncTask 物件,並在測試用例中執行 doTaskOne()doTaskTwo()doTaskThree() 三個方法。
@RunWith(SpringRunner.class)
@SpringBootTest
public class AsyncTaskTest {
    @Autowired
    private AsyncTask task;

    @Test
    public void testAsyncTasks() throws Exception {
        task.doTaskOne();
        task.doTaskTwo();
        task.doTaskThree();
    }
}
複製程式碼
  • 執行單元測試,可以看到類似如下輸出:
開始做任務三
開始做任務一
開始做任務二
複製程式碼

如果反覆執行單元測試,可能會遇到各種不同的結果,比如:

  1. 沒有任何任務相關的輸出
  2. 有部分任務相關的輸出
  3. 亂序的任務相關的輸出

原因是目前 doTaskOne()doTaskTwo()doTaskThree() 這三個方法已經 非同步執行 了。主程式在 非同步呼叫 之後,主程式並不會理會這三個函式是否執行完成了,由於沒有其他需要執行的內容,所以程式就 自動結束 了,導致了 不完整 或是 沒有輸出任務 相關內容的情況。

注意:@Async所修飾的函式不要定義為static型別,這樣非同步呼叫不會生效。

4. 非同步回撥

為了讓 doTaskOne()doTaskTwo()doTaskThree() 能正常結束,假設我們需要統計一下三個任務 併發執行 共耗時多少,這就需要等到上述三個函式都完成動用之後記錄時間,並計算結果。

那麼我們如何判斷上述三個 非同步呼叫 是否已經執行完成呢?我們需要使用 Future<T> 來返回 非同步呼叫結果

  • 建立 AsyncCallBackTask 類,宣告 doTaskOneCallback()doTaskTwoCallback()doTaskThreeCallback() 三個方法,對原有的三個方法進行包裝。
@Component
public class AsyncCallBackTask extends AbstractTask {
    @Async
    public Future<String> doTaskOneCallback() throws Exception {
        super.doTaskOne();
        return new AsyncResult<>("任務一完成");
    }

    @Async
    public Future<String> doTaskTwoCallback() throws Exception {
        super.doTaskTwo();
        return new AsyncResult<>("任務二完成");
    }

    @Async
    public Future<String> doTaskThreeCallback() throws Exception {
        super.doTaskThree();
        return new AsyncResult<>("任務三完成");
    }
}
複製程式碼
  • 單元測試 用例中,注入 AsyncCallBackTask 物件,並在測試用例中執行 doTaskOneCallback()doTaskTwoCallback()doTaskThreeCallback() 三個方法。迴圈呼叫 FutureisDone() 方法等待三個 併發任務 執行完成,記錄最終執行時間。
@RunWith(SpringRunner.class)
@SpringBootTest
public class AsyncCallBackTaskTest {
    @Autowired
    private AsyncCallBackTask task;

    @Test
    public void testAsyncCallbackTask() throws Exception {
        long start = currentTimeMillis();
        Future<String> task1 = task.doTaskOneCallback();
        Future<String> task2 = task.doTaskTwoCallback();
        Future<String> task3 = task.doTaskThreeCallback();

        // 三個任務都呼叫完成,退出迴圈等待
        while (!task1.isDone() || !task2.isDone() || !task3.isDone()) {
            sleep(1000);
        }

        long end = currentTimeMillis();
        out.println("任務全部完成,總耗時:" + (end - start) + "毫秒");
    }
}
複製程式碼

看看都做了哪些改變:

  • 在測試用例一開始記錄開始時間;
  • 在呼叫三個非同步函式的時候,返回Future型別的結果物件;
  • 在呼叫完三個非同步函式之後,開啟一個迴圈,根據返回的Future物件來判斷三個非同步函式是否都結束了。若都結束,就結束迴圈;若沒有都結束,就等1秒後再判斷。
  • 跳出迴圈之後,根據結束時間 - 開始時間,計算出三個任務併發執行的總耗時。

執行一下上述的單元測試,可以看到如下結果:

開始做任務一
開始做任務三
開始做任務二
完成任務二,耗時:4882毫秒
完成任務三,耗時:6484毫秒
完成任務一,耗時:8748毫秒
任務全部完成,總耗時:9043毫秒
複製程式碼

可以看到,通過 非同步呼叫,讓任務一、任務二、任務三 併發執行,有效的 減少 了程式的 執行總時間

5. 定義執行緒池

在上述操作中,建立一個 執行緒池配置類 TaskConfiguration ,並配置一個 任務執行緒池物件 taskExecutor

@Configuration
public class TaskConfiguration {
    @Bean("taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(200);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("taskExecutor-");
        executor.setRejectedExecutionHandler(new CallerRunsPolicy());
        return executor;
    }
}
複製程式碼

上面我們通過使用 ThreadPoolTaskExecutor 建立了一個 執行緒池,同時設定了以下這些引數:

執行緒池屬性 屬性的作用 設定初始值
核心執行緒數 執行緒池建立時候初始化的執行緒數 10
最大執行緒數 執行緒池最大的執行緒數,只有在緩衝佇列滿了之後,才會申請超過核心執行緒數的執行緒 20
緩衝佇列 用來緩衝執行任務的佇列 200
允許執行緒的空閒時間 當超過了核心執行緒之外的執行緒,在空閒時間到達之後會被銷燬 60秒
執行緒池名的字首 可以用於定位處理任務所在的執行緒池 taskExecutor-
執行緒池對拒絕任務的處理策略 這裡採用CallerRunsPolicy策略,當執行緒池沒有處理能力的時候,該策略會直接在execute方法的呼叫執行緒中執行被拒絕的任務;如果執行程式已關閉,則會丟棄該任務 CallerRunsPolicy
  • 建立 AsyncExecutorTask類,三個任務的配置和 AsyncTask 一樣,不同的是 @Async 註解需要指定前面配置的 執行緒池的名稱 taskExecutor
@Component
public class AsyncExecutorTask extends AbstractTask {
    @Async("taskExecutor")
    public void doTaskOne() throws Exception {
        super.doTaskOne();
        out.println("任務一,當前執行緒:" + currentThread().getName());
    }

    @Async("taskExecutor")
    public void doTaskTwo() throws Exception {
        super.doTaskTwo();
        out.println("任務二,當前執行緒:" + currentThread().getName());
    }

    @Async("taskExecutor")
    public void doTaskThree() throws Exception {
        super.doTaskThree();
        out.println("任務三,當前執行緒:" + currentThread().getName());
    }
}
複製程式碼
  • 單元測試 用例中,注入 AsyncExecutorTask 物件,並在測試用例中執行 doTaskOne()doTaskTwo()doTaskThree() 三個方法。
@RunWith(SpringRunner.class)
@SpringBootTest
public class AsyncExecutorTaskTest {
    @Autowired
    private AsyncExecutorTask task;

    @Test
    public void testAsyncExecutorTask() throws Exception {
        task.doTaskOne();
        task.doTaskTwo();
        task.doTaskThree();

        sleep(30 * 1000L);
    }
}
複製程式碼

執行一下上述的 單元測試,可以看到如下結果:

開始做任務一
開始做任務三
開始做任務二
完成任務二,耗時:3905毫秒
任務二,當前執行緒:taskExecutor-2
完成任務一,耗時:6184毫秒
任務一,當前執行緒:taskExecutor-1
完成任務三,耗時:9737毫秒
任務三,當前執行緒:taskExecutor-3
複製程式碼

執行上面的單元測試,觀察到 任務執行緒池執行緒池名的字首 被列印,說明 執行緒池 成功執行 非同步任務

6. 優雅地關閉執行緒池

由於在應用關閉的時候非同步任務還在執行,導致類似 資料庫連線池 這樣的物件一併被 銷燬了,當 非同步任務 中對 資料庫 進行操作就會出錯。

解決方案如下,重新設定執行緒池配置物件,新增執行緒池 setWaitForTasksToCompleteOnShutdown()setAwaitTerminationSeconds() 配置:

@Bean("taskExecutor")
public Executor taskExecutor() {
    ThreadPoolTaskScheduler executor = new ThreadPoolTaskScheduler();
    executor.setPoolSize(20);
    executor.setThreadNamePrefix("taskExecutor-");
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(60);
    return executor;
}
複製程式碼
  • setWaitForTasksToCompleteOnShutdown(true): 該方法用來設定 執行緒池關閉 的時候 等待 所有任務都完成後,再繼續 銷燬 其他的 Bean,這樣這些 非同步任務銷燬 就會先於 資料庫連線池物件 的銷燬。

  • setAwaitTerminationSeconds(60): 該方法用來設定執行緒池中 任務的等待時間,如果超過這個時間還沒有銷燬就 強制銷燬,以確保應用最後能夠被關閉,而不是阻塞住。

小結

本文介紹了在 Spring Boot 中如何使用 @Async 註解配置 非同步任務非同步回撥任務,包括結合 任務執行緒池 的使用,以及如何 正確優雅 地關閉 任務執行緒池


歡迎關注技術公眾號: 零壹技術棧

零壹技術棧

本帳號將持續分享後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。

相關文章