并发编程-10.使用 Visual Studio 调试多线程应用程序
引入多线程调试
调试是每个 .NET 开发人员技能的关键组成部分。 没有人会编写没有错误的代码,将多线程结构引入到您的项目中只会增加引入的机会
错误。 由于 .NET 和 C# 添加了更多对并行编程和并发的支持,Visual Studio 添加了更多调试功能来支持这些构造。
如今,Visual Studio 为现代 .NET 开发人员提供了以下多线程调试功能:
- 线程(Threads):此窗口显示应用程序在调试时使用的线程列表。 它还指示哪个线程在代码中的断点处停止时处于活动状态。
- 并行堆栈(Parallel Stacks):此窗口允许开发人员在单个视图中可视化应用程序中每个线程的调用堆栈。 在窗口中选择一个线程将在“调用堆栈”窗口中显示所选线程的调用堆栈信息。
- 并行监视:此窗口的工作方式类似于“监视”窗口,不同之处在于您可以查看应用程序中每个活动线程上的监视表达式的值。
- 调试位置:此工具栏允许您在调试多线程应用程序时缩小焦点。 它具有用于选择进程、线程和堆栈帧的字段。 工具栏上还有一些按钮,以便您可以标记和取消标记要监视的线程。
- 任务:此窗口显示应用程序中每个正在运行的任务,并提供有关正在运行该任务的线程、任务状态及其调用堆栈的信息。 您还可以查看每个任务的起点(传递给要运行的任务的方法或委托)。
- 附加到进程:此窗口允许您将 Visual Studio 调试器附加到本地计算机或远程计算机上的进程。 使用多线程 UI 应用程序时,远程调试非常有用。 它允许开发人员在处理器内核数量与机器上不同的系统上调试应用程序。 它们还可以附加到在运行生产环境中存在的其他进程的系统上运行的远程进程。
- GPU 线程:此窗口显示有关 GPU 上运行的线程的信息。 这用于 C++ 应用程序,超出了本书的范围。
在前面的章节中,我们将使用这些调试工具单步调试本书前面一些章节中的项目中的多线程代码。 让我们首先了解“线程”和“附加到进程”窗口以及“调试位置”工具栏。
调试线程和进程
在本节中,我们将调试第 1 章中的 BackgroundPingConsoleApp。您可以使用第 1 章中已完成的项目。 我们将调试应用程序并发现“调试位置”工具栏和“线程”窗口的一些功能。
调试具有多个线程的项目
我们将要处理的项目是一个简单的项目,它创建一个后台线程来检查网络是否可用。
让我们开始调试示例:
-
首先在 Visual Studio 中打开 BackgroundPingConsoleApp,然后在 C# 编辑器中打开 Program.cs。
-
在 while 循环内的 Thread.Sleep(100) 语句上设置断点。
-
选择查看 | 工具栏| 调试位置显示“调试位置”工具栏:
图 10.1 – Visual Studio 中的调试位置工具栏
当我们开始调试时,我们将使用这个工具栏。 当 Visual Studio 中没有活动的调试会话时,所有字段都会被禁用。
- 开始调试项目。 当 Visual Studio 在断点处中断时,请注意“调试位置”工具栏的状态:
图 10.2 – 使用“调试位置”工具栏进行调试
工具栏提供了几个下拉控件来选择范围内的进程、线程和堆栈帧属性。 除非您使用“附加到进程”窗口显式调试多个进程,否则“进程”下拉列表将仅包含单个进程。 您还可以在 Visual Studio 中设置多个启动项目来实现此目的。
线程下拉列表包含属于所选进程的所有线程。 该控件中选定的线程是我们创建的后台线程,因为断点是添加在该后台线程执行的代码中的。
堆栈帧下拉列表包含当前线程调用堆栈中的帧列表。
线程下拉列表右侧有一个“切换当前线程标记状态”按钮。 我们稍后将在“切换和标记线程”部分中了解如何标记线程。
- 接下来,选择调试 | 窗户 | 线程打开“线程”窗口:
图 10.3 – 在“线程”窗口处于活动状态时进行调试
默认情况下,“线程”窗口将在左下面板中打开,其中包含“输出”、“局部变量”和“监视”调试窗口。
- 最后,展开“Threads”窗口,以便我们可以探索和讨论其功能:
图 10.4 – 仔细观察“线程”窗口
探索线程窗口
线程窗口在一个小窗口中提供了大量有用的信息。 我们将首先讨论为列表中每个线程显示的数据:
进程 ID:默认情况下,线程列表按进程 ID 分组。 此分组可以通过窗口工具栏中的分组依据下拉列表进行控制。 进程 ID 分组还显示其组中的线程数。 这在处理大量线程时非常有用。
- ID:这是列表中每个线程的 ID
- 托管ID:这是每个线程的Thread.ManagedThreadId属性
- 类别(Category):描述线程的类型(主线程、工作线程等)
- 名称(Name):该字段包含每个线程的 Thread.Name 属性。 如果线程没有名称,则此字段中将显示
。 - 位置(Location):该字段包含其调用堆栈中每个线程的当前堆栈帧。 您可以单击此字段中的下拉列表以显示线程的完整调用堆栈。
- 默认情况下,一些附加字段是隐藏的。 您可以通过选择“线程”窗口工具栏中的“列”按钮来隐藏或显示列。 选择或取消选择您想要显示或隐藏的列。 这些是最初隐藏的列:
- 优先级(Priority):显示系统分配给线程的优先级
- 关联掩码(Affinity Mask):关联掩码确定线程可以在哪些处理器上运行。 这是系统决定的
- 进程名称(Process Name:):这是线程所属进程的名称
- 进程ID(Process ID):这是线程所属进程的ID
- 传输限定符(Transport Qualifier):这标识连接到调试器的机器。 这对于远程调试很有用
现在,让我们回顾一下“线程”窗口中可用的工具栏项目:
• 搜索:这允许您搜索话题。 如果您希望搜索结果包含所有调用堆栈信息,您可以打开“在搜索中包含调用堆栈”按钮
• 标记:使用此下拉按钮,您可以选择仅标记我的代码或标记自定义模块选择
• 分组依据:此下拉列表允许您按不同字段对话题进行分组。 默认情况下,它们按进程 ID 分组
• 列:这将打开“列”选择窗口,以便您可以自定义“线程”窗口中显示的列
• 展开/折叠调用堆栈:这两个按钮在“位置”列中展开或折叠调用堆栈
• 展开/折叠组:这两个按钮可展开或折叠线程分组
• 冻结线程:这会冻结窗口中所有选定的线程
• 解冻线程:这会解冻窗口中所有选定的线程
让我们尝试一下搜索功能。 开始调试BackgroundPingConsoleApp 项目。 当它到达断点时,在搜索字段中搜索 Anon 以查找其调用堆栈包含我们的匿名方法的线程:
图 10.5 – 在“线程”窗口中搜索
Threads 窗口现在应该只包含工作线程的行,AnonymousMethod 的 Anon 部分以黄色突出显示。
切换和标记线程
调试多线程应用程序时,“线程”窗口提供了强大的功能。 我们在上一节中谈到了其中一些功能。 在本节中,我们将学习如何切换线程、标记线程以及冻结或解冻线程。 让我们从在 BackgroundPingConsoleApp 项目中的线程之间切换开始。
切换线程
您可以使用“线程”窗口中的上下文菜单将上下文切换到不同的线程。 运行项目并等待调试器在我们的匿名方法的断点处暂停。 当调试器中暂停执行时,右键单击“主线程”行并选择“切换到线程”。调试器中的光标应切换到 Console.ReadLine() 语句。这是主线程等待 用户在控制台中按任意键:
图 10.6 – 在 Visual Studio 调试器中切换线程
您可以看到,在调试具有六个或更多活动线程的并行操作时,此函数非常有用。 接下来,我们将学习如何使用“标记线程”功能来关注特定线程。
标记线程
在本节中,您将了解如何在“线程”窗口中调试时缩小视野。 通过仅标记我们关心的线程,我们可以减少窗口中的混乱。以下是标记线程的方法:
- 如果您尚未调试BackgroundPingConsoleApp 项目,请立即开始调试并等待其在断点处停止。
- 当调试器在应用程序中暂停时,右键单击“主线程”行并选择“标记”。 该行中的标志图标现在应为橙色。
- 对调用堆栈中包含带有 AnonymousMethod 的 Worker Thread 的行执行相同操作
- 接下来,单击窗口工具栏中的“仅显示标记的线程”按钮:
图 10.7 – 仅在“线程”窗口中显示标记的线程
这使得仅跟踪对当前调试会话重要的线程变得更简单。您可以再次单击该按钮以关闭该按钮并查看所有线程。 还可以在“并行监视”和“并行堆栈”窗口中标记线程。 它们的标记状态将在所有这些窗口和调试位置工具栏上持续存在。
有一种更简单的方法可以在我们的应用程序中标记这两个线程。 这是我们应用程序代码中仅有的两个线程。 因此,我们可以使用工具栏中的“仅标记我的代码”按钮来标记它们。
- 取消选择“仅显示标记的线程”工具栏按钮
- 右键单击窗口中标记的行之一,然后选择全部取消标记
- 现在,单击工具栏中的“仅标记我的代码”。 相同的两个线程将再次被标记:
图 10.8 – 仅标记属于我们代码的线程
这比在列表中一一选择线程要容易得多。 哪些线程是我们代码的一部分可能并不总是那么明显。 在下一节中,我们将学习如何冻结线程。
冻结线程
在“线程”窗口中冻结或解冻线程相当于调用 SuspendThread 或 ResumeThread Windows 函数。 如果冻结的线程尚未执行任何代码,则除非解冻,否则它将永远不会启动。 如果线程当前正在执行,则当在 Visual Studio 中调用 Freeze 线程时,该线程将会暂停。
让我们尝试冻结和解冻 BackgroundPingConsoleApp 项目中的工作线程,看看调试器中会发生什么:
- 在运行应用程序之前,在 while (true) 和 Console 处添加新断点。 ReadKey() 语句。 将现有断点保留在 Thread.Sleep(100)
- 开始调试应用程序
- 当调试器在 while (true) 行中断时,右键单击包含 AnonymousMethod 的工作线程并选择 Freeze.
- 继续调试; 它应该在 Console.ReadKey() 行而不是 Thread.Sleep(100) 处中断。 这是因为工作线程当前没有运行:
图 10.9 – 在“线程”窗口中冻结工作线程
- 再次右键单击工作线程并选择解冻
- 现在,再次继续调试。 Visual Studio 在匿名方法内的 Thread.Sleep(100) 行处中断。
这显示了“线程”窗口的功能在调试多线程应用程序时非常有用。
现在我们已经了解了如何通过使用“线程”窗口切换、冻结和标记线程来调试多线程应用程序,接下来我们来了解如何在调试时利用并行堆栈和并行监视窗口等附加功能。
调试并行应用程序
Visual Studio 提供了多个用于并行调试的窗口。 虽然“线程”窗口适用于任何类型的多线程应用程序,但其他窗口在处理应用程序中的“任务”对象时提供了附加功能和视图。
我们将从“并行堆栈”窗口开始了解这些功能。
使用并行堆栈窗口
“并行堆栈”窗口提供应用程序中线程或任务的可视化表示。这是窗口中的两个不同视图。 您可以通过在视图下拉框中选择线程或任务来在它们之间切换。 以下屏幕截图显示了调试BackgroundPingConsoleApp 项目时的线程视图示例:
图 10.10 – 在“线程”视图中查看“并行堆栈”窗口
“并行堆栈”窗口包含一个工具栏,其中从左到右包含以下项目。 您可以通过检查 Visual Studio 窗口中工具栏项的工具提示来进行操作:
-
搜索:这允许使用与“线程”窗口中可用的相同类型的搜索功能。搜索字段右侧有“查找上一个”和“查找下一个”按钮。
-
视图:此下拉菜单在“线程”和“任务”视图之间切换
-
仅显示标记:此切换将隐藏任何未标记的线程
-
切换方法视图:这将切换到当前所选方法及其调用堆栈的视图
-
自动滚动到当前堆栈帧:这将在单步执行调试器时将当前堆栈帧滚动到图中的视图中。 默认情况下此选项处于打开状态。
-
切换缩放控件:这会隐藏或显示图表表面上的缩放控件。 该选项默认打开。
-
反向布局:此选项镜像当前视图的布局
-
保存图表:此选项将当前图表保存到.png 文件中
要检查窗口的任务视图,我们需要打开一个包含一些任务对象的不同项目。 让我们通过打开本书前一章中的项目来使用“任务”视图:
-
打开第 5 章中的 TaskSamples 项目
-
打开 Examples.cs 并在 ProcessOrders 方法的第一行设置断点。
-
开始调试。 当调试器在断点处停止时,选择 调试| 窗户 | 并行堆栈。
-
切换到并行堆栈窗口中的任务视图:
图 10.11 – 任务视图中的并行堆栈窗口
尚未开始任何任务,因此这里没什么可看的。 有一个异步逻辑堆栈块看起来已经准备好开始分析一些异步工作。
-
在 Tasks.WaitAll 语句上添加断点,然后单击继续
-
现在,再次检查并行堆栈窗口:
图 10.12 – 任务处于活动状态时的并行堆栈窗口
在这种情况下,并行堆栈窗口捕获了一个正在运行的任务的执行情况和另一个准备运行的任务的执行情况。 这个任务视图和我们在本章中所做的一些线程分析之间存在一些差异:
- 任务视图中仅显示正在运行的任务
- 任务视图的堆栈尝试仅显示相关的调用堆栈信息。 如果堆栈框架不相关,则可以从顶部和底部修剪它们。 如果您需要查看整个调用堆栈,请切换回“线程”视图。
- 任务视图中将为每个活动任务显示一个单独的块,即使它们被分配给同一线程也是如此。
您可以将鼠标悬停在任务调用堆栈中的一行上以查看有关其线程和堆栈帧的更多信息:
图 12.13 – 查看有关调用堆栈帧的更多信息
如果要将“任务”视图旋转到特定方法,可以使用“切换方法视图”按钮:
- 在 TaskSamples 项目中启动新的调试会话
- 在PrepareOrders方法中的退货订单语句上设置新的断点
- 单击继续。 当调试器在PrepareOrders 方法中中断时,并行监视窗口将显示活动任务。
- 单击“切换方法视图”按钮。 您现在拥有以方法为中心的“任务”视图视图,并且可以将鼠标悬停在“PrepareOrders”方法上以获取更多调用堆栈和线程信息:
图 10.14 – 利用并行堆栈窗口的方法视图区域
使用并行监视窗口
并行监视窗口与 Visual Studio 中的监视窗口类似,但它显示有关跨线程的监视表达式值的附加信息,并可访问表达式中的数据。
在此示例中,我们将修改 TaskSamples 项目中的 Examples 类以添加可供多个线程使用的状态:
- 首先向 Examples 类添加一个私有变量:
private List<Order> _sharedOrders;
- 在 ProcessOrders 中添加一行,将订单分配给 _sharedOrders:
private List<Order> PrepareOrders(List<Order> orders)
{
// TODO: Prepare orders here
_sharedOrders = orders;
return orders;
}
-
保留上一个示例中的断点并开始调试。 继续,直到调试器在 ProcessOrders 内的返回订单语句处中断。
-
选择调试 | 窗户 | 并行监视 1 打开“并行监视 1”窗口。 您最多可以打开四个并行观察窗口来分隔您观察的表情。
-
在 Parallel Watch 1 窗口中,您将在上下文中看到当前线程的一行。 将监视添加到 _sharedOrders 私有变量:
图 10.15 – 在 Parallel Watch 1 窗口中添加监视表达式
该窗口指示任务 6 在范围内具有 _sharedOrders,并且变量中的订单计数为 0。
- 右键单击“线程”窗口中的“主线程”,然后选择“切换到线程”。 在Parallel Watch 1窗口中,任务不再在范围内,因此标题标签已从Task更改为Thread,并且将显示Main Thread的ID属性:
图 10.16 – 在主线程上查看监视的变量
- 最后,选择调试 | 窗户 | 任务打开任务窗口:
图 10.17 – 调试时查看任务窗口
“任务”窗口将显示有关调试会话范围内的任务的信息。 窗口中显示以下列:
• 标记:指示当前任务是否已被标记的图标。 您可以单击此字段来标记或取消标记任务。
• ID:任务的ID
• 状态:任务的Task.Status 属性
• 开始时间(秒):这表示任务在调试会话中启动了多少秒
• 持续时间(秒):这表示任务已经运行了多长时间
• 位置:这显示线程上任务的调用堆栈位置
• 任务:任务开始的初始方法。 已传递的任何参数也将显示在此字段中。
通过在窗口中右键单击并选择“列”,可以显示其他几个隐藏字段:
图 10.18 – 在“任务”窗口中添加或删除列
您可以在“任务”窗口中对任务进行排序和分组,类似于“线程”窗口的工作方式。不同之处在于“任务”窗口没有工具栏。 所有操作均通过右键单击上下文菜单执行。
调试并行 .NET 代码时可以使用的另一个工具是“调试位置”工具栏。 如果它尚未显示在 Visual Studio 中,您可以通过转到“查看”|“打开它”来打开它。 工具栏| 调试位置。调试时,工具栏功能会亮起:
图 10.19 – 调试时查看“调试位置”工具栏
从工具栏中,您可以选择活动的进程、线程和堆栈帧。 切换当前所选线程的标记状态也很容易。