概述
编程圈子里隔三差五的就会有场“谁是最强IDE”之争,重要的是我们需要对使用的IDE有充分的了解,正所谓工欲善其事,必先利其器。
本文主要讲述Visual Studio常用的调试技巧,包括多类型断点,数据监视,以及多线程调试等。下篇则侧重于性能分析和Visual Studio Enterprise 版本所特有的一些高级功能。
调试原理
PDB文件
PDB被称为符号文件或程序数据库文件,它主要用来保存源代码和二进制指令文件(dll)之间的映射关系。主要用途有两个:1.指出二进制指令所对应的代码位置,即源文件和行号;2.帮调试器确定断点位置。
Visual Studio在Release模式下也会生成.pdb文件,但这并不意味着我们项目发布需要它。事实上,由于.pdb是编译时与.dll一起生成的,因此它只会记录当时代码与dll的映射关系,随着存储库代码的变更,它的价值会越来越低。并且它是保存有编译机器上的代码路径信息的,因此存在安全隐患。
有同学会将堆栈信息与pdb的关系搞混,异常发生时的堆栈信息是CLR生成的,跟pdb没有任何关系。
Visual Studio要求pdb版本与所调试代码版本必须完全一致。
调试外部代码
Visual Studio默认开启仅我的代码调试,如果希望能直接调试第三方代码,需要开启外部代码调试:
调试 ->常规 -> 启用.NET Framework单步执行 & 启用源服务器支持
调试 -> 符号 -> 符号文件(.pdb)位置,勾选Microsoft 和Nuget符号服务器
启动调试后,Visual Studio会首先将第三方模块的pdb文件下载到本地目录,再根据pdb文件中指出的源文件地址,到源文件服务器下载源文件,这一切完成之后,我们的断点才可以被命中,之后再按F11即可调试第三方源代码。
启用外部代码调试通常比较耗时,一般不推荐使用。
断点
一切调试的前提都基于断点,Visual Studio为我们提供了多种类型的断点,熟悉不同类型的断点并在合适的场景下运用它们,可以提高调试效率。
★表达式断点★
用当前可访问对象来设置一条bool表达式,当表达式条件为true时才会命中的断点
命中次数断点
为循环语句而设计的断点,假如你认为程序循环执行到一定次数将会出错时可以使用这种断点。
一次性断点
顾名思义,命中一次之后会自动消失的断点。
前置依赖断点
必须先命中指定的断点,才能被命中的断点。
函数名断点
当函数有多个重载或函数名称重复时,可以设置函数名断点,它会在调试时自动为所有函数设置断点。
数据监视
调试的目的是为了观察程序当前的运行状态,在Visual Studio中我们主要通过以下几种窗口来观察当前数据。
局部变量窗口
我们可以查看当前作用域内的对象或者变量值。
监视窗口
当前数据较多时,更方便的查看某个对象或者变量值;也可用来追踪超范围对象,监视函数返回值等。
★即时窗口★
可以对当前作用域内的元素进行运算,或者执行某个方法获取结果,为我们推断程序运行结果,排查复杂问题提供了极大的便利。
自动窗口
可以在函数执行完之后查看到函数的返回值,而不必在调试时创建一个变量来查看执行结果。
超范围对象跟踪
在局部变量窗口为对象创建一个id,并且添加到监视变量窗口当中。这样即使变量离开了当前代码的监测范围,我们依然可以观察到该对象的实时状态。
集合可视化
从Visual Studio 2022开始支持集合对象的可视化,集合中的元素可以用表格的形式展现在VS中,并且支持数据导出。
★多线程调试★
多线程调试则是开发者必须要掌握的重要技能。Visual Studio用于调试线程的主要工具有线程窗口、线程标记、并行堆栈窗口、并行监视窗口等,我们可以查看并行堆栈信息,进行线程标记,切换,冻结等操作。
模拟多线程场景
同学们应该都了解过线程安全集合与非线程安全集合相关知识,我们先用以下代码来模拟一个非线程安全的List。
class SampleList
{
public int Index = 0;
public int[] Array = new int[100];
public void Add(int i)
{
if(Index > 99)
{
return;
}
Array[Index] = i;
Index++;
Console.WriteLine($"Insert {i} at array index:{Index}");
}
}
然后我们在Main函数中创建1个SampleList,使用5个线程同时插入数据,观察不同线程中Index和Array的值。
var sample = new SampleList();
for(int i = 0; i < 5; i++)
{
new Thread(() =>
{
for(int j = 0; j < 20; j++)
{
sample.Add(1);
Thread.Sleep(500);
}
}).Start();
}
Console.ReadKey();
在源中显示线程
点击Visual Studio菜单中的"双绞线"图标,开启在源中显示线程。
它的作用是在源代码中标记不同的线程正在执行的位置,和断点显示的位置一样,它会显示一个双绞线图标。我们还可以右击该图标进行线程切换。
线程窗口
开始调试,等程序运行几秒钟后打上断点,在线程窗口我们可以观察到进程当前的所有线程状态。我们可以查看线程ID,类型,当前所在位置等,你还可以选择窗口上方的"列"来展示更多信息。
当我们命中断点之后,所有的线程都会停下来。展开线程调用堆栈,我们可以清楚的观察到当前有五个线程正在执行SampleList.Add() 方法,除了主线程外,还有一些.NET项目自身的基础线程在工作。
并行堆栈
并行堆栈展示的是当前线程之间的调用关系,以及堆栈信息。我们可以切换到视图模式,可以导出。并且因为C#中具有更高级别的抽象"线程":Task, 并行堆栈窗口还支持任务这种异步逻辑堆栈。
并行监视
前面我们已经观察到,当前有5个线程正在执行SampleList.Add()方法,而监视窗口只能观察到当前线程的数据,因此我们需要用到并行监视窗口。窗口中会显示每个线程中 Index和Array 的值。
线程标记
假如我们希望只看执行SampleList.Add() 方法方法的5个线程,则可以将它们设置为标记状态,该状态可以用于筛选线程。选择"仅显示标记线程"之后,并行监视窗口也会同步筛选条件。
跟踪单个线程
在别处再打一个断点,此时你按F5进行调试时,会发现调试器将以乱序进行工作,有没有办法在同一线程内进行调试呢?我们可以利用前面所提到的条件断点来实现这一操作。
使用筛选器,设置ThreadId,这样断点就只会在指定线程执行时命中了。
冻结线程
有时候,我们希望某个线程停下来,或者仅让一个线程单独执行,或者让线程以我们想要的顺序来执行,以此来观察程序状态,比如解决死锁问题等。
这时候可以利用线程冻结操作来完成。如图所示,我冻结了其余4个线程,仅让线程23908执行,此时使用F10、F11调试就与我们熟悉的单线程调试的情况无异了。
当前线程23908正在执行第26行,而其他线程则冻结在了34行。
冻结线程23908,解冻线程4744, 按F11, 调试将从线程4744堆栈所指示的位置(第34行)往下执行。
★多任务调试★
因为有更高级别的抽象库:System.Threading.Tasks.Task,所以C#中很少直接使用Thread来编写并行代码。Task并不是与线程一一对应的,一个线程可能会执行多个Task(C#并行编程: 从线程,线程池到任务_郭麻花的博客),Visual Studio同样支持基于Task类型的多线程调试。
并行堆栈窗口
将代码改造成Task模式,我们可以在并行堆栈窗口查看执行Task的线程信息:
任务窗口
监视窗口
方法视图
我们还可以通过切换并行堆栈窗口为方法视图,查看当前任务调用关系。可以看到由主线程产生五个异步任务正在执行SampleList.Add(), 而其中一个正在执行Console.WriteLine(), 我们可以清楚的看到Console.WriteLine()的调用堆栈信息。
热重载
热重载指的是在程序运行时可以直接修改源代码并且继续运行,无需重新启动调试。目前绝大部分.NET项目都支持热重载技术。
异常中断
即使我们在程序中使用了try-catch,但在调试时可能仍希望出现异常时能够及时的中断,以便排查问题。我们只需要在Visual Studio使用 调试 -> 异常设置 将指定异常设置成引发中断即可,并且异常可以用dll名称作为触发筛选条件。
总结
到这里我们介绍了Visual Studio中常用的代码调试技巧,Visual Studio还有支持远程调试,附加进程调试,多进程调试等,因为我不常用,所以就不介绍了。
实际开发当中调试技术并不是最重要的,最重要的是调试思路,思路(70%)+技巧(30%)=更高效的调试。就好比中医所讲的“望闻问切”,观察现象,思考原因,查看代码,调试是最后一步。