.NET 进阶之路:异步、并发与内存管理的系统性认知

异步编程模式的演进与 TAP 最佳实践

.NET 的异步编程经历了三个时代。理解这段历史不是为了考古,而是因为你在维护老代码时必然会遭遇它们,理解它们才能优雅地迁移。

模式 时代 标志 状态
APM(异步编程模型) .NET 1.x BeginXxx / EndXxx 已淘汰
EAP(基于事件的异步) .NET 2.0 XxxAsync + XxxCompleted 事件 遗留代码
TAP(基于任务的异步) .NET 4.0+ Task / async / await 推荐使用

TAP 方法的命名与签名规范

很多人写异步方法时忽视规范,导致 API 设计混乱。TAP 有一套严格的约定:

csharp 复制代码
// ✅ 标准命名:方法名 + 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 参数在 TAP 中禁止使用
// 应将多返回值包装为 tuple 或自定义类型
public Task<(bool Success, string Error)> TryParseAsync(string input);

Task 的生命周期:一个经常被忽视的细节

Task冷任务(Cold Task)热任务(Hot Task) 之分。new Task(...) 创建的是冷任务,需要手动调用 Start()。但 TAP 方法返回的 Task 必须是已激活的热任务------调用者不应该也不需要调用 Start()

⚠️ 常见错误

如果你在 TAP 方法内部通过 new Task() 构造任务后忘记调用 Start() 就返回它,调用者会陷入永久等待。始终确保返回的 Task 已处于运行状态。

异常处理的正确姿势

异步方法中的异常处理有一个重要原则:参数验证异常应该在 async 方法外层同步抛出,这样调用者能立即捕获,而不必 await 后才能发现错误。

csharp 复制代码
// ✅ 推荐:参数验证在外层同步完成
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,你可能已经在用 CancellationToken,但真正理解它的状态机和设计模式的人并不多。

CancellationToken 的三种终态

复制代码
          Task 状态机:
Created ──Start()──▶ Running
│
┌─────────────┼─────────────┐
▼             ▼             ▼
Canceled Faulted RanToCompletion
(取消请求) (未处理异常) (正常完成)
│             │             │
└─────────────┴─────────────┘
       IsCompleted = true

取消时 Task 进入 Canceled 状态,IsCompleted 返回 true,但 await 它会抛出 OperationCanceledException

最佳实践:在计算密集型任务中轮询取消

csharp 复制代码
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 的正确用法

不要用事件或回调来报告进度------IProgress<T> 是官方推荐的模式,它能自动处理线程同步问题(回调总是在创建 Progress<T> 实例的同步上下文中执行,通常是 UI 线程)。

csharp 复制代码
// 定义时接受 IProgress<T> 参数
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 安全调用
    }
    return results.ToArray();
}

// 调用端:Progress<T> 捕获 UI 线程的同步上下文
var reporter = new Progress<int>(count =>
    progressBar.Value = count); // 这里可以安全更新 UI

await FindFilesAsync("*.cs", ct, reporter);

💡 设计建议

如果某个方法不支持取消,不要提供接受 CancellationToken 的重载------这会误导调用者。反之,如果支持取消,应当始终提供带 token 的重载。

任务并行库(TPL)与 Parallel 编程

并行编程最大的陷阱是分不清 CPU 密集型I/O 密集型 任务,用错了工具反而更慢。

📌 核心原则

CPU 密集型Task.Run() 分发到线程池;I/O 密集型async/await + TaskCompletionSource,不应绑定线程。

父子任务与 DenyChildAttach

使用第三方库时,如果对方内部用 TaskCreationOptions.AttachedToParent 创建任务,会导致你的父任务必须等待所有子任务完成------即使你不需要这种行为。使用 DenyChildAttach 可以隔离这种副作用。

csharp 复制代码
// ❌ 默认行为:第三方 Widget 内部创建的子任务会延迟父任务完成
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 的适用场景

csharp 复制代码
// WhenAll:等待所有任务完成(并行 I/O 的核心武器)
var tasks = urls.Select(url => httpClient.GetStringAsync(url));
string[] results = await Task.WhenAll(tasks);

// WhenAny:哪个先完成用哪个(超时控制的经典写法)
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 优化缓存命中

当异步方法能从缓存直接返回时,创建真正的异步操作是不必要的开销。Task.FromResult() 返回一个已完成的任务,零开销。

csharp 复制代码
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 生产者-消费者场景 枚举不保证顺序稳定
BlockingCollection 有界缓冲 + 阻塞语义 需要配合 CompleteAdding() 正确关闭
ConcurrentBag 混合生产者-消费者(同线程添加取出) 纯生产消费场景比其他集合慢

ConcurrentDictionary 的非原子陷阱

这是很多人犯错的地方:ConcurrentDictionary 的所有单个方法是线程安全的,但复合操作("检查-然后-添加")不是原子的。

csharp 复制代码
// ⚠️ 注意:valueFactory 可能被多个线程调用
// 但只有一个线程的结果会被保留
var value = dict.GetOrAdd(key, k =>
{
    // 这里的代码可能被并发执行多次!
    // 如果 factory 有副作用(如 DB 写入),需要额外处理
    return new ExpensiveObject(k);
});

// ✅ 如果 factory 有副作用,使用 Lazy<T> 确保只执行一次
var lazy = dict.GetOrAdd(key, k =>
    new Lazy<ExpensiveObject>(() => new ExpensiveObject(k)));
var obj = lazy.Value; // 真正的构造只发生一次

BlockingCollection:生产者-消费者管道

csharp 复制代码
var queue = new BlockingCollection<WorkItem>(boundedCapacity: 100);

// 生产者
Task producer = Task.Run(() =>
{
    foreach (var item in GetWorkItems())
        queue.Add(item);

    queue.CompleteAdding(); // ⚠️ 必须调用!否则消费者永远阻塞
});

// 消费者:GetConsumingEnumerable 会在 CompleteAdding 后自动退出
Task consumer = Task.Run(() =>
{
    foreach (var item in queue.GetConsumingEnumerable())
        ProcessItem(item);
});

await Task.WhenAll(producer, consumer);

P/Invoke 与 Native 互操作

P/Invoke 是调用 Windows API 或 C 库的标准方式,但很多 .NET 开发者很少接触。理解它的基本原理能帮你在需要时快速上手,也能读懂底层库的代码。

最小化示例:调用 Windows API

csharp 复制代码
using System.Runtime.InteropServices;

public static class NativeMethods
{
    // DllImport 声明:映射到 kernel32.dll 中的函数
    [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    public static extern bool CreateDirectory(
        string lpPathName,
        IntPtr lpSecurityAttributes);

    // 现代写法(.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 回收

将委托转换为函数指针传给 Native 代码后,.NET GC 不知道 Native 代码还在使用这个指针。如果委托对象被回收,程序会崩溃。

csharp 复制代码
// ❌ 危险:委托可能在 Native 调用期间被回收
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

内存管理:GC、Dispose 与非托管资源

"C# 有 GC,不用管内存" 是一个危险的误解。非托管资源(文件句柄、数据库连接、网络套接字)GC 不会自动释放,这是绝大多数内存泄漏的根源。

标准 Dispose 模式

持有非托管资源的类必须实现 IDisposable。以下是经典实现模式:

csharp 复制代码
public class ResourceHolder : IDisposable
{
    private IntPtr _nativeHandle;     // 非托管资源
    private Stream _managedStream;    // 托管的 IDisposable
    private bool _disposed = false;

    // 公共方法:供调用方手动释放
    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this); // 告知 GC 不必再调用析构函数
    }

    // 核心释放逻辑
    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;
    }

    // 析构函数:GC 兜底(不能保证调用时机)
    ~ResourceHolder() => Dispose(disposing: false);
}

异步 Dispose:IAsyncDisposable

.NET Core 3.0+ 引入了 IAsyncDisposable,用于需要异步释放资源的场景(如关闭网络连接需要发送 FIN 包)。配合 await using 语法使用:

csharp 复制代码
public class AsyncConnection : IAsyncDisposable
{
    private readonly NetworkStream _stream;

    public async ValueTask DisposeAsync()
    {
        await _stream.FlushAsync();  // 异步刷新缓冲区
        await _stream.DisposeAsync(); // 异步关闭连接
    }
}

// await using 确保无论是否异常都会调用 DisposeAsync
await using var conn = new AsyncConnection(endpoint);
await conn.SendAsync(data);

💡 性能提示

返回 ValueTask 而不是 Task 可以在同步完成的情况下避免堆分配。当你的异步方法大多数时候能同步完成(如缓存命中)时,ValueTask 能显著提升性能。

写出健壮异步代码的自查清单

在提交 PR 之前,不妨过一遍这份清单:

  • TAP 方法名以 Async 结尾,返回 TaskTask<T>
  • 参数验证在 async 方法外层同步完成,不被包裹进 Task
  • 接受 CancellationToken 的方法在循环或 I/O 前检查取消状态
  • 返回 Task 的方法不在同步路径上长时间阻塞
  • 没有 .Result.Wait()ASP.NET 环境中极易死锁)
  • 持有非托管资源的类实现了 IDisposable,并在 Dispose(false) 中释放非托管部分
  • 向 Native 代码传递的委托有足够长的生命周期(类字段或 GC.KeepAlive
  • 使用 BlockingCollection 时生产者最终调用了 CompleteAdding()
  • 并发访问 ConcurrentDictionary 的复合操作用了适当的原子方法或锁
  • async void 仅用于事件处理器,其他任何地方都应返回 Task

📚 深入阅读

本文内容均来自 Microsoft 官方 .NET 高级编程文档。建议系统阅读 TAP 实现模式任务并行库P/Invoke 最佳实践三个章节,收益最大。

相关推荐
俊俊谢3 小时前
LabVIEW如何排查和修复dll缺失问题
驱动开发·.net·labview·dll
武藤一雄7 小时前
C# 设计模式大全(第一弹|7种)
microsoft·设计模式·微软·c#·.net·.netcore
麦壳饼10 小时前
JekyllNet .Net 版本的Jekyll , 你博客 文档的静态生成利器 。
.net
步步为营DotNet11 小时前
.NET 11 中 ASP.NET Core 10 在分布式系统中的安全通信与性能调优
安全·asp.net·.net
唐青枫13 小时前
C#.NET Consul + Steeltoe 深入解析:服务注册发现、健康检查与微服务接入
c#·.net
我是唐青枫15 小时前
C#.NET ConcurrentQueue<T> 深入解析:无锁队列原理、FIFO 语义与使用边界
c#·.net
时光追逐者15 小时前
一个基于 .NET Core + Vue3 构建的开源全栈平台 Admin 系统
开源·c#·.net·.netcore·admin系统
追逐时光者1 天前
一个基于 .NET Core + Vue3 构建的开源全栈平台 Admin 系统
后端·.net
light blue bird1 天前
多页签Razor组支轴业务整顿组件
数据库·.net·ai大数据·多功能图表报表·web mvc + razor