多線程下的調用上下文 : 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博客)

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章