為什麼HttpContextAccessor要這麼設計?

前言

周五在群裏面有小夥伴問,ASP.NET Core這個HttpContextAccessor為什麼改成了這個樣子?

在印象中,這已經是第三次遇到有小夥伴問這個問題了,特意來寫一篇記錄,來回答一下這個問題。

聊一聊歷史

關於HttpContext其實我們大家都不陌生,它封裝了HttpRequestHttpResponse,在處理Http請求時,起着至關重要的作用。

CallContext時代

那麼如何訪問HttpContext對象呢?回到await/async出現以前的ASP.NET的時代,我們可以通過HttpContext.Current方法直接訪問當前Http請求的HttpContext對象,因為當時基本都是同步的代碼,一個Http請求只會在一個線程中處理,所以我們可以使用能在當前線程中傳播的CallContext.HostContext來保存HttpContext對象,它的代碼長這個樣子。

namespace System.Web.Hosting {
 
    using System.Web;
    using System.Web.Configuration;
    using System.Runtime.Remoting.Messaging;
    using System.Security.Permissions;
    
    internal class ContextBase {
 
        internal static Object Current {
            get {
                // CallContext在不同的線程中不一樣
                return CallContext.HostContext;
            }
 
            [SecurityPermission(SecurityAction.Demand, Unrestricted = true)]
            set {
                CallContext.HostContext = value;
            }
        }
        ......
    }
}}

一切都很美好,但是後面微軟在C#為了進一步增強增強了異步IO的性能,從而實現的stackless協程,加入了await/async關鍵字(感興趣的小夥伴可以閱讀黑洞的這一系列文章),同一個方法內的代碼await前與後不一定在同一個線程中執行,那麼就會造成在await之後的代碼使用HttpContext.Current的時候訪問不到當前的HttpContext對象,下面有一段這個問題簡單的復現代碼。

// 設置當前線程HostContext
CallContext.HostContext = new Dictionary<string, string> 
{
	["ContextKey"] = "ContextValue"
};
// await前,可以正常訪問
Console.Write($"[{Thread.CurrentThread.ManagedThreadId}] await before:");
Console.WriteLine(((Dictionary<string,string>)CallContext.HostContext)["ContextKey"]);

await Task.Delay(100);

// await後,切換了線程,無法訪問
Console.Write($"[{Thread.CurrentThread.ManagedThreadId}] await after:");
Console.WriteLine(((Dictionary<string,string>)CallContext.HostContext)["ContextKey"]);


可以看到await執行之前HostContext是可以正確的輸出賦值的對象和數據,但是await以後的代碼由於線程從16切換到29,所以訪問不到上面代碼給HostContext設置的對象了。
配圖-CallContext問題.drawio

AsyncLocal時代

為了解決這個問題,微軟在.NET 4.6中引入了AsyncLocal<T>類,後面重新設計的ASP.NET Core自然就用上了AsyncLocal<T>來存儲當前Http請求的HttpContext對象,也就是開頭截圖的代碼一樣,我們來嘗試一下。

var asyncLocal = new AsyncLocal<Dictionary<string,string>>();

// 設置當前線程HostContext
asyncLocal.Value = new Dictionary<string, string> 
{
	["ContextKey"] = "ContextValue"
};
// await前,可以正常訪問
Console.Write($"[{Thread.CurrentThread.ManagedThreadId}] await before:");
Console.WriteLine(asyncLocal.Value["ContextKey"]);

await Task.Delay(100);

// await後,切換了線程,可以訪問
Console.Write($"[{Thread.CurrentThread.ManagedThreadId}] await after:");
Console.WriteLine(asyncLocal.Value["ContextKey"]);


沒有任何問題,線程從16切換到了17,一樣的可以訪問。對AsyncLocal感興趣的小夥伴可以看黑洞的這篇文章。簡單的說就是AsyncLocal默認會將當前線程保存的上下對象在發生await的時候傳播到後續的線程上。
配圖-await和Asynclocal.drawio
這看起來就非常的美好了,既能開開心心的用await/async又不用擔心上下文數據訪問不到,那為什麼ASP.NET Core的後續版本需要修改HttpContextAccesor呢?我們自己來實現ContextAccessor,大家看下面一段代碼。

// 給Context賦值一下
var accessor = new ContextAccessor();
accessor.Context =  "ContextValue";
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Main-1:{accessor.Context}");

// 執行方法
await Method();

// 再打印一下
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Main-2:{accessor.Context}");

async Task Method()
{
	// 輸出Context內容
	Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Method-1:{accessor.Context}");
	await Task.Delay(100);
	// 注意!!!,我在這裡將Context對象清空
	Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Method-2:{accessor.Context}");
	accessor.Context = null;
	Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Method-3:{accessor.Context}");
}

// 實現一個簡單的Context Accessor
public class ContextAccessor
{
	static AsyncLocal<string> _contextCurrent = new AsyncLocal<string>();

	public string Context
	{
		get => _contextCurrent.Value;
		set => _contextCurrent.Value = value;
	}
}

奇怪的事情就發生了,為什麼明明在Method中把Context對象置為null了,Method-3中已經輸出為null了,為啥在Main-2輸出中還是ContextValue呢?
配圖-await和Asynclocal問題.drawio

AsyncLocal使用的問題

其實這已經解答了上面的問題,就是為什麼在ASP.NET Core 6.0中的實現方式突然變了,有這樣一種場景,已經當前線程中把HttpContext置空了,但是其它線程仍然能訪問HttpContext對象,導致後續的行為可能不一致。

那為什麼會造成這個問題呢?首先我們得知道AsyncLocal是如何實現的,這裡我就不在贅述,詳細可以看我前面給的鏈接(黑洞大佬的文章)。這裡只簡單的說一下,我們只需要知道AsyncLocal底層是通過ExecutionContext實現的,每次設置Value時都會用新的Context對象來覆蓋原有的,代碼如下所示(有刪減)。

public sealed class AsyncLocal<T> : IAsyncLocal
{
    public T Value
    {
        [SecuritySafeCritical]
        get
        {
            // 從ExecutionContext中獲取當前線程的值
            object obj = ExecutionContext.GetLocalValue(this);
            return (obj == null) ? default(T) : (T)obj;
        }
        [SecuritySafeCritical]
        set
        {
            // 設置值 
            ExecutionContext.SetLocalValue(this, value, m_valueChangedHandler != null);
        }
    }
}

......
public sealed class ExecutionContext : IDisposable, ISerializable
{
	internal static void SetLocalValue(IAsyncLocal local, object newValue, bool needChangeNotifications)
	{
		var current = Thread.CurrentThread.GetMutableExecutionContext();

		object previousValue = null;

		if (previousValue == newValue)
			return;

		var newValues = current._localValues;
        // 無論是AsyncLocalValueMap.Create 還是 newValues.Set 
        // 都會創建一個新的IAsyncLocalValueMap對象來覆蓋原來的值
		if (newValues == null)
		{
			newValues = AsyncLocalValueMap.Create(local, newValue, treatNullValueAsNonexistent: !needChangeNotifications);
		}
		else
		{
			newValues = newValues.Set(local, newValue, treatNullValueAsNonexistent: !needChangeNotifications);
		}
		current._localValues = newValues;
        ......
	}
}

接下來我們需要避開await/async語法糖的影響,反編譯一下IL代碼,使用C# 1.0來重新組織代碼(使用ilspy或者dnspy之類都可以)。

可以看到原本的語法糖已經被拆解成stackless狀態機,這裡我們重點關注Start方法。進入Start方法內部,我們可以看到以下代碼,源碼鏈接

......
// Start方法
public static void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
    if (stateMachine == null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.stateMachine);
    }

    Thread currentThread = Thread.CurrentThread;
    // 備份當前線程的 executionContext
    ExecutionContext? previousExecutionCtx = currentThread._executionContext;
    SynchronizationContext? previousSyncCtx = currentThread._synchronizationContext;

    try
    {
        // 執行狀態機
        stateMachine.MoveNext();
    }
    finally
    {
        if (previousSyncCtx != currentThread._synchronizationContext)
        {
            // Restore changed SynchronizationContext back to previous
            currentThread._synchronizationContext = previousSyncCtx;
        }

        ExecutionContext? currentExecutionCtx = currentThread._executionContext;
        // 如果executionContext發生變化,那麼調用RestoreChangedContextToThread方法還原
        if (previousExecutionCtx != currentExecutionCtx)
        {
            ExecutionContext.RestoreChangedContextToThread(currentThread, previousExecutionCtx, currentExecutionCtx);
        }
    }
}
......
// 調用RestoreChangedContextToThread方法
internal static void RestoreChangedContextToThread(Thread currentThread, ExecutionContext? contextToRestore, ExecutionContext? currentContext)
{
    Debug.Assert(currentThread == Thread.CurrentThread);
    Debug.Assert(contextToRestore != currentContext);

    // 將改變後的ExecutionContext恢復到之前的狀態
    currentThread._executionContext = contextToRestore;
    ......
}


通過上面的代碼我們就不難看出,為什麼會存在這樣的問題了,是因為狀態機的Start方法會備份當前線程的ExecuteContext,如果ExecuteContext在狀態機內方法調用時發生了改變,那麼就會還原回去。
又因為上文提到的AsyncLocal底層實現是ExecuteContext,每次SetValue時都會生成一個新的IAsyncLocalValueMap對象覆蓋當前的ExecuteContext,必然修改就會被還原回去了。

配圖-await和Asynclocal原因解析

ASP.NET Core的解決方案

在ASP.NET Core中,解決這個問題的方法也很巧妙,就是簡單的包了一層。我們也可以簡單的包一層對象。

public class ContextHolder
{ 
	public string Context {get;set;}
}

public class ContextAccessor
{
	static AsyncLocal<ContextHolder> _contextCurrent = new AsyncLocal<ContextHolder>();

	public string Context
	{
		get => _contextCurrent.Value?.Context;
		set 
		{ 
			var holder = _contextCurrent.Value;
            // 拿到原來的holder 直接修改成新的value
            // asp.net core源碼是設置為null 因為在它的邏輯中執行到了這個Set方法
            // 就必然是一個新的http請求,需要把以前的清空
			if (holder != null) holder.Context = value;
            // 如果沒有holder 那麼新建
			else _contextCurrent.Value = new ContextHolder { Context = value};
		}
	}
}

最終結果就和我們預期的一致了,流程也如下圖一樣。自始至終都是修改的同一個ContextHolder對象。
解決問題

總結

由上可見,ASP.NET Core 6.0的HttpContextAccessor那樣設計的原因就是為了解決AsyncLocal在await環境中會發生複製,導致不能及時清除歷史的HttpContext的問題。
筆者水平有限,如果錯漏,歡迎指出,感謝各位的閱讀!

附錄

ASP.NET Core 2.1 HttpContextAccessor源碼:link
ASP.NET Core 6.0 HttpContextAccessor源碼:link
AsyncMethod Start方法源碼: link
AsyncLocal源碼:link