尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C#异步编程演进:从APM回调地狱到async/await同步写法

C#异步编程演进:从APM回调地狱到async/await同步写法 1. 从“回调地狱”到“同步写法”C#异步编程的演进之路干了这么多年C#开发要说最让我感觉“天亮了”的特性async/await绝对排第一。这玩意儿彻底改变了我们写异步代码的方式让那些曾经缠绕不清的回调、状态管理变得清晰无比。但很多刚入行的朋友可能只知道async/await好用却不清楚C#的异步编程是怎么一步步走到今天的。今天我就结合自己踩过的坑和项目里的实际应用掰开揉碎了聊聊C#异步编程模型的变迁特别是从古老的APM异步编程模型到如今主流的TAP基于任务的异步模式这段历程。无论你是正在维护遗留代码还是想深入理解async/await背后的机制这篇文章应该都能给你一些启发。2. 异步编程的“史前时代”APM模式与回调的泥潭在.NET Framework早期处理I/O密集型操作比如文件读写、网络请求时为了不阻塞主线程微软引入了一套标准的异步编程模型也就是我们常说的APMAsynchronous Programming Model。它的核心模式是BeginXXX和EndXXX方法对。2.1 APM模式的核心机制与典型代码举个例子在System.Net命名空间下WebRequest类获取响应就是一个经典的APM实现。public void GetDataTheOldWay() { WebRequest request WebRequest.Create(http://example.com); // 开始异步操作传入一个异步回调委托 IAsyncResult asyncResult request.BeginGetResponse(new AsyncCallback(ResponseCallback), request); // 主线程可以继续做其他事情不会被阻塞 Console.WriteLine(请求已发起主线程继续执行...); } // 回调方法当异步操作完成时由线程池线程调用 private static void ResponseCallback(IAsyncResult ar) { WebRequest request (WebRequest)ar.AsyncState; try { // 结束异步操作获取结果。这里可能会阻塞直到操作真正完成。 WebResponse response request.EndGetResponse(ar); using (StreamReader reader new StreamReader(response.GetResponseStream())) { string content reader.ReadToEnd(); Console.WriteLine($获取到数据长度{content.Length}); // 注意这里无法直接更新UI线程的控件 } } catch (WebException ex) { Console.WriteLine($请求失败{ex.Message}); } }这段代码的“坑”在哪里首先业务逻辑被硬生生拆成了两段发起请求的GetDataTheOldWay和处理结果的ResponseCallback。这还只是一个操作如果我们需要在获取响应后再用响应里的某个ID去发起第二个请求代码就会变成“回调套回调”可读性急剧下降这就是所谓的“回调地狱”Callback Hell。其次ResponseCallback是在线程池的某个线程上执行的这意味着如果你在WinForm或WPF程序中想在这里更新一个文本框TextBox的内容必须通过Control.Invoke或Dispatcher.Invoke来封送回UI线程否则就会引发跨线程访问异常。这又增加了代码的复杂度和出错几率。最后错误处理变得非常棘手。异常发生在回调方法内部你需要小心地在回调里用try-catch包裹EndGetResponse并且很难将错误优雅地传递回主流程。2.2 EAP模式事件的引入与局限为了改善APM的使用体验.NET Framework 2.0引入了基于事件的异步模式Event-based Asynchronous Pattern, EAP。这种模式的代表是带有Async后缀的方法和对应的Completed事件比如WebClient组件的DownloadStringAsync方法。public void GetDataWithEAP() { WebClient client new WebClient(); client.DownloadStringCompleted Client_DownloadStringCompleted; client.DownloadStringAsync(new Uri(http://example.com)); Console.WriteLine(请求已发起EAP...); } private void Client_DownloadStringCompleted(object sender, DownloadStringCompletedEventArgs e) { if (e.Error ! null) { Console.WriteLine($EAP请求失败{e.Error.Message}); } else { string result e.Result; Console.WriteLine($EAP获取到数据{result.Substring(0, Math.Min(50, result.Length))}...); } }EAP模式通过事件机制将回调逻辑以事件处理程序的形式组织比APM的直接回调在结构上清晰一些。而且像WebClient这类组件在UI应用程序中会自动将完成事件封送到发起调用的线程通常是UI线程这解决了跨线程更新UI的问题。但是EAP的痛点依然明显状态管理复杂如果你需要顺序执行多个异步操作比如先登录再获取列表你不得不在事件处理程序里手动触发下一个操作导致代码逻辑仍然分散在各个事件处理方法中难以维护。组合能力差很难优雅地实现“等待所有操作完成”或“等待任一操作完成”这样的常见场景。资源绑定事件订阅如果忘记取消可能导致对象无法被垃圾回收引发内存泄漏。3. TAP模式的革命Task与async/await的登场.NET Framework 4.0引入了Task和TaskT类型标志着基于任务的异步模式Task-based Asynchronous Pattern, TAP的诞生。而C# 5.0推出的async和await关键字则让TAP模式变得无比易用堪称异步编程的“语法糖核弹”。3.1 Task是什么为什么是它Task代表一个异步操作。你可以把它看作一个“未来值的承诺”Promise。它比之前的IAsyncResult更强大状态查询可以通过Status属性查看任务是否完成、被取消或出错。延续任务可以使用ContinueWith方法指定任务完成后要执行的动作这提供了基础的组合能力。结果获取通过Result属性对于TaskT可以阻塞地获取结果但强烈不推荐在主线程上直接使用会导致死锁。async/await并不是取代了Task而是在语言层面为Task提供了无与伦比的支持。编译器会将async方法编译成一个状态机使得我们用看似同步的代码写法实现了真正的异步执行。3.2 一个完整的async/await示例与解析让我们用最新的HttpClient重写之前的例子public async Taskstring GetDataWithTAPAsync() { // 注意方法签名中的 async 和 返回类型 Taskstring using (HttpClient client new HttpClient()) { try { Console.WriteLine(开始发起TAP异步请求...); // 关键点await // 这里会发起一个真正的异步I/O操作。当遇到await时方法会在此处“挂起” // 并将控制权返回给调用者。但并不是阻塞线程 // 线程很可能是UI线程或请求线程会被释放去处理其他工作。 string responseBody await client.GetStringAsync(http://example.com); // 当网络请求完成数据就绪后执行会从这里恢复。 // 神奇的是在UI上下文中如WinForm、WPF恢复默认会在原来的UI线程上无需手动Invoke。 // 在ASP.NET Core等非UI上下文中恢复的线程可能是线程池中的任意线程。 Console.WriteLine($TAP模式获取到数据长度{responseBody.Length}); return responseBody; } catch (HttpRequestException ex) { // 异步方法中的异常可以像同步方法一样用try-catch捕获这是巨大的进步 Console.WriteLine($TAP请求异常{ex.Message}); return string.Empty; } } } // 调用方 public async void Button_Click(object sender, EventArgs e) { // 调用异步方法同样用await等待 string data await GetDataWithTAPAsync(); textBoxResult.Text data; // 直接更新UI安全 }这段代码好在哪里线性逻辑代码的书写顺序和执行顺序高度一致先请求后处理结果再返回。彻底告别了回调。自然的错误处理使用标准的try-catch块即可捕获异步操作中发生的异常错误处理流程和同步代码完全一致。自动的上下文流转在UI程序中await之后的代码默认会回到UI线程执行开发者无需关心线程切换。在ASP.NET Core中它也能很好地保持HttpContext等请求上下文但要注意在非标准场景下可能丢失后面会讲。强大的组合能力可以轻松地组合多个异步操作。3.3 异步操作的组合WhenAll与WhenAny这是TAP模式组合能力的集中体现也是项目中最常用的模式之一。public async Taskstring AggregateDataAsync() { HttpClient client new HttpClient(); var task1 client.GetStringAsync(http://api.example.com/data1); var task2 client.GetStringAsync(http://api.example.com/data2); var task3 client.GetStringAsync(http://api.example.com/data3); // 等待所有任务完成 string[] allResults await Task.WhenAll(task1, task2, task3); return string.Join(, , allResults.Select(s s.Length.ToString())); // 或者等待任意一个任务完成 // Taskstring firstFinishedTask await Task.WhenAny(task1, task2, task3); // return await firstFinishedTask; // 获取第一个完成的结果 }Task.WhenAll和Task.WhenAny本身返回的也是Task因此可以继续用await来等待使得并发控制代码极其简洁。4. 深入async/await原理、配置与高级用法理解了基本用法我们还需要深入一层知道怎么用好它避免踩坑。4.1 async/await状态机简析当你标记一个方法为async时编译器会对你“施魔法”。它将这个方法的代码重写为一个实现了IAsyncStateMachine接口的状态机类。这个状态机负责在遇到第一个await其等待的对象尚未完成时记录当前执行位置和局部变量然后返回一个Task给调用者。在内部异步操作如网络请求完成后通过回调机制通知状态机。状态机从线程池获取一个线程或在UI同步上下文中回到UI线程恢复执行点并还原之前的局部变量状态。所以async方法并不是“另起一个线程”去运行它的核心是“在等待时释放当前线程”。I/O操作文件、网络由操作系统底层完成不占用.NET线程池线程CPU密集型计算则需要手动用Task.Run卸载到线程池。4.2 ConfigureAwait(false)性能优化与死锁规避这是async/await中最重要的高级配置没有之一。public async Taskstring GetDataOptimizedAsync() { using (var client new HttpClient()) { // 关键配置ConfigureAwait(false) string data await client.GetStringAsync(http://example.com).ConfigureAwait(false); // 注意执行恢复到这里时上下文不再是原始的UI上下文或ASP.NET请求上下文。 // 它会在线程池的任意线程上恢复。 ProcessData(data); // 这是一个CPU密集型操作在线程池线程运行很好。 // 但如果这里需要更新UI就必须用Invoke/BeginInvoke封送回去。 // await Dispatcher.InvokeAsync(() textBox.Text data); return data; } }为什么要用ConfigureAwait(false)避免死锁尤其在库代码中这是最著名的场景。想象一个库方法在UI线程中被调用它await了一个任务并且该任务需要在UI线程上恢复默认行为。如果UI线程此时正同步阻塞地等待这个库方法完成例如调用.Result或.Wait()就会发生死锁UI线程在等任务完成任务在等UI线程空闲。ConfigureAwait(false)告诉任务“完成后不必回到原始上下文”从而打破了这种循环等待。重要提示在编写通用类库.NET Standard/Core时除非明确需要操作上下文相关对象如UI控件的Dispatcher否则应对所有await使用ConfigureAwait(false)。这是微软官方的编码规范。轻微的性能提升避免不必要的上下文切换尤其是回到UI线程的封送操作可以带来一点点性能好处。对于服务器端应用这有助于提高吞吐量。什么时候不能用ConfigureAwait(false)当await之后的代码必须在特定的同步上下文中执行时。最常见的就是在UI线程中更新控件。如果你在await后面直接写textBox.Text data并且前面用了ConfigureAwait(false)那么大概率会触发跨线程访问异常。4.3 返回值类型Task, Task , ValueTaskTask表示一个没有返回值的异步操作。TaskT表示一个返回T类型结果的异步操作。ValueTaskT从.NET Core 2.0开始引入是一个结构体struct。它的设计目的是为了优化高频调用且可能同步完成的异步方法。因为Task是引用类型频繁分配可能带来GC压力。如果异步方法有很大概率直接从缓存或内存中返回结果同步完成使用ValueTaskT可以避免不必要的堆分配。// 传统方式 public async Taskint GetCachedDataAsync() { if (_cache.TryGetValue(key, out int value)) return value; // 这里同步返回但仍会创建一个Taskint对象 value await _database.GetDataAsync(); _cache[key] value; return value; } // 使用ValueTask优化 public async ValueTaskint GetCachedDataOptimizedAsync() { if (_cache.TryGetValue(key, out int value)) return value; // 同步返回时返回的是ValueTaskint结构体无堆分配 value await _database.GetDataAsync(); _cache[key] value; return value; // 异步完成时内部会适配成Taskint }使用建议对于公共库API或者性能极其敏感的热路径代码可以考虑使用ValueTaskT。对于一般应用层代码使用TaskT即可可读性更好工具链支持也更完善。5. 实战中的典型问题与排查技巧理论说再多不如解决几个实际问题来得实在。下面是我在项目中遇到的一些典型场景和解决方案。5.1 “async void”的陷阱async void方法几乎只应该用于事件处理程序如按钮点击事件。// 正确事件处理器 private async void btnDownload_Click(object sender, EventArgs e) { await DownloadFileAsync(); } // 错误普通方法 public async void SaveDataAsync() // 不要这样做 { await _repository.SaveAsync(); // 问题1调用者无法await这个方法。 // 问题2方法内部抛出的异常会直接触发进程级的异常事件如AppDomain.UnhandledException // 导致难以调试的崩溃。 }为什么危险无法等待调用者无法知道这个异步操作何时完成。异常无法捕获在async void方法中抛出的异常无法被外层的try-catch捕获它会直接上升到同步上下文对于UI程序可能导致应用崩溃对于ASP.NET程序会导致请求失败且难以记录具体错误。黄金法则异步方法除了事件处理器一律返回Task或TaskT。5.2 在构造函数中调用异步方法这是一个常见需求但C#构造函数不支持async修饰。错误的做法是直接调用.Result或.Wait()这极易导致死锁。public class MyService { private readonly string _initializedData; // 错误做法在构造函数中阻塞等待 public MyService() { _initializedData InitializeAsync().Result; // 死锁风险极高 } private async Taskstring InitializeAsync() await Task.Delay(100).ContinueWith(_ Data); }推荐解决方案工厂模式或异步初始化模式// 方案1工厂方法 public class MyService { private MyService(string data) { _data data; } public static async TaskMyService CreateAsync() { string data await InitializeAsync(); return new MyService(data); } } // 使用var service await MyService.CreateAsync(); // 方案2.NET Core/5 引入的异步初始化模式 (IAsyncDisposable, IAsyncInitialization 需自定义) // 方案3在Startup或程序入口显式初始化后再注入或使用服务。5.3 ASP.NET Core中的HttpContext丢失问题在ASP.NET Core中HttpContext是通过AsyncLocal在异步流中传递的。但在某些情况下如果你在await之后创建了新的线程或任务可能会丢失上下文。public async TaskIActionResult MyAction() { var originalContext HttpContext; // 能获取到 await SomeAsyncOperation(); // 这里通常还能获取到HttpContext // 危险操作 await Task.Run(() { // 在新线程中HttpContext.Current 很可能为 null // var context HttpContext; // 可能为null }); // 或者使用了ConfigureAwait(false)且后续逻辑依赖HttpContext await SomeLibraryMethod().ConfigureAwait(false); // 库方法内部可能用了ConfigureAwait(false) // 如果SomeLibraryMethod内部await后用了ConfigureAwait(false)且后续代码需要HttpContext这里就可能丢失。 return Ok(); }解决方案如果需要在后台线程中访问请求上下文信息在await之前就将所需数据如HttpContext.Request.Headers[User-Agent]提取到局部变量中。谨慎使用Task.Run来包装需要HttpContext的逻辑。理解你调用的库是否使用了ConfigureAwait(false)如果库是通用的它很可能会用。5.4 常见问题速查表问题现象可能原因排查与解决思路UI程序点击按钮后卡死无响应在UI线程上同步阻塞地等待异步任务如调用.Result或.Wait()导致死锁。1. 检查是否在事件处理器async void中正确使用了await。2. 将同步阻塞调用改为await异步等待。3. 如果必须在同步方法中调用异步方法考虑使用Task.Run(() asyncMethod()).GetAwaiter().GetResult()谨慎使用仍可能死锁或彻底重构为异步调用链。ASP.NET程序偶尔抛出空引用异常指向HttpContext异步流中HttpContext丢失。1. 检查代码中是否有ConfigureAwait(false)后访问HttpContext的情况。2. 检查是否在Task.Run或新线程中访问了HttpContext。3. 将上下文数据的访问提前到可能丢失上下文的位置之前存入局部变量。异步方法中的异常无法被catch捕获异常在async void方法中抛出或异常在Task中但未被观察如未await。1. 将async void改为async Task。2. 确保对返回Task的异步方法进行了await或正确使用ContinueWith处理异常。3. 对于Task可以监听TaskScheduler.UnobservedTaskException事件仅调试用。内存使用量缓慢增长未及时取消或释放长期运行的异步操作或存在异步操作回调中形成的事件绑定循环引用。1. 使用CancellationTokenSource和CancellationToken来支持取消长时间运行的任务。2. 检查事件订阅确保在对象生命周期结束时如页面关闭、服务停止取消订阅。3. 使用内存分析工具如dotMemory、Visual Studio诊断工具查找根源。ValueTaskT多次await导致错误ValueTaskT只能被await一次或在其结果就绪前调用.Result。1. 如果需要对同一个异步操作进行多次await应先将其转换为TaskTvar task asyncValueTaskMethod().AsTask();然后await这个task多次。2. 避免对未完成的ValueTaskT调用.Result。6. 从旧项目迁移到async/await的实践建议如果你接手了一个大量使用APM或EAP模式的老项目想逐步迁移到TAP这里有一些实操建议“包装”而非“重写”对于稳定的APM/EAP底层代码可以先使用Task.Factory.FromAsync或TaskCompletionSource将其包装成TAP方法。这样上层业务代码可以先改用async/await调用底层实现后续再慢慢重构。// 将APM的WebRequest包装成TAP public static TaskWebResponse GetResponseTaskAsync(this WebRequest request) { return TaskWebResponse.Factory.FromAsync(request.BeginGetResponse, request.EndGetResponse, null); }“由外及内”重构从最顶层的入口点如按钮点击事件、Controller Action开始逐步将调用链向内部模块推进。先让调用方变成async方法然后一层层修改它调用的方法。注意线程同步上下文在WinForms/WPF项目中如果老代码大量使用了Control.Invoke在改为async/await后由于await默认会回到UI线程很多Invoke调用可以安全地移除代码会大大简化。但要仔细测试确保没有在非UI线程上访问控件的场景。利用IDE重构工具Visual Studio提供了强大的“将同步方法转换为异步方法”的重构建议。虽然不能完全自动处理但可以节省大量基础代码修改的时间。设置清晰的边界在迁移过程中可能会存在同步和异步代码共存的“混合边界”。在这些边界上要非常小心死锁。一个实用的法则是在边界处让异步代码“渗透”一层到同步代码中即同步方法调用异步方法时使用.GetAwaiter().GetResult()并清楚其风险而不是反过来让异步方法调用阻塞的同步方法。长远目标还是消除这些边界。异步编程的变迁本质上是让开发者更专注于业务逻辑而将复杂的并发、回调、线程管理交给编译器和运行时。从APM到TAPasync/await的普及极大地提升了C#开发的生产力和代码的可维护性。理解其演变历史和工作原理能帮助我们在享受便利的同时写出更健壮、高效的代码。在如今async几乎成为I/O操作标准写法的时代花时间掌握它绝对是值得的。
返回列表