本文所講方式僅適用于托管在Kestrel Server中的應用。如果托管在IIS和IIS Express上時,ASP.NET Core Module(ANCM)并不會告訴ASP.NET Core在客戶端斷開連接時中止請求。但可喜的是,ANCM預計在.NET Core 2.2中會完善這一機制。
1. 引言
假設有一個耗時的Action,在瀏覽器發出請求返回響應之前,如果刷新了頁面,對于瀏覽器(客戶端)來說前一個請求就會被終止。而對于服務端來說,又是怎樣呢?前一個請求也會自動終止,還是會繼續運行呢?
下面我們通過實例尋求答案。
2. 實例演示
創建一個SlowRequestController
,再定義一個Get
請求,并通過Task.Delay(10_000)
模擬耗時行為。代碼如下:
public class SlowRequestController : Controller
{
private readonly ILogger _logger;
public SlowRequestController(ILogger<SlowRequestController> logger)
{
_logger = logger;
}
[HttpGet("/slowtest")]
public async Task<string> Get()
{
_logger.LogInformation("Starting to do slow work");
// slow async action, e.g. call external api
await Task.Delay(10_000);
var message = "Finished slow delay of 10 seconds.";
_logger.LogInformation(message);
return message;
}
}
如果我們發起請求,那么該頁面將耗時10s才能完成顯示。
如果我們檢查運行日志,我們發現其輸出符合預期:
如果在第一次請求返回之前,刷新頁面,結果將是怎樣呢??
從日志中我們可以看出:刷新后,第一個請求雖然在客戶端被取消了,但是服務端仍舊會持續運行。
從而可以說明MVC的默認行為: 即使用戶刷新了瀏覽器會取消原始請求,但MVC對其一無所知,已經被取消的請求還是會在服務端繼續運行,而最終的運行結果將會被丟棄。
這樣就會造成嚴重的性能浪費。如果服務端能感知用戶中斷了請求,并終止運行耗時的任務就好了。
幸好,ASP.NET Core開發團隊體貼的考慮了這一點,允許我們通過以下兩種方式來獲取客戶端的請求是否被終止。
- 通過
HttpContex
的RequestAborted
屬性: - 通過方法注入
CancellationToken
參數:
if (HttpContext.RequestAborted.IsCancellationRequested)
{
// can stop working now
}
[HttpGet]
public async Task<ActionResult> GetHardWork(CancellationToken cancellationToken)
{
// ...
if (cancellationToken.IsCancellationRequested)
{
// stop!
}
// ...
}
而這兩種方式其實是一樣的,因為HttpContext.RequestAborted
和cancellationToken
對應的是同一個對象:
if(cancellationToken == HttpContext.RequestAborted)
{
// this is true!
}
下面我們就來以cancellationToken
為例,看看如何感知客戶端請求終止并終止服務端服務。
3. 在Action中使用CancellationToken
CancellationToken
是由CancellationTokenSource
創建的輕量級對象。當某個CancellationTokenSource
被取消時,它會通知所有的消費者CancellationToken
。
取消時,CancellationToken
的IsCancellationRequested
屬性將設置為True,表示CancellationTokenSource
已取消。
再回到前面的實例,我們有一個長期運行的操作方法(例如,通過調用許多其他API生成只讀報告)。由于它是一種昂貴的方法,我們希望在用戶取消請求時盡快停止執行操作。
下面的代碼顯示了通過在action方法中注入一個CancellationToken
,并將其傳遞給Task.Delay
,來達到同步終止服務端請求的目的:
public class SlowRequestController : Controller
{
private readonly ILogger _logger;
public SlowRequestController(ILogger<SlowRequestController> logger)
{
_logger = logger;
}
[HttpGet("/slowtest")]
public async Task<string> Get(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting to do slow work");
// slow async action, e.g. call external api
await Task.Delay(10_000, cancellationToken);
var message = "Finished slow delay of 10 seconds.";
_logger.LogInformation(message);
return message;
}
}
MVC將使用CancellationTokenModelBinder
自動將Action中的任何CancellationToken
參數綁定到HttpContext.RequestAborted
。當我們在Startup.ConfigureServices()
中調用services.AddMvc()
或 services.AddMvcCore()
時,CancellationTokenModelBinder
模型綁定器就會被自動注冊。
通過這個小改動,我們再嘗試在第一個請求返回之前刷新頁面,從日志中我們發現,第一個請求將不會繼續完成。而是當Task.Delay
檢測到CancellationToken.IsCancellationRequested
屬性為true時立即停止執行時并拋出TaskCancelledException
。
簡而言之,用戶刷新瀏覽器,在服務端通過拋出TaskCancelledException
異常終止了第一個請求,而該異常通過請求管道再傳播回來。
在這個場景中,Task.Delay()
會監視CancellationToken
,因此無需我們手動檢查CancellationToken
是否被取消。
4. 手動檢查CancellationToken狀態
如果你正在調用支持CancellationToken
的內置方法,比如Task.Delay()
或HttpClient.SendAsync()
,那么你可以直接傳入CancellationToken
,并讓內部方法負責實際取消。
在其他情況下,您可能正在進行一些同步工作,您希望能夠取消這些工作。例如,假設正在構建一份報告來計算公司員工的所有傭金。你循環每個員工,然后遍歷他們的每一筆銷售。
能夠在中途取消此報告生成的簡單解決方案是檢查for循環內的CancellationToken
,如果用戶取消請求則跳出循環。
以下示例通過循環10次并執行某些同步(不可取消)工作來表示此類情況,該工作由對Thread.Sleep()
來模擬。在每個循環開始時,我們檢查CancellationToken
,如果取消則拋出異常。這使得我們可以終止一個長時間運行的同步任務。
public class SlowRequestController : Controller
{
private readonly ILogger _logger;
public SlowRequestController(ILogger<SlowRequestController> logger)
{
_logger = logger;
}
[HttpGet("/slowtest")]
public async Task<string> Get(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting to do slow work");
for(var i=0; i<10; i++)
{
cancellationToken.ThrowIfCancellationRequested();
// slow non-cancellable work
Thread.Sleep(1000);
}
var message = "Finished slow delay of 10 seconds.";
_logger.LogInformation(message);
return message;
}
}
現在,如果你取消請求,則對ThrowIfCancelletionRequested()
的調用將拋出一個OperationCanceledException
,它將再次傳播回過濾器管道和中間件管道。
5. 使用ExceptionFilter捕捉取消異常
ExceptionFilters是一個MVC概念,可用于處理在您的操作方法或操作過濾器中發生的異常。可以參考官方文檔。
可以將過濾器應用到控制器級別和操作級別,也可以應用于全局級別。為了簡單起見,我們創建一個過濾器并添加到全局過濾器。
public class OperationCancelledExceptionFilter : ExceptionFilterAttribute
{
private readonly ILogger _logger;
public OperationCancelledExceptionFilter(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<OperationCancelledExceptionFilter>();
}
public override void OnException(ExceptionContext context)
{
if(context.Exception is OperationCanceledException)
{
_logger.LogInformation("Request was cancelled");
context.ExceptionHandled = true;
context.Result = new StatusCodeResult(499);
}
}
}
我們通過重載OnException
方法并特殊處理OperationCanceledException
異常即可成功捕獲取消異常。
Task.Delay()
拋出的異常是TaskCancelledException
類型,其為OperationCanceledException
的基類,所以,以上過濾器也可正確捕捉。
然后注冊過濾器:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options =>
{
options.Filters.Add<OperationCancelledExceptionFilter>();
});
}
}
現在再測試,我們發現運行日志將不會包含異常信息,取而代之的是我們自定義的信息。
6. 服務端是如何知曉客戶端的中斷請求的呢
這就要提到FTP的四次揮手流程了,當客戶端中斷請求,會發送一個FIN報文段,服務端據此來判斷請求是否中斷。
具體可以參照KestrelHttpServer中
SocketConnection
中的代碼實現:
private async Task DoReceive()
{
try
{
while (true)
{
// Ensure we have some reasonable amount of buffer space
var buffer = _input.Alloc(MinAllocBufferSize);
try
{
var bytesReceived = await _socket.ReceiveAsync(GetArraySegment(buffer.Buffer), SocketFlags.None);
if (bytesReceived == 0)
{
// We receive a FIN so throw an exception so that we cancel the input
// with an error
throw new TaskCanceledException("The request was aborted");
}
buffer.Advance(bytesReceived);
}
finally
{
buffer.Commit();
}
var result = await buffer.FlushAsync();
if (result.IsCompleted)
{
// Pipe consumer is shut down, do we stop writing
_socket.Shutdown(SocketShutdown.Receive);
break;
}
}
_input.Complete();
}
catch (Exception ex)
{
_connectionContext.Abort(ex);
_input.Complete(ex);
}
}
7. 最后
通過本文,我們知道用戶可以通過點擊瀏覽器上的停止或重新加載按鈕隨時取消Web應用的請求。而實際上僅僅是終止了客戶端的請求,服務端的請求還在繼續運行。對于簡單耗時短的請求來說,我們可以不予理睬。但是,對于耗時任務來說,我們卻不可以置若罔聞,因為其有很高的性能損耗。
而如何解決呢?其關鍵是通過CancellationToken
來捕捉用戶請求的狀態,從而根據需要進行相應的處理。
參考資料:
CancellationTokens and Aborted ASP.NET Core Requests
Using CancellationTokens in ASP.NET Core MVC controllers
SocketTransport: FIN handling