.NetCore Csharp 小笨蛋 发布于:2021年06月26日 更新于:2021年06月26日 151

最近在分析现在团队的项目代码(基于.NET Framework 4.5),经常发现一个CallContext的调用,记得多年前的时候用到了它,但是印象已经不深刻了,于是现在来复习一下。

CallContext是个啥?

如果说,一个对象保证全局唯一,大家肯定会想到一个经典的设计模式:单例模式。但是,如果要使用的对象必须是线程内唯一的呢?

在.NET Framework中,Microsoft给我们设计了一个CallContext类。

  • 命名空间:System.Runtime.Remoting.Messaging
  • 类型完全限定名称:System.Runtime.Remoting.Messaging.CallContext

CallContext类似于方法调用的线程本地存储区的专用集合对象,并提供对每个逻辑执行线程都唯一的数据槽。数据槽不在其他逻辑线程上的调用上下文之间共享。当 CallContext 沿执行代码路径往返传播并且由该路径中的各个对象检查时,可将对象添加到其中。

简而言之,CallContext提供线程(多线程/单线程)代码执行路径中数据传递的能力。

方法 描述 线程安全
SetData 存储给定的对象并将其与指定名称关联。
GetData 从System.Runtime.Remoting.Messaging.CallContext中检索具有指定名称的对象
LogicalSetData 将给定的对象存储在逻辑调用上下文,并将其与指定名称关联。
LogicalGetData 从逻辑调用上下文中检索具有指定名称的对象。
FreeNamedDataSlot 清空具有指定名称的数据槽。
HostContext 获取或设置与当前线程相关联的主机上下文。在Web环境下等于System.Web.HttpContext.Current -

探究CallContext方法

上面介绍了CallContext提供的核心方法,下面我们就来通过实践来理解一下。

准备工作

这里准备一个User类作为数据传递对象:

  1. public class User
  2. {
  3.   public string Id { get; set; }
  4.   public string Name { get; set; }
  5. }

测试1:GetData、SetData 与 FreeNamedDataSlot
测试代码很简单,就是在主线程 和 子线程之中分别传递User对象实例,看看最后的效果。

  1. public void TestGetSetData()
  2. {
  3. // 主线程执行
  4. Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
  5. var user = new User()
  6. {
  7. Id = DateTime.Now.ToString(),
  8. Name = "代码片段CodeSnippet.cn"
  9. };
  10. CallContext.SetData("key", user);
  11. var value1 = CallContext.GetData("key");
  12. Console.WriteLine(user == value1);
  13. // 异步线程执行
  14. Task.Run(() =>
  15. {
  16. Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
  17. var value2 = CallContext.GetData("key");
  18. Console.WriteLine(value2 == null ?
  19. "NULL" : (value2 == value1).ToString());
  20. });
  21. // 主线程执行
  22. Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
  23. value1 = CallContext.GetData("key");
  24. Console.WriteLine(value1 == user);
  25. // 清理数据槽
  26. CallContext.FreeNamedDataSlot("key");
  27. var value3 = CallContext.GetData("key");
  28. Console.WriteLine(value3 == null ?
  29. "NULL" : (value3 == value1).ToString());
  30. }

上面示例代码的运行结果如下图所示:

  1. //主线程
  2. Current ThreadId=1
  3. True
  4. Current ThreadId=1
  5. True
  6. NULL
  7. //子线程
  8. Current ThreadId=3
  9. NULL

根据上面所示的结果,基本可以得出以下两个结论:

  1. GetDataSetData方法只能用于单线程环境,如果发生了线程切换,存储的数据也会随之丢失。
  2. GetDataSetData 可以用于同一线程中的不同地方,传递数据。

可以知道,要在多线程环境下使用,我们需要用到另外两个方法:LogicalSetDataLogicalGetData

测试2:LogicalGetData、LogicalSetData 与 FreeNamedDataSlot

  1. public void TestLogicalGetSetData()
  2. {
  3. // 主线程执行
  4. Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
  5. var user = new User()
  6. {
  7. Id = DateTime.Now.ToString(),
  8. Name = "代码片段CodeSnippet.cn"
  9. };
  10. CallContext.LogicalSetData("key", user);
  11. var value1 = CallContext.LogicalGetData("key");
  12. Console.WriteLine(user == value1);
  13. // 异步线程执行
  14. Task.Run(() =>
  15. {
  16. Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
  17. var value2 = CallContext.LogicalGetData("key");
  18. Console.WriteLine(value2 == null ?
  19. "NULL" : (value2 == value1).ToString());
  20. Thread.Sleep(1000);
  21. value2 = CallContext.LogicalGetData("key");
  22. Console.WriteLine(value2 == null ?
  23. "NULL" : (value2 == value1).ToString());
  24. });
  25. // 主线程执行
  26. Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
  27. // 清理数据槽
  28. CallContext.FreeNamedDataSlot("key");
  29. var value3 = CallContext.LogicalGetData("key");
  30. Console.WriteLine(value3 == null ?
  31. "NULL" : (value3 == value1).ToString());
  32. }

这段示例代码的运行结果如下面所示:

  1. Current ThreadId=1
  2. True
  3. Current ThreadId=1
  4. NULL
  5. //不影响
  6. Current ThreadId=3
  7. True
  8. True

根据上面所示的结果,基本可以得出以下三个结论:

  1. FreeNamedDataSlot只能清除当前线程的数据槽,不能清除子线程的数据槽;
  2. LogicalSetDataLogicalGetData可用于在多线程环境下传递数据
  3. FreeNamedDataSlot清除当前线程的数据槽后,之前已经运行的子任务,不受影响

测试3:LogicalGetData后修改传递的数据
在多线程环境下传递共享对象数据,如果某个线程通过LogicalGetData后对其进行了修改又重新LogicalSetData会怎样?

  1. public static void TestLogicalGetSetDataV2()
  2. {
  3. // 主线程执行 CodeSnippet.cn
  4. Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
  5. var user = new User()
  6. {
  7. Id = DateTime.Now.ToString(),
  8. Name = "代码片段CodeSnippet.cn"
  9. };
  10. CallContext.LogicalSetData("key", user);
  11. var value1 = CallContext.LogicalGetData("key");
  12. Console.WriteLine(user == value1);
  13. // 异步线程同步执行:加了.Wait()
  14. Task.Run(() =>
  15. {
  16. Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
  17. var value2 = CallContext.LogicalGetData("key");
  18. Console.WriteLine(value2 == null ?
  19. "NULL" : (value2 == value1).ToString());
  20. CallContext.FreeNamedDataSlot("key");
  21. value2 = CallContext.LogicalGetData("key");
  22. Console.WriteLine(value2 == null ?
  23. "NULL" : (value2 == value1).ToString());
  24. }).Wait();
  25. // 异步线程同步执行:加了.Wait()
  26. Task.Run(() =>
  27. {
  28. Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
  29. var value2 = CallContext.LogicalGetData("key") as User;
  30. Console.WriteLine(value2 == null ?
  31. "NULL" : (value2 == value1).ToString());
  32. value2.Name = "片段";
  33. CallContext.LogicalSetData("key", new User() { Id = DateTime.Now.ToString(), Name = "代码" }); // 只影响当前线程
  34. value2 = CallContext.LogicalGetData("key") as User;
  35. Console.WriteLine(value2 == null ?
  36. "NULL" : (value2 == value1).ToString());
  37. Console.WriteLine($"User.Name={value2.Name}");
  38. }).Wait();
  39. // 主线程执行
  40. Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
  41. var value3 = CallContext.LogicalGetData("key") as User;
  42. Console.WriteLine(value3 == null ?
  43. "NULL" : (value3 == value1).ToString());
  44. Console.WriteLine($"User.Name={value3.Name}");
  45. }

上面示例代码的运行结果如下面所示:

  1. Current ThreadId=1
  2. True
  3. Current ThreadId=3
  4. True
  5. NULL
  6. Current ThreadId=3
  7. True
  8. False
  9. User.Name=代码
  10. Current ThreadId=1
  11. True
  12. //子线程修改后,父线程读到了
  13. User.Name=片段

根据上面的示例运行结果,我们又可以得到以下一些结论:

  1. FreeNamedDataSlot只能清除当前线程的数据槽;
  2. LogicalSetData只会存储当前线程以及子线程的数据槽
  3. LogicalGetData获取的是当前线程或父线程的数据槽对象,拿到的是对象的引用,因此如果对其进行修改,会影响父线程读取的一致性,在关系型数据库中也被称为不可重复读。
  4. 子线程中使用LogicalSetData改变数据槽的值,不会影响父线程的数据槽,即使他们的key是同一个

.NET Core下没有CallContext

在.NET Core下没有CallContext类,取而代之的是使用AsyncLocal代替,实现的是CallContext.LogicalGetDataCallContext.SetLogicalCallContext

例如,下面是一个示例代码,我们可以借助AsyncLocal来自己实现一个CallContext类。如果你是将.NET Framework升级为.NET Core,那么你可能需要自己实现一个CallContext类来代替之前的CallContext

  1. public static class CallContext
  2. {
  3. static ConcurrentDictionary<string, AsyncLocal<object>> state = new ConcurrentDictionary<string, AsyncLocal<object>>();
  4. public static void SetData(string name, object data) =>
  5. state.GetOrAdd(name, _ => new AsyncLocal<object>()).Value = data;
  6. public static object GetData(string name) =>
  7. state.TryGetValue(name, out AsyncLocal<object> data) ? data.Value : null;
  8. }

EF DbContext场景

对于像UnitOfWork这种操作模式,是比较适合于CallContext发挥的地方,让EF DbContext在线程上下文内保持唯一。

注意:这里提到的EF均指EF 而非 EF Core。

因此,我们经常可以看到如下所示的示例代码:

  1. public class DbContextFactory
  2. {
  3. public static DbContext CreateDbContext()
  4. {
  5. DbContext dbContext = (DbContext)CallContext.GetData("dbContext");
  6. if (dbContext == null)
  7. {
  8. dbContext = new WebAppEntities();
  9. CallContext.SetData("dbContext", dbContext);
  10. }
  11. return dbContext;
  12. }
  13. }

此用法像极了 Cache(缓存)的使用。

But,鉴于目前广泛使用线程池的前提,线程在处理完一个请求之后,并没有被销毁,存储在CallContext中的上下文对象也一直存在,如果是下一次拿出这个线程去处理另一个请求,这个上下文对象其实也在不断的膨胀,只不过比全局的膨胀的稍微慢一些。而且,有时候一个线程并不一定是拿去处理请求了,如果是服务器拿去处理其他的业务,那就可能引发一些其他的问题。

这时,或许我们可以考虑另一个方案,在ASP.NET中的HttpContext中有一个Items属性,它也可以用来保存key-value,这就完美了,一次请求正好对应着一个HttpContext,请求结束,它自动释放,EF上下文也就不存在了。

因此,这里把上面代码中的CallContext改为HttpContext.Current.Items

  1. public class DbContextFactory
  2. {
  3. public static DbContext CreateDbContext()
  4. {
  5. DbContext dbContext = HttpContext.Current.Items["dbContext"] as DbContext;
  6. if (dbContext == null)
  7. {
  8. dbContext = new WebAppEntities();
  9. HttpContext.Current.Items["dbContext"] = dbContext;
  10. }
  11. return dbContext;
  12. }
  13. }

其实,HttpContext这个类和CallContext是有关联的,查看源码我们可以发现:HttpContext.Current是通过CallContext.HostContext实现的。

  1. internal static Object Current {
  2. get {
  3. return CallContext.HostContext;
  4. }
  5. [SecurityPermission(SecurityAction.Demand, Unrestricted = true)]
  6. set {
  7. CallContext.HostContext = value;
  8. }
  9. }

关于HttpContext.Current:ASP.NET会为每个请求分配一个线程,这个线程会执行我们的代码来生成响应结果, 即使我们的代码散落在不同的地方(类库),线程仍然会执行它们。所以,我们可以在任何地方访问HttpContext.Current获取到与当前请求相关HttpContext对象,毕竟这些代码是由同一个线程来执行的嘛,所以得到的HttpContext引用也就是那个与请求相关的对象。因此,将HttpContext.Current设计成与当前线程相关联是合适的。有关CallContext.HostContext的知识可以自行查阅资料,这里就不再赘述。

刚刚提到UnitOfWork模式,我们完成了DbContext的线程上下文内的唯一性,那么SaveChanges呢?嗯,我们可以基于之前的唯一性保证,来写一个SaveChanges的唯一入口。

  1. public class DbSession
  2. {
  3. public static int SaveChanges()
  4. {
  5. return DbContextFactory.GetDbContext().SaveChanges();
  6. }
  7. }

总结

本文简单介绍了CallContext类的基本概念、方法,做了一些测试验证了其提供的方法的适用范围和限制。

如果我们需要在.NET代码中向下传递对象,除了层层递进的传递参数之外,适时使用CallContext是一个不错的解耦的方案。

这里⇓感觉得写点什么,要不显得有点空,但还没想好写什么... 返回顶部
About 京ICP备13038605号 © 代码片段 2025