本教程演示如何使用并行堆栈窗口的“线程”视图调试 C# 多线程应用程序。 此窗口可帮助你了解和验证多线程代码的运行时行为。
C++和 Visual Basic 也支持“线程”视图,因此适用于 C# 的本文中所述的相同原则也适用于 C++ 和 Visual Basic。
“线程”视图可帮助你:
查看多个线程的调用堆栈可视化效果,该可视化效果提供应用状态比调用堆栈窗口更完整的图片,该窗口仅显示当前线程的调用堆栈。
帮助识别诸如阻塞或死锁线程的问题。
C# 示例
本演练中的示例代码适用于模拟大猩猩生活中的一天的应用程序。 本练习的目的是了解如何使用“并行堆栈”窗口的“线程”视图调试多线程应用程序。
此示例包括一个死锁案例,发生于两个线程相互等待的情况。
为了直观显示调用堆栈,示例应用执行以下顺序步骤:
- 创建表示大猩猩的对象。
- 大猩猩醒来了
- 大猩猩早上散步。
- 大猩猩在丛林里发现了香蕉。
- 大猩猩在吃东西。
- 这是违规行为。
多线程调用堆栈
调用堆栈的相同部分组合在一起,以简化复杂应用的可视化效果。
以下概念动画演示了如何将分组应用于调用堆栈。 仅对调用堆栈的相同段进行分组。
创建示例项目
要创建项目,请执行以下步骤:
打开 Visual Studio 并创建新项目。
如果启动窗口未打开,请选择 “文件”>“开始”窗口。
在“开始”窗口中,选择“ 新建项目”。
在 “创建新项目 ”窗口中,在搜索框中输入或键入 控制台 。 接下来,从语言列表中选择 C#,然后从平台列表中选择 Windows。
应用语言和平台筛选器后,选择适用于 .NET 的 控制台应用 ,然后选择“ 下一步”。
Note
如果未看到正确的模板,请转到 “工具>获取工具和功能...”,这将打开 Visual Studio 安装程序。 选择 .NET 桌面开发工作负载,然后选择修改。
在 “配置新项目 ”窗口中,键入名称或使用 “项目名称 ”框中的默认名称。 然后,选择“下一步”。
对于 .NET,请选择建议的目标框架或 .NET 8,然后选择“ 创建”。
新的控制台项目随即显示。 创建项目后,将显示源文件。
在项目中打开 .cs 代码文件。 删除其内容以创建空代码文件。
将所选语言的以下代码粘贴到空代码文件中。
using System.Diagnostics; namespace Multithreaded_Deadlock { class Jungle { public static readonly object tree = new object(); public static readonly object banana_bunch = new object(); public static Barrier barrier = new Barrier(2); public static int FindBananas() { // Lock tree first, then banana lock (tree) { lock (banana_bunch) { Console.WriteLine("Got bananas."); return 0; } } } static void Gorilla_Start(object lockOrderObj) { Debugger.Break(); bool lockTreeFirst = (bool)lockOrderObj; Gorilla koko = new Gorilla(lockTreeFirst); int result = 0; var done = new ManualResetEventSlim(false); Thread t = new Thread(() => { result = koko.WakeUp(); done.Set(); }); t.Start(); done.Wait(); } static void Main(string[] args) { List<Thread> threads = new List<Thread>(); // Start two threads with opposite lock orders threads.Add(new Thread(Gorilla_Start)); threads[0].Start(true); // First gorilla locks tree then banana threads.Add(new Thread(Gorilla_Start)); threads[1].Start(false); // Second gorilla locks banana then tree foreach (var t in threads) { t.Join(); } } } class Gorilla { private readonly bool lockTreeFirst; public Gorilla(bool lockTreeFirst) { this.lockTreeFirst = lockTreeFirst; } public int WakeUp() { int myResult = MorningWalk(); return myResult; } public int MorningWalk() { Debugger.Break(); if (lockTreeFirst) { lock (Jungle.tree) { Jungle.barrier.SignalAndWait(5000); // For thread timing consistency in sample Jungle.FindBananas(); GobbleUpBananas(); } } else { lock (Jungle.banana_bunch) { Jungle.barrier.SignalAndWait(5000); // For thread timing consistency in sample Jungle.FindBananas(); GobbleUpBananas(); } } return 0; } public void GobbleUpBananas() { Console.WriteLine("Trying to gobble up food..."); DoSomeMonkeyBusiness(); } public void DoSomeMonkeyBusiness() { Thread.Sleep(1000); Console.WriteLine("Monkey business done"); } } }
更新代码文件后,保存更改并生成解决方案。
在“文件”菜单上,单击“全部保存”。
在“生成”菜单上,选择“生成解决方案”。
使用“并行堆栈”窗口的“线程”视图
若要开始调试,请执行以下操作:
在 “调试 ”菜单上,选择“ 开始调试 ”(或 F5),等待第一个
Debugger.Break()
命中。按 F5 一次,调试器在同一
Debugger.Break()
行上再次暂停。这会在对
Gorilla_Start
的第二次调用中暂停,该调用发生在第二个线程中。Tip
调试器按每个线程分解代码。 例如,这意味着,如果按 F5 继续执行,并且应用遇到下一个断点,它可能会在不同的线程上中断代码。 如果需要管理此行为以进行调试,可以添加其他断点、条件断点或使用 “全部中断”。 有关使用条件断点的更多信息,请参阅使用条件断点跟踪单个线程。
选择“调试 > Windows > 并行堆栈”以打开“并行堆栈”窗口,然后从窗口中的“视图”下拉列表中选择“线程”。
在 线程 视图中,当前线程的堆栈帧和调用路径以蓝色突出显示。 线程的当前位置由黄色箭头显示。
请注意,调用堆栈
Gorilla_Start
的标签为 2 个线程。 上次按下 F5 时,将启动另一个线程。 为了简化复杂应用,相同的调用堆栈将组合成单个视觉表示形式。 这简化了潜在的复杂信息,尤其是在具有许多线程的情况下。在调试期间,可以切换是否显示外部代码。 若要切换该功能,请选择或清除 “显示外部代码”。 如果显示外部代码,仍可以使用本演练,但结果可能与插图不同。
再次按下F5,调试器将在
Debugger.Break()
方法中的MorningWalk
行暂停。“并行堆栈”窗口显示方法中
MorningWalk
当前正在执行的线程的位置。将鼠标悬停在
MorningWalk
方法上以获取有关由分组调用堆栈表示的两个线程的信息。当前线程也显示在“调试”工具栏的 “线程 ”列表中。
可以使用 “线程 ”列表将调试器上下文切换到其他线程。 这不会更改当前正在执行的线程,而只会更改调试器上下文。
或者,可以通过双击“线程”视图中的方法或右键单击“线程”视图中的方法并选择“ 切换到 Frame>[thread ID]”来切换调试器上下文。
再次按 F5 ,调试器会在第二个线程的
MorningWalk
方法内暂停。根据线程执行的时序,您会看到单独或分组的调用堆栈。
在上图中,两个线程的调用堆栈被部分汇总。 调用堆栈的相同段被分组,箭头线指向分离的段(即不相同的段)。 当前堆栈帧用蓝色高亮显示。
再次按 F5 ,你将看到出现长时间的延迟,“线程”视图不显示任何调用堆栈信息。
延迟是由死锁引起的。 线程视图中不会显示任何内容,因为即使线程可能被阻止,您当前也不会在调试器中暂停。
Tip
如果发生死锁或所有线程当前均被阻塞,“全部中断” 按钮是获取调用堆栈信息的好方法。
在“调试”工具栏的 IDE 顶部,选择“ 全部中断 ”按钮(暂停图标),或使用 Ctrl + Alt + Break。
“线程”视图中调用堆栈的顶部显示
FindBananas
已死锁。 执行指针FindBananas
是一个卷曲的绿色箭头,指示当前调试器上下文,但也告诉我们线程当前未运行。在代码编辑器中,我们在函数中找到卷曲的
lock
绿色箭头。 两个线程在lock
方法中的FindBananas
函数上被阻止。根据线程执行的顺序,死锁可能出现在
lock(tree)
语句或lock(banana_bunch)
语句。调用
lock
会阻塞FindBananas
方法中的线程。 一个线程正在等待另一个线程释放对tree
的锁,但另一个线程正在等待对banana_bunch
的锁被释放,然后才能释放对tree
的锁。 这是典型死锁的一个示例,当两个线程相互等待时会发生这种情况。如果使用 Copilot,还可以获取 AI 生成的线程摘要,以帮助识别潜在的死锁。
修复示例代码
若要修复此代码,请始终在所有线程中以一致的全局顺序获取多个锁。 这可以防止循环等待并消除死锁。
若要修复死锁,请将代码
MorningWalk
替换为以下代码。public int MorningWalk() { Debugger.Break(); // Always lock tree first, then banana_bunch lock (Jungle.tree) { Jungle.barrier.SignalAndWait(5000); // OK to remove lock (Jungle.banana_bunch) { Jungle.FindBananas(); GobbleUpBananas(); } } return 0; }
重启应用。
Summary
本演练演示了 并行堆栈 调试器窗口。 对使用多线程代码的实际项目使用此窗口。 可以检查用 C++、C# 或 Visual Basic 编写的并行代码。