什么原因会导致Visual Studio调试器停止评估ToString重写方法?

226
环境:Visual Studio 2015 RTM。(我没有尝试过旧版本。)
最近,我在调试我的 Noda Time 代码时注意到,当我有一个 NodaTime.Instant 类型的局部变量(Noda Time 中的中心 struct 类型之一)时,“Locals” 和 “Watch” 窗口似乎没有调用其 ToString() 重载。如果我在监视窗口中显式调用 ToString(),则会看到适当的表示形式,但否则我只能看到:
variableName       {NodaTime.Instant}

这并不是非常有用。

如果我更改覆盖以返回一个常量字符串,则字符串在调试器中显示出来,因此显然能够捕获到该字符串 - 它只是不想在其“正常”状态下使用它。

我决定在一个小的演示应用程序中本地复现这个问题,以下是我的成果。(请注意,在此帖子的早期版本中,DemoStruct是一个类,DemoClass根本不存在 - 这是我的错,但它解释了一些现在看起来奇怪的评论...)

using System;
using System.Diagnostics;
using System.Threading;

public struct DemoStruct
{
    public string Name { get; }

    public DemoStruct(string name)
    {
        Name = name;
    }

    public override string ToString()
    {
        Thread.Sleep(1000); // Vary this to see different results
        return $"Struct: {Name}";
    }
}

public class DemoClass
{
    public string Name { get; }

    public DemoClass(string name)
    {
        Name = name;
    }

    public override string ToString()
    {
        Thread.Sleep(1000); // Vary this to see different results
        return $"Class: {Name}";
    }
}

public class Program
{
    static void Main()
    {
        var demoClass = new DemoClass("Foo");
        var demoStruct = new DemoStruct("Bar");
        Debugger.Break();
    }
}

在调试器中,我现在看到:
demoClass    {DemoClass}
demoStruct   {Struct: Bar}

然而,如果我将Thread.Sleep调用从1秒减少到900毫秒,仍然会有短暂的暂停,但然后我会看到Class: Foo作为值。似乎DemoStruct.ToString()中的Thread.Sleep调用持续多长时间并不重要,它总是被正确显示——调试器在睡眠完成前显示了该值。(就像Thread.Sleep已禁用一样。)
现在,Noda Time中的Instant.ToString()做了相当多的工作,但它肯定不需要整整一秒钟——因此可能有更多的条件导致调试器放弃评估ToString()调用。当然,它本来就是一个结构体。
我尝试递归以查看是否存在堆栈限制,但似乎并非如此。
那么,我该如何确定是什么阻止了VS完全评估Instant.ToString()?如下所述,DebuggerDisplayAttribute似乎有所帮助,但如果不知道原因,我永远不会完全自信地知道何时需要它,何时不需要它。

如果我使用DebuggerDisplayAttribute,事情就会改变:

// For the sample code in the question...
[DebuggerDisplay("{ToString()}")]
public class DemoClass

给我:

demoClass      Evaluation timed out

当我在Noda Time中应用它时:

[DebuggerDisplay("{ToString()}")]
public struct Instant

一个简单的测试应用程序向我展示了正确的结果:
instant    "1970-01-01T00:00:00Z"

因此,Noda Time 中的问题可能是某些条件被 DebuggerDisplayAttribute 强制执行,即使它不强制执行超时。(这符合我的预期,即 Instant.ToString 足够快,可以避免超时。)这可能是一个足够好的解决方案,但我仍然想知道发生了什么,并且是否可以更改代码,以避免在所有各种值类型中都放置属性。

更加好奇了

无论是什么让调试器感到困惑,它只有在某些情况下才会感到困惑。让我们创建一个类,它 持有 一个 Instant 并将其用于自己的 ToString() 方法:

using NodaTime;
using System.Diagnostics;

public class InstantWrapper
{
    private readonly Instant instant;

    public InstantWrapper(Instant instant)
    {
        this.instant = instant;
    }

    public override string ToString() => instant.ToString();
}

public class Program
{
    static void Main()
    {
        var instant = NodaConstants.UnixEpoch;
        var wrapper = new InstantWrapper(instant);

        Debugger.Break();
    }
}

现在我最终看到:
instant    {NodaTime.Instant}
wrapper    {1970-01-01T00:00:00Z}

然而,在评论中Eren的建议下,如果我将InstantWrapper更改为结构体,则会得到:

instant    {NodaTime.Instant}
wrapper    {InstantWrapper}

因此,它可以评估“Instant.ToString()” - 只要这是由另一个ToString方法调用的...而且该方法在类中。基于显示的变量类型而不是需要执行的代码,类/结构部分似乎很重要。
作为另一个例子,如果我们使用:
object boxed = NodaConstants.UnixEpoch;

如果这样做,它可以正常工作,并显示正确的值。让我感到困惑。


7
@John 在 VS 2013 中表现相同(我不得不删除 C#6 相关内容),并出现了一个额外的消息:名称“Function evaluation”已禁用,因为上一次函数评估超时。您必须继续执行以重新启用函数评估。 字符串 - vc 74
1
欢迎使用C# 6.0 @3-14159265358979323846264 - Neel
1
也许一个 DebuggerDisplayAttribute 会让它更加努力尝试。 - Rawling
1
你可以在注册表中更改超时时间... https://dev59.com/g3RA5IYBdhLWcg3w6SNH#1212068。(感谢@Neel)! - 3-14159265358979323846264
5
@DiomidisSpinellis说:“我在这里提问是因为,一方面,看过同样问题或了解Visual Studio内部的人可以回答我的问题;另一方面,未来遇到同样问题的人也能够快速得到答案。”请注意,本翻译保留原意,简化语言,不加解释。 - Jon Skeet
显示剩余41条评论
1个回答

194

更新:

此错误已在Visual Studio 2015更新2中修复。如果您仍然在使用更新2或更高版本时遇到评估ToString结构值的问题,请告诉我。

原始答案:

您正在遇到Visual Studio 2015中已知的错误/设计限制,即调用结构类型的ToString。这也可以在处理System.DateTimeSpan时观察到。System.DateTimeSpan.ToString()在使用Visual Studio 2013的评估窗口中有效,但在2015年并不总是有效。

如果您对低级详细信息感兴趣,以下是正在进行的操作:

为了评估ToString,调试器执行所谓的“函数评估”。大大简化,调试器暂停进程中除当前线程外的所有线程,将当前线程的上下文更改为ToString函数,设置隐藏的守卫断点,然后允许进程继续。当命中守卫断点时,调试器将进程恢复到其先前的状态,并使用函数的返回值填充窗口。

为了支持Lambda表达式,我们不得不在Visual Studio 2015中完全重写CLR表达式评估器。在高层面上,实现方式如下:

  1. Roslyn为表达式/本地变量生成MSIL代码,以获取要在各种检查窗口中显示的值。
  2. 调试器解释IL以获取结果。
  3. 如果有任何“call”指令,则调试器执行如上所述的函数评估。
  4. 调试器/Roslyn将此结果格式化为向用户显示的树状视图。

由于执行IL,调试器始终处理“真实”和“虚假”值的复杂混合。真实值实际上存在于正在调试的进程中。虚假值仅存在于调试器进程中。为了实现适当的结构语义,调试器在将结构值推送到IL堆栈时总是需要复制该值。复制的值不再是“真实”值,现在仅存在于调试器进程中。这意味着,如果我们稍后需要执行ToString的函数评估,我们不能因为该值不存在于进程中。为了尝试获取所需的值,我们需要模拟执行ToString方法。虽然我们可以模拟一些事情,但有许多限制。例如,我们无法模拟本地代码,也无法执行对“真实”委托值或反射值的调用。

考虑到所有这些,以下是导致您看到的各种行为的原因:

  1. 调试器无法评估 NodaTime.Instant.ToString -> 这是因为它是结构类型,而 ToString 的实现不能像上面描述的那样被调试器模拟。
  2. Thread.Sleep 在结构体的 ToString 调用时似乎需要零时间 -> 这是因为仿真器正在执行 ToString。Thread.Sleep 是本机方法,但仿真器知道它并且只是忽略调用。我们这样做是为了尝试获取一个值以显示给用户。在这种情况下,延迟是没有帮助的。
  3. DisplayAttibute("ToString()") 可以工作。-> 这很令人困惑。隐式调用 ToStringDebuggerDisplay 之间唯一的区别是,隐式 ToString 评估的任何超时都将禁用该类型的所有隐式 ToString 评估,直到下一个调试会话。您可能正在观察到这种行为。

关于设计问题/错误,这是我们计划在 Visual Studio 的未来版本中解决的问题。

希望这能澄清事情。如果您有更多问题,请告诉我。 :-)


1
你有没有想过如果Instant.ToString的实现只是“返回一个字符串文字”,它是如何工作的?这听起来似乎还有一些复杂性没有考虑到 :) 我会检查我是否真的可以重现那种行为... - Jon Skeet
8
理想情况下,我们希望CLR执行所有内容。这提供了最准确可靠的结果。这就是为什么我们对ToString调用进行实际函数评估的原因。当不可能时,我们退回到模拟调用。这意味着调试器假装是CLR执行方法。显然,如果实现为<code>return "Hello"</code>,那么这很容易做到。如果实现使用P-Invoke,那么这就更困难或不可能了。 - Patrick Nelson - MSFT
3
@tzachs,这个模拟器完全是单线程的。如果“innerResult”一开始就是空的,那么循环将永远不会结束,并且最终评估将超时。实际上,默认情况下,评估只允许进程中的一个线程运行,因此无论是否使用模拟器,您都会看到相同的行为。 - Patrick Nelson - MSFT
2
顺便提一下,如果您知道您的评估需要多个线程,请查看Debugger.NotifyOfCrossThreadDependency。调用此方法将中止评估,并显示一个消息,指出评估需要所有线程运行,并且调试器将提供一个按钮,用户可以按下以强制进行评估。缺点是在评估期间其他线程上触发的任何断点都将被忽略。 - Patrick Nelson - MSFT
1
调试器能否利用 NodaTime.Instant 是不可变的这一事实呢? - Matt Johnson-Pint
显示剩余8条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接