徹底搞懂Web異步編程模型
長期以來,Spring Web MVC 運行在 Tomcat、JBoss 等 Servlet 容器上,是我們開發 Web 服務的主流框架。但你要注意的是,Servlet 容器是阻塞式的,所以 WebMVC 也建立在阻塞 I/O 之上。
換句話說,任何一個請求的響應過程都是同步的,需要在服務器工作線程接收請求、阻塞等待 I/O 以及完成請求處理之后才能返回。
圖 1 同步請求處理過程示意圖
這樣的同步請求處理機制對普通應用場景來說是合適的,但在一些特定場景下,這種同步機制會存在局限性,需要開發人員采用異步的方式來處理 Web 請求。這就引出了今天我們要討論的主題,Web 異步編程模型。
讓我們先從 Web 異步處理需求和場景開始說起。
Web 異步處理需求和場景
Web 異步處理的第一個應用場景是為了 提升系統性能。
我們知道,同步請求處理機制采用的是一個請求對應一個線程的實現過程。這樣,系統請求數量越大,我們就需要創建越多的線程,而線程是一種資源,系統的響應能力會隨著資源的消耗而逐漸下降。但異步處理機制不需要在處理請求時全程保持某一個線程,這樣線程資源就能做到復用。
圖 2 異步請求處理過程示意圖
接下來是異步處理的第二個應用場景,對于有些請求而言,我們實際上并不關注請求的返回結果,也就是說這些請求采用的是一種 即發即棄(Fire and Forget)模式。
這個模式有點類似于消息中間件的處理過程,請求線程發送請求然后直接返回。如果采用同步模式,那么請求必須等待服務端返回。因此,相比于異步處理,同步模式會造成浪費。
圖 3 即發即棄處理過程示意圖
最后,異步處理的第三種場景,在日常開發過程中, 某個請求需要處理大量業務數據,這也是我們會經常碰到的情況。比較典型的例子就是導出數據報表。在這種場景下,如果采用同步模式,很可能會導致出現請求超時。
這時候,合理的解決方案是先對請求做出快速響應,然后再啟動異步線程來執行大數據處理邏輯。
圖 4 大數量請求處理過程示意圖
現在來簡單總結一下,從三個特定場景的異步模式應用中,我們可以看出:
對于傳統請求場景,異步模式能夠確保線程復用;
對于即發即棄場景,異步模式能夠節省系統資源;
而對于大數量請求場景,異步模式則能夠提高用戶體驗。
所以,如果能夠在復雜的業務場景中集成這三種場景中的異步調用機制,我們就可以高效處理 Web 請求。
那么,應該如何使用異步模式來高效應對這些場景呢?Spring 為我們提供了完整的解決方案,我們一起來看一下。
Spring Web 異步編程模型
異步處理的主要優勢是調用方不必等待被調用方完成執行過程,這就需要啟動新的線程。為了在一個新的線程中執行目標方法,Spring 異步編程模型提供了一個全新的@Async 注解。該注解可以與 JDK 中的 Future 機制以及線程池進行無縫整合。我們先來看這個@Async 注解。
@Async 注解
想要在 Spring 應用程序中啟用異步編程模式,我們可以通過@EnableAsync 注解實現這一目標。常見的做法是在 Spring 配置類上添加這一注解。
@Configuration
@EnableAsync
public class SpringConfig { ... }@Async 注解支持兩種處理模式,即 即發即棄模式和普通的請求響應模式。我們先來看即發即棄模式的代碼示例。
@Async
public void recordUserHealthData() {
logger.info("Record user health data successfully.");
}可以看到,我們在一個返回值為 void 的方法上添加了@Async 注解,這樣該方法中將以異步的方式進行執行。
然后,我們來看一下請求響應式的異步方式代碼示例。
@Service
public class HealthService {
@Async
public Future<String> getHealthDescription() throws InterruptedException {
LOGGER.info("Thread id: " + Thread.currentThread().getId());
//睡眠 2 秒
Thread.sleep(2000);
String healthDescription = “health description”;
LOGGER.info(processInfo);
return new AsyncResult<String>(healthDescription);
}
}可以看到,這里我們在方法入口打印了當前的線程 ID,然后讓主線程睡眠 2 秒用來模擬長時間的業務處理流程。接著,我們返回異步調用的結果對象 AsyncResult。
AsyncResult 是 Spring 框架對 JDK 中 Future 接口的一種實現,我們可以通過 AsyncResult 對象跟蹤異步調用的結果。為了更好理解上述方法的執行過程,我們有必要先來看看 JDK 中的 Future 對象。
傳統模式調用和 Future 模式調用的對比可以參考圖 5。我們看到在 Future 模式調用過程中,客戶端在向服務器端發起請求之后馬上返回,可以繼續執行其他任務直到服務器端通知 Future 調用的結果,體現了 Future 調用異步化特點。
圖 5 傳統調用(左)和 Future 機制(右)對比示意圖
但原生的 Future 也有同步等待問題,因為通過 Future 對象直接獲取調用結果同樣會導致線程等待。為了解決這個問題,Java 8 中引入了 CompletableFuture 對原生的 Future 進行了優化,可以直接通過 CompletableFuture 將異步執行結果交給另外一個異步線程來處理。這樣在異步任務完成后,我們在獲取任務結果時則不需要等待。
例如,如果想要在異步執行任務完成之后返回值,那么可以使用 CompletableFuture 的 supplyAsync() 方法,示例代碼如下所示。
@RequestMapping(value = "/health_description")
public CompletableFuture<String> syncHealthDescription () {
CompletableFuture.supplyAsync(new Supplier<String>() {
@Override
public String get() {
try {
return healthService.getHealthDescription().get();
} catch (InterruptedException | ExecutionException e) {
LOGGER.error(e);
}
return"No health description found";
}
});
return completableFuture;
}WebAsyncTask
前面介紹的@Async 注解實際上是通用的,我們可以用它來完成包含 Web 請求在內的任意場景下的異步處理流程。而隨著 Spring Boot 的誕生,也出現了 WebAsyncTask 這一專門針對 Web 場景下的異步執行組件。
相較@Async 注解,WebAsyncTask 為開發人員提供了更靈活的異步任務處理機制,并內置了異步回調、超時處理和異常處理。如果想要初始化一個 WebAsyncTask 對象,我們需要設置一個超時時間,并啟動一個線程對象。
public WebAsyncTask(long timeout, Callable<V> callable)基于這一使用方式,我們先來看一下 WebAsyncTask 的簡單示例。
@RequestMapping(value = "task_normal", method = RequestMethod.GET)
public WebAsyncTask<String> task1() {
System.out.println("The main Thread name is " +
Thread.currentThread().getName());
// 此處模擬開啟一個異步任務
WebAsyncTask<String> task1 = new WebAsyncTask<String>(4 * 1000L, () -> {
System.out.println("The first Thread name is " +
Thread.currentThread().getName());
Thread.sleep(2 * 1000L);
return"task1 executed!";
});
// 任務執行完成時調用該方法
task1.onCompletion(() -> {
System.out.println("task1 finished!");
});
// 可以繼續執行其他操作
System.out.println("task1 can do other things!");
return task1;
}可以看到,這里初始化了一個 WebAsyncTask 對象,并設置任務的超時時間為 4s。異步任務執行采用 Thread.sleep 方法來進行模擬,這里設置異步線程的睡眠時間為 2s。然后,我們還通過 WebAsyncTask 的 onCompletion() 方法指定了任務執行完成時的回調函數。
執行以上代碼,我們在控制臺可以得到如下日志信息。
The main Thread name is http-nio-7000-exec-5
task1 can do other things!
The first Thread name is MvcAsync2
task1 finished!顯然,我們先打印出了主線程的名稱,然后主線程可以繼續執行并返回結果。然后我們啟動異步線程,并打印出該線程的名稱。當異步線程執行完畢時,同樣打印出了這一信息。如果你在瀏覽器中訪問這個 HTTP 端點,那么可以獲取異步方法的正常返回值"task1 executed!"。
我們接著來看一下如何設置異常處理回調的方法,示例代碼如下所示。
@RequestMapping(value = "task_error", method = RequestMethod.GET)
public WebAsyncTask<String> getUserWithError() {
System.out.println("The main Thread name is "
+ Thread.currentThread().getName());
// 此處模擬開啟一個異步任務
WebAsyncTask<String> task3 = new WebAsyncTask<String>(4 * 1000L, () -> {
System.out.println("The second Thread name is "
+ Thread.currentThread().getName());
int num = 1 / 0;
System.err.println(num);
return"";
});
// 發生異常時調用該方法
task3.onError(() -> {
System.err.println(Thread.currentThread().getName());
System.err.println("task3 error occured!");
return"";
});
// 任務執行完成時調用該方法
task3.onCompletion(() -> {
System.out.println("task3 finished!");
});
// 可以繼續執行其他操作
System.out.println("task3 can do other things!");
return task3;
}這里設置了一個 onError() 回調,并通過除 0 操作觸發了這一回調,結果如下所示。
The main Thread name is http-nio-7000-exec-10
task3 can do other things!
The second Thread name is MvcAsync4
http-nio-7000-exec-1
task3 error occured!
task3 finished!這樣,基于 WebAsyncTask 的異步編程模型就介紹完畢了。從上文中我們可以看出,WebAsyncTask 除了能夠實現異步調用,它所提供的異步編程模型充分考慮了異步執行過程中可能出現的異常情況和超時機制。同時,基于回調的異步處理結果的獲取過程也顯得非常自然。相比@Async 注解,WebAsyncTask 的功能更加強大。
所以,在日常開發過程中,我建議你使用這個工具類來實現對 Web 請求的異步處理。
總結
今天我們系統分析了在 Web 應用程序開發過程中,如何使用 Spring 框架提供的異步編程能力來提高系統的響應性。
我們從異步處理場景講起,引出 Spring 中所提供了@Async 注解,該注解是對異步處理過程的抽象。在具體使用過程中,我們一般結合 CompletableFuture 來處理異步線程之間的交互過程。同時,針對 Web 開發場景,Spring 還專門提供了一個 WebAsyncTask 工具類來簡化開發過程。
在日常開發過程中,@Async 注解為開發人員提供的是一種通用型的異步編程,我們可以使用它在應用程序的各層組件中添加異步處理機制。而 WebAsyncTask 則專門面向 Web 請求處理,因此,如果你正在開發 Web 應用程序,那么 WebAsyncTask 無疑是你的首選。































