CodeSnippet.Cn
代码片段
Csharp
架构设计
.NetCore
西班牙语
kubernetes
MySql
Redis
Algorithm
Ubuntu
Linux
Other
.NetMvc
VisualStudio
Git
pm
Python
WPF
java
Plug-In
分布式
CSS
微服务架构
JavaScript
DataStructure
Shared
为什么HttpContextAccessor要这么设计?
0
.NetCore
小笨蛋
发布于:2022年04月25日
更新于:2022年04月25日
121
#custom-toc-container
ASP.NET Core这个HttpContextAccessor为什么改成了这个样子? ![图片alt](/uploads/images/20220425/160711-eb4dc78cbdc4449e8127c59b14f3e0f0.png ''代码片段:Www.CodeSnippet.Cn'') ### 聊一聊历史 关于`HttpContext`其实我们大家都不陌生,它封装了`HttpRequest`和`HttpResponse`,在处理Http请求时,起着至关重要的作用。 ### CallContext时代 那么如何访问`HttpContext`对象呢?回到`await/async`出现以前的ASP.NET的时代,我们可以通过`HttpContext.Current`方法直接访问当前Http请求的`HttpContext`对象,因为当时基本都是同步的代码,一个Http请求只会在一个线程中处理,所以我们可以使用能在当前线程中传播的`CallContext.HostContext`来保存`HttpContext`对象,它的代码长这个样子。 ```csharp 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`对象,下面有一段这个问题简单的复现代码。 ```csharp // 设置当前线程HostContext CallContext.HostContext = new Dictionary
{ ["ContextKey"] = "ContextValue" }; // await前,可以正常访问 Console.Write($"[{Thread.CurrentThread.ManagedThreadId}] await before:"); Console.WriteLine(((Dictionary
)CallContext.HostContext)["ContextKey"]); await Task.Delay(100); // await后,切换了线程,无法访问 Console.Write($"[{Thread.CurrentThread.ManagedThreadId}] await after:"); Console.WriteLine(((Dictionary
)CallContext.HostContext)["ContextKey"]); ``` ![图片alt](/uploads/images/20220425/161117-36ae831d38bc4f33b98d3f99bb463cff.png ''代码片段:Www.CodeSnippet.Cn'') 可以看到`await`执行之前`HostContext`是可以正确的输出赋值的对象和数据,但是`await`以后的代码由于线程从**16**切换到**29**,所以访问不到上面代码给`HostContext`设置的对象了。 ![代码片段](/uploads/images/20220425/161411-ef3a3b01d3984c88b44468011680c04b.png) ### AsyncLocal时代 为了解决这个问题,微软在.NET 4.6中引入了`AsyncLocal
`类,后面重新设计的`ASP.NET Core`自然就用上了`AsyncLocal
`来存储当前Http请求的`HttpContext`对象,也就是开头截图的代码一样,我们来尝试一下。 ```csharp var asyncLocal = new AsyncLocal
>(); // 设置当前线程HostContext asyncLocal.Value = new Dictionary
{ ["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"]); ``` ![图片alt](/uploads/images/20220425/161504-fcbc12be7e4540b39015f8ff7903af76.png ''代码片段:Www.CodeSnippet.Cn'') 没有任何问题,线程从**16**切换到了**17**,一样的可以访问。简单的说就是`AsyncLocal`默认会将当前线程保存的上下对象在发生`await`的时候传播到后续的线程上。 ![](/uploads/images/20220425/161540-c05ce34c955c41ca9574a0adb19eb003.png ''代码片段:Www.CodeSnippet.Cn'') 这看起来就非常的美好了,既能开开心心的用`await/async`又不用担心上下文数据访问不到,那为什么`ASP.NET Core`的后续版本需要修改`HttpContextAccesor`呢?我们自己来实现`ContextAccessor`,大家看下面一段代码。 ```csharp // 给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
_contextCurrent = new AsyncLocal
(); public string Context { get => _contextCurrent.Value; set => _contextCurrent.Value = value; } } ``` ![图片alt](/uploads/images/20220425/161629-ad62e80a165141749202fe971c86f9df.png ''代码片段:Www.CodeSnippet.Cn'') 奇怪的事情就发生了,为什么明明在`Method`中把`Context`对象置为`null`了,`Method-3`中已经输出为`null`了,为啥在`Main-2`输出中还是`ContextValue`呢? ![](/uploads/images/20220425/161706-e59e6e0f0a5a43f981c377920b394124.png ''代码片段:Www.CodeSnippet.Cn'') ### AsyncLocal使用的问题 其实这已经解答了上面的问题,就是为什么在`ASP.NET Core 6.0`中的实现方式突然变了,有这样一种场景,已经当前线程中把`HttpContext`置空了,但是其它线程仍然能访问`HttpContext`对象,导致后续的行为可能不一致。 那为什么会造成这个问题呢?首先我们得知道`AsyncLocal`是如何实现的,这里我就不在赘述,详细可以看我前面给的链接(黑洞大佬的文章)。这里只简单的说一下,我们只需要知道`AsyncLocal`底层是通过`ExecutionContext`实现的,每次设置Value时都会用新的`Context`对象来覆盖原有的,代码如下所示(有删减)。 ```csharp public sealed class AsyncLocal
: 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`之类都可以)。 ![代码片段](/uploads/images/20220425/161818-8ac0e7b92ed948e69eaff37d18c98188.png) 可以看到原本的语法糖已经被拆解成`stackless`状态机,这里我们重点关注`Start`方法。进入`Start`方法内部,我们可以看到以下代码,[源码链接](https://github.com/dotnet/runtime/blob/e99fb185aa10ef177d19a51fd77b7a4b75db5395/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncMethodBuilderCore.cs#L21 "源码链接")。 ```csharp ...... // Start方法 public static void Start
(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`,必然修改就会被还原回去了。 ![代码片段](/uploads/images/20220425/161942-465f3d5d9c394192ba02fd1bf658658d.png) ### ASP.NET Core的解决方案 在`ASP.NET Core`中,解决这个问题的方法也很巧妙,就是简单的包了一层。我们也可以简单的包一层对象。 ```csharp public class ContextHolder { public string Context {get;set;} } public class ContextAccessor { static AsyncLocal
_contextCurrent = new AsyncLocal
(); 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}; } } } ``` ```csharp public class ContextHolder { public string Context {get;set;} } public class ContextAccessor { static AsyncLocal
_contextCurrent = new AsyncLocal
(); 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}; } } } ``` ![图片alt](/uploads/images/20220425/162105-a5839ae1042744c5acc95e6b546aece8.png ''代码片段:Www.CodeSnippet.Cn'') 最终结果就和我们预期的一致了,流程也如下图一样。自始至终都是修改的同一个`ContextHolder`对象。 ![代码片段](/uploads/images/20220425/162128-380c639652e6491982ba601f8d7501e5.png) ### 总结 由上可见,`ASP.NET Core 6.0`的`HttpContextAccessor`那样设计的原因就是为了解决`AsyncLocal`在`await`环境中会发生复制,导致不能及时清除历史的`HttpContext`的问题。 ### 附录 [ASP.NET Core 2.1 HttpContextAccessor源码](https://github.com/dotnet/aspnetcore/blob/v2.1.33/src/Http/Http/src/HttpContextAccessor.cs "ASP.NET Core 2.1 HttpContextAccessor源码") [ASP.NET Core 6.0 HttpContextAccessor源码](https://github.com/dotnet/aspnetcore/blob/v6.0.4/src/Http/Http/src/HttpContextAccessor.cs "ASP.NET Core 6.0 HttpContextAccessor源码") [AsyncMethod Start方法源码](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncMethodBuilderCore.cs#L21 "AsyncMethod Start方法源码") [AsyncLocal源码](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/AsyncLocal.cs "AsyncLocal源码")
这里⇓感觉得写点什么,要不显得有点空,但还没想好写什么...
返回顶部
About
京ICP备13038605号
© 代码片段 2024