聊到C#异步,很多开发者的第一反应就是Sleep——准确地说,是Task.Delay。仿佛学会了await Task.Delay(1000),就掌握了异步编程的精髓。我见过太多代码,整个异步体系就是靠Task.Delay撑起来的:模拟耗时用Delay,控制并发用Delay,甚至处理重试也用Delay。等上了压测,线程池直接被打穿,才意识到自己对await的理解其实停留在“换个姿势让线程睡觉”这个层面。
Task.Delay只是awaitable家族里最不起眼的一员。IO等待是awaitable,互斥锁的异步版本是awaitable,跨线程调度本质上也依赖awaitable。把这几个维度打通,才能真正构建高性能异步系统。这篇文章会把IO绑定的底层逻辑、异步锁的正确用法、以及如何自定义一个跨线程调度的Awaiter讲透,适合正在写业务并发代码、或者排查过异步死锁/线程池饥饿问题的C#开发者。看完你会有一种感觉:await不是“让线程休息”,而是一套让线程“先放手、晚点再接着干”的协作契约。
1. 拆开Awaitable:异步的真正底座是什么
1.1 await不是“等”,而是“让出”
我们先把一个最核心的观念掰正:await的真正语义不是“等待”,而是“让出”(yield)。
一个async方法执行到await时,如果被等待的操作还没有完成,它不会傻站着等,而是立刻把控制权返回到调用方。这一点和同步代码完全不同。同步代码里你调用一个耗时函数,线程就吊在那个函数里直到返回;异步代码里,线程只负责“把任务发出去”,然后马上回到线程池继续接其他活儿。
这个“让出”的动作,靠的是awaitable模式。C#编译器遇到await表达式时,会去查找表达式类型是否具有以下成员:一个可访问的GetAwaiter()方法,返回的awaiter类型需要暴露IsCompleted属性、GetResult()方法,以及OnCompleted或UnsafeOnCompleted方法。满足这个模式的对象,await就能用。Task、Task<T>、ValueTask显然都满足,但Stream、Timer、SemaphoreSlim的WaitAsync返回值也都满足。甚至你完全可以在自定义类型上实现这套方法,让它也能被await。这就是标题里说的“全能武器库”的底层支撑。
有个类比我一直觉得特别贴切:同步调用就像你站在取餐窗口前干等,厨房出餐慢你就只能耗在那儿;异步调用则是你把订单递进去,拿了号先去找座位、回微信、处理别的正事,等窗口喊号你再起身去取。await就是“拿号”这个动作,被await的对象就是“那扇会喊号的窗口”。窗口(IO设备)什么时候喊你,你不用管,那是系统的事。
1.2 状态机与回调:编译器的承诺
await的“让出”不是魔法,而是编译器把你的async方法改造成了一个状态机。理解这一点,很多困惑会迎刃而解。
假设你写了这样一段代码:
async Task DemoAsync() { var data = await ReadFromNetworkAsync(); await SaveToDiskAsync(data); Console.WriteLine("done"); }编译器会生成一个类似下面的状态机结构(极度简化,用于理解原理):
public struct DemoAsyncStateMachine : IAsyncStateMachine { public int _state; public TaskAwaiter<string> _awaiter1; public TaskAwaiter _awaiter2; public void MoveNext() { try { switch (_state) { case 0: _awaiter1 = ReadFromNetworkAsync().GetAwaiter(); if (!_awaiter1.IsCompleted) { _state = 1; _awaiter1.UnsafeOnCompleted(MoveNext); return; } goto case 1; case 1: var data = _awaiter1.GetResult(); _awaiter2 = SaveToDiskAsync(data).GetAwaiter(); if (!_awaiter2.IsCompleted) { _state = 2; _awaiter2.UnsafeOnCompleted(MoveNext); return; } goto case 2; case 2: _awaiter2.GetResult(); Console.WriteLine("done"); break; } } catch (Exception ex) { // 处理异常并设置Task结果 } } }仔细看这个过程:每次await遇到未完成的操作,MoveNext就把“接下来我要继续执行”这个回调挂到OnCompleted上,然后return。等IO完成、或者Task.Delay计时器到期、或者信号量释放时,那个回调会被触发,MoveNext再次被调进来,从暂停的那个状态继续往下跑。整个过程中,没有一条线程长时间被某个操作独占。
这个状态机设计还有个重要好处:它让异步方法内部的局部变量、参数、try/catch/finally结构都能被正确地保留和恢复。你在await前后看到的普通代码,本质上是被编译器“切片”重组过的。正因为它重新组织了执行顺序,所以“状态机里哪个字段在哪个阶段生效”就成了调试高级异步问题时需要关注的点。
1.3 为什么IO等待不占线程
既然await能让线程去干别的,那IO完成之后又是谁把线程叫回来继续跑的?答案在操作系统层面:IO完成端口(IOCP,IO Completion Port)。
拿HttpClient.GetStringAsync举例。你调用它时,真正的网络发送是通过系统调用交给网卡驱动去处理的。HttpClient内部注册了一个IO完成回调,然后当前线程立刻回到线程池继续接其他任务。当数据包从对端返回、网卡把数据拷贝到内存后,内核会往IOCP队列里投递一个完成包。线程池里专门有若干线程在等待这个队列,拿到完成包后就执行你之前挂上去的继续回调,MoveNext被再次驱动,异步方法从await处恢复。
这就是IO异步的核心:它不依赖于“有一条线程专门盯着这个IO”,而是靠完成通知来驱动后续代码。这也解释了为什么真正的异步IO场景下,处理几万个并发连接只需要几百条线程——大部分线程在等待期间都退出来做别的事了。
知道了这套机制,就明白一个常见的性能隐患:如果你用Task.Run(() => httpClient.GetStringAsync(url).Result)来“优化”同步调用,那不是在搞异步,只是把同一个阻塞操作从调用线程挪到了线程池线程。IO还没完成时,那条线程池线程依然卡死在.Result上。并发一高,线程池线程数量就会被这种“伪异步”代码迅速拉满,大量CPU时间花在线程上下文切换上,系统吞吐骤降。这就是线程池饥饿的典型入口。
2. IO绑定的正确姿势:从Sleep式思维切换到IO式思维
2.1 同步IO为什么可恨
回想一下你见过的同步IO代码:File.ReadAllText、Stream.Read、HttpClient.GetAsync().Result、Socket.Receive。这些调用有一个共同特征:它们在等待硬件完成操作时,持有当前线程不放。
一条线程默认有1MB的栈空间,线程池为了应对高并发会不断创建新线程,每多一条线程,系统的内存占用、调度开销都在涨。而且线程切换是有真实成本的:寄存器的保存与恢复、内核态与用户态的切换、缓存热度的丢失。当你有200条线程都在等待IO时,操作系统光是把CPU时间切来切去就能吃掉大量性能。这不是技术理想到哪儿去了,这就是我压测过的系统最常出现的问题:CPU看着不高,但吞吐上不去、延迟忽高忽低。
反观异步写法:
// 同步风格 public void DownloadAndSave(string url) { var html = _httpClient.GetStringAsync(url).Result; // 线程阻塞等待网络 File.WriteAllText("page.html", html); // 线程阻塞等待磁盘 } // 异步风格 public async Task DownloadAndSaveAsync(string url) { var html = await _httpClient.GetStringAsync(url); await File.WriteAllTextAsync("page.html", html); }延迟数量级不同,差距截然不同。异步版本在网络和磁盘等待期间,线程已经回到线程池,可以去处理别的请求。等完成通知到了,再从线程池拿一条线程继续执行。这种“高并发低线程数”才是高性能异步系统的典型形态。
2.2 一个异步IO的真实链路
为了更具体,我们拆解一个File.ReadAllTextAsync的完整链路,看看await前后到底发生了什么。
- 你的代码调用
File.ReadAllTextAsync(path),它内部创建FileStream并请求操作系统打开文件。这一步是异步的,内核收到请求后立即返回未完成状态。 await发现任务未完成,当前线程执行UnsafeOnCompleted挂上继续回调,然后返回线程池,开始处理线程池队列里的其他任务。- 磁盘控制器完成读取后,唤醒内核的IO完成逻辑,把数据放入缓冲区,并向IOCP队列写入完成包。
- 线程池中负责IOCP的线程取出完成包,把结果填充到
Task对应的状态中,然后调用之前挂载的继续回调,驱动状态机的MoveNext继续执行。 - 你的方法从
await处继续,拿到文件内容,执行后续代码。
这个链路里,任何用户态线程都没有“专门等待”这个IO。需要注意FileStream的细节:如果要追求极致IO性能,创建FileStream时建议显式指定FileOptions.Asynchronous;如果你只是用File.ReadAllTextAsync这种便捷方法,框架在内部已做处理。自己封装流式读写时,这个选项能决定你的异步是否是“真异步”。
2.3 反模式清单:你可能一直在写假异步
写异步IO,最常见的是踩以下五个坑。我在代码评审里几乎每个季度都能见一遍:
Task.Run包裹同步IO,假装提升性能:Task.Run(() => File.ReadAllBytes(path))。任务确实在线程池线程执行了,但读取期间那条线程依然被阻塞。并发数一高,线程池照样被打满。async方法里没有await:如果方法体里没有任何await,编译器会警告,而且方法会同步执行。这种“async空壳”不仅没有性能收益,还会增加状态机的开销。await之前阻塞:var client = new HttpClient(); client.Timeout = TimeSpan.FromSeconds(5); var task = client.GetStringAsync(url); task.Wait();。你以为异步了,其实Wait把线程又按回水里了。- 循环里串行做可以并行的IO:比如批量下载100个文件,用
foreach循环逐个await,总耗时等于所有请求之和。应该先收集Task,再await Task.WhenAll。 - 无节制使用
Task.Delay:Task.Delay有它的价值(重试退避、节流、模拟延迟),但它不是IO、不是锁,也不适合作为周期调度的替代品。周期任务应该用PeriodicTimer或System.Threading.Timer。
顺手提一句:用HttpClient时务必使用单例或IHttpClientFactory管理生命周期,避免频繁创建导致Socket耗尽。这是同步代码迁移到异步后最常见的新问题。
3. 互斥锁的异步形态:lock进不了async,怎么办?
3.1 lock为什么不能进async:线程亲和性
有的开发者尝试过这样写:
lock (_lockObj) { await DoSomethingAsync(); }编译器会直接报错,因为lock(本质是Monitor.Enter/Exit)要求必须是“同一个线程”执行进入和退出。一旦await让出线程,等恢复时可能已经跑到线程池的另一条线程上,退出锁的线程和进入锁的线程就不是同一个了,锁的配对关系被彻底破坏。即便用Monitor.Enter手动写,也会在运行时遇到SynchronizationLockException。这不是语言限制,而是Monitor本身就有“线程亲和性”这个属性。它根本不能用在异步世界里。
那异步并发下需要保护临界区怎么办?用没有线程亲和性的同步原语:SemaphoreSlim是其中最常用的。
3.2 SemaphoreSlim:异步世界的互斥与限流
SemaphoreSlim是信号量,它维护一个计数器。调用WaitAsync时,如果计数器大于0,就减1并立即通过;如果计数器为0,就把你挂到等待队列上,等到有其他线程Release(增加计数)时再唤醒。因为它不记录线程ID,所以“谁获取、谁释放”并不重要——完全可以在A线程获取、B线程释放。这正是异步代码需要的特性。
把初始计数设为1,SemaphoreSlim就是一个异步互斥锁:
private readonly SemaphoreSlim _asyncLock = new SemaphoreSlim(1, 1); public async Task WithLockAsync() { await _asyncLock.WaitAsync(); try { // 临界区:在同一时间只会有一个调用者进入 await DoWorkAsync(); } finally { _asyncLock.Release(); } }try/finally不是可选项,是必须项。如果临界区抛异常,finally里的Release还能保证信号量计数恢复。一旦漏掉Release,就是“只借不还”,信号量可用计数一点点耗尽,直到整个系统再也无法获取锁。这种问题极其隐蔽,往往要运行很久、触发很多次异常后才会爆发。
如果并发数不是1,而是希望限制同时最多N个操作,那信号量的价值就更明显了。比如某个下游服务只接受10个并发请求,你就把初始计数设为10,多余的请求排队等待。
3.3 真实场景:数据库并发限流代码
一个真实案例:某服务要批量导入数据,每次请求都会触发多次数据库写入。数据库端的最大并发连接数有限,不能放任所有请求同时砸进去。我在代码里用SemaphoreSlim做了一个全局限流网关:
private static readonly SemaphoreSlim DbGate = new SemaphoreSlim(5); public static async Task InvokeDbAsync(string sql, CancellationToken ct) { if (!await DbGate.WaitAsync(TimeSpan.FromSeconds(10), ct)) { throw new TimeoutException("排队超过10秒仍未获得数据库并发配额"); } try { // 真正的数据库异步调用 await ExecuteSqlAsync(sql, ct); } finally { DbGate.Release(); } }有几个实践心得值得写下来:
WaitAsync带超时比不带超时安全得多。没有超时的等待,一旦上游出问题,所有请求都积压在信号量队列上,系统直接“假死”。- 传入
CancellationToken,让调用方取消时能及时从等待队列中出来,而不是永远吊着。 SemaphoreSlim是非重入的:同一个作用域内,如果你已经获取了信号量,再次WaitAsync同一个信号量,会死锁。这和lock的可重入行为不同。解决办法是拆分锁的粒度,不要让锁内部的代码再去获取同一把锁。- 如果是单机高吞吐场景,
SemaphoreSlim已经足够好;如果你做的是跨进程、跨机器的分布式限流,那就得考虑分布式协调组件了,SemaphoreSlim管不到其他进程。
4. 跨线程调度:await前后的线程是谁,由你说了算
4.1 SynchronizationContext:await回到哪里
你知道await完成之后,代码在哪个线程继续执行吗?答案取决于捕获的SynchronizationContext。
在WinForms、WPF这类UI应用中,UI线程上有一个SynchronizationContext,它负责把回调投递回UI消息循环。所以你在按钮点击事件里写await ...,await之后自动回到UI线程。这非常方便,不用手动Dispatcher.Invoke。在ASP.NET Core里,默认没有SynchronizationContext(或者说是线程池上下文),所以await之后在哪条线程池线程上继续完全不确定。在控制台应用和类库里,同样没有特别的上下文。
ConfigureAwait(false)的作用就是不试图恢复到捕获的上下文,而是在当前IO完成线程或线程池线程上继续执行。这对类库作者特别重要:你无法预知调用者是什么环境,为了减少不必要的上下文跳转和降低死锁风险,库内部默认ConfigureAwait(false)是比较稳妥的。但在UI层的代码里,如果你ConfigureAwait(false)之后还要操作界面控件,就要自己处理线程切换,所以UI层通常不要这么干。
还有一个经典死锁场景值得画重点:
public void Button_Click() { var result = DoWorkAsync().Result; // 阻塞UI线程等待任务完成 } private async Task<int> DoWorkAsync() { await Task.Delay(1000); // 完成后需要回到UI线程上下文 return 42; }DoWorkAsync内部的await之后想回到UI线程,但UI线程已经被.Result阻塞死了,两边互相等待,死锁。解法只有一个:从UI事件到内部方法全程用async/await,不要在异步链路上用.Result或.Wait()做同步阻塞。
4.2 自定义Awaitable:等待条件成立的完整实现
当内置的Task、SemaphoreSlim不够用时,我们可以自己造一个awaitable。这是在跨线程调度场景下最灵活的手段。下面这个示例,实现一个“等待某个条件成立”的awaitable,常用于等待硬件设备就绪、等待后台任务进入某个状态等场景。
public static class AsyncCondition { public static ConditionAwaiter Until( Func<bool> predicate, TimeSpan pollInterval, CancellationToken cancellationToken = default) => new ConditionAwaiter(predicate, pollInterval, cancellationToken); } public sealed class ConditionAwaiter : ICriticalNotifyCompletion { private readonly Func<bool> _predicate; private readonly TimeSpan _pollInterval; private readonly CancellationToken _cancellationToken; private Action? _continuation; private Timer? _timer; public ConditionAwaiter(Func<bool> predicate, TimeSpan pollInterval, CancellationToken cancellationToken) { _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); _pollInterval = pollInterval; _cancellationToken = cancellationToken; } // 编译器调用:返回等待器本身 public ConditionAwaiter GetAwaiter() => this; // 是否已经完成:条件满足或已取消 public bool IsCompleted => _predicate() || _cancellationToken.IsCancellationRequested; // 关于ExecutionContext的差异,见下一节说明 public void OnCompleted(Action continuation) => UnsafeOnCompleted(continuation); public void UnsafeOnCompleted(Action continuation) { _continuation = continuation; _cancellationToken.Register(Resume); _timer = new Timer(_ => Resume(), null, _pollInterval, _pollInterval); } // 等待结束后执行:检查取消与最终条件 public void GetResult() { _timer?.Dispose(); _timer = null; _cancellationToken.ThrowIfCancellationRequested(); if (!_predicate()) { throw new TimeoutException("等待条件成立超时"); } } private void Resume() { if (!_predicate() && !_cancellationToken.IsCancellationRequested) { return; } // 用 Interlocked 保证回调只执行一次,不会被Timer和取消回调同时触发 var continuation = Interlocked.Exchange(ref _continuation, null); if (continuation != null) { ThreadPool.QueueUserWorkItem(_ => continuation()); } } }用法非常直观:
await AsyncCondition.Until( () => device.IsReady, TimeSpan.FromMilliseconds(50), cancellationToken);4.3 自定义Awaitable的原理拆解与ExecutionContext注意事项
上面这段代码,几个关键点值得专门解释,避免你拿去改了就跑出隐秘问题。
第一,GetAwaiter()返回this,编译器会把整个ConditionAwaiter当作awaiter来使用。await表达式的执行流程是:先调用GetAwaiter()得到等待器,检查IsCompleted,如果未完成,调用OnCompleted/UnsafeOnCompleted挂载继续回调,并且立即返回让调用方继续。等条件满足、取消触发或超时计时器到点后,Resume()被调用,通过ThreadPool.QueueUserWorkItem把continuation(也就是状态机的MoveNext)投递到线程池执行。这就完成了一次完整的跨线程调度:从Timer线程池线程,触发到另一条线程池线程继续执行用户代码。
第二,OnCompleted和UnsafeOnCompleted的区别,核心是ExecutionContext的捕获。OnCompleted会捕获当前线程的ExecutionContext(包括AsyncLocal<T>数据、安全上下文等信息),并在continuation执行前恢复它;UnsafeOnCompleted不做这件事。性能敏感场景用UnsafeOnCompleted更快,但你需要清楚,如果等待期间依赖AsyncLocal传递上下文,使用Unsafe方式可能会丢失这些数据。框架内置的TaskAwaiter在绝大多数默认情况下走的是UnsafeOnCompleted,因为它相信你会在异步方法里正确地传递必需的数据。自定义等待器时,这个选择要结合场景判断。
第三,Resume里用Interlocked.Exchange把_continuation置空再执行,是为了防止Timer回调和取消回调同时触发导致continuation被调用两次。异步状态机对“继续回调只调用一次”是有要求的,重复调用轻则逻辑错乱,重则直接抛异常。这种并发细节最容易被忽略。
第四,GetResult里先取消检查、再做条件检查。取消触发时,IsCompleted会返回true,GetResult会抛出OperationCanceledException。如果不做ThrowIfCancellationRequested,则取消只表现为“条件始终不满足”,等待方会误以为是超时。加上取消逻辑,语义才完整。
以这个自定义Awaiter为起点,你可以扩展出更多玩法:比如一个“等待网页可访问”的Awaiter、一个“等待队列消费到指定水位”的Awaiter。它们的基本骨架都是一样的,只是IsCompleted的判断逻辑不同,触发继续执行的事件源不同而已。这就是awaitable模式的扩展能力:核心机制不变,业务判断可以任意定制。
5. 常见问题与排查技巧实录
5.1 五个高频事故与对应解法
我把实际项目中反复出现的异步问题整理成一张速查表,碰到对应症状时可以直接对照排查。
| 现象 | 根因 | 解法 |
|---|---|---|
| UI点击后界面卡死,无响应 | await链路中混用了.Result/.Wait(),UI线程被阻塞,等待后续回调回UI线程 | 全部改用async/await,切断同步阻塞点 |
| 高并发压测下延迟呈断崖式上升,线程数暴涨,CPU不高 | 同步IO被包装成“假异步”,线程池线程被阻塞型IO占满 | 换用真正的异步IO API,如ReadAllTextAsync、GetStringAsync |
| 信号量/网关最终永久不可用 | SemaphoreSlim没有在finally中Release | 所有WaitAsync必须搭配try/finally释放 |
| 两个或多个协程互相等待,全部超时 | SemaphoreSlim非重入,临界区内再次获取同一信号量 | 重构锁粒度,避免“锁中锁” |
| 自定义Awaiter偶尔重复执行或丢失取消 | 未使用原子操作保护continuation,未注册取消回调 | 参照上文用Interlocked.Exchange,并注册CancellationToken回调 |
前三个问题80%的项目都踩过。第二个尤其隐蔽,表面看起来已经“用了Task.Run包装”,实际线程池压力一点没减轻。排查这类问题,不能只看代码表面,要观察线程数和线程池队列长度。
5.2 排查线程池耗时与死锁的实用手段
分享几个我实际用过的排查手段,都很朴素但特别管用。
观察线程池状态。在可疑代码路径前后打印线程池可用线程数和线程数,能快速判断是否存在线程饥饿:
ThreadPool.GetAvailableThreads(out int workerThreads, out int ioThreads); ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxIoThreads); Console.WriteLine($"可用工作线程: {workerThreads}/{maxWorkerThreads}, 可用IO线程: {ioThreads}/{maxIoThreads}");如果可用工作线程长期趋近于0,且业务线程数还在不断上涨,大概率存在同步阻塞。再叠加一个简单的二分压测:把并发数从10提到20、50、100,观察延迟曲线。如果延迟不是平稳线性上升,而是在某个并发阈值突然上涨好几倍,基本可以断定线程池饥饿或锁竞争过度。
用dotnet-counters采集运行时计数器,关注threadpool-queue-length、threadpool-thread-count这两个数值。正常健康的异步系统,线程数不会随并发请求数线性膨胀;如果线程数涨得比请求数还快,那就说明异步链路里有“假异步”混进去了。
排查死锁时,我会在异步方法入口和每个await之后打印线程ID(Environment.CurrentManagedThreadId)。如果发现某个await之后线程ID没有变化,但代码又明显在等待另一个任务,那死锁就八九不离十了。配合dotnet-dump抓进程快照,查线程栈,看线程阻塞在哪个Monitor.Enter或.Result上,定位非常快。
还有一个我自己的小习惯:所有信号量、Channel、自定义Awaiter,只要涉及“谁等我、我等谁”这种等待逻辑,我都要求自己先写一个5行左右的“最小死锁测试用例”,专门验证等待超时和取消路径。这个习惯帮我挡掉过好几个线上隐患。
6. 我踩过的坑与坚持的几条原则
6.1 三个写异步代码的稳定习惯
把异步写得又稳又快的开发者,通常有几个共同习惯。第一个习惯:从入口到出口全链路都用async/await,不搞“部分异步、部分同步”的混搭。每次在异步代码里看到.Result、.Wait()、Thread.Sleep,我都当它是bug来对待。同步阻塞混进异步链路,轻则拖慢吞吐,重则引发死锁,没有任何收益。
第二个习惯:库代码默认ConfigureAwait(false)。调用方是什么上下文无法预知,库内部减少对上下文的依赖是最稳妥的做法。UI层代码则反过来,必须保留上下文捕获,否则后续访问控件会面临线程安全问题。
第三个习惯:所有异步等待都设置超时和取消。数据库调用带上CancellationToken,信号量等待带上TimeSpan,自定义Awaiter一定要处理ThrowIfCancellationRequested。等待不是永远等,一个没有超时的等待,就是系统“假死”的最佳温床。
6.2 什么时候仍然该用Sleep/Task.Delay
说了这么多“别只知道Sleep”,也得说清楚Task.Delay该用在哪。
模拟异步耗时、写测试用例、做节流和退避重试,Task.Delay依然是很好的选择。它在内部用的是Timer,不像Thread.Sleep那样阻塞线程,所以在异步代码里模拟延迟是安全的。但有两个使用边界必须记住:第一,它是延迟,不是并发保护;控制并发访问要靠信号量或锁。第二,周期性的任务不要用Task.Delay循环自造调度器,直接用PeriodicTimer更准确,延迟更稳定。
拿我自己写重试逻辑的习惯来说,我会用Task.Delay(TimeSpan.FromMilliseconds(delayMs))配合CancellationToken做退避等待,同时给最大重试次数设上限。即便这样,我也会在每一轮重试前检查取消令牌,确保调用方取消时重试循环能立刻退出。这套组合用得非常频繁,没有出过问题。
回到开头说的那个“全能武器库”——await的存量理解不该停留在Task.Delay上。IO等待让我们理解“等待期间线程可以退出”,互相锁让我们理解“异步世界同步原语不能带线程亲和性”,跨线程调度让我们理解“await之后的位置由上下文和完成源决定”。这三块拼在一起,你对awaitable的掌握才算真正完整。我自己的体会是,异步系统的所有复杂性,本质上都是“什么条件下唤醒继续执行”这个问题的变体。把这个问题想透了,写出来的代码就自然清晰可控。