ASP.NET Core知多少(12):中斷請求了解一下

ASP.NET Core知多少系列:總體介紹及目錄

本文所講方式僅適用于托管在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才能完成顯示。


頁面展示

如果我們檢查運行日志,我們發現其輸出符合預期:


運行Log

如果在第一次請求返回之前,刷新頁面,結果將是怎樣呢??

刷新后運行日志

從日志中我們可以看出:刷新后,第一個請求雖然在客戶端被取消了,但是服務端仍舊會持續運行。

從而可以說明MVC的默認行為: 即使用戶刷新了瀏覽器會取消原始請求,但MVC對其一無所知,已經被取消的請求還是會在服務端繼續運行,而最終的運行結果將會被丟棄。

這樣就會造成嚴重的性能浪費。如果服務端能感知用戶中斷了請求,并終止運行耗時的任務就好了。

幸好,ASP.NET Core開發團隊體貼的考慮了這一點,允許我們通過以下兩種方式來獲取客戶端的請求是否被終止。

  1. 通過HttpContexRequestAborted屬性:
  2. 通過方法注入CancellationToken參數:
if (HttpContext.RequestAborted.IsCancellationRequested)
{
    // can stop working now
}
[HttpGet]
public async Task<ActionResult> GetHardWork(CancellationToken cancellationToken)
{
    // ...
 
    if (cancellationToken.IsCancellationRequested)
    {
        // stop!
    }
     
    // ...
}

而這兩種方式其實是一樣的,因為HttpContext.RequestAbortedcancellationToken對應的是同一個對象:

if(cancellationToken == HttpContext.RequestAborted)
{
    // this is true!
}

下面我們就來以cancellationToken為例,看看如何感知客戶端請求終止并終止服務端服務。

3. 在Action中使用CancellationToken

CancellationToken是由CancellationTokenSource創建的輕量級對象。當某個CancellationTokenSource被取消時,它會通知所有的消費者CancellationToken

取消時,CancellationTokenIsCancellationRequested屬性將設置為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報文段,服務端據此來判斷請求是否中斷。

FTP四次揮手

具體可以參照KestrelHttpServerSocketConnection中的代碼實現:

        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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,963評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,348評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,083評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,706評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,442評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,802評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,795評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,983評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,542評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,287評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,486評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,030評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,710評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,116評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,412評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,224評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,462評論 2 378

推薦閱讀更多精彩內容

  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,825評論 18 139
  • 目錄:ASP.NET Core 知多少(1):從官方模板開始[http://www.lxweimin.com/p/...
    圣杰閱讀 11,783評論 3 79
  • 我愿做自己的英雄 筑起堡壘 拯救自己的世界 hero,zero。
    Mrs葵閱讀 186評論 0 0
  • 現在已是23:14,我們說好的要在23:20前臥倒在床,可我知道,今晚你又得忙到通宵了。寢室準時十一點熄燈,現在已...
    Catherine94閱讀 1,383評論 1 1
  • “是的,你很特別。可是,你再也不會遇到另一個我。”
    RosaRosaLee閱讀 197評論 0 0