从 ThreadLocal 到 AsyncLocal
前些天跟大佬們在群里討論如何在不使用構造函數,不增加方法參數的情況下把一個上下文注入到方法內部使用,得出的結論是 AsyncLocal 。感嘆自己才疏學淺,居然才知道有 AsyncLocal 這種神器。于是趕緊惡補一下。
ThreadLocal
要說 AsyncLocal 還得先從 ThreadLocal 說起。ThreadLocal 封裝的變量,可以在線程間進行隔離。不同線程對同一個變量的修改只在當前線程有效。這個應該大家都比較熟悉不多說了。下面簡單演示一下:threadLocal 初始值為1,然后啟動多個線程對這個變量進行修改,最后主線程等待1秒,保證其它線程都執行成功后再次打印threadLocal的值。
ThreadLocal<int>?threadLocal?=?new?ThreadLocal<int>(); threadLocal.Value?=?1; Console.WriteLine("thread?id?{0}?value:{1}?START",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value);new?Thread(()?=>?{threadLocal.Value?=?2;Console.WriteLine("thread?id?{0}?value:{1}",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value); }).Start(); new?Thread(()?=>?{threadLocal.Value?=?3;Console.WriteLine("thread?id?{0}?value:{1}",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value); }).Start(); new?Thread(()?=>?{threadLocal.Value?=?4;Console.WriteLine("thread?id?{0}?value:{1}",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value); }).Start(); new?Thread(()?=>?{threadLocal.Value?=?5;Console.WriteLine("thread?id?{0}?value:{1}",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value); }).Start(); new?Thread(()?=>?{threadLocal.Value?=?6;Console.WriteLine("thread?id?{0}?value:{1}",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value); }).Start();Thread.Sleep(1000); Console.WriteLine("thread?id?{0}?value:{1}?END",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value);Console.Read();輸出:
Hello,?World! thread?id?1?value:1?START thread?id?7?value:2 thread?id?8?value:3 thread?id?9?value:4 thread?id?10?value:5 thread?id?11?value:6 thread?id?1?value:1?END通過一系列線程修改后 threadLocal 的值在 1 號線程始終為 1 ,這也符合我們對 ThreadLocal 預期。
當 ThreadLocal 遇到 await
上面的示例我們使用的是 new Thread 的辦法進行多線程操作,現在這種做法已經很少見了。我們現在更多的時候會使用 async/await Task 來幫我們做多線程異步操作。這個時候我們的 ThreadLocal 就會力不從心了,讓我們改造一下代碼:我們把 new Thread 全部改造成 Task.Run 來執行修改變量的操作。
ThreadLocal<int>?threadLocal?=?new?ThreadLocal<int>(); threadLocal.Value?=?1; Console.WriteLine("thread?id?{0}?value:{1}?START",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value);await?Task.Run(()?=>?{threadLocal.Value?=?2;Console.WriteLine("thread?id?{0}?value:{1}",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value); }); await?Task.Run(()?=>?{threadLocal.Value?=?3;Console.WriteLine("thread?id?{0}?value:{1}",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value); }); await?Task.Run(()?=>?{threadLocal.Value?=?4;Console.WriteLine("thread?id?{0}?value:{1}",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value); }); await?Task.Run(()?=>?{threadLocal.Value?=?5;Console.WriteLine("thread?id?{0}?value:{1}",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value); }); await?Task.Run(()?=>?{threadLocal.Value?=?6;Console.WriteLine("thread?id?{0}?value:{1}",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value); }); Console.WriteLine("thread?id?{0}?value:{1}?END",?Thread.CurrentThread.ManagedThreadId,?threadLocal.Value);Console.Read();輸出:
Hello,?World! thread?id?1?value:1?START thread?id?7?value:2 thread?id?8?value:3 thread?id?10?value:4 thread?id?11?value:5 thread?id?12?value:6 thread?id?11?value:5?END通過輸出我們可以看到 START 跟 END 的輸出已經不一樣了。至于為什么,如果理解 Task 的原理,其實也很好理解。簡單來說,Task 的異步是一種基于狀態機實現方式,編譯器碰到 await 會把代碼編譯成一個代碼塊,表示一種狀態。Task 的任務調度器會調度空閑線程去處理每一個狀態。當一個狀態完成后,調度器調度一個空閑線程去處理下一個任務,這樣一個接一個處理。這里最大的困擾其實是主觀上的當前線程(打印 START 跟 END 的線程)已經不是同一個了,打印 START 的是 1 號線程,打印 END 的是 11 號線程,那么 ThreadLocal 自然不適合這種場景了。
AsyncLocal
上面我們已經知道 ThreadLocal 已經不適合在新的 TPL 模型下的多線程變量隔離。那么我們該如何進行應對呢?答案就是 AsyncLocal 。
讓我們改造下代碼,把 Threadlocal 替換成 AsyncLocal ,其它不變,運行一下代碼。
輸出:
thread?id?1?value:1?START thread?id?6?value:2 thread?id?7?value:3 thread?id?8?value:4 thread?id?11?value:5 thread?id?7?value:6 thread?id?7?value:1?END結果如我們所愿, START 跟 END 的值是一致的。我們可以看到雖然線程發生了切換,但是值被很好的保留在了當前流程下。
讓我們使用另外一個代碼實例來演示下 AsyncLocal 的特性。上面的代碼演示的是一個 Task 接一個 Task 的場景,一下我們演示下 Task 嵌套 Task 的場景。
輸出
thread?id?1?value:1?START thread?id?6?value:2 thread?id?7?value:3 thread?id?7?value:2 thread?id?7?value:1?END跟你預期的結果一致嗎?結果為:1 2 3 2 1 。AsyncLocal 的變量值會被隔離在每個 Task 流程內,就算嵌套,子流程對變量的修改也不會影響到父流程的值。
AsyncLocal 實用
AsyncLocal 的特性說的差不多了。那么 AsyncLocal 到底該使用在什么場景呢?
當我們重構代碼的時候如果需要把一個上下文參數傳遞進去,最傻瓜的辦法就是在所有的調用類的構造函數上加入這個參數,或者在所有的方法調用上加入這個參數。但是這種辦法是破壞性比較大的,因為函數簽名被破壞意味著接口(廣義上)約束被破壞了。這個時候我們可以通過 AsyncLocal 把上下文傳遞進去。
定義一個 MyContext 類:
假設我們已經有了 Func1 方法,現在在不破壞任何接口約束的情況下可以把 MyContext 直接通過靜態變量 MyContext.Current 獲取到。
void?Func1() {Console.WriteLine(MyContext.Current?.Value); }using?(var?ctx?=?new?MyContext("context?1")) {Func1(); }using?(var?ctx?=?new?MyContext("context?2")) {Func1(); } using?(var?ctx?=?new?MyContext("context?3")) {await?Task.Run(Func1);await?Task.Run(Func1);await?Task.Run(Func1); }另外一個實現其實是大家非常常見的 HttpContextAccessor 。ASP.NET Core 下我們獲取 HttpContext 會通過 HttpContextAccessor 獲取。HttpContextAccessor 通常被注冊為單例。大家有沒有想過為啥單例的 HttpContextAccessor.HttpContext 變量不會被多線程或者異步方法打亂?原因也就在于 AsyncLocal 。源碼在這 HttpContextAccessor ,并不復雜大家可以看看。
關注我的公眾號一起玩轉技術
創作挑戰賽新人創作獎勵來咯,堅持創作打卡瓜分現金大獎總結
以上是生活随笔為你收集整理的从 ThreadLocal 到 AsyncLocal的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 宝藏好物gRPCurl
- 下一篇: 单元测试 | 如何在Mock时匹配匿名类