1. 程式人生 > 其它 >多執行緒下的呼叫上下文 : CallContext

多執行緒下的呼叫上下文 : CallContext

最近在分析現在團隊的專案程式碼(基於.NET Framework 4.5),經常發現一個CallContext的呼叫,記得多年前的時候用到了它,但是印象已經不深刻了,於是現在來複習一下。

1 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

2 探究CallContext方法

上面介紹了CallContext提供的核心方法,下面我們就來通過實踐來理解一下。

準備工作

這裡準備一個User類作為資料傳遞物件:

public class User
{
  public string Id { get; set; }

  public string Name { get; set; }
}

測試1:GetData、SetData 與 FreeNamedDataSlot

測試程式碼很簡單,就是在主執行緒 和 子執行緒之中分別傳遞User物件例項,看看最後的效果。

public void TestGetSetData()
{
    // 主執行緒執行
    Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
    var user = new User()
    {
        Id = DateTime.Now.ToString(),
        Name = "Edison"
    };
    CallContext.SetData("key", user);
    var value1 = CallContext.GetData("key");
    Console.WriteLine(user == value1);

    // 非同步執行緒執行
    Task.Run(() =>
    {
        Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
        var value2 = CallContext.GetData("key");
        Console.WriteLine(value2 == null ?
            "NULL" : (value2 == value1).ToString());
    });

    // 主執行緒執行
    Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
    value1 = CallContext.GetData("key");
    Console.WriteLine(value1 == user);

    // 清理資料槽
    CallContext.FreeNamedDataSlot("key");
    var value3 = CallContext.GetData("key");
    Console.WriteLine(value3 == null ?
            "NULL" : (value3 == value1).ToString());
}

上面示例程式碼的執行結果如下圖所示:

根據上圖所示的結果,基本可以得出以下兩個結論:

1、GetData、SetData方法只能用於單執行緒環境,如果發生了執行緒切換,儲存的資料也會隨之丟失。

2、GetData 和 SetData 可以用於同一執行緒中的不同地方,傳遞資料

可以知道,要在多執行緒環境下使用,我們需要用到另外兩個方法:LogicalSetData 與 LogicalGetData。

測試2:LogicalGetData、LogicalSetData 與 FreeNamedDataSlot

測試程式碼如下:

public void TestLogicalGetSetData()
{
    // 主執行緒執行
    Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
    var user = new User()
    {
        Id = DateTime.Now.ToString(),
        Name = "Edison"
    };
    CallContext.LogicalSetData("key", user);
    var value1 = CallContext.LogicalGetData("key");
    Console.WriteLine(user == value1);

    // 非同步執行緒執行
    Task.Run(() =>
    {
        Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
        var value2 = CallContext.LogicalGetData("key");
        Console.WriteLine(value2 == null ?
            "NULL" : (value2 == value1).ToString());

        Thread.Sleep(1000);

        value2 = CallContext.LogicalGetData("key");
        Console.WriteLine(value2 == null ?
            "NULL" : (value2 == value1).ToString());
    });

    // 主執行緒執行
    Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
    // 清理資料槽
    CallContext.FreeNamedDataSlot("key");
    var value3 = CallContext.LogicalGetData("key");
    Console.WriteLine(value3 == null ?
            "NULL" : (value3 == value1).ToString());
}

這段示例程式碼的執行結果如下圖所示:

根據上圖所示的結果,基本可以得出以下三個結論:

1、FreeNamedDataSlot只能清除當前執行緒的資料槽,不能清除子執行緒的資料槽;

2、LogicalSetData、LogicalGetData可用於在多執行緒環境下傳遞資料

3、FreeNamedDataSlot清除當前執行緒的資料槽後,之前已經執行的子任務,不受影響

測試3:LogicalGetData後修改傳遞的資料

在多執行緒環境下傳遞共享物件資料,如果某個執行緒通過LogicalGetData後對其進行了修改又重新LogicalSetData會怎樣?

public void TestLogicalGetSetDataV2()
{
    // 主執行緒執行
    Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
    var user = new User()
    {
        Id = DateTime.Now.ToString(),
        Name = "Edison"
    };
    CallContext.LogicalSetData("key", user);
    var value1 = CallContext.LogicalGetData("key");
    Console.WriteLine(user == value1);

    // 非同步執行緒同步執行:加了.Wait()
    Task.Run(() =>
    {
        Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
        var value2 = CallContext.LogicalGetData("key");
        Console.WriteLine(value2 == null ?
            "NULL" : (value2 == value1).ToString());

        CallContext.FreeNamedDataSlot("key");

        value2 = CallContext.LogicalGetData("key");
        Console.WriteLine(value2 == null ?
            "NULL" : (value2 == value1).ToString());
    }).Wait();

    // 非同步執行緒同步執行:加了.Wait()
    Task.Run(() =>
    {
        Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
        var value2 = CallContext.LogicalGetData("key") as User;
        Console.WriteLine(value2 == null ?
            "NULL" : (value2 == value1).ToString());

        value2.Name = "Leo";

        CallContext.LogicalSetData("key", new User() { Id = DateTime.Now.ToString(), Name = "Jack" }); // 隻影響當前執行緒
        value2 = CallContext.LogicalGetData("key") as User;
        Console.WriteLine(value2 == null ?
            "NULL" : (value2 == value1).ToString());
        Console.WriteLine($"User.Name={value2.Name}");
    }).Wait();

    // 主執行緒執行
    Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");
    var value3 = CallContext.LogicalGetData("key") as User;
    Console.WriteLine(value3 == null ?
            "NULL" : (value3 == value1).ToString());
    Console.WriteLine($"User.Name={value3.Name}");
}

上面示例程式碼的執行結果如下圖所示:

根據上面的示例執行結果,我們又可以得到以下一些結論:

1、FreeNamedDataSlot只能清除當前執行緒的資料槽

2、LogicalSetData只會儲存當前執行緒以及子執行緒的資料槽

3、LogicalGetData獲取的是當前執行緒或父執行緒的資料槽物件,拿到的是物件的引用,因此如果對其進行修改,會影響父執行緒讀取的一致性,在關係型資料庫中也被稱為不可重複讀。

4、子執行緒中使用LogicalSetData改變資料槽的值,不會影響父執行緒的資料槽,即使他們的key是同一個

3 .NET Core下沒有CallContext

在.NET Core下沒有CallContext類,取而代之的是使用AsyncLocal代替,實現的是CallContext.LogicalGetData 和 CallContext.SetLogicalCallContext。

例如,下面是一個示例程式碼,我們可以藉助AsyncLocal來自己實現一個CallContext類。如果你是將.NET Framework升級為.NET Core,那麼你可能需要自己實現一個CallContext類來代替之前的CallContext:

public static class CallContext
{
    static ConcurrentDictionary<string, AsyncLocal<object>> state = new ConcurrentDictionary<string, AsyncLocal<object>>();

    public static void SetData(string name, object data) =>
        state.GetOrAdd(name, _ => new AsyncLocal<object>()).Value = data;

    public static object GetData(string name) =>
        state.TryGetValue(name, out AsyncLocal<object> data) ? data.Value : null;
}

4 EF DbContext場景

對於像UnitOfWork這種操作模式,是比較適合於CallContext發揮的地方,讓EF DbContext線上程上下文內保持唯一。

注意:這裡提到的EF均指EF 而非 EF Core。

因此,我們經常可以看到如下所示的示例程式碼:

public class DbContextFactory
{
   public static DbContext CreateDbContext()
   {
      DbContext dbContext = (DbContext)CallContext.GetData("dbContext");
      if (dbContext == null)
      {
         dbContext = new WebAppEntities();
         CallContext.SetData("dbContext", dbContext);
      }
      return dbContext;
   }
}

此用法像極了 Cache(快取)的使用。

But,鑑於目前廣泛使用執行緒池的前提,執行緒在處理完一個請求之後,並沒有被銷燬,儲存在CallContext中的上下文物件也一直存在,如果是下一次拿出這個執行緒去處理另一個請求,這個上下文物件其實也在不斷的膨脹,只不過比全域性的膨脹的稍微慢一些。而且,有時候一個執行緒並不一定是拿去處理請求了,如果是伺服器拿去處理其他的業務,那就可能引發一些其他的問題。

這時,或許我們可以考慮另一個方案,在ASP.NET中的HttpContext中有一個Items屬性,它也可以用來儲存key-value,這就完美了,一次請求正好對應著一個HttpContext,請求結束,它自動釋放,EF上下文也就不存在了。

因此,這裡把上面程式碼中的CallContext改為HttpContext.Current.Items:

public class DbContextFactory
{
   public static DbContext CreateDbContext()
   {
      DbContext dbContext = HttpContext.Current.Items["dbContext"] as DbContext;
      if (dbContext == null)
      {
         dbContext = new WebAppEntities();
         HttpContext.Current.Items["dbContext"] = dbContext;
      }
      return dbContext;
   }
}

其實,HttpContext這個類和CallContext是有關聯的,檢視原始碼我們可以發現:HttpContext.Current是通過CallContext.HostContext實現的。

internal static Object Current {
   get {
     return CallContext.HostContext;
   }
 
   [SecurityPermission(SecurityAction.Demand, Unrestricted = true)]
   set {
     CallContext.HostContext = value;
   }
}

關於HttpContext.Current:ASP.NET會為每個請求分配一個執行緒,這個執行緒會執行我們的程式碼來生成響應結果, 即使我們的程式碼散落在不同的地方(類庫),執行緒仍然會執行它們。所以,我們可以在任何地方訪問HttpContext.Current獲取到與當前請求相關的HttpContext物件,畢竟這些程式碼是由同一個執行緒來執行的嘛,所以得到的HttpContext引用也就是那個與請求相關的物件。因此,將HttpContext.Current設計成與當前執行緒相關聯是合適的。有關CallContext.HostContext的知識可以自行查閱資料,這裡就不再贅述。

剛剛提到UnitOfWork模式,我們完成了DbContext的執行緒上下文內的唯一性,那麼SaveChanges呢?嗯,我們可以基於之前的唯一性保證,來寫一個SaveChanges的唯一入口。

public class DbSession
{
  public static int SaveChanges()
  {
    return DbContextFactory.GetDbContext().SaveChanges();
  }
}

5 總結

本文簡單介紹了CallContext類的基本概念、方法,做了一些測試驗證了其提供的方法的適用範圍和限制。

如果我們需要在.NET程式碼中向下傳遞物件,除了層層遞進的傳遞引數之外,適時使用CallContext是一個不錯的解耦的方案。

參考資料

Microsoft Doc,CallContext

.NET原始碼,https://referencesource.microsoft.com/#System.Web/HttpContext.cs

雯海,.NET多執行緒之CallContext(cnblogs部落格)

Koma,EF上下文物件執行緒內唯一性與優化(csdn部落格)

作者:周旭龍

出處:https://edisonchou.cnblogs.com

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連結。