也算後知後覺啦,最近在看 AI 生成的程式碼無意學到的,.NET 4.6 就存在 AsyncLocalThreadLocal

一般來說,我們在寫多執行緒或 async 非同步作業時,要避免共用屬性、變數,防止同時執行的程式碼搶著修改同一份內容,彼此干擾造成無法預期的結果。我用以下這個例子示範:(不要問我為什麼一個簡單的加法運算要寫成這個鬼樣子,是為了研究用途)

await Demo.RunAsync();

class NumberAdder
{
    public int A;
    public int B;
    public override string ToString() => $"{A} + {B} = {A + B}";
}

static class Demo
{
    private static readonly Random rand = new();
    static NumberAdder adder = new();

    public static async Task RunAsync()
    {
        await Task.WhenAll(
            TestNumAdder("Alice", 1, 2),
            TestNumAdder("Bob", 3, 4),
            TestNumAdder("Charlie", 5, 6),
            TestNumAdder("Diana", 7, 8),
            TestNumAdder("Eve", 3, 8)
        );
    }

    static async Task TestNumAdder(string user, int a, int b)
    {
        await Task.Delay(rand.Next(5, 700));
        adder.A = a;
        await Task.Delay(rand.Next(5, 700));
        adder.B = b;
        Console.WriteLine($" - {user,-10} Test {a} + {b} / Result: {adder, -12} T:{Thread.CurrentThread.ManagedThreadId}");
    }
}

明眼人一看就知道會出事,五個 TestNumAdder() 非同步執行卻共用同一份會記憶 A, B 值的 NumberAdder 物件,一定會覆寫來覆寫去,很難不亂成一鍋粥。

若堅持共用這個有狀態的 NumberAdder,一種改法是加上鎖定,一次限定只能被一個 TestNumAdder() 使用。async 作業不能用 lock,一般會用 SemaphoreSlim 實現互斥效果:

    // async 不能用 lock,改用 SemaphoreSlim 實現互斥鎖
    private static readonly SemaphoreSlim semaphore = new(1, 1);
    static async Task TestNumAdder(string user, int a, int b)
    {
        await semaphore.WaitAsync();
        try
        {
            await Task.Delay(rand.Next(5, 700));
            adder.A = a;
            await Task.Delay(rand.Next(5, 700));
            adder.B = b;
            Console.WriteLine($" - {user,-10} Test {a} + {b} / Result: {adder, -12} T:{Thread.CurrentThread.ManagedThreadId}");
        }
        finally
        {
            semaphore.Release();
        }
    }

修改後改排隊執行,一次一個,慢一點,但不會有覆寫的問題:

最後來看看,如何用 AsyncLocal 解決同步作業的資料共用問題:

await Demo.RunAsync();

class NumberAdder
{
    public AsyncLocal<int> A = new();
    public AsyncLocal<int> B = new();
    public override string ToString() => $"{A.Value} + {B.Value} = {A.Value + B.Value}";
}

static class Demo
{
    private static readonly Random rand = new();
    static NumberAdder adder = new();

    public static async Task RunAsync()
    {
        await Task.WhenAll(
            TestNumAdder("Alice", 1, 2),
            TestNumAdder("Bob", 3, 4),
            TestNumAdder("Charlie", 5, 6),
            TestNumAdder("Diana", 7, 8),
            TestNumAdder("Eve", 6, 9)
        );
    }
    static async Task TestNumAdder(string user, int a, int b)
    {
        await Task.Delay(rand.Next(5, 500));
        adder.A.Value = a;
        await Task.Delay(rand.Next(5, 500));
        adder.B.Value = b;
        Console.WriteLine($" - {user,-10} Test {a} + {b} / Result: {adder, -12} T:{Thread.CurrentThread.ManagedThreadId}");
    }
}

我們將 A, B 宣告成 AsyncLocal<int> 存取改透過 AsyncLocal<T>.Value,每次指定值時,.NET 在背後會為其建立一個新的 ExecutionContext,並將資料儲存於內部一個不可變 (Immutable) 的字典中,當非同步操作 (如 await) 開始時,.NET 會捕捉 (Capture) 目前執行緒的 ExecutionContext,操作完成並準備執行續接 (Continuation) 時,系統會將之前捕捉到的內容恢復(Restore) 到執行該任務的執行緒上。

為確保非同步作業能共用同一份資料不打架,AsyncLocal<T> 採用 Copy-on-Write 策略,啟動新執行緒或非同步作業時,子邏輯會繼承父邏輯的 ExecutionContext,若子邏輯修改 AsyncLocal<T>,.NET 會建立一個新的字典 Snapshot,只適用於子邏輯的範圍。總之,改用 AsyncLocal<int> 後,可想成每個 TestNumAdder() 都有自己一份的 A、B,不必擔心覆寫或資料錯亂:

await Demo.RunAsync();

class NumberAdder
{
    public AsyncLocal<int> A = new();
    public AsyncLocal<int> B = new();
    public override string ToString() => $"{A.Value} + {B.Value} = {A.Value + B.Value}";
}

static class Demo
{
    private static readonly Random rand = new();
    static NumberAdder adder = new();

    public static async Task RunAsync()
    {
        await Task.WhenAll(
            TestNumAdder("Alice", 1, 2),
            TestNumAdder("Bob", 3, 4),
            TestNumAdder("Charlie", 5, 6),
            TestNumAdder("Diana", 7, 8),
            TestNumAdder("Eve", 6, 9)
        );
    }
    static async Task TestNumAdder(string user, int a, int b)
    {
        await Task.Delay(rand.Next(5, 500));
        adder.A.Value = a;
        await Task.Delay(rand.Next(5, 500));
        adder.B.Value = b;
        Console.WriteLine($" - {user,-10} Test {a} + {b} / Result: {adder, -12} T:{Thread.CurrentThread.ManagedThreadId}");
    }
}

不過,基於 AsyncLocal 的運作原理,不難想見它有一些潛在問題,例如:

  • 由於 AsyncLocal<T> 的生命週期較不直覺,加上 Copy-on-Write 的複製行為,慎有不慎便可能殘留在 ExecutionContext,造成 Memory Leaking
  • ExecutionContext 本身不可變,但如果存入的是可變物件 (Mutable Object) (如大部分的 Reference Type 物件),修改其屬性仍可能出現覆寫狀況

雖然學到新工具,我在設計上應會避免使用這類手法,一旦需要動用可視為警訊,通常有不共享資料,更直觀更可靠的寫法,宜三思。

Introduces AsyncLocal and ThreadLocal in .NET to safely share state across async flows without locks, explaining ExecutionContext, copy-on-write behavior, benefits, and pitfalls, and why such patterns should be used cautiously in design.


Comments

# by ChrisTorng

https://github.com/StephenCleary/AsyncEx 裡有 AsyncLock 物件,可用來解決「async 作業不能用 lock」問題。同套件還有很多其他非同步的功能。

# by Jeffrey

to ChrisTorng,感謝分享~

Post a comment