最近在分析現在團隊的項目代碼(基於.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博客)