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

