為什麼HttpContextAccessor要這麼設計?
前言
週五在群裡面有小夥伴問,ASP.NET Core這個HttpContextAccessor
為什麼改成了這個樣子?
在印象中,這已經是第三次遇到有小夥伴問這個問題了,特意來寫一篇記錄,來回答一下這個問題。
聊一聊歷史
關於HttpContext
其實我們大家都不陌生,它封裝了HttpRequest
和HttpResponse
,在處理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設定的物件了。
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/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呢?
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
,必然修改就會被還原回去了。
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
轉 https://www.cnblogs.com/InCerry/p/Why-The-Design-HttpContextAccessor.html