.NET | 多線程下的調(diào)用上下文 : CallContext

最近在分析現(xiàn)在團(tuán)隊(duì)的項(xiàng)目代碼(基于.NET Framework 4.5),經(jīng)常發(fā)現(xiàn)一個(gè)CallContext的調(diào)用,記得多年前的時(shí)候用到了它,但是印象已經(jīng)不深刻了,于是現(xiàn)在來復(fù)習(xí)一下。
如果說,一個(gè)對(duì)象保證全局唯一,大家肯定會(huì)想到一個(gè)經(jīng)典的設(shè)計(jì)模式:?jiǎn)卫J?。但是,如果要使用的?duì)象必須是線程內(nèi)唯一的呢?
在.NET Framework中,Microsoft給我們?cè)O(shè)計(jì)了一個(gè)CallContext類。
命名空間:System.Runtime.Remoting.Messaging
類型完全限定名稱:System.Runtime.Remoting.Messaging.CallContext
CallContext類似于方法調(diào)用的線程本地存儲(chǔ)區(qū)的專用集合對(duì)象,并提供對(duì)每個(gè)邏輯執(zhí)行線程都唯一的數(shù)據(jù)槽。數(shù)據(jù)槽不在其他邏輯線程上的調(diào)用上下文之間共享。當(dāng) CallContext 沿執(zhí)行代碼路徑往返傳播并且由該路徑中的各個(gè)對(duì)象檢查時(shí),可將對(duì)象添加到其中。
簡(jiǎn)而言之,CallContext提供線程(多線程/單線程)代碼執(zhí)行路徑中數(shù)據(jù)傳遞的能力。
描述 | 線程安全 | |
SetData | 存儲(chǔ)給定的對(duì)象并將其與指定名稱關(guān)聯(lián)。 | 否 |
GetData | 從System.Runtime.Remoting.Messaging.CallContext中檢索具有指定名稱的對(duì)象 | 否 |
LogicalSetData | 將給定的對(duì)象存儲(chǔ)在邏輯調(diào)用上下文,并將其與指定名稱關(guān)聯(lián)。 | 是 |
LogicalGetData | 從邏輯調(diào)用上下文中檢索具有指定名稱的對(duì)象。 | 是 |
FreeNamedDataSlot | 清空具有指定名稱的數(shù)據(jù)槽。 | 是 |
HostContext | 獲取或設(shè)置與當(dāng)前線程相關(guān)聯(lián)的主機(jī)上下文。在Web環(huán)境下等于System.Web.HttpContext.Current |
上面介紹了CallContext提供的核心方法,下面我們就來通過實(shí)踐來理解一下。
準(zhǔn)備工作
這里準(zhǔn)備一個(gè)User類作為數(shù)據(jù)傳遞對(duì)象:
public class User{public string Id { get; set; }public string Name { get; set; }}
測(cè)試1:GetData、SetData 與 FreeNamedDataSlot
測(cè)試代碼很簡(jiǎn)單,就是在主線程 和 子線程之中分別傳遞User對(duì)象實(shí)例,看看最后的效果。
public void TestGetSetData(){// 主線程執(zhí)行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);// 異步線程執(zhí)行Task.Run(() =>{Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");var value2 = CallContext.GetData("key");Console.WriteLine(value2 == null ?"NULL" : (value2 == value1).ToString());});// 主線程執(zhí)行Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");value1 = CallContext.GetData("key");Console.WriteLine(value1 == user);// 清理數(shù)據(jù)槽CallContext.FreeNamedDataSlot("key");var value3 = CallContext.GetData("key");Console.WriteLine(value3 == null ?"NULL" : (value3 == value1).ToString());}
上面示例代碼的運(yùn)行結(jié)果如下圖所示:

根據(jù)上圖所示的結(jié)果,基本可以得出以下兩個(gè)結(jié)論:
1、GetData、SetData方法只能用于單線程環(huán)境,如果發(fā)生了線程切換,存儲(chǔ)的數(shù)據(jù)也會(huì)隨之丟失。
2、GetData 和 SetData 可以用于同一線程中的不同地方,傳遞數(shù)據(jù)。
可以知道,要在多線程環(huán)境下使用,我們需要用到另外兩個(gè)方法:LogicalSetData 與 LogicalGetData。
測(cè)試2:LogicalGetData、LogicalSetData 與 FreeNamedDataSlot
public void TestLogicalGetSetData(){// 主線程執(zhí)行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);// 異步線程執(zhí)行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());});// 主線程執(zhí)行Console.WriteLine($"Current ThreadId={Thread.CurrentThread.ManagedThreadId}");// 清理數(shù)據(jù)槽CallContext.FreeNamedDataSlot("key");var value3 = CallContext.LogicalGetData("key");Console.WriteLine(value3 == null ?"NULL" : (value3 == value1).ToString());}
這段示例代碼的運(yùn)行結(jié)果如下圖所示:

根據(jù)上圖所示的結(jié)果,基本可以得出以下三個(gè)結(jié)論:
1、FreeNamedDataSlot只能清除當(dāng)前線程的數(shù)據(jù)槽,不能清除子線程的數(shù)據(jù)槽;
2、LogicalSetData、LogicalGetData可用于在多線程環(huán)境下傳遞數(shù)據(jù);
3、FreeNamedDataSlot清除當(dāng)前線程的數(shù)據(jù)槽后,之前已經(jīng)運(yùn)行的子任務(wù),不受影響;
測(cè)試3:LogicalGetData后修改傳遞的數(shù)據(jù)
在多線程環(huán)境下傳遞共享對(duì)象數(shù)據(jù),如果某個(gè)線程通過LogicalGetData后對(duì)其進(jìn)行了修改又重新LogicalSetData會(huì)怎樣?
public void TestLogicalGetSetDataV2(){// 主線程執(zhí)行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);// 異步線程同步執(zhí)行:加了.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();// 異步線程同步執(zhí)行:加了.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" }); // 只影響當(dāng)前線程value2 = CallContext.LogicalGetData("key") as User;Console.WriteLine(value2 == null ?"NULL" : (value2 == value1).ToString());Console.WriteLine($"User.Name={value2.Name}");}).Wait();// 主線程執(zhí)行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}");}
上面示例代碼的運(yùn)行結(jié)果如下圖所示:

根據(jù)上面的示例運(yùn)行結(jié)果,我們又可以得到以下一些結(jié)論:
1、FreeNamedDataSlot只能清除當(dāng)前線程的數(shù)據(jù)槽
2、LogicalSetData只會(huì)存儲(chǔ)當(dāng)前線程以及子線程的數(shù)據(jù)槽;
3、LogicalGetData獲取的是當(dāng)前線程或父線程的數(shù)據(jù)槽對(duì)象,拿到的是對(duì)象的引用,因此如果對(duì)其進(jìn)行修改,會(huì)影響父線程讀取的一致性,在關(guān)系型數(shù)據(jù)庫(kù)中也被稱為不可重復(fù)讀。
4、子線程中使用LogicalSetData改變數(shù)據(jù)槽的值,不會(huì)影響父線程的數(shù)據(jù)槽,即使他們的key是同一個(gè);
在.NET Core下沒有CallContext類,取而代之的是使用AsyncLocal代替,實(shí)現(xiàn)的是CallContext.LogicalGetData 和 CallContext.SetLogicalCallContext。
例如,下面是一個(gè)示例代碼,我們可以借助AsyncLocal來自己實(shí)現(xiàn)一個(gè)CallContext類。如果你是將.NET Framework升級(jí)為.NET Core,那么你可能需要自己實(shí)現(xiàn)一個(gè)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;}
對(duì)于像UnitOfWork這種操作模式,是比較適合于CallContext發(fā)揮的地方,讓EF DbContext在線程上下文內(nèi)保持唯一。
注意:這里提到的EF均指EF 而非 EF Core。
因此,我們經(jīng)??梢钥吹饺缦滤镜氖纠a:
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,鑒于目前廣泛使用線程池的前提,線程在處理完一個(gè)請(qǐng)求之后,并沒有被銷毀,存儲(chǔ)在CallContext中的上下文對(duì)象也一直存在,如果是下一次拿出這個(gè)線程去處理另一個(gè)請(qǐng)求,這個(gè)上下文對(duì)象其實(shí)也在不斷的膨脹,只不過比全局的膨脹的稍微慢一些。而且,有時(shí)候一個(gè)線程并不一定是拿去處理請(qǐng)求了,如果是服務(wù)器拿去處理其他的業(yè)務(wù),那就可能引發(fā)一些其他的問題。
這時(shí),或許我們可以考慮另一個(gè)方案,在ASP.NET中的HttpContext中有一個(gè)Items屬性,它也可以用來保存key-value,這就完美了,一次請(qǐng)求正好對(duì)應(yīng)著一個(gè)HttpContext,請(qǐng)求結(jié)束,它自動(dòng)釋放,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;}}
其實(shí),HttpContext這個(gè)類和CallContext是有關(guān)聯(lián)的,查看源碼我們可以發(fā)現(xiàn):HttpContext.Current是通過CallContext.HostContext實(shí)現(xiàn)的。
internal static Object Current {get {return CallContext.HostContext;}[]set {CallContext.HostContext = value;}}
關(guān)于HttpContext.Current:ASP.NET會(huì)為每個(gè)請(qǐng)求分配一個(gè)線程,這個(gè)線程會(huì)執(zhí)行我們的代碼來生成響應(yīng)結(jié)果, 即使我們的代碼散落在不同的地方(類庫(kù)),線程仍然會(huì)執(zhí)行它們。所以,我們可以在任何地方訪問HttpContext.Current獲取到與當(dāng)前請(qǐng)求相關(guān)的HttpContext對(duì)象,畢竟這些代碼是由同一個(gè)線程來執(zhí)行的嘛,所以得到的HttpContext引用也就是那個(gè)與請(qǐng)求相關(guān)的對(duì)象。因此,將HttpContext.Current設(shè)計(jì)成與當(dāng)前線程相關(guān)聯(lián)是合適的。有關(guān)CallContext.HostContext的知識(shí)可以自行查閱資料,這里就不再贅述。
剛剛提到UnitOfWork模式,我們完成了DbContext的線程上下文內(nèi)的唯一性,那么SaveChanges呢?嗯,我們可以基于之前的唯一性保證,來寫一個(gè)SaveChanges的唯一入口。
public class DbSession{public static int SaveChanges(){return DbContextFactory.GetDbContext().SaveChanges();}}
本文簡(jiǎn)單介紹了CallContext類的基本概念、方法,做了一些測(cè)試驗(yàn)證了其提供的方法的適用范圍和限制。
如果我們需要在.NET代碼中向下傳遞對(duì)象,除了層層遞進(jìn)的傳遞參數(shù)之外,適時(shí)使用CallContext是一個(gè)不錯(cuò)的解耦的方案。
參考資料
Microsoft Doc,CallContext
.NET源碼,https://referencesource.microsoft.com/#System.Web/HttpContext.cs
雯海,.NET多線程之CallContext(cnblogs博客)
Koma,EF上下文對(duì)象線程內(nèi)唯一性與優(yōu)化(csdn博客)

