使用“线程”视图调试死锁

本教程演示如何使用并行堆栈窗口的“线程”视图调试 C# 多线程应用程序。 此窗口可帮助你了解和验证多线程代码的运行时行为。

C++和 Visual Basic 也支持“线程”视图,因此适用于 C# 的本文中所述的相同原则也适用于 C++ 和 Visual Basic。

“线程”视图可帮助你:

  • 查看多个线程的调用堆栈可视化效果,该可视化效果提供应用状态比调用堆栈窗口更完整的图片,该窗口仅显示当前线程的调用堆栈。

  • 帮助识别诸如阻塞或死锁线程的问题。

C# 示例

本演练中的示例代码适用于模拟大猩猩生活中的一天的应用程序。 本练习的目的是了解如何使用“并行堆栈”窗口的“线程”视图调试多线程应用程序。

此示例包括一个死锁案例,发生于两个线程相互等待的情况。

为了直观显示调用堆栈,示例应用执行以下顺序步骤:

  1. 创建表示大猩猩的对象。
  2. 大猩猩醒来了
  3. 大猩猩早上散步。
  4. 大猩猩在丛林里发现了香蕉。
  5. 大猩猩在吃东西。
  6. 这是违规行为。

多线程调用堆栈

调用堆栈的相同部分组合在一起,以简化复杂应用的可视化效果。

以下概念动画演示了如何将分组应用于调用堆栈。 仅对调用堆栈的相同段进行分组。

调用堆栈分组的插图。

创建示例项目

要创建项目,请执行以下步骤:

  1. 打开 Visual Studio 并创建新项目。

    如果启动窗口未打开,请选择 “文件”>“开始”窗口

    在“开始”窗口中,选择“ 新建项目”。

    “创建新项目 ”窗口中,在搜索框中输入或键入 控制台 。 接下来,从语言列表中选择 C#,然后从平台列表中选择 Windows

    应用语言和平台筛选器后,选择适用于 .NET 的 控制台应用 ,然后选择“ 下一步”。

    Note

    如果未看到正确的模板,请转到 “工具>获取工具和功能...”,这将打开 Visual Studio 安装程序。 选择 .NET 桌面开发工作负载,然后选择修改

    “配置新项目 ”窗口中,键入名称或使用 “项目名称 ”框中的默认名称。 然后,选择“下一步”。

    对于 .NET,请选择建议的目标框架或 .NET 8,然后选择“ 创建”。

    新的控制台项目随即显示。 创建项目后,将显示源文件。

  2. 在项目中打开 .cs 代码文件。 删除其内容以创建空代码文件。

  3. 将所选语言的以下代码粘贴到空代码文件中。

     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");
             }
         }
     }
    

    更新代码文件后,保存更改并生成解决方案。

  4. 在“文件”菜单上,单击“全部保存”

  5. 在“生成”菜单上,选择“生成解决方案”

使用“并行堆栈”窗口的“线程”视图

若要开始调试,请执行以下操作:

  1. “调试 ”菜单上,选择“ 开始调试 ”(或 F5),等待第一个 Debugger.Break() 命中。

  2. F5 一次,调试器在同一 Debugger.Break() 行上再次暂停。

    这会在对 Gorilla_Start 的第二次调用中暂停,该调用发生在第二个线程中。

    Tip

    调试器按每个线程分解代码。 例如,这意味着,如果按 F5 继续执行,并且应用遇到下一个断点,它可能会在不同的线程上中断代码。 如果需要管理此行为以进行调试,可以添加其他断点、条件断点或使用 “全部中断”。 有关使用条件断点的更多信息,请参阅使用条件断点跟踪单个线程

  3. 选择“调试 > Windows > 并行堆栈”以打开“并行堆栈”窗口,然后从窗口中的“视图”下拉列表中选择“线程”。

    “并行堆栈”窗口中的“线程”视图的屏幕截图。

    线程 视图中,当前线程的堆栈帧和调用路径以蓝色突出显示。 线程的当前位置由黄色箭头显示。

    请注意,调用堆栈 Gorilla_Start 的标签为 2 个线程。 上次按下 F5 时,将启动另一个线程。 为了简化复杂应用,相同的调用堆栈将组合成单个视觉表示形式。 这简化了潜在的复杂信息,尤其是在具有许多线程的情况下。

    在调试期间,可以切换是否显示外部代码。 若要切换该功能,请选择或清除 “显示外部代码”。 如果显示外部代码,仍可以使用本演练,但结果可能与插图不同。

  4. 再次按下F5,调试器将在Debugger.Break()方法中的MorningWalk行暂停。

    “并行堆栈”窗口显示方法中 MorningWalk 当前正在执行的线程的位置。

    F5 后的“线程”视图的屏幕截图。

  5. 将鼠标悬停在 MorningWalk 方法上以获取有关由分组调用堆栈表示的两个线程的信息。

    与调用堆栈关联的线程的屏幕截图。

    当前线程也显示在“调试”工具栏的 “线程 ”列表中。

    调试工具栏中当前线程的屏幕截图。

    可以使用 “线程 ”列表将调试器上下文切换到其他线程。 这不会更改当前正在执行的线程,而只会更改调试器上下文。

    或者,可以通过双击“线程”视图中的方法或右键单击“线程”视图中的方法并选择“ 切换到 Frame>[thread ID]”来切换调试器上下文。

  6. 再次按 F5 ,调试器会在第二个线程的 MorningWalk 方法内暂停。

    第二个 F5 之后的“线程”视图的屏幕截图。

    根据线程执行的时序,您会看到单独或分组的调用堆栈。

    在上图中,两个线程的调用堆栈被部分汇总。 调用堆栈的相同段被分组,箭头线指向分离的段(即不相同的段)。 当前堆栈帧用蓝色高亮显示。

  7. 再次按 F5 ,你将看到出现长时间的延迟,“线程”视图不显示任何调用堆栈信息。

    延迟是由死锁引起的。 线程视图中不会显示任何内容,因为即使线程可能被阻止,您当前也不会在调试器中暂停。

    Tip

    如果发生死锁或所有线程当前均被阻塞,“全部中断” 按钮是获取调用堆栈信息的好方法。

  8. 在“调试”工具栏的 IDE 顶部,选择“ 全部中断 ”按钮(暂停图标),或使用 Ctrl + Alt + Break

    “线程视图”在选择“全部中断”后的屏幕截图。

    “线程”视图中调用堆栈的顶部显示 FindBananas 已死锁。 执行指针 FindBananas 是一个卷曲的绿色箭头,指示当前调试器上下文,但也告诉我们线程当前未运行。

    在代码编辑器中,我们在函数中找到卷曲的 lock 绿色箭头。 两个线程在lock方法中的FindBananas函数上被阻止。

    选择“全部中断”后代码编辑器的屏幕截图。

    根据线程执行的顺序,死锁可能出现在lock(tree)语句或lock(banana_bunch)语句。

    调用lock会阻塞FindBananas方法中的线程。 一个线程正在等待另一个线程释放对tree的锁,但另一个线程正在等待对banana_bunch的锁被释放,然后才能释放对tree的锁。 这是典型死锁的一个示例,当两个线程相互等待时会发生这种情况。

    如果使用 Copilot,还可以获取 AI 生成的线程摘要,以帮助识别潜在的死锁。

    Copilot 线程摘要说明的屏幕截图。

修复示例代码

若要修复此代码,请始终在所有线程中以一致的全局顺序获取多个锁。 这可以防止循环等待并消除死锁。

  1. 若要修复死锁,请将代码 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;
    }
    
  2. 重启应用。

Summary

本演练演示了 并行堆栈 调试器窗口。 对使用多线程代码的实际项目使用此窗口。 可以检查用 C++、C# 或 Visual Basic 编写的并行代码。