.NET?異步、并發(fā)與內(nèi)存管理的系統(tǒng)性認(rèn)知
異步編程模式的演進(jìn)與 TAP 最佳實(shí)踐
.NET 的異步編程經(jīng)歷了三個(gè)時(shí)代。理解這段歷史不是為了考古,而是因?yàn)槟阍诰S護(hù)老代碼時(shí)必然會(huì)遭遇它們,理解它們才能優(yōu)雅地遷移。
| 模式 | 時(shí)代 | 標(biāo)志 | 狀態(tài) |
|---|---|---|---|
| APM(異步編程模型) | .NET 1.x | BeginXxx / EndXxx | 已淘汰 |
| EAP(基于事件的異步) | .NET 2.0 | XxxAsync + XxxCompleted 事件 | 遺留代碼 |
| TAP(基于任務(wù)的異步) | .NET 4.0+ | Task / async / await | 推薦使用 |
TAP 方法的命名與簽名規(guī)范
很多人寫異步方法時(shí)忽視規(guī)范,導(dǎo)致 API 設(shè)計(jì)混亂。TAP 有一套嚴(yán)格的約定:
// ? 標(biāo)準(zhǔn)命名:方法名 + Async 后綴 public Task<int> ReadAsync(byte[] buffer, int offset, int count); // ? 已有同名 EAP 方法時(shí),用 TaskAsync 后綴 public Task<string> GetTaskAsync(string url); // ? 返回 void 的同步對(duì)應(yīng)版本 → 返回 Task public Task SaveAsync(string path); // ? 返回 T 的同步對(duì)應(yīng)版本 → 返回 Task<T> public Task<UserDto> GetUserAsync(int userId); // ? 避免:out/ref 參數(shù)在 TAP 中禁止使用 // 應(yīng)將多返回值包裝為 tuple 或自定義類型 public Task<(bool Success, string Error)> TryParseAsync(string input);
Task 的生命周期:一個(gè)經(jīng)常被忽視的細(xì)節(jié)
Task 有 冷任務(wù)(Cold Task) 和 熱任務(wù)(Hot Task) 之分。new Task(...) 創(chuàng)建的是冷任務(wù),需要手動(dòng)調(diào)用 Start()。但 TAP 方法返回的 Task 必須是已激活的熱任務(wù)——調(diào)用者不應(yīng)該也不需要調(diào)用 Start()。
?? 常見錯(cuò)誤
如果你在 TAP 方法內(nèi)部通過 new Task() 構(gòu)造任務(wù)后忘記調(diào)用 Start() 就返回它,調(diào)用者會(huì)陷入永久等待。始終確保返回的 Task 已處于運(yùn)行狀態(tài)。
異常處理的正確姿勢(shì)
異步方法中的異常處理有一個(gè)重要原則:參數(shù)驗(yàn)證異常應(yīng)該在 async 方法外層同步拋出,這樣調(diào)用者能立即捕獲,而不必 await 后才能發(fā)現(xiàn)錯(cuò)誤。
// ? 推薦:參數(shù)驗(yàn)證在外層同步完成
public Task<int> ProcessAsync(string input)
{
if (input == null)
throw new ArgumentNullException(nameof(input)); // 同步拋出
return ProcessCoreAsync(input); // 委托給真正的 async 方法
}
private async Task<int> ProcessCoreAsync(string input)
{
// 真正的異步工作
var result = await DoWorkAsync(input);
return result;
}取消令牌與進(jìn)度報(bào)告:讓異步操作可控
寫了 3 年 .NET,你可能已經(jīng)在用 CancellationToken,但真正理解它的狀態(tài)機(jī)和設(shè)計(jì)模式的人并不多。
CancellationToken 的三種終態(tài)
Task 狀態(tài)機(jī):
Created ──Start()──? Running
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Canceled Faulted RanToCompletion
(取消請(qǐng)求) (未處理異常) (正常完成)
│ │ │
└─────────────┴─────────────┘
IsCompleted = true
取消時(shí) Task 進(jìn)入 Canceled 狀態(tài),IsCompleted 返回 true,但 await 它會(huì)拋出 OperationCanceledException。
最佳實(shí)踐:在計(jì)算密集型任務(wù)中輪詢?nèi)∠?/h3>
internal Task<Bitmap> RenderAsync(
ImageData data, CancellationToken cancellationToken)
{
return Task.Run(() =>
{
var bmp = new Bitmap(data.Width, data.Height);
for (int y = 0; y < data.Height; y++)
{
// 每行檢查一次取消請(qǐng)求,不要每像素都檢查(性能損耗)
cancellationToken.ThrowIfCancellationRequested();
for (int x = 0; x < data.Width; x++)
{
// 渲染像素 [x, y]
}
}
return bmp;
}, cancellationToken); // 傳入 token 以便 Task 啟動(dòng)前就取消
}
internal Task<Bitmap> RenderAsync(
ImageData data, CancellationToken cancellationToken)
{
return Task.Run(() =>
{
var bmp = new Bitmap(data.Width, data.Height);
for (int y = 0; y < data.Height; y++)
{
// 每行檢查一次取消請(qǐng)求,不要每像素都檢查(性能損耗)
cancellationToken.ThrowIfCancellationRequested();
for (int x = 0; x < data.Width; x++)
{
// 渲染像素 [x, y]
}
}
return bmp;
}, cancellationToken); // 傳入 token 以便 Task 啟動(dòng)前就取消
}進(jìn)度報(bào)告:IProgress 的正確用法
不要用事件或回調(diào)來報(bào)告進(jìn)度——IProgress<T> 是官方推薦的模式,它能自動(dòng)處理線程同步問題(回調(diào)總是在創(chuàng)建 Progress<T> 實(shí)例的同步上下文中執(zhí)行,通常是 UI 線程)。
// 定義時(shí)接受 IProgress<T> 參數(shù)
public async Task<string[]> FindFilesAsync(
string pattern,
CancellationToken ct = default,
IProgress<int> progress = null) // 允許為 null
{
var results = new List<string>();
int count = 0;
await foreach (var file in EnumerateFilesAsync(pattern, ct))
{
results.Add(file);
progress?.Report(++count); // null 安全調(diào)用
}
return results.ToArray();
}
// 調(diào)用端:Progress<T> 捕獲 UI 線程的同步上下文
var reporter = new Progress<int>(count =>
progressBar.Value = count); // 這里可以安全更新 UI
await FindFilesAsync("*.cs", ct, reporter);?? 設(shè)計(jì)建議
如果某個(gè)方法不支持取消,不要提供接受 CancellationToken 的重載——這會(huì)誤導(dǎo)調(diào)用者。反之,如果支持取消,應(yīng)當(dāng)始終提供帶 token 的重載。
任務(wù)并行庫(TPL)與 Parallel 編程
并行編程最大的陷阱是分不清 CPU 密集型 和 I/O 密集型 任務(wù),用錯(cuò)了工具反而更慢。
?? 核心原則
CPU 密集型用 Task.Run() 分發(fā)到線程池;I/O 密集型用 async/await + TaskCompletionSource,不應(yīng)綁定線程。
父子任務(wù)與 DenyChildAttach
使用第三方庫時(shí),如果對(duì)方內(nèi)部用 TaskCreationOptions.AttachedToParent 創(chuàng)建任務(wù),會(huì)導(dǎo)致你的父任務(wù)必須等待所有子任務(wù)完成——即使你不需要這種行為。使用 DenyChildAttach 可以隔離這種副作用。
// ? 默認(rèn)行為:第三方 Widget 內(nèi)部創(chuàng)建的子任務(wù)會(huì)延遲父任務(wù)完成
Task<Task> runWidget = Task.Factory.StartNew(
() => thirdPartyWidget.Run()); // 子任務(wù) sleep 5s,父任務(wù)也等 5s
// ? 正確做法:DenyChildAttach 隔離第三方任務(wù)
Task<Task> runWidget = Task.Factory.StartNew(
() => thirdPartyWidget.Run(),
TaskCreationOptions.DenyChildAttach); // 父任務(wù)立即完成Task.WhenAll vs Task.WhenAny 的適用場(chǎng)景
// WhenAll:等待所有任務(wù)完成(并行 I/O 的核心武器)
var tasks = urls.Select(url => httpClient.GetStringAsync(url));
string[] results = await Task.WhenAll(tasks);
// WhenAny:哪個(gè)先完成用哪個(gè)(超時(shí)控制的經(jīng)典寫法)
var dataTask = FetchDataAsync();
var timeout = Task.Delay(TimeSpan.FromSeconds(5));
var winner = await Task.WhenAny(dataTask, timeout);
if (winner == timeout)
throw new TimeoutException("請(qǐng)求超時(shí)");
return await dataTask; // 再次 await 以解包異常Task.FromResult 優(yōu)化緩存命中
當(dāng)異步方法能從緩存直接返回時(shí),創(chuàng)建真正的異步操作是不必要的開銷。Task.FromResult() 返回一個(gè)已完成的任務(wù),零開銷。
private static readonly ConcurrentDictionary<string, string>
_cache = new();
public static Task<string> DownloadStringAsync(string url)
{
// 緩存命中:返回已完成的 Task,無線程切換開銷
if (_cache.TryGetValue(url, out string? cached))
return Task.FromResult(cached);
// 緩存未命中:真正的異步下載
return Task.Run(async () =>
{
var content = await _httpClient.GetStringAsync(url);
_cache.TryAdd(url, content);
return content;
});
}線程安全集合的選型與陷阱
System.Collections.Concurrent 命名空間提供了幾個(gè)高性能線程安全集合,但選錯(cuò)了反而不如加鎖的普通集合性能好。
| 集合類型 | 適用場(chǎng)景 | 注意事項(xiàng) |
|---|---|---|
ConcurrentDictionary | 多線程頻繁讀寫鍵值對(duì) | GetOrAdd / AddOrUpdate 非原子操作 |
ConcurrentQueue | FIFO 生產(chǎn)者-消費(fèi)者場(chǎng)景 | 枚舉不保證順序穩(wěn)定 |
BlockingCollection | 有界緩沖 + 阻塞語義 | 需要配合 CompleteAdding() 正確關(guān)閉 |
ConcurrentBag | 混合生產(chǎn)者-消費(fèi)者(同線程添加取出) | 純生產(chǎn)消費(fèi)場(chǎng)景比其他集合慢 |
ConcurrentDictionary 的非原子陷阱
這是很多人犯錯(cuò)的地方:ConcurrentDictionary 的所有單個(gè)方法是線程安全的,但復(fù)合操作("檢查-然后-添加")不是原子的。
// ?? 注意:valueFactory 可能被多個(gè)線程調(diào)用
// 但只有一個(gè)線程的結(jié)果會(huì)被保留
var value = dict.GetOrAdd(key, k =>
{
// 這里的代碼可能被并發(fā)執(zhí)行多次!
// 如果 factory 有副作用(如 DB 寫入),需要額外處理
return new ExpensiveObject(k);
});
// ? 如果 factory 有副作用,使用 Lazy<T> 確保只執(zhí)行一次
var lazy = dict.GetOrAdd(key, k =>
new Lazy<ExpensiveObject>(() => new ExpensiveObject(k)));
var obj = lazy.Value; // 真正的構(gòu)造只發(fā)生一次BlockingCollection:生產(chǎn)者-消費(fèi)者管道
var queue = new BlockingCollection<WorkItem>(boundedCapacity: 100);
// 生產(chǎn)者
Task producer = Task.Run(() =>
{
foreach (var item in GetWorkItems())
queue.Add(item);
queue.CompleteAdding(); // ?? 必須調(diào)用!否則消費(fèi)者永遠(yuǎn)阻塞
});
// 消費(fèi)者:GetConsumingEnumerable 會(huì)在 CompleteAdding 后自動(dòng)退出
Task consumer = Task.Run(() =>
{
foreach (var item in queue.GetConsumingEnumerable())
ProcessItem(item);
});
await Task.WhenAll(producer, consumer);P/Invoke 與 Native 互操作
P/Invoke 是調(diào)用 Windows API 或 C 庫的標(biāo)準(zhǔn)方式,但很多 .NET 開發(fā)者很少接觸。理解它的基本原理能幫你在需要時(shí)快速上手,也能讀懂底層庫的代碼。
最小化示例:調(diào)用 Windows API
using System.Runtime.InteropServices;
public static class NativeMethods
{
// DllImport 聲明:映射到 kernel32.dll 中的函數(shù)
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CreateDirectory(
string lpPathName,
IntPtr lpSecurityAttributes);
// 現(xiàn)代寫法(.NET 7+):LibraryImport + Source Generator(更快,AOT 友好)
[LibraryImport("kernel32.dll", SetLastError = true,
StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool CreateDirectoryModern(
string lpPathName,
IntPtr lpSecurityAttributes);
}最危險(xiǎn)的陷阱:委托被 GC 回收
將委托轉(zhuǎn)換為函數(shù)指針傳給 Native 代碼后,.NET GC 不知道 Native 代碼還在使用這個(gè)指針。如果委托對(duì)象被回收,程序會(huì)崩潰。
// ? 危險(xiǎn):委托可能在 Native 調(diào)用期間被回收
NativeMethods.RegisterCallback(
Marshal.GetFunctionPointerForDelegate(
new MyCallback(OnEvent))); // 匿名委托,無引用!
// ? 正確:持有委托的引用直到 Native 不再使用
private readonly MyCallback _callback = OnEvent; // 類級(jí)別字段
void Init()
{
var fnPtr = Marshal.GetFunctionPointerForDelegate(_callback);
NativeMethods.RegisterCallback(fnPtr);
GC.KeepAlive(_callback); // 明確告知 GC 此對(duì)象不可回收
}?? 跨平臺(tái)注意
C/C++ 的 long 在 Windows 上是 32 位,在 macOS/Linux 上是 64 位??缙脚_(tái)時(shí)應(yīng)使用 .NET 6+ 提供的 CLong / CULong 類型,而不是 int 或 C# 的 long。
內(nèi)存管理:GC、Dispose 與非托管資源
"C# 有 GC,不用管內(nèi)存" 是一個(gè)危險(xiǎn)的誤解。非托管資源(文件句柄、數(shù)據(jù)庫連接、網(wǎng)絡(luò)套接字)GC 不會(huì)自動(dòng)釋放,這是絕大多數(shù)內(nèi)存泄漏的根源。
標(biāo)準(zhǔn) Dispose 模式
持有非托管資源的類必須實(shí)現(xiàn) IDisposable。以下是經(jīng)典實(shí)現(xiàn)模式:
public class ResourceHolder : IDisposable
{
private IntPtr _nativeHandle; // 非托管資源
private Stream _managedStream; // 托管的 IDisposable
private bool _disposed = false;
// 公共方法:供調(diào)用方手動(dòng)釋放
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // 告知 GC 不必再調(diào)用析構(gòu)函數(shù)
}
// 核心釋放邏輯
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// 釋放托管資源(只在主動(dòng) Dispose 時(shí))
_managedStream?.Dispose();
}
// 釋放非托管資源(無論哪種路徑都要釋放)
if (_nativeHandle != IntPtr.Zero)
{
NativeFree(_nativeHandle);
_nativeHandle = IntPtr.Zero;
}
_disposed = true;
}
// 析構(gòu)函數(shù):GC 兜底(不能保證調(diào)用時(shí)機(jī))
~ResourceHolder() => Dispose(disposing: false);
}異步 Dispose:IAsyncDisposable
.NET Core 3.0+ 引入了 IAsyncDisposable,用于需要異步釋放資源的場(chǎng)景(如關(guān)閉網(wǎng)絡(luò)連接需要發(fā)送 FIN 包)。配合 await using 語法使用:
public class AsyncConnection : IAsyncDisposable
{
private readonly NetworkStream _stream;
public async ValueTask DisposeAsync()
{
await _stream.FlushAsync(); // 異步刷新緩沖區(qū)
await _stream.DisposeAsync(); // 異步關(guān)閉連接
}
}
// await using 確保無論是否異常都會(huì)調(diào)用 DisposeAsync
await using var conn = new AsyncConnection(endpoint);
await conn.SendAsync(data);?? 性能提示
返回 ValueTask 而不是 Task 可以在同步完成的情況下避免堆分配。當(dāng)你的異步方法大多數(shù)時(shí)候能同步完成(如緩存命中)時(shí),ValueTask 能顯著提升性能。
寫出健壯異步代碼的自查清單
在提交 PR 之前,不妨過一遍這份清單:
- TAP 方法名以
Async結(jié)尾,返回Task或Task<T> - 參數(shù)驗(yàn)證在 async 方法外層同步完成,不被包裹進(jìn) Task
- 接受
CancellationToken的方法在循環(huán)或 I/O 前檢查取消狀態(tài) - 返回
Task的方法不在同步路徑上長(zhǎng)時(shí)間阻塞 - 沒有
.Result或.Wait()(ASP.NET 環(huán)境中極易死鎖) - 持有非托管資源的類實(shí)現(xiàn)了
IDisposable,并在Dispose(false)中釋放非托管部分 - 向 Native 代碼傳遞的委托有足夠長(zhǎng)的生命周期(類字段或
GC.KeepAlive) - 使用
BlockingCollection時(shí)生產(chǎn)者最終調(diào)用了CompleteAdding() - 并發(fā)訪問
ConcurrentDictionary的復(fù)合操作用了適當(dāng)?shù)脑臃椒ɑ蜴i async void僅用于事件處理器,其他任何地方都應(yīng)返回Task
到此這篇關(guān)于.NET 進(jìn)階之路:異步、并發(fā)與內(nèi)存管理的系統(tǒng)性認(rèn)知的文章就介紹到這了,更多相關(guān).NET 進(jìn)階之路:異步、并發(fā)與內(nèi)存管理的系統(tǒng)性認(rèn)知內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
asp.net 光棒效應(yīng)實(shí)現(xiàn)代碼
asp.net 光棒效應(yīng)(今天剛剛學(xué)到的)2009-12-12
ASP.NET生成eurl.axd Http異常錯(cuò)誤的處理方法
在IIS6中同時(shí)啟用了ASP.NET 2.0 和 ASP.NET 4.0 后,網(wǎng)站程序可能會(huì)出現(xiàn)如下錯(cuò)誤:“ System.Web.HttpException: Path ‘//eurl.axd/‘ was not found. ”2011-05-05
asp.net基于HashTable實(shí)現(xiàn)購物車的方法
這篇文章主要介紹了asp.net基于HashTable實(shí)現(xiàn)購物車的方法,涉及asp.net中HashTable結(jié)合session實(shí)現(xiàn)購物車功能的相關(guān)技巧,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2015-12-12
ASP.NET MVC驗(yàn)證碼功能實(shí)現(xiàn)代碼
ASP.NET MVC驗(yàn)證碼功能實(shí)現(xiàn)代碼,需要的朋友可以參考一下2013-06-06
ASP.NET MVC3網(wǎng)站創(chuàng)建與發(fā)布(1)
這篇文章主要介紹了ASP.NET MVC3網(wǎng)站創(chuàng)建與發(fā)布,根據(jù)文章內(nèi)容大家可以實(shí)現(xiàn)發(fā)布網(wǎng)站,感興趣的小伙伴們可以參考一下2015-08-08
.Net獲取URL中文參數(shù)值的亂碼問題解決方法總結(jié)
這篇文章主要介紹了.Net獲取URL中文參數(shù)值的亂碼問題解決方法,總結(jié)分析了針對(duì)URL參數(shù)傳遞中出現(xiàn)的亂碼問題與相應(yīng)的解決方法,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2016-08-08
詳解ASP.NET Core和ASP.NET Framework共享身份驗(yàn)證
本篇文章主要介紹了詳解ASP.NET Core和ASP.NET Framework共享身份驗(yàn)證 ,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2016-12-12
Asp.net MVC實(shí)現(xiàn)生成Excel并下載功能
這篇文章主要為大家詳細(xì)介紹了Asp.net MVC實(shí)現(xiàn)生成Excel并下載功能,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-12-12
.NET Core讀取配置文件方式詳細(xì)總結(jié)
這篇文章主要為大家詳細(xì)總結(jié)了.NET Core讀取配置文件方式,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-08-08

