为什么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
为什么HttpContextAccessor要这么设计?的更多相关文章
- 【转】.NET(C#):浅谈程序集清单资源和RESX资源  关于单元测试的思考--Asp.Net Core单元测试最佳实践  封装自己的dapper lambda扩展-设计篇  编写自己的dapper lambda扩展-使用篇  正确理解CAP定理  Quartz.NET的使用(附源码)  整理自己的.net工具库  GC的前世与今生  Visual Studio Package 插件开发之自动生
		
[转].NET(C#):浅谈程序集清单资源和RESX资源 目录 程序集清单资源 RESX资源文件 使用ResourceReader和ResourceSet解析二进制资源文件 使用ResourceM ...
 - 全新升级的AOP框架Dora.Interception[6]: 框架设计和实现原理
		
本系列前面的五篇文章主要介绍Dora.Interception(github地址,觉得不错不妨给一颗星)的编程模式以及对它的扩展定制,现在我们来聊聊它的设计和实现原理.(拙著<ASP.NET C ...
 - 如何一步一步用DDD设计一个电商网站(九)—— 小心陷入值对象持久化的坑
		
阅读目录 前言 场景1的思考 场景2的思考 避坑方式 实践 结语 一.前言 在上一篇中(如何一步一步用DDD设计一个电商网站(八)—— 会员价的集成),有一行注释的代码: public interfa ...
 - 如何一步一步用DDD设计一个电商网站(八)—— 会员价的集成
		
阅读目录 前言 建模 实现 结语 一.前言 前面几篇已经实现了一个基本的购买+售价计算的过程,这次再让售价丰满一些,增加一个会员价的概念.会员价在现在的主流电商中,是一个不大常见的模式,其带来的问题是 ...
 - 设计爬虫Hawk背后的故事
		
本文写于圣诞节北京下午慵懒的午后.本文偏技术向,不过应该大部分人能看懂. 五年之痒 2016年,能记入个人年终总结的事情没几件,其中一个便是开源了Hawk.我花不少时间优化和推广它,得到的评价还算比较 ...
 - 如何一步一步用DDD设计一个电商网站(十)—— 一个完整的购物车
		
阅读目录 前言 回顾 梳理 实现 结语 一.前言 之前的文章中已经涉及到了购买商品加入购物车,购物车内购物项的金额计算等功能.本篇准备把剩下的购物车的基本概念一次处理完. 二.回顾 在动手之前我对之 ...
 - 如何一步一步用DDD设计一个电商网站(一)—— 先理解核心概念
		
一.前言 DDD(领域驱动设计)的一些介绍网上资料很多,这里就不继续描述了.自己使用领域驱动设计摸滚打爬也有2年多的时间,出于对知识的总结和分享,也是对自我理解的一个公开检验,介于博客园这个平 ...
 - 如何一步一步用DDD设计一个电商网站(七)—— 实现售价上下文
		
阅读目录 前言 明确业务细节 建模 实现 结语 一.前言 上一篇我们已经确立的购买上下文和销售上下文的交互方式,传送门在此:http://www.cnblogs.com/Zachary-Fan/p/D ...
 - 如何一步一步用DDD设计一个电商网站(六)—— 给购物车加点料,集成售价上下文
		
阅读目录 前言 如何在一个项目中实现多个上下文的业务 售价上下文与购买上下文的集成 结语 一.前言 前几篇已经实现了一个最简单的购买过程,这次开始往这个过程中增加一些东西.比如促销.会员价等,在我们的 ...
 
随机推荐
- git 多人在同一分支上迭代开发时,如何保证分支提交历史保持线性
			
背景 最近我们组几个同事都投入到了一个新项目,互相之间的功能耦合比较紧密,因此,是打算从master上新拉一个分支,可以理解为我们几个人的开发分支,以develop代替. 一开始,我们是打算像svn那 ...
 - 半吊子菜鸟学Web开发 -- PHP学习 4 --异常
			
PHP异常处理 1 抛出一个异常 与Python的try except类似,PHP用try catch来捕获异常 基本语法 try{ //可能出现错误或异常的代码 //catch表示捕获,Except ...
 - Spring cache源码分析
			
Spring cache是一个缓存API层,封装了对多种缓存的通用操作,可以借助注解方便地为程序添加缓存功能. 常见的注解有@Cacheable.@CachePut.@CacheEvict,有没有想过 ...
 - @Required  注解?
			
这个注解表明bean的属性必须在配置的时候设置,通过一个bean定义的显式的属性值或通过自动装配,若@Required注解的bean属性未被设置,容器将抛出BeanInitializationExce ...
 - SpringBoot DevTools 的用途是什么?
			
SpringBoot 开发者工具,或者说 DevTools,是一系列可以让开发过程变得简便的工具.为了引入这些工具,我们只需要在 POM.xml 中添加如下依赖: 1 <dependency&g ...
 - Correct the classpath of your application so that it contains a single, compatible version of org.springframework.util.Assert
			
一.问题描述 今天启动springboot工程时,报上面的错误. 二.解决方法 加入如下pom: <dependency> <groupId>org.springframewo ...
 - phpstorm 快捷生成函数
			
在函数上一行键入 /** /** * @param $a * @param $b * @return mixed */ function abc($a, $b) { $c = $a + $b; ret ...
 - 8_LQR 控制器_状态空间系统Matlab/Simulink建模分析
			
再线性控制器中讲到: 举例说明(线性控制器中的一个例子)博客中有说明 在matlab中:使用lqr求解K1.K2 这里希望角度(即x1)能迅速变化,所以Q矩阵中Q11为100,并没有关心角速度(dot ...
 - 一个关于小程序与单片机的通信实例(TCP/IP)
			
前言 这是一个18年初的创业项目的核心功能要求,我们当时打算做一个共享类的项目,项目的主题是共享图书,线下的形式租借图书,我们当时是考虑做一个借书柜的形式,然后线下生产投放借书柜,这些借书柜本身能存放 ...
 - Web 开发中 Blob 与 FileAPI 使用简述
			
本文节选自 Awesome CheatSheet/DOM CheatSheet,主要是对 DOM 操作中常见的 Blob.File API 相关概念进行简要描述. Web 开发中 Blob 与 Fil ...