本教程演示如何使用 “并行堆栈” 窗口的任务视图调试 C# 异步应用程序。 此窗口可帮助你了解和验证使用异步/await 模式的代码的运行时行为,也称为基于任务的异步模式 (TAP)。
对于使用任务并行库(TPL)但不是异步/等待模式的应用,或者对于使用并发运行时的C++应用,请使用并行堆栈窗口中的“线程”视图进行调试。 有关详细信息,请参阅“并行堆栈”窗口中 调试死锁 和 查看线程和任务。
“任务”视图可帮助你:
查看使用 async/await 模式的应用的调用堆栈可视化效果。 在这些场景中,“任务”视图提供了应用状态的更完整图片。
标识计划运行的异步代码,但尚未运行。 例如,未返回任何数据的 HTTP 请求更有可能显示在“任务”视图中,而不是“线程”视图,这有助于隔离问题。
帮助识别异步中同步模式等问题,以及与阻塞或等待任务等潜在问题相关的提示。 同步-过-异步代码模式是指以同步的方式调用异步方法的代码,该代码会阻塞线程,是导致线程池饥饿的最常见原因。
异步调用堆栈
并行堆栈中的“任务”视图为异步调用堆栈提供了可视化效果,因此可以查看应用程序中发生的情况(或应该发生)。
以下是在“任务”视图中解释数据时要记住的几个要点。
异步调用堆栈是逻辑或虚拟调用堆栈,而不是表示堆栈的物理调用堆栈。 使用异步代码(例如,使用
await关键字)时,调试器提供“异步调用堆栈”或“虚拟调用堆栈”的视图。 异步调用堆栈不同于基于线程的调用堆栈或“物理堆栈”,因为异步调用堆栈不一定在任何物理线程上运行。 相反,异步调用堆栈是将在将来异步运行的代码的延续或“承诺”。 调用堆栈使用延续创建。计划但当前未运行的异步代码不会出现在物理调用堆栈上,但应在“任务”视图中的异步调用堆栈上显示。 如果你使用类似
.Wait或.Result的方法来阻止线程,则可能在物理调用堆栈中看到这些代码。异步虚拟调用堆栈并不总是直观的,因为使用方法调用(例如
.WaitAny或.WaitAll)会导致分支。“调用堆栈”窗口可能与“任务”视图结合使用,因为它显示当前正在执行的线程的物理调用堆栈。
虚拟调用堆栈的相同部分组合在一起,以简化复杂应用的可视化效果。
以下概念动画演示如何将分组应用于虚拟调用堆栈。 仅对虚拟调用堆栈的相同段进行分组。 将鼠标悬停在分组调用堆栈上,将鼠标悬停在运行任务的线程的 idenitfy 上。
C# 示例
本演练中的示例代码适用于模拟大猩猩生活中的一天的应用程序。 本练习的目的是了解如何使用“并行堆栈”窗口的“任务”视图调试异步应用程序。
此示例包括使用异步中同步模式的示例,该模式可能会导致线程池枯竭。
为了直观显示调用堆栈,示例应用执行以下顺序步骤:
- 创建表示大猩猩的对象。
- 大猩猩醒来了
- 大猩猩早上散步。
- 大猩猩在丛林里发现了香蕉。
- 大猩猩在吃东西。
- 这是违规行为。
创建示例项目
打开 Visual Studio 并创建新项目。
如果启动窗口未打开,请选择 “文件”>“开始”窗口。
在“开始”窗口中,选择“ 新建项目”。
在 “创建新项目 ”窗口中,在搜索框中输入或键入 控制台 。 接下来,从语言列表中选择 C#,然后从平台列表中选择 Windows。
应用语言和平台筛选器后,选择适用于 .NET 的 控制台应用 ,然后选择“ 下一步”。
注释
如果未看到正确的模板,请转到 “工具>获取工具和功能...”,这将打开 Visual Studio 安装程序。 选择 .NET 桌面开发工作负载,然后选择修改。
在 “配置新项目 ”窗口中,键入名称或使用 “项目名称 ”框中的默认名称。 然后,选择“下一步”。
对于 .NET,请选择建议的目标框架或 .NET 8,然后选择“ 创建”。
新的控制台项目随即显示。 创建项目后,将显示源文件。
在项目中打开 .cs 代码文件。 删除其内容以创建空代码文件。
将所选语言的以下代码粘贴到空代码文件中。
using System.Diagnostics; namespace AsyncTasks_SyncOverAsync { class Jungle { public static async Task<int> FindBananas() { await Task.Delay(1000); Console.WriteLine("Got bananas."); return 0; } static async Task Gorilla_Start() { Debugger.Break(); Gorilla koko = new Gorilla(); int result = await Task.Run(koko.WakeUp); } static async Task Main(string[] args) { List<Task> tasks = new List<Task>(); for (int i = 0; i < 2; i++) { Task task = Gorilla_Start(); tasks.Add(task); } await Task.WhenAll(tasks); } } class Gorilla { public async Task<int> WakeUp() { int myResult = await MorningWalk(); return myResult; } public async Task<int> MorningWalk() { int myResult = await Jungle.FindBananas(); GobbleUpBananas(myResult); return myResult; } /// <summary> /// Calls a .Wait. /// </summary> public void GobbleUpBananas(int food) { Console.WriteLine("Trying to gobble up food synchronously..."); Task mb = DoSomeMonkeyBusiness(); mb.Wait(); } public async Task DoSomeMonkeyBusiness() { Debugger.Break(); while (!System.Diagnostics.Debugger.IsAttached) { Thread.Sleep(100); } await Task.Delay(30000); Console.WriteLine("Monkey business done"); } } }更新代码文件后,保存更改并生成解决方案。
在“文件”菜单上,单击“全部保存”。
在“生成”菜单上,选择“生成解决方案”。
使用“并行堆栈”窗口的“任务”视图
在 “调试 ”菜单上,选择“ 开始调试 ”(或 F5),等待第一个
Debugger.Break()命中。按 F5 一次,调试器在同一
Debugger.Break()行上再次暂停。这在对
Gorilla_Start的第二个调用中暂停,该调用发生在第二个异步任务中。选择“调试 > Windows > 并行堆栈”以打开“并行堆栈”窗口,然后从窗口中的“视图”下拉列表中选择“任务”。
请注意异步调用堆栈的标签描述 2 个异步逻辑堆栈。 上次按下 F5 时,你启动了另一个任务。 为了简化复杂应用,相同的异步调用堆栈将组合成单个视觉表示形式。 这提供了更完整的信息,尤其是在具有许多任务的方案中。
与“任务”视图相反, “调用堆栈” 窗口仅显示当前线程的调用堆栈,而不适用于多个任务。 同时查看这两个方面对于获得应用程序状态的更完整图景通常很有帮助。
小窍门
“调用堆栈”窗口可以使用说明
Async cycle显示死锁等信息。在调试期间,可以切换是否显示外部代码。 若要切换该功能,请右键单击“调用堆栈”窗口的“名称”表标题,然后选择或清除“显示外部代码”。 如果显示外部代码,仍可以使用本演练,但结果可能与插图不同。
再次按 F5 ,调试器在
DoSomeMonkeyBusiness方法中暂停。
在将更多异步方法添加到内部延续链后,此视图会显示一个更完整的异步调用堆栈,这在使用
await和类似的方法时发生。DoSomeMonkeyBusiness可能或可能不存在于异步调用堆栈的顶部,因为它是异步方法,但尚未添加到延续链。 我们将探讨在后续步骤中出现这种情况的原因。此视图还显示
Jungle.Main
的阻止图标。 这是信息丰富的,但通常并不表示问题。 被阻止的任务指因其正等待另一个任务完成、信号事件触发或锁释放而处于阻止状态的任务。将鼠标悬停在
GobbleUpBananas方法上以获取有关运行任务的两个线程的信息。
当前线程也显示在“调试”工具栏的 “线程 ”列表中。
可以使用 “线程 ”列表将调试器上下文切换到其他线程。
再次按 F5 ,调试器在第二个任务的
DoSomeMonkeyBusiness方法中暂停。第二次按下 F5 后的任务视图的屏幕截图。
根据任务执行的时间,此时会看到单独的或分组的异步调用堆栈。
在上图中,两个任务的异步调用堆栈是分开的,因为它们不同。
再次按 F5 ,你将看到出现长时间的延迟,任务视图不显示任何异步调用堆栈信息。
延迟是由长时间运行的任务引起的。 对于此示例,它模拟长时间运行的任务,例如 Web 请求,这可能会导致线程池饥饿的情况。 “任务”视图中不会显示任何内容,因为即使任务可能被阻止,您当前也不会在调试器中暂停。
小窍门
发生死锁或所有任务和线程当前被阻塞时,使用“全部中断”按钮是获取调用堆栈信息的有效方法。
在 IDE 的顶部的“调试”工具栏中,选择“全部中断”按钮(暂停图标),Ctrl + Alt + Break。
在“任务”视图中异步调用堆栈的顶部附近,可以看到该
GobbleUpBananas堆栈被阻止。 事实上,两个任务在同一点被阻塞。 被阻止的任务不一定是意外的,也不一定意味着有问题。 但是,观察到的执行延迟表示问题,此处的调用堆栈信息显示问题的位置。在上一屏幕截图的左侧,卷曲的绿色箭头指示当前调试器上下文。 这两个任务在
mb.Wait()方法的GobbleUpBananas处被阻塞。“调用堆栈”窗口还显示当前线程被阻止。
在同步调用
Wait()中,调用GobbleUpBananas会阻塞线程。 这是异步中同步的一个示例,如果这发生在 UI 线程上或在大型处理工作负荷下,通常会使用await进行代码修复来解决。 有关详细信息,请参阅 调试线程池不足。 若要使用分析工具调试线程池资源不足,请参阅案例研究 - 隔离性能问题。同样值得注意,
DoSomeMonkeyBusiness不会出现在调用堆栈中。 它当前已计划,未运行,因此它仅在“任务”视图中的异步调用堆栈中显示。小窍门
调试器按每个线程分解代码。 例如,这意味着,如果按 F5 继续执行,并且应用遇到下一个断点,它可能会在不同的线程上中断代码。 如果需要管理此项以进行调试,可以添加其他断点、添加条件断点或使用 “全部中断”。 有关此行为的详细信息,请参阅使用条件断点跟踪单个线程。
修复示例代码
将
GobbleUpBananas方法替换为以下代码。public async Task GobbleUpBananas(int food) // Previously returned void. { Console.WriteLine("Trying to gobble up food..."); //Task mb = DoSomeMonkeyBusiness(); //mb.Wait(); await DoSomeMonkeyBusiness(); }在
MorningWalk方法中,使用await调用GobbleUpBananas。await GobbleUpBananas(myResult);选择 “重启 ”按钮(Ctrl + Shift + F5),然后按 F5 多次,直到应用显示为“挂起”。
按全部中断”。
这一次,
GobbleUpBananas异步运行。 中断时,会看到异步调用堆栈。
“调用堆栈”窗口除了
ExternalCode条目之外为空。代码编辑器不会显示任何内容,只不过它提供一条消息,指示所有线程都在执行外部代码。
但是,“任务”视图确实提供了有用的信息。
DoSomeMonkeyBusiness位于异步调用堆栈的顶部,如预期所示。 这准确地告诉我们长期运行的方法的位置。 当调用堆栈窗口中的物理调用堆栈未提供足够的详细信息时,这有助于隔离异步/等待问题。
概要
本演练演示了 并行堆栈 调试器窗口。 对使用 async/await 模式的应用使用此窗口。