從 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 ,其它不變,運行一下代碼。

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 ,並不復雜大家可以看看。

關注我的公眾號一起玩轉技術


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM