-
C#网络编程之异步编程常见陷阱(死锁、资源泄漏)
第五部分:网络编程高级特性
第11章 异步编程常见陷阱
11.1 异步编程常见陷阱(死锁、资源泄漏)
一、我踩过的异步“坑”:死锁让服务器直接崩溃
刚做异步HTTP服务器时,我在控制器方法里用了Task.Result,结果压测时服务器直接死锁——所有线程都被阻塞,请求全部超时。后来排查了3天,才发现是同步上下文导致的死锁。这节我把自己踩过的坑、踩坑后的总结都揉进去,用大白话讲vb.net教程C#教程python教程SQL教程access 2010教程透异步编程的常见陷阱,结合代码示例,让你避开这些坑,写出稳定的异步程序。
二、陷阱一:死锁——异步编程的“头号杀手”
死锁是异步编程最常见的问题,也是最难排查的问题之一。死锁的本质是两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行。
-
最常见的死锁:同步上下文+Task.Wait()/Task.Result
这是异步编程中最容易犯的错误,尤其是在UI线程或ASP.NET(非Core)中。
场景:在有同步上下文的线程(比如UI线程、ASP.NET线程)里,用Task.Wait()或Task.Result等待异步方法完成。
示例:UI线程的死锁
csharp
using System;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace AsyncDeadlockDemo;
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
// 异步方法:模拟网络请求
private async Task<string> GetDataAsync()
{
await Task.Delay(1000); // 模拟网络延迟
return "Hello World";
}
// 按钮点击事件:同步调用异步方法,导致死锁
private void btnGetData_Click(object sender, EventArgs e)
{
// 错误:用Task.Result等待异步方法完成,导致死锁
string data = GetDataAsync().Result;
MessageBox.Show(data);
}
}
死锁的原因:
1.GetDataAsync()被调用,执行到await Task.Delay(1000),捕获当前的UI线程同步上下文;
2.Task.Delay(1000)完成后,GetDataAsync()需要回到UI线程上下文继续执行;
3.但UI线程被GetDataAsync().Result阻塞,无法处理GetDataAsync()的后续代码;
4.结果就是GetDataAsync()等UI线程释放,UI线程等GetDataAsync()返回,互相等,死锁。
正确做法:用await调用异步方法,不要用Task.Wait()或Task.Result
csharp
// 正确:用await调用异步方法,不会导致死锁
private async void btnGetData_Click(object sender, EventArgs e)
{
string data = await GetDataAsync();
MessageBox.Show(data);
}
-
另一种死锁:嵌套异步方法的同步等待
示例:嵌套异步方法的死锁
csharp
// 异步方法A
private async Task<string> MethodAAsync()
{
await Task.Delay(1000);
return "A";
}
// 异步方法B:调用方法A
private async Task<string> MethodBAsync()
{
return await MethodAAsync();
}
// 同步方法:调用方法B,导致死锁
private string MethodC()
{
// 错误:用Task.Result等待方法B完成,导致死锁
return MethodBAsync().Result;
}
解决方法:把同步方法改成异步方法,用await调用
csharp
// 正确:把同步方法改成异步方法
private async Task<string> MethodCAsync()
{
return await MethodBAsync();
}
-
如何避免死锁?
永远不要在有同步上下文的线程里用Task.Wait()或Task.Result;
所有等待都要用await,而不是同步等待;
在异步方法里用ConfigureAwait(false),告诉await不需要回到原来的同步上下文(UI线程除外)。
示例:用ConfigureAwait(false)避免死锁
csharp
// 异步方法:用ConfigureAwait(false)避免回到原来的同步上下文
private async Task<string> GetDataAsync()
{
// 用ConfigureAwait(false),不需要回到原来的同步上下文
await Task.Delay(1000).ConfigureAwait(false);
return "Hello World";
}
// 同步方法:调用异步方法,不会导致死锁(因为GetDataAsync()不需要回到原来的同步上下文)
private string MethodC()
{
// 这里不会死锁,但不推荐这么做——还是应该用await调用
return GetDataAsync().Result;
}
注意:在UI线程里不要用ConfigureAwait(false),否则更新UI的代码会在非UI线程里执行,导致异常。
三、陷阱二:资源泄漏——异步编程的“隐形杀手”
资源泄漏是指程序在使用完资源后没有正确释放,导致资源被永久占用,最终耗尽系统资源(比如内存、文件句柄、网络连接)。
-
最常见的资源泄漏:忘记释放异步资源
示例:忘记释放HttpClient
csharp
// 错误:每次请求都创建新的HttpClient,导致端口耗尽
public async Task<string> GetDataAsync()
{
var httpClient = new HttpClient();
var response = await httpClient.GetAsync("https://example.com");
return await response.Content.ReadAsStringAsync();
}
问题:HttpClient会创建一个底层的HttpMessageHandler,而HttpMessageHandler会占用TCP端口——每次创建新的HttpClient,会导致端口被占用,最终端口耗尽,无法发起新的网络请求。
正确做法:用单例HttpClient或IHttpClientFactory
csharp
// 正确:用单例HttpClient,避免端口耗尽
private static readonly HttpClient _httpClient = new HttpClient();
public async Task<string> GetDataAsync()
{
var response = await _httpClient.GetAsync("https://example.com");
return await response.Content.ReadAsStringAsync();
}
或者用IHttpClientFactory(推荐):
csharp
// 在Program.cs中注册IHttpClientFactory
builder.Services.AddHttpClient();
// 在控制器中注入IHttpClientFactory
private readonly IHttpClientFactory _httpClientFactory;
public UsersController(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<string> GetDataAsync()
{
var httpClient = _httpClientFactory.CreateClient();
var response = await httpClient.GetAsync("https://example.com");
return await response.Content.ReadAsStringAsync();
}
-
另一种资源泄漏:忘记取消异步操作
如果异步操作需要很长时间才能完成,而用户已经取消了操作(比如关闭了窗口),但程序没有取消异步操作,会导致资源被浪费。
示例:忘记取消异步操作
csharp
// 异步方法:模拟长时间的网络请求
private async Task<string> LongRunningTaskAsync()
{
await Task.Delay(10000); // 模拟10秒的网络请求
return "Done";
}
// 窗口关闭事件:忘记取消异步操作
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
// 错误:没有取消正在执行的异步操作,导致异步操作继续执行,浪费资源
}
正确做法:用CancellationToken取消异步操作
csharp
private CancellationTokenSource _cts;
// 按钮点击事件:启动异步操作
private async void btnStart_Click(object sender, EventArgs e)
{
_cts = new CancellationTokenSource();
try
{
string result = await LongRunningTaskAsync(_cts.Token);
MessageBox.Show(result);
}
catch (OperationCanceledException)
{
MessageBox.Show("操作已取消");
}
}
// 异步方法:支持CancellationToken
private async Task<string> LongRunningTaskAsync(CancellationToken cancellationToken)
{
// 把CancellationToken传给异步操作
await Task.Delay(10000, cancellationToken);
return "Done";
}
// 窗口关闭事件:取消异步操作
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
_cts?.Cancel();
_cts?.Dispose();
}
-
如何避免资源泄漏?
用using语句释放实现了IDisposable的资源:比如HttpClient、SqlConnection、Stream等;
用单例或池化资源:比如HttpClient、DbConnection等,避免频繁创建和销毁;
用CancellationToken取消不需要的异步操作:比如用户取消操作、窗口关闭时;
避免异步方法的无限循环:比如while(true)没有退出条件,导致异步方法永远不完成,资源被永久占用。
四、陷阱三:异步方法的返回值错误
异步方法的返回值必须是Task、Task或ValueTask ,如果返回值错误,会导致程序行为异常。 -
错误:异步方法返回void(除了事件处理程序)
示例:异步方法返回void
csharp
// 错误:异步方法返回void,无法捕获异常,也无法等待它完成
private async void GetDataAsync()
{
await Task.Delay(1000);
throw new Exception("错误");
}
问题:
无法用await等待这个方法完成;
方法抛出的异常无法被捕获,会导致程序崩溃;
无法知道方法是否完成。
正确做法:异步方法返回Task或Task
csharp
// 正确:异步方法返回Task,能捕获异常,也能等待它完成
private async Task GetDataAsync()
{
await Task.Delay(1000);
throw new Exception("错误");
}
// 调用异步方法,捕获异常
private async void btnGetData_Click(object sender, EventArgs e)
{
try
{
await GetDataAsync();
}
catch (Exception ex)
{
MessageBox.Show($"错误:{ex.Message}");
}
}
-
错误:异步方法返回null
示例:异步方法返回null
csharp
// 错误:异步方法返回null,导致NullReferenceException
private Task<string> GetDataAsync()
{
return null;
}
// 调用异步方法,导致NullReferenceException
private async void btnGetData_Click(object sender, EventArgs e)
{
string data = await GetDataAsync();
}
正确做法:异步方法返回Task.FromResult或Task.CompletedTask
csharp
// 正确:异步方法返回Task.FromResult,不会返回null
private Task<string> GetDataAsync()
{
return Task.FromResult("Hello World");
}
// 无返回值的异步方法,返回Task.CompletedTask
private Task DoSomethingAsync()
{
return Task.CompletedTask;
}
五、陷阱四:异步方法的异常处理
异步方法的异常处理和同步方法不同,如果处理不当,会导致异常被忽略,程序行为异常。
-
错误:忽略异步方法的异常
示例:忽略异步方法的异常
csharp
// 错误:调用异步方法但不等待,异常被忽略
private void btnGetData_Click(object sender, EventArgs e)
{
GetDataAsync(); // 异常被忽略,程序不会崩溃,但也不会处理异常
}
private async Task GetDataAsync()
{
await Task.Delay(1000);
throw new Exception("错误");
}
正确做法:用await调用异步方法,捕获异常
csharp
// 正确:用await调用异步方法,捕获异常
private async void btnGetData_Click(object sender, EventArgs e)
{
try
{
await GetDataAsync();
}
catch (Exception ex)
{
MessageBox.Show($"错误:{ex.Message}");
}
}
-
错误:在异步方法里用try-catch包裹await
示例:在异步方法里用try-catch包裹await
csharp
// 错误:try-catch包裹await,导致异常被捕获,但无法传递给调用者
private async Task<string> GetDataAsync()
{
try
{
await Task.Delay(1000);
throw new Exception("错误");
}
catch (Exception ex)
{
// 异常被捕获,但没有重新抛出,调用者无法知道发生了错误
return "Error";
}
}
正确做法:要么在异步方法里处理异常,要么重新抛出异常
csharp
// 正确:重新抛出异常,让调用者处理
private async Task<string> GetDataAsync()
{
try
{
await Task.Delay(1000);
throw new Exception("错误");
}
catch (Exception ex)
{
// 记录日志
Console.WriteLine($"异常:{ex.Message}");
// 重新抛出异常
throw;
}
}
六、基础知识拓展
-
什么是同步上下文(SynchronizationContext)?
同步上下文是一个抽象类,用来表示线程的上下文,比如UI线程的同步上下文会保证代码在UI线程执行,ASP.NET的同步上下文会保证代码在同一个请求的线程执行。
查看当前线程的同步上下文:
csharp
SynchronizationContext currentContext = SynchronizationContext.Current;
if (currentContext != null)
{
Console.WriteLine("当前线程有同步上下文");
}
else
{
Console.WriteLine("当前线程没有同步上下文");
}
注意:线程池线程默认没有同步上下文,异步方法在没有同步上下文的线程里执行时,await不会捕获同步上下文,而是在线程池线程里继续执行。
2. 如何检测死锁?
Visual Studio调试:在调试时,查看线程窗口,看是否有线程处于“等待”状态,并且互相等待;
用工具检测:比如Visual Studio的“并发可视化工具”、JetBrains dotTrace等;
日志:在异步方法的关键步骤打日志,看是否有步骤没有执行。
3. 异步方法的最佳实践
异步方法名以Async结尾:比如GetDataAsync、SaveDataAsync;
异步方法不要返回void:除了事件处理程序,异步方法应该返回Task或Task
用ConfigureAwait(false)提高性能:在不需要回到同步上下文的异步方法里,用ConfigureAwait(false);
用CancellationToken支持取消操作:所有长时间运行的异步方法都应该支持CancellationToken;
用using语句释放资源:所有实现了IDisposable的资源都应该用using语句释放。
七、总结:异步编程的“避坑指南”
1.避免死锁:永远不要在有同步上下文的线程里用Task.Wait()或Task.Result,所有等待都要用await;
2.避免资源泄漏:用using语句释放资源,用单例或池化资源,用CancellationToken取消不需要的异步操作;
3.正确处理返回值:异步方法返回Task或Task
4.正确处理异常:用await调用异步方法,捕获异常,不要忽略异常;
5.最佳实践:异步方法名以Async结尾,用ConfigureAwait(false)提高性能,用CancellationToken支持取消操作。
本站原创,转载请注明出处:https://www.xin3721.com/ArticlecSharp/c49526.html










