C# 异步编程深入解析
C#中的异步方法是我个人感觉坑最大最深的一个功能,内部有非常多的反直觉的逻辑。
1. 什么是异步方法
1.1 基本概念
异步方法是使用 async 和 await 关键字标记的方法,它允许在等待异步操作完成时不阻塞调用线程。异步方法通常返回 Task、Task<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线程上执行异步方法,你会发现所有等待后的逻辑都无法执行,因为线程不会等待消息而是会直接退出。