前言
當一個 HTTP 請求到達 Tomcat,Tomcat 將會從執行緒池中取出執行緒,然後按照如下流程處理請求:
- 將請求資訊解析為
HttpServletRequest
- 分發到具體 Servlet 處理相應的業務
- 通過
HttpServletResponse
將響應結果返回給等待客戶端
整體流程如下所示:
這是我們日常最常用同步請求模型,所有動作都交給同一個 Tomcat 執行緒處理,所有動作處理完成,執行緒才會被釋放回執行緒池。
想象一下如果業務需要較長時間處理,那麼這個 Tomcat 執行緒其實一直在被佔用,隨著請求越來越多,可用 I/O 執行緒越來越少,直到被耗盡。這時後續請求只能等待空閒 Tomcat 執行緒,這將會加長了請求執行時間。
如果客戶端不關心返回業務結果,這時我們可以自定義執行緒池,將請求任務提交給執行緒池,然後立刻返回。
也可以使用 Spring Async 任務,大家感興趣可以自行查詢一下資料
但是很多場景下,客戶端需要處理返回結果,我們沒辦法使用上面的方案。在 Servlet2 時代,我們沒辦法優化上面的方案。
不過等到 Servlet3 ,引入非同步 Servelt 新特性,可以完美解決上面的需求。
非同步 Servelt 執行請求流程:
- 將請求資訊解析為
HttpServletRequest
- 分發到具體
Servlet
處理,將業務提交給自定義業務執行緒池,請求立刻返回,Tomcat 執行緒立刻被釋放 - 當業務執行緒將任務執行結束,將會將結果轉交給 Tomcat 執行緒
- 通過
HttpServletResponse
將響應結果返回給等待客戶端
引入非同步 Servelt3 整體流程如下:
使用非同步 Servelt,Tomcat 執行緒僅僅處理請求解析動作,所有耗時較長的業務操作全部交給業務執行緒池,所以相比同步請求, Tomcat 執行緒可以處理 更對請求。
雖然我們將業務處理交給業務執行緒池非同步處理,但是對於客戶端來講,其還在同步等待響應結果。
可能有些同學會覺得非同步請求將會獲得更快響應時間,其實不是的,相反可能由於引入了更多執行緒,增加執行緒上下文切換時間。
雖然沒有降低響應時間,但是通過請求非同步化帶來其他明顯優點:
- 可以處理更高併發連線數,提高系統整體吞吐量
- 請求解析與業務處理完全分離,職責單一
- 自定義業務執行緒池,我們可以更容易對其監控,降級等處理
- 可以根據不同業務,自定義不同執行緒池,相互隔離,不用互相影響
所以具體使用過程,我們還需要進行的相應的壓測,觀察響應時間以及吞吐量等其他指標,綜合選擇。
非同步 Servelt 使用方式
非同步 Servelt 使用方式不是很難,小黑哥總結就是就是下面三板斧:
HttpServletRequest#startAsync
獲取AsyncContext
非同步上下文物件- 使用自定義的業務執行緒池處理業務邏輯
- 業務執行緒處理結束,通過
AsyncContext#complete
返回響應結果
下面的例子將會使用 SpringBoot ,Web 容器選擇 Tomcat
示例程式碼如下:
ExecutorService executorService = Executors.newFixedThreadPool(10);
@RequestMapping("/hello")
public void hello(HttpServletRequest request) {
AsyncContext asyncContext = request.startAsync();
// 超時時間
asyncContext.setTimeout(10000);
executorService.submit(() -> {
try {
// 休眠 5s,模擬業務操作
TimeUnit.SECONDS.sleep(5);
// 輸出響應結果
asyncContext.getResponse().getWriter().println("hello world");
log.info("非同步執行緒處理結束");
} catch (Exception e) {
e.printStackTrace();
} finally {
asyncContext.complete();
}
});
log.info("servlet 執行緒處理結束");
}
複製程式碼
瀏覽器訪問該請求將會同步等待 5s 得到輸出響應,應用日誌輸出結果如下:
2020-03-24 07:27:08.997 INFO 79257 --- [nio-8087-exec-4] com.xxxx : servlet 執行緒處理結束
2020-03-24 07:27:13.998 INFO 79257 --- [pool-1-thread-3] com.xxxx : 非同步執行緒處理結束
複製程式碼
這裡我們需要注意設定合理的超時時間,防止客戶端長時間等待。
SpringMVC
Servlet3 API ,無法使用 SpringMVC 為我們提供的特性,我們需要自己處理響應資訊,處理方式相對繁瑣。
SpringMVC 3.2 基於 Servelt3 引入非同步請求處理方式,我們可以跟使用同步請求一樣,方便使用非同步請求。
SpringMVC 提供有兩種非同步方式,只要將 Controller
方法返回值修改下述類即可:
DeferredResult
Callable
DeferredResult
DeferredResult
是 SpringMVC 3.2 之後引入新的類,只要讓請求方法返回 DeferredResult
,就可以快速使用非同步請求,示例程式碼如下:
ExecutorService executorService = Executors.newFixedThreadPool(10);
@RequestMapping("/hello_v1")
public DeferredResult<String> hello_v1() {
// 設定超時時間
DeferredResult<String> deferredResult = new DeferredResult<>(7000L);
// 非同步執行緒處理結束,將會執行該回撥方法
deferredResult.onCompletion(() -> {
log.info("非同步執行緒處理結束");
});
// 如果非同步執行緒執行時間超過設定超時時間,將會執行該回撥方法
deferredResult.onTimeout(() -> {
log.info("非同步執行緒超時");
// 設定返回結果
deferredResult.setErrorResult("timeout error");
});
deferredResult.onError(throwable -> {
log.error("異常", throwable);
// 設定返回結果
deferredResult.setErrorResult("other error");
});
executorService.submit(() -> {
try {
TimeUnit.SECONDS.sleep(5);
deferredResult.setResult("hello_v1");
// 設定返回結果
} catch (Exception e) {
e.printStackTrace();
// 若非同步方法內部異常
deferredResult.setErrorResult("error");
}
});
log.info("servlet 執行緒處理結束");
return deferredResult;
}
複製程式碼
建立 DeferredResult
例項時可以傳入特定超時時間。另外我們可以設定預設超時時間:
# 非同步請求超時時間
spring.mvc.async.request-timeout=2000
複製程式碼
如果非同步程式執行完成,可以呼叫 DeferredResult#setResult
返回響應結果。此時若有設定 DeferredResult#onCompletion
回撥方法,將會觸發該回撥方法。
Go to implementation(s)
最後 DeferredResult
還提供其他異常的回撥方法 onError
,起初小黑哥以為只要非同步執行緒內發生異常,就會觸發該回撥方法。嘗試在非同步執行緒內丟擲異常,但是無法成功觸發。
後續小黑哥檢視這個方法的 doc,當 web 容器執行緒處理非同步請求是時發生異常,才能成功觸發。
小黑哥不知道如何才能發生這個異常,有經驗的小夥伴們的可以留言告知下。
Callable
Spring 另外還提供一種非同步請求使用方式,直接使用 JDK Callable
。示例程式碼如下:
@RequestMapping("/hello_v2")
public Callable<String> hello_v2() {
return new Callable<String>() {
@Override
public String call() throws Exception {
TimeUnit.SECONDS.sleep(5);
log.info("非同步方法結束");
return "hello_v2";
}
};
}
複製程式碼
預設情況下,直接執行將會輸出 WARN 日誌:
這是因為預設情況使用 SimpleAsyncTaskExecutor
執行非同步請求,每次呼叫執行都將會新建執行緒。由於這種方式不復用執行緒,生產不推薦使用這種方式,所以我們需要使用執行緒池代替。
我們可以使用如下方式自定義執行緒池:
@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor executor() {
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
threadPoolTaskExecutor.setThreadNamePrefix("test-");
threadPoolTaskExecutor.setCorePoolSize(10);
threadPoolTaskExecutor.setMaxPoolSize(20);
return threadPoolTaskExecutor;
}
複製程式碼
注意 Bean 名稱一定要是 applicationTaskExecutor
,若不一致, Spring 將不會使用自定義執行緒池。
或者可以直接使用 SpringBoot 配置檔案方式配置代替:
# 核心執行緒數
spring.task.execution.pool.core-size=10
# 最大執行緒數
spring.task.execution.pool.max-size=20
# 執行緒名字首
spring.task.execution.thread-name-prefix=test
# 還有另外一些配置,讀者們可以自行配置
複製程式碼
這種方式非同步請求的超時時間只能通過配置檔案方式配置。
spring.mvc.async.request-timeout=10000
複製程式碼
如果需要為單獨請求的配置特定的超時時間,我們需要使用 WebAsyncTask
包裝 Callable
。
@RequestMapping("/hello_v3")
public WebAsyncTask<String> hello_v3() {
System.out.println("asdas");
Callable<String> callable=new Callable<String>() {
@Override
public String call() throws Exception {
TimeUnit.SECONDS.sleep(5);
log.info("非同步方法結束");
return "hello_v3";
}
};
// 單位 ms
WebAsyncTask<String> webAsyncTask=new WebAsyncTask<>(10000,callable);
return webAsyncTask;
}
複製程式碼
總結
SpringMVC 兩種非同步請求方式,本質上就是幫我們包裝 Servlet3 API ,讓我們不用關心具體實現細節。雖然日常使用我們一般會選擇使用 SpringMVC 兩種非同步請求方式,但是我們還是需要了解非同步請求實際原理。所以大家如果在使用之前,可以先嚐試使用 Servlet3 API 練習,後續再使用 SpringMVC。