前些天跟大佬們在群里討論如何在不使用構造函數,不增加方法參數的情況下把一個上下文注入到方法內部使用,得出的結論是 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 ,其它不變,運行一下代碼。
AsyncLocal<int> asyncLocal = new AsyncLocal<int>();
asyncLocal.Value = 1;
Console.WriteLine("thread id {0} value:{1} START", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
await Task.Run(() => {
asyncLocal.Value = 2;
Console.WriteLine("thread id {0} value:{1}", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
});
await Task.Run(() => {
asyncLocal.Value = 3;
Console.WriteLine("thread id {0} value:{1}", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
});
await Task.Run(() => {
asyncLocal.Value = 4;
Console.WriteLine("thread id {0} value:{1}", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
});
await Task.Run(() => {
asyncLocal.Value = 5;
Console.WriteLine("thread id {0} value:{1}", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
});
await Task.Run(() => {
asyncLocal.Value = 6;
Console.WriteLine("thread id {0} value:{1}", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
});
Console.WriteLine("thread id {0} value:{1} END", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
Console.Read();
輸出:
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 的場景。
AsyncLocal<int> asyncLocal = new AsyncLocal<int>();
//block 1
asyncLocal.Value = 1;
Console.WriteLine("thread id {0} value:{1} START", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
await Task.Run(async () =>
{
//block 2
asyncLocal.Value = 2;
Console.WriteLine("thread id {0} value:{1} ", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
await Task.Run(() => {
//block 3
asyncLocal.Value = 3;
Console.WriteLine("thread id {0} value:{1} ", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
});
Console.WriteLine("thread id {0} value:{1} ", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
});
Console.WriteLine("thread id {0} value:{1} END", Thread.CurrentThread.ManagedThreadId, asyncLocal.Value);
Console.Read();
輸出
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 類:
public class MyContext : IDisposable
{
static AsyncLocal<MyContext> _scope = new AsyncLocal<MyContext>();
public MyContext(object val)
{
Value = val;
_scope.Value = this;
}
public object Value { get;}
public static MyContext? Current
{
get
{
return _scope.Value;
}
}
public void Dispose()
{
if (Value != null)
{
(Value as IDisposable)?.Dispose();
}
}
}
假設我們已經有了 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 ,並不復雜大家可以看看。