异步方法-C#中坑最大最深的功能

C# 异步编程深入解析

C#中的异步方法是我个人感觉坑最大最深的一个功能,内部有非常多的反直觉的逻辑。

1. 什么是异步方法

1.1 基本概念

异步方法是使用 asyncawait 关键字标记的方法,它允许在等待异步操作完成时不阻塞调用线程。异步方法通常返回 TaskTask<T>ValueTask<T>

1.2 语法特征

csharp 复制代码
public async Task<int> GetDataAsync()
{
    // 同步执行部分
    Console.WriteLine("开始获取数据");

    // 异步等待部分
    int data = await FetchDataFromNetworkAsync();

    // 异步等待后的 continuation
    Console.WriteLine("数据处理完成");
    return data;
}

1.3 设计目的

  • 提高响应性:UI线程不会被阻塞
  • 提高资源利用率:避免线程阻塞等待I/O操作
  • 简化异步编程模型:相比传统的回调模式更易理解和维护

2. 异步方法底层逻辑说明

2.1 线程调度机制

非常的反直觉,异步方法会切换执行的线程,会跳过一段代码直接执行后续的代码逻辑。

csharp 复制代码
internal class Program
{
    public static void Main()
    {
        Program p = new Program();
        p.AsyncTask();
        Console.WriteLine("主线程退出 id:" + Environment.CurrentManagedThreadId);
    }

    private async Task AsyncTask()
    {
        Console.WriteLine("执行异步方法 id:" + Environment.CurrentManagedThreadId);
        await Task.Delay(1);
        Console.WriteLine("await之后 id:" + Environment.CurrentManagedThreadId);
    }
}

执行后输出如下:

复制代码
执行异步方法 id:1
主线程退出 id:1
await之后 id:10

关键发现

  • await 之前的代码会在调用线程(主线程)上执行。
  • 当遇到 await 时,方法会暂停执行,但这不会阻塞调用线程。
  • 我们可以看到,主线程退出方法后继续执行,输出"主线程退出"。
  • await 之后的代码会在线程池中随机的一个线程上继续执行(这里是线程ID 10)。
  • 可以看到,虽然我们没有创建新线程,但异步方法的后续部分确实在不同的线程上运行。

2.2 状态机与编译器生成代码

个人感觉该逻辑不需要了解,异步方法的使用难点不在状态机的状态保留功能上,这里完全可以忽略。

3. 异步方法的使用方式

3.1 调用方式的对比

✅ 调用方式1:直接调用(fire-and-forget)

csharp 复制代码
public void ProcessData()
{
    // 异常无法捕获 可能造成"静默失败"
    ProcessDataAsync();
}

private async Task ProcessDataAsync()
{
    await Task.Delay(1000);
    throw new Exception("处理失败");
}

适用场景 :适用于不关心结果和异常的后台任务。
其他说明:若需要捕获异常,可在异步方法内部使用 try-catch。

✅ 调用方式2:直接调用 但获取了 Task 对象

csharp 复制代码
public void ProcessData()
{
    Task task = ProcessDataAsync();
    // 可以选择在后续某个时间点等待或检查任务状态
}

适用场景:适用于需要稍后检查任务状态或结果的场景。

✅ 调用方式3:阻塞等待

csharp 复制代码
public void ProcessData()
{
    // 当线程存在同步上下文时可能导致死锁
    ProcessDataAsync().Wait();

    // 或者
    object result = ProcessDataAsync().Result;
}

特点 :主线程会被阻塞直到异步方法完成。
适用场景:适用于控制台应用或无同步上下文的环境。

✅ 调用方式4:使用 Task.Run 包装

csharp 复制代码
public void ProcessDataInBackground()
{
    // 完全使用新线程执行异步方法
    // 异常无法捕获
    Task.Run(async () =>
    {
        await ProcessDataInternalAsync();
    });
}

适用场景:适用于需要在后台线程执行异步操作且不阻塞调用线程的场景。

其他说明:若需要捕获异常,可在异步方法内部使用 try-catch。

❌ 调用方法5:使用 Thread

csharp 复制代码
public void ProcessDataInNewThread()
{
    Thread thread = new Thread(() =>
    {
        // 这里无法使用 await 本质上还是同步阻塞
        ProcessDataInternalAsync().Wait();
    });
    thread.Start();
}

缺点:无法利用异步编程的优势,且线程创建开销较大。

3.2 Task 对象的灵活运用

多个Task的协调
csharp 复制代码
public async Task ExecuteMultipleTasksAsync()
{
    Task task1 = ProcessDataAsync();
    Task task2 = FetchDataAsync();
    Task task3 = ValidateDataAsync();

    // 等待所有任务完成
    await Task.WhenAll(task1, task2, task3);

    // 或者等待任意一个任务完成
    Task completedTask = await Task.WhenAny(task1, task2, task3);

    // 带超时的等待
    Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(30));
    Task finishedTask = await Task.WhenAny(task1, timeoutTask);
    if (finishedTask == timeoutTask)
    {
        throw new TimeoutException("操作超时");
    }
}
任务状态监控
csharp 复制代码
public async Task MonitorTaskProgressAsync()
{
    Task task = LongRunningOperationAsync();

    while (!task.IsCompleted)
    {
        Console.WriteLine($"任务状态: {task.Status}");
        await Task.Delay(1000);
    }

    if (task.IsFaulted)
    {
        Console.WriteLine($"任务失败: {task.Exception?.Message}");
    }
    else if (task.IsCanceled)
    {
        Console.WriteLine("任务被取消");
    }
    else
    {
        Console.WriteLine("任务完成");
        object result = await task; // 对于 Task<T>,可以获取结果
    }
}

3.3 异步方法的异常处理

异常传播机制

csharp 复制代码
public static void Main()
{
    Task testTask = Task.Run(TestAsync);

    // 方式1:使用 Wait() 捕获 AggregateException
    try
    {
        testTask.Wait();
    }
    catch (AggregateException ae)
    {
        Console.WriteLine($"AggregateException: {ae.InnerException?.Message}");
    }

    // 方式2:使用 await 捕获原始异常 只能在异步方法中使用
    try
    {
        await testTask;
    }
    catch (ApplicationException ex)
    {
        Console.WriteLine($"ApplicationException: {ex.Message}");
    }
}

private static async Task TestAsync()
{
    for (int i = 0; i < 10; i++)
    {
        Console.WriteLine(i);
        await Task.Delay(1000);
        if (i == 8)
            throw new ApplicationException("异步方法异常");
    }
}

多个任务的异常处理

csharp 复制代码
public async Task HandleMultipleTaskExceptionsAsync()
{
    Task task1 = TaskThatFailsAsync();
    Task task2 = AnotherTaskThatFailsAsync();
    Task task3 = Task.Delay(500); // 假设这是一个不会抛异常的任务

    try
    {
        // 等待所有任务完成,若有异常会聚合抛出
        await Task.WhenAll(task1, task2, task3);
    }
    catch
    {
        // Task.WhenAll抛出的是第一个异常,但所有任务的异常都被保存在各自的Exception属性中
        List<Exception> exceptions = new List<Exception>();
        if (task1.Exception != null)
            exceptions.AddRange(task1.Exception.InnerExceptions);
        if (task2.Exception != null)
            exceptions.AddRange(task2.Exception.InnerExceptions);
        if (task3.Exception != null)
            exceptions.AddRange(task3.Exception.InnerExceptions);

        foreach (var ex in exceptions)
        {
            Console.WriteLine($"捕获到异常: {ex.Message}");
        }
    }
}

private async Task TaskThatFailsAsync()
{
    await Task.Delay(100);
    throw new InvalidOperationException("任务1失败");
}

private async Task AnotherTaskThatFailsAsync()
{
    await Task.Delay(200);
    throw new ApplicationException("任务2失败");
}

4. 线程池与异步方法

4.1 线程池基础

异步方法不会创建新线程,而是使用的内置线程池来调度任务。

C#(更准确地说,是 .NET 框架/ .NET Core)自带了一个由 CLR 管理和维护的全局线程池 。这个线程池在应用程序启动时就被初始化好了,无需手动创建。通过 System.Threading.ThreadPool 这个静态类访问。

因此,我们需要在使用异步方法时,了解当前线程池的状态,避免因线程池耗尽而导致的性能问题。

csharp 复制代码
// 查看线程池状态
ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxCompletionPortThreads);
ThreadPool.GetAvailableThreads(out int availableWorkerThreads, out int availableCompletionPortThreads);

Console.WriteLine($"最大工作线程: {maxWorkerThreads}");
Console.WriteLine($"最大I/O线程: {maxCompletionPortThreads}");
Console.WriteLine($"可用工作线程: {availableWorkerThreads}");
Console.WriteLine($"可用I/O线程: {availableCompletionPortThreads}");

4.2 线程池耗尽问题

csharp 复制代码
public async Task DemonstrateThreadPoolStarvationAsync()
{
    List<Task> tasks = new List<Task>();

    // 创建大量阻塞线程池线程的任务
    for (int i = 0; i < 1000; i++)
    {
        tasks.Add(Task.Run(async () =>
        {
            // 模拟同步阻塞操作
            Thread.Sleep(5000);
            await Task.Delay(100);
        }));
    }

    // 此时线程池可能已耗尽 新的异步操作会被阻塞
    try
    {
        await Task.WhenAll(tasks);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"执行失败: {ex.Message}");
    }
}

应尽量避免在异步方法中使用同步阻塞操作,如 Thread.Sleep 等所有长时间占用线程的操作。

5. 同步上下文(SynchronizationContext)

同步上下文提供了将代码执行封送到特定线程的机制。在UI应用程序中,这确保了代码在UI线程上执行以操作控件;在ASP.NET中,它维护了HttpContext等请求上下文。

5.1 同步上下文的作用

UI同步上下文(Windows Forms/WPF的UI线程)

csharp 复制代码
public async Task UIContextExampleAsync()
{
    Console.WriteLine($"Before await - Thread: {Thread.CurrentThread.ManagedThreadId}, " +
                     $"Context: {SynchronizationContext.Current?.GetType().Name ?? "null"}");

    await Task.Delay(1000);

    // 默认会回到原始UI线程
    Console.WriteLine($"After await - Thread: {Thread.CurrentThread.ManagedThreadId}, " +
                     $"Context: {SynchronizationContext.Current?.GetType().Name ?? "null"}");
}

带有同步上下文后,可以确保 await 之后的代码在UI线程上继续执行,避免跨线程操作UI控件的问题。

无同步上下文 (控制台应用/ASP.NET Core/或Windows Forms/WPF中的非UI线程)

csharp 复制代码
public async Task NoContextExampleAsync()
{
    Console.WriteLine($"Before await - Thread: {Thread.CurrentThread.ManagedThreadId}, " +
                     $"Context: {SynchronizationContext.Current?.GetType().Name ?? "null"}");

    await Task.Delay(1000);

    // 不保证是同一线程
    // 当线程为ASP.NET Core的请求线程时 执行后虽然线程不同 但请求上下文会被保留
    Console.WriteLine($"After await - Thread: {Thread.CurrentThread.ManagedThreadId}, " +
                     $"Context: {SynchronizationContext.Current?.GetType().Name ?? "null"}");
}

ConfigureAwait方法

可以使用 ConfigureAwait(false) 无视同步上下文,直接在线程池线程上继续执行。

csharp 复制代码
await SomeAsyncOperation().ConfigureAwait(false);

5.3 同步上下文导致的死锁

在有同步上下文的环境中,使用异步方法需要非常谨慎,避免使用同步阻塞等待(如 Wait().Result)来等待异步方法完成。

csharp 复制代码
// ❌ 死锁案例
public void DeadlockExample()
{
    // 假设在UI线程或ASP.NET请求上下文中调用
    Task task = ProcessAsync();
    task.Wait(); // 同步阻塞等待任务完成

    // 死锁原因:
    // 1. 主线程被Wait()阻塞
    // 2. ProcessAsync()完成后尝试回到原始同步上下文
    // 3. 但原始上下文被阻塞,无法处理回调
    // 4. 形成互相等待的死锁
}

private async Task ProcessAsync()
{
    await Task.Delay(1000);
    // 这里会尝试通过同步上下文回到原始线程
    // 但由于原始线程被阻塞,永远无法执行到这里
}

6. 确保Windows Forms/WPF程序中仅有一个UI线程

虽然理论上可以通过自定义 SynchronizationContext,为其他UI线程"模拟"一个同步上下文,但这远远不够。

除了同步上下文之外,Windows Forms/WPF 的 UI 线程还依赖于其他关键机制:

  • 消息循环(Message Loop)
    WinForms/WPF 的 UI 线程核心是消息循环(如 Application.Run()),它负责处理窗口消息、控件事件、重绘等。如果没有消息循环,UI线程无法响应用户操作和系统事件。
  • 窗口句柄和消息分发
    控件的创建、消息的分发、事件的触发都依赖于窗口句柄和底层的消息机制。仅有同步上下文无法驱动这些机制。
  • 线程亲和性
    UI控件只能在创建它们的线程上操作。即使人为添加同步上下文,也无法绕过控件的线程亲和性检查。
  • 异常处理和资源管理
    UI线程还涉及异常分发、资源释放等复杂流程,这些都不是同步上下文能解决的。

因此,若准备使用异步方法,一定要保证程序中仅有一个UI线程,否则会在异步方法中遇到各种不可预见的问题。例如,当你尝试在缺少消息循环的的UI线程上执行异步方法,你会发现所有等待后的逻辑都无法执行,因为线程不会等待消息而是会直接退出。

相关推荐
爱写代码的小朋友1 小时前
21天学通Python全栈开发实战指南
开发语言·python
软件测试曦曦1 小时前
使用Python接口自动化测试post请求和get请求,获取请求返回值
开发语言·自动化测试·软件测试·python·功能测试·程序人生·职场和发展
p***s911 小时前
Windows安装Rust环境(详细教程)
开发语言·windows·rust
卡比巴拉—林2 小时前
Python print()函数详讲
开发语言·python
奶思图米球2 小时前
Python多环境管理
开发语言·python
JienDa2 小时前
JienDa聊PHP:基于协同架构的PHP主流框架优势整合与劣势补救策略
开发语言·架构·php
i***39582 小时前
JAVA系统中Spring Boot 应用程序的配置文件:application.yml
java·开发语言·spring boot
时光追逐者2 小时前
C# 中 ?、??、??=、?: 、?. 、?[] 各种问号的用法和说明
开发语言·c#·.net·.net core
量化Mike2 小时前
【python报错】解决卸载Python时报错问题:No Python installation was detected
开发语言·python