C#.NET AsyncLocal 異步上下文透傳實戰(zhàn)
簡介
異步代碼一多,參數(shù)傳遞很快就會開始變味。
最常見的場景是這樣:
- 入口層拿到了
TraceId - 服務(wù)層要打日志
- 倉儲層也想拿到同一個
TraceId - 調(diào)了好幾層
await以后,這個值還得一直跟著走
如果每一層都靠方法參數(shù)往下傳,代碼會越來越啰嗦。
這時候很多人會先想到:
- 放靜態(tài)變量里,不行,會串請求
- 放
ThreadLocal<T>里,不穩(wěn),await之后線程可能早換了
AsyncLocal<T> 就是專門解決這類問題的。
一句話先說透:
AsyncLocal<T> 綁定的不是線程,而是異步調(diào)用鏈的執(zhí)行上下文。
所以這篇文章重點會放在幾件事上:
AsyncLocal<T>到底解決什么問題- 為什么它能跨
await保持值不丟 - 它和
ThreadLocal<T>、靜態(tài)變量的邊界是什么 - 哪些場景適合用,哪些場景反而容易踩坑
- 怎么寫出接近真實項目的 demo,而不是只背幾個 API
AsyncLocal<T>是什么?
AsyncLocal<T> 位于:
System.Threading
它的作用可以直接理解成:
- 給當(dāng)前異步執(zhí)行流掛一份上下文數(shù)據(jù)
- 這份數(shù)據(jù)可以穿過
await - 后續(xù)方法不用顯式傳參,也能讀到它
先別急著把它想得太玄乎。
它并不是全局變量,也不是線程變量,更不是鎖。
更準(zhǔn)確的說法是:
AsyncLocal<T> 是一份“跟著當(dāng)前邏輯調(diào)用鏈走”的上下文數(shù)據(jù)槽位。
為什么會需要它?
先看一個非常典型的需求。
接口請求進(jìn)來時生成一個鏈路 ID:
string traceId = Guid.NewGuid().ToString("N");
隨后調(diào)用過程可能會經(jīng)過:
- 控制器
- 應(yīng)用服務(wù)
- 領(lǐng)域服務(wù)
- 倉儲
- 日志組件
如果每一層都這樣傳:
await service.CreateOrderAsync(orderDto, traceId);
再繼續(xù)往下:
await repository.SaveAsync(order, traceId);
很快就會出現(xiàn)兩個問題:
- 很多方法參數(shù)只是為了透傳上下文,業(yè)務(wù)本身并不關(guān)心
- 參數(shù)一多,真正有業(yè)務(wù)意義的輸入反而被淹沒了
這類場景下,AsyncLocal<T> 會比較順手:
- 請求入口設(shè)置一次
- 后面整條異步調(diào)用鏈都能讀取
- 不需要每層顯式帶著跑
它和ThreadLocal<T>的區(qū)別到底在哪?
這個點一定要先分清。
ThreadLocal<T>
綁定的是物理線程。
也就是說:
- 當(dāng)前線程寫進(jìn)去的值
- 只能保證當(dāng)前線程繼續(xù)讀到
- 一旦
await后續(xù)體切到別的線程,就可能讀不到原來的值
AsyncLocal<T>
綁定的是邏輯執(zhí)行上下文。
也就是說:
- 即使
await之后換了線程 - 只要還在同一條異步調(diào)用鏈上
- 值通常就還能繼續(xù)拿到
一句話總結(jié)最方便記:
ThreadLocal<T>看線程AsyncLocal<T>看異步調(diào)用鏈
Demo 1:跨await保持上下文
先看最基礎(chǔ)的例子。
using System;
using System.Threading;
using System.Threading.Tasks;
static AsyncLocal<string> TraceId = new();
static async Task Main()
{
TraceId.Value = "trace-1001";
Console.WriteLine($"Main 開始,線程:{Thread.CurrentThread.ManagedThreadId},值:{TraceId.Value}");
await ProcessAsync();
Console.WriteLine($"Main 結(jié)束,線程:{Thread.CurrentThread.ManagedThreadId},值:{TraceId.Value}");
}
static async Task ProcessAsync()
{
Console.WriteLine($"ProcessAsync 開始,線程:{Thread.CurrentThread.ManagedThreadId},值:{TraceId.Value}");
await Task.Delay(100);
Console.WriteLine($"ProcessAsync 恢復(fù)后,線程:{Thread.CurrentThread.ManagedThreadId},值:{TraceId.Value}");
}
輸出通常類似這樣:
Main 開始,線程:1,值:trace-1001 ProcessAsync 開始,線程:1,值:trace-1001 ProcessAsync 恢復(fù)后,線程:7,值:trace-1001 Main 結(jié)束,線程:7,值:trace-1001
關(guān)鍵點不是線程號,而是:
- 線程可能變了
TraceId沒丟
這就是 AsyncLocal<T> 的核心價值。
Demo 2:和ThreadLocal<T>放在一起看,差別會非常直觀
using System;
using System.Threading;
using System.Threading.Tasks;
static ThreadLocal<string> ThreadTrace = new(() => "empty-thread");
static AsyncLocal<string> AsyncTrace = new();
static async Task Main()
{
ThreadTrace.Value = "thread-trace";
AsyncTrace.Value = "async-trace";
await Task.Delay(100).ConfigureAwait(false);
Console.WriteLine($"ThreadLocal:{ThreadTrace.Value}");
Console.WriteLine($"AsyncLocal:{AsyncTrace.Value}");
}
這里的結(jié)果最常見的是:
ThreadLocal<T>可能讀到默認(rèn)值,或者讀到當(dāng)前線程自己的那份舊值AsyncLocal<T>仍然能拿到async-trace
原因就在于兩者綁定對象根本不同。
AsyncLocal<T>為什么能做到這件事?
背后關(guān)鍵不是 AsyncLocal<T> 自己,而是 .NET 的:
ExecutionContext
可以把它想成一份“當(dāng)前執(zhí)行環(huán)境的上下文包裹”。
這個包裹里可以帶很多信息,其中就包括 AsyncLocal<T> 的值。
當(dāng)異步方法遇到 await 時,運行時通常會做這些事:
- 先把當(dāng)前執(zhí)行上下文捕獲下來
- 異步操作完成后,再把這個上下文恢復(fù)回來
- 于是后續(xù)代碼還能繼續(xù)看到原來的
AsyncLocal<T>值
所以真正流動的不是線程,而是上下文。
Demo 3:父流程設(shè)置值,子流程可以直接讀取
using System;
using System.Threading;
using System.Threading.Tasks;
static AsyncLocal<string> CurrentUser = new();
static async Task Main()
{
CurrentUser.Value = "admin";
await CreateOrderAsync();
}
static async Task CreateOrderAsync()
{
Console.WriteLine($"CreateOrderAsync: {CurrentUser.Value}");
await SaveOrderAsync();
}
static async Task SaveOrderAsync()
{
await Task.Delay(50);
Console.WriteLine($"SaveOrderAsync: {CurrentUser.Value}");
}
輸出會保持一致:
CreateOrderAsync: admin SaveOrderAsync: admin
這正是它在請求上下文、租戶上下文、日志作用域里被頻繁使用的原因。
Demo 4:子流程改值,父流程不會被永久污染
這是 AsyncLocal<T> 很容易讓人誤判的地方。
很多人第一次接觸時,會以為它就是一份所有子流程共享的全局變量。實際上不是。
看例子:
using System;
using System.Threading;
using System.Threading.Tasks;
static AsyncLocal<string> Context = new();
static async Task Main()
{
Context.Value = "root";
Console.WriteLine($"Main 調(diào)用前:{Context.Value}");
await OuterAsync();
Console.WriteLine($"Main 調(diào)用后:{Context.Value}");
}
static async Task OuterAsync()
{
Console.WriteLine($"OuterAsync 開始:{Context.Value}");
Context.Value = "outer";
await InnerAsync();
Console.WriteLine($"OuterAsync 結(jié)束:{Context.Value}");
}
static async Task InnerAsync()
{
Console.WriteLine($"InnerAsync 開始:{Context.Value}");
Context.Value = "inner";
await Task.Delay(50);
Console.WriteLine($"InnerAsync 結(jié)束:{Context.Value}");
}
一類常見輸出會像這樣:
Main 調(diào)用前:root OuterAsync 開始:root InnerAsync 開始:outer InnerAsync 結(jié)束:inner OuterAsync 結(jié)束:outer Main 調(diào)用后:root
這個現(xiàn)象最值得記住:
- 子流程能繼承父流程當(dāng)下的值
- 子流程里重新賦值,只會影響它自己的那段上下文
- 父流程恢復(fù)后,還是原來的值
所以更準(zhǔn)確的理解應(yīng)該是:
AsyncLocal<T>更像“上下文作用域”,不是單純的一份共享變量。
Demo 5:最貼近項目的場景,鏈路追蹤TraceId
這個例子最接近真實項目。
using System;
using System.Threading;
using System.Threading.Tasks;
public static class TraceContext
{
private static readonly AsyncLocal<string?> _traceId = new();
public static string? TraceId
{
get => _traceId.Value;
set => _traceId.Value = value;
}
}
public sealed class OrderService
{
public async Task CreateAsync()
{
Console.WriteLine($"[Service] TraceId={TraceContext.TraceId}");
await new OrderRepository().SaveAsync();
}
}
public sealed class OrderRepository
{
public async Task SaveAsync()
{
await Task.Delay(50);
Console.WriteLine($"[Repository] TraceId={TraceContext.TraceId}");
}
}
static async Task Main()
{
TraceContext.TraceId = Guid.NewGuid().ToString("N");
try
{
await new OrderService().CreateAsync();
}
finally
{
TraceContext.TraceId = null;
}
}
這種模式非常適合:
- 鏈路追蹤 ID
- 當(dāng)前租戶 ID
- 當(dāng)前請求用戶
- 日志作用域
最關(guān)鍵的收益是:
- 業(yè)務(wù)方法簽名不會為了透傳上下文越來越臃腫
- 底層組件也能直接讀取當(dāng)前上下文
Demo 6:引用類型是個大坑
這點必須單獨講。
很多人知道 AsyncLocal<T> 能隔離上下文,就誤以為里面放引用類型也天然安全。其實并不是。
看這個例子:
using System;
using System.Threading;
using System.Threading.Tasks;
public sealed class RequestInfo
{
public string TraceId { get; set; } = string.Empty;
}
static AsyncLocal<RequestInfo> RequestContext = new();
static async Task Main()
{
RequestContext.Value = new RequestInfo { TraceId = "root-trace" };
await Task.Run(async () =>
{
Console.WriteLine($"子任務(wù)開始:{RequestContext.Value.TraceId}");
RequestContext.Value.TraceId = "child-updated";
await Task.Delay(50);
});
Console.WriteLine($"主流程恢復(fù)后:{RequestContext.Value.TraceId}");
}
這里很可能輸出:
子任務(wù)開始:root-trace
主流程恢復(fù)后:child-updated
原因不是 AsyncLocal<T> 失效了,而是:
AsyncLocal<T>傳的是RequestInfo這個引用- 子流程和父流程拿到的是同一個對象引用
- 改的是對象內(nèi)部屬性,不是重新給
.Value賦新對象
所以實戰(zhàn)里最好優(yōu)先遵循這個原則:
- 傳簡單值類型
- 傳字符串
- 傳不可變對象
- 盡量少傳可變大對象
如果非要放復(fù)雜對象,最好按“整體替換”來寫,而不是到處改內(nèi)部屬性。
Demo 7:變化通知
AsyncLocal<T> 還有一個不算常用但挺有意思的能力:值變化通知。
using System;
using System.Threading;
var local = new AsyncLocal<string?>(args =>
{
Console.WriteLine(
$"上下文變化:舊值={args.PreviousValue ?? "<null>"},新值={args.CurrentValue ?? "<null>"}," +
$"線程切換={args.ThreadContextChanged}");
});
local.Value = "A";
local.Value = "B";
local.Value = null;
這個能力更適合:
- 調(diào)試上下文傳播
- 驗證鏈路是否被改寫
- 觀察異步恢復(fù)時值變化
業(yè)務(wù)代碼里一般不需要到處用,但排查問題時很有幫助。
它和靜態(tài)變量的邊界是什么?
這一點也很容易混。
靜態(tài)變量
是全局共享的。
如果多個請求同時進(jìn)來,都改同一個靜態(tài)字段,數(shù)據(jù)就會互相覆蓋。
AsyncLocal<T>
通常是“靜態(tài)字段 + 每條異步調(diào)用鏈各自一份值”的組合。
也就是說:
- 靜態(tài)的是容器入口
- 真正的值不是全局共享那一份
- 每條執(zhí)行流看到的是自己的上下文值
這也是為什么框架里很多上下文訪問器,會把 AsyncLocal<T> 寫成 static readonly 字段。
什么時候適合用AsyncLocal<T>?
比較適合:
TraceIdCorrelationId- 當(dāng)前租戶信息
- 當(dāng)前用戶標(biāo)識
- 日志上下文
- 需要跨
await透傳、但不想層層傳參的控制類信息
不太適合:
- 大對象緩存
- 大量頻繁寫入的臨時業(yè)務(wù)數(shù)據(jù)
- 需要長期?;畹馁Y源對象
- 復(fù)雜可變對象圖
- 真正的全局共享狀態(tài)
為什么不建議往里面塞大對象?
原因有兩個。
1. 它的定位不是緩存容器
AsyncLocal<T> 最適合裝的是“小而關(guān)鍵的上下文信息”,比如 ID、名稱、輕量上下文對象。
2. 上下文傳播本身也有成本
異步鏈路越復(fù)雜,傳播越頻繁,塞的對象越重,排查問題和控制生命周期就越麻煩。
尤其是在高并發(fā)服務(wù)里,AsyncLocal<T> 應(yīng)該盡量保持輕量。
后臺任務(wù)場景一定要格外小心
還有一個很容易踩坑的點:
Task.Run 默認(rèn)會捕獲當(dāng)前 ExecutionContext,也就意味著會把當(dāng)前 AsyncLocal<T> 一起帶過去。
這有時是好事,有時反而是壞事。
例如某個請求里啟動了一個后臺任務(wù):
_ = Task.Run(() =>
{
Console.WriteLine(TraceContext.TraceId);
});
如果本意只是“順手丟個后臺工作”,卻不希望把請求上下文一起傳過去,那這個默認(rèn)行為就可能造成誤判甚至污染。
這種時候要意識到一件事:
AsyncLocal<T>默認(rèn)是會隨執(zhí)行上下文流動的,不是只在當(dāng)前方法里生效。
高級一點的控制:禁止上下文流動
如果確實明確不想把當(dāng)前上下文傳給后臺任務(wù),可以用:
using System.Threading;
using (ExecutionContext.SuppressFlow())
{
_ = Task.Run(() =>
{
Console.WriteLine(TraceContext.TraceId); // 大概率拿不到原上下文值
});
}
這個 API 不算日常高頻,但在基礎(chǔ)設(shè)施代碼里很有用。
它適合那種語義非常明確的場景:
- 就是不希望繼承當(dāng)前請求上下文
- 就是要啟動一個“脫離當(dāng)前鏈路”的后臺任務(wù)
一個更完整的實戰(zhàn)寫法:帶作用域的上下文包裝
項目里直接到處寫:
TraceContext.TraceId = xxx;
時間長了很容易忘記恢復(fù)。
更穩(wěn)一點的方式是做一個小作用域包裝:
using System;
using System.Threading;
public static class TraceContext
{
private static readonly AsyncLocal<string?> _traceId = new();
public static string? TraceId
{
get => _traceId.Value;
private set => _traceId.Value = value;
}
public static IDisposable BeginScope(string traceId)
{
string? previous = TraceId;
TraceId = traceId;
return new RestoreScope(previous);
}
private sealed class RestoreScope : IDisposable
{
private readonly string? _previous;
public RestoreScope(string? previous)
{
_previous = previous;
}
public void Dispose()
{
TraceId = _previous;
}
}
}
使用時:
using (TraceContext.BeginScope(Guid.NewGuid().ToString("N")))
{
await service.CreateAsync();
}
這種寫法的好處很直接:
- 進(jìn)入作用域時設(shè)置值
- 離開作用域時自動恢復(fù)
- 比手動清理更不容易漏
它和HttpContext.Items、方法參數(shù)傳遞怎么選?
這幾個工具也經(jīng)常被放在一起比較。
方法參數(shù)傳遞
優(yōu)點是顯式、清楚、最容易追蹤。
缺點是透傳鏈路一長,簽名會越來越臃腫。
HttpContext.Items
適合明確綁定在 ASP.NET Core 請求對象上的數(shù)據(jù)。
但它依賴 HttpContext,脫離 Web 請求上下文就不好用了。
AsyncLocal<T>
適合那種:
- 需要跨很多層
await - 不想層層傳參
- 又不應(yīng)該做成全局共享變量
最實用的判斷標(biāo)準(zhǔn)可以這么記:
- 業(yè)務(wù)核心輸入,優(yōu)先參數(shù)傳遞
- 請求附屬上下文,適合
AsyncLocal<T> - 只在 ASP.NET Core 請求里臨時掛點數(shù)據(jù),
HttpContext.Items也可以
總結(jié)
AsyncLocal<T> 最核心的價值,不是“存?zhèn)€值”這么簡單,而是:
- 讓上下文跟著異步調(diào)用鏈走
- 跨
await也不容易丟 - 不用為了透傳上下文把方法簽名寫得越來越重
但邊界也必須記清楚:
- 它不是線程變量
- 它不是全局共享變量
- 它不適合裝大對象和復(fù)雜可變對象
- 它默認(rèn)會隨著執(zhí)行上下文繼續(xù)傳播
AsyncLocal<T> 不是把數(shù)據(jù)綁在線程上,而是把數(shù)據(jù)掛在當(dāng)前異步調(diào)用鏈上。
到此這篇關(guān)于C#.NET AsyncLocal 異步上下文透傳實戰(zhàn)的文章就介紹到這了,更多相關(guān)C#.NET AsyncLocal 異步上下文透傳內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
C#利用WinForm實現(xiàn)查看指定目錄下所有圖片功能
Windows 窗體是用于生成 Windows 桌面應(yīng)用的 UI 框架, 它提供了一種基于 Visual Studio 中提供的可視化設(shè)計器創(chuàng)建桌面應(yīng)用的高效方法,本文介紹了C#利用WinForm實現(xiàn)可以查看指定目錄文件下所有圖片功能,需要的朋友可以參考下2024-05-05

