Await关键字与ContinueWith()的核心差异及死锁问题排查
两段异步代码的死锁差异分析
问题场景
我有两段实现类似逻辑的异步代码,其中用await的版本出现死锁,但ContinueWith的版本能正常运行,想搞清楚核心差异。
使用await的死锁版本代码
public async Task LoadAsync() { task = loader.LoadAsync(...); await task.ConfigureAwait(false); PrintStatistics(); }
死锁详情:PrintStatistics()会等待任务X,而任务X是loader.LoadAsync()返回的原始task的延续任务,任务X一直处于WaitingForActivation状态。已确认SynchronizationContext.Current为null,TaskScheduler.Current和TaskScheduler.Default均为ThreadPoolTaskScheduler实例。
使用ContinueWith的正常版本代码
public Task LoadAsync() { task = loader.LoadAsync(...); return task.ContinueWith(_ => PrintStatistics(), TaskContinuationOptions.OnlyOnRanToCompletion); }
核心差异分析
1. 任务执行的绑定关系不同
- await版本:
PrintStatistics()是外层异步方法StartTaskAwait的延续逻辑,被封装成独立的调度任务,在loadingTask完成后由线程池调度执行。这个延续属于StartTaskAwait异步状态机的一部分,和原始loadingTask的任务链是松耦合的。 - ContinueWith版本:
PrintStatistics()是loadingTask的直接延续任务,直接绑定在loadingTask的完成事件上,loadingTask一完成就会立即调度这个延续,属于loadingTask任务链的一环,执行时机和原始任务的完成更紧密。
2. 死锁的直接原因:执行顺序导致的依赖阻塞
结合测试代码的执行流程来看:
- 原始
itemLoading任务(LoadItem的长运行线程)完成后,会同时触发两个调度:StartTaskAwait中await loadingTask的延续(即PrintStatistics())被调度到线程池;PostProcessingAsync中await previousProcessing的延续(设置loadingResult=2)也被调度到线程池。
- 如果
PrintStatistics()先抢到线程池线程,就会同步等待itemLoading(此时是PostProcessingAsync返回的任务)完成。但PostProcessingAsync的任务要完成,必须等它的延续代码执行完毕,而这个延续代码需要另一个线程池线程。 - 一旦
PrintStatistics()占用的线程池线程被同步等待阻塞,而线程池暂时没有空闲线程(或调度延迟),PostProcessingAsync的延续就无法执行,itemLoading任务永远无法完成,最终形成死锁。
而在ContinueWith版本中,PrintStatistics()作为loadingTask的直接延续,调度优先级和PostProcessingAsync的延续相当,两者大概率会并行执行。就算PrintStatistics()先执行,线程池也会动态创建新线程来处理PostProcessingAsync的延续,很难出现阻塞导致的死锁。
3. await的隐藏调度特性
await的延续是由.NET异步运行时的状态机管理的,调度时会经过一层封装,相比ContinueWith的直接回调注册,可能存在细微的延迟或优先级差异,这就导致了两种场景下执行顺序的不确定性,进而引发死锁。
ContinueWith版本是否可能死锁?
理论上存在死锁的可能性,但实际发生概率极低,需要满足特定条件:
- 如果
PrintStatistics()的ContinueWith延续先于PostProcessingAsync的延续执行,并且线程池没有足够的空闲线程来执行PostProcessingAsync的延续,导致PrintStatistics()阻塞线程池线程,而PostProcessingAsync的延续无法获得线程执行,就会形成死锁。 - 但实际中,线程池会动态调整线程数量,而且
ContinueWith的延续和PostProcessingAsync的延续是并行调度的,所以很难出现这种情况。另外,如果给ContinueWith指定TaskContinuationOptions.ExecuteSynchronously,延续可能会在原始任务完成的线程(比如测试代码中的长运行线程)上执行,不会占用线程池线程,此时PostProcessingAsync的延续可以在另一个线程池线程执行,更不容易死锁。
完整测试代码
using System; using System.Threading.Tasks; using System.Threading; public class Program { private static Task itemLoading; private static int loadingResult; private static TaskFactory taskFactory = new TaskFactory(); public static void Main() { // 替换为StartTaskContinueWith即可正常运行 StartTaskAwait().ConfigureAwait(false).GetAwaiter().GetResult(); } private static async Task StartTaskAwait() { Task loadingTask = LoadAllAsync(); AddPostProcessing(); await loadingTask.ConfigureAwait(false); PrintStatistics(); } private static Task StartTaskContinueWith() { Task loadingTask = LoadAllAsync(); AddPostProcessing(); return loadingTask.ContinueWith(t => PrintStatistics()); } private static void AddPostProcessing() { itemLoading = PostProcessingAsync(itemLoading); } private static async Task PostProcessingAsync(Task previousProcessing) { await previousProcessing.ConfigureAwait(false); Interlocked.CompareExchange(ref loadingResult, 2, 1); } private static async Task LoadAllAsync() { // 等待所有项加载完成 await Task.WhenAll([LoadItemAsync()]); } private static Task LoadItemAsync() { itemLoading = taskFactory.StartNew(LoadItem, TaskCreationOptions.LongRunning); return itemLoading; } private static void LoadItem() { Thread.Sleep(500); Interlocked.CompareExchange(ref loadingResult, 1, 0); } private static void PrintStatistics() { itemLoading.ConfigureAwait(false).GetAwaiter().GetResult(); Console.WriteLine($"加载结果: {loadingResult}"); } }
内容的提问来源于stack exchange,提问作者uebe
相关产品推荐
相关产品推荐

