【问题标题】:IDisposable with member from missing external assembly fails in finalizerIDisposable 与缺少外部程序集的成员在终结器中失败
【发布时间】:2011-11-24 07:43:36
【问题描述】:

我有 2 个程序集,A 包含 Main 方法和 Foo 类,它们使用程序集 B 中的 Bar 类:

杆组件(组件 B):

public sealed class Bar : IDisposable { 
    /* ... */ 
    public void Dispose() { /* ... */ }
}

Foo 类(程序集 A):

public class Foo : IDisposable {
    private readonly Bar external;
    private bool disposed;
    public Foo()
    { 
        Console.WriteLine("Foo");
        external = new Bar(); 
    }
    ~Foo()
    { 
        Console.WriteLine("~Foo");
        this.Dispose(false); 
    }
    public void Dispose()
    {
        this.Dispose(true);
        GC.SuppressFinalize(this);
    }
    protected virtual void Dispose(bool disposing)
    {
        if (disposed) return;
        if (disposing) external.Dispose();
        disposed = true;
    }
}

入口点(在程序集 A 中):

class Program
{
    static void Main(string[] args)
    {
        try
        {
            var foo = new Foo();
            Console.WriteLine(foo);
        }
        catch (FileNotFoundException ex) 
        {
            // handle exception
            Console.WriteLine(ex.ToString());
        }
        Console.ReadLine();
    }
}

这个软件的一个要求是它必须优雅地处理缺少 dll 的情况。

因此,当我删除程序集 B 并启动应用程序时,我希望 main 方法中的 try catch 块处理程序集 B 丢失时抛出的 FileNotFoundException。确实如此,但这就是问题开始的地方......

当应用程序继续时(在控制台中输入一行),Foo 类的终结器被调用(?!)虽然没有创建 Foo 的实例 - 构造函数还没有被调用。由于没有该类的实例,因此我无法在外部对实例调用 GC.SupressFinalize。在没有 B 程序集的情况下运行项目时,您在控制台输出中看到的唯一内容是 ~Foo

所以问题:

  • 为什么即使没有创建类的实例,也会调用终结器? (对我来说这完全没有意义!我很想开悟)
  • 如果没有终结器中的 try-catch 块,是否可以防止应用程序崩溃? (这意味着重构整个代码库...)

一些背景知识:我在编写启用插件的企业应用程序时遇到了这个问题,要求它必须在插件部署文件夹中缺少 dll 并标记故障插件时继续运行。我认为外部插件加载过程周围的 try-catch 块就足够了,但显然它没有,因为在捕获第一个异常之后,终结器仍然被调用(在 GC 线程上),最终导致应用程序崩溃。

备注 上面的代码是我可以编写的用于在终结器中重现异常的最简单的代码。

备注 2 如果我在 Foo 构造函数中设置断点(在删除 Bar 的 dll 之后),它不会被命中。这意味着如果我在构造函数中设置一个创建关键资源的语句(在更新 Bar 之前),它将不会被执行,因此不需要调用终结器:

// in class Foo
public Foo() {
    // ...
    other = new OtherResource(); // this is not called when Bar's dll is missing
    external = new Bar();        // runtime throws before entering the constructor
}

protected virtual void Dispose(bool disposing) {
    // ...
    other.Dispose();    // doesn't get called either, since I am
    external.Dispose(); // invoking a method on external
    // ...
}

备注3 一个明显的解决方案是像下面那样实现 IDisposable,但这意味着破坏参考模式的实现(甚至 FxCop 也会抱怨)。

public abstract class DisposableBase : IDisposable {
    private readonly bool constructed;
    protected DisposableBase() {
        constructed = true;
    }
    ~DisposableBase() {
        if(!constructed) return;
        this.Dispose(false);
    } 
    /* ... */
}   

【问题讨论】:

  • 你发布了一个不好的例子。这甚至在进入 Main() 之前就崩溃了。当 jit 编译器无法为构造函数生成代码时,它会出错。 catch 子句无法捕获它。发布实际重现问题的代码。
  • @Hans Passant - 为我工作...... m0sa - 这只是一个摘录,但你需要你的终结器吗?在上面的代码中,您没有从该代码路径做任何事情(相关)。
  • 我宁愿使用将您的 dll 编译成单个托管 dll 的工具。这也可以防止用户和错误的部署工具混合不同版本的 dll。
  • @ordag:当缺少带有 Bar 的程序集时它可以工作吗?
  • @m0sa:我们使用来自preemptive.com的dotfuscator,但也有一个名为ILmerge的免费工具:codeproject.com/KB/dotnet/mergingassemblies.aspx

标签: c# .net dll idisposable finalizer


【解决方案1】:

为什么即使没有创建类的实例,也会调用终结器?

这个问题毫无意义。显然创建了一个实例;如果没有创建实例,终结器将完成什么?您是想告诉我们终结器中没有“this”引用吗?

构造函数没有被调用

构造函数不能被调用,因为jitting构造函数引用了一个类型缺失的字段。连jit都不能调用的构造函数体怎么可能被调用?

您似乎认为仅仅因为无法调用构造函数,就无法创建实例。这根本不符合逻辑。显然,在调用ctor之前必须有一个实例,因为对该实例的引用作为“this”传递给它。所以内存管理器创建了一个实例——垃圾收集器知道分配了内存——然后它调用构造函数。如果调用构造函数引发异常 - 或被异步异常(如线程中止)中断 - 那里仍然有一个实例,垃圾收集器知道,因此在它死亡时需要终结。

由于该对象永远不会被分配给任何活动变量——不可能,因为分配发生在 ctor 之后,并且当 jitter 试图对其进行 jit 时,ctor 抛出了——它将被确定为死在下一代零系列。然后它将被放入终结器队列中,这将使其处于活动状态。

终结器仍然被调用(在 GC 线程上),最终导致应用程序崩溃。

然后修复终结器,使其不会那样做。

请记住,ctor 可以随时被异步异常(例如线程中止)中断。您不能依赖在终结器中维护的对象的 任何 不变量。终结器是非常奇怪的代码;您应该假设它们可以在任意线程上以任意顺序运行,并且对象处于任意不良状态。 您需要在终结器中编写极具防御性的代码。

如果我在 Foo 构造函数中设置断点(在删除 Bar 的 dll 之后),它不会被命中。

正确。正如我所说,构造函数体甚至不能被 jitted。你怎么能在一个甚至不能被 jitted 的方法中打断点?

这意味着如果我在构造函数中设置一个创建关键资源的语句(在更新 Bar 之前),它将不会被执行,因此不需要调用终结器。

是否认为需要调用终结器与垃圾收集器完全无关。终结器可能具有其他语义,而不仅仅是清理资源。垃圾收集器不会尝试从心理上确定开发人员的意图并决定是否需要调用终结器。 对象已分配并具有终结器,而您没有抑制终结器,因此它将被终结。如果您不喜欢这样,那么 不要制作终结器。 em> 你做了一个终结器,因为大概你希望对象的所有实例都被终结,所以它们将会被终结。

坦率地说,我会重新审视您的基本情况。您可以安全地恢复并继续在缺少所需 DLL 的应用程序域中执行代码的想法对我来说似乎是一个非常糟糕的想法。做到这一点将非常困难。

【讨论】:

  • 我完全错过了 ctor 中已经提供的这一点。当你这样说(不是双关语)时,这绝对是有道理的。
  • @m0sa:很高兴我能帮上忙。请记住,一个ctor 只是一个实例方法。它是一个有点奇怪的实例方法——它没有被继承,没有名字,也不能被用户代码直接调用——但它最终仍然只是一个实例方法。
  • 为什么没有 Bar 的元数据可以 jitizer 而构造函数却不能呢?
  • @configurator:报告的问题不是终结器也抛出了吗?我实际上还没有尝试过OP的代码。要回答更一般的问题:抖动通常是逐个方法进行的。如果方法没有引用类型 X 的字段,则抖动不会加载类型 X。
【解决方案2】:

我认为这里的抱怨是对象构造失败,但终结器仍然被调用,并且因为终结器线程正在捕获异常,所以您无法自己捕获它?

这是完全合法的行为。假设类部分构建,并且在它抛出之前已经打开了一些关键资源?如果终结器没有运行会发生什么?这方面的案例在 C# 中得到了简化,但在 C++ 中,它是许多帖子和书籍的主题(Sutter:Exceptional C++)。

SO: Is Finalizer Called if constructor throws (C++/c#)

隐含问题的答案是,我如何在运行时处理丢失/可选程序集的绑定失败?,是你不知道。最好的解决方案是轮询目录,手动加载程序集,并根据程序集公开的接口和您的约定检索包含的类型。

终结器线程内部绑定失败的屏幕截图。如果注释掉 Bar 的 disposing 行,异常就会消失,但绑定失败不会。

【讨论】:

  • 但是这里的问题是构造函数甚至没有被调用。如果我在构造函数中设置断点,它不会被命中。这意味着如果我在构造函数中设置一个创建关键资源的语句(在更新 Bar 之前),它将不会被执行,因此不需要调用终结器。
  • m0sa - 它与 Bar 的构造函数无关,问题在于 Foo 内部的 Bar 绑定。 Foo 无法构造,因为 Bar 无法被 .net 运行时绑定。终结器运行并且运行时仍然无法绑定到 Bar,因此出现异常。您的逻辑有缺陷,因为您正在处理程序集绑定失败而不是正常的程序流程。
  • 但是为什么runtime无法构造实例后还是调用finalizer呢?没有需要完成的实例...
  • mOsa _ 不是。它的 Foo 终结器导致 Bar 的绑定查找。如果在 Foo 的 dispose 中取出对 Bar 的调用,异常就消失了,当 Foo 构造失败时,应用线程发生绑定失败。
  • 当然,它不是调用 Bar 的终结器,也不是构造函数。但它甚至没有调用 Foo 的构造函数(在那里设置一个断点自己看),但它仍然调用 Foo 的终结器
【解决方案3】:

您可能希望将您的Foo 类放在第三个程序集AssemblyC 中,然后首先在您的主函数中为AppDomain.CurrentDomain.AssemblyResolve 事件设置一个处理程序。然后尝试通过Reflection加载并执行Foo类;这样,您可以检查文件是否存在并正确反应。之后,只要缺少程序集,事件处理程序就会触发(从 AssemblyC 的直接依赖项开始,在本例中为 Bar 类)。

【讨论】:

  • 在实际实现中,实例是通过反射创建的,但仍会调用 Finalizer。
  • 这是一个非常重要的细节,问题中也没有。
  • @HansPassant 问题是为什么首先调用终结器 - 代码中的示例是重现异常的最简约示例。
  • 我必须承认我不知道为什么仍然调用终结器 - 您是否也已经在使用 AssemblyResolve 委托?我希望它在找不到 Boo 时触发
  • @Andrea 我还没有尝试过,但我肯定会尝试。我希望 AssemblyResolve 处理程序被触发两次——一次在 try catch 块中,一次在终结器调用发生时。
【解决方案4】:

这是一个猜测,但我尝试过这样的事情:

public class Foo : IDisposable {
private Bar external;
private bool disposed;

public static Foo CreateFoo() {
    Foo foo = new Foo();
    foo.external = new Bar();
    return foo;
}

private Foo() {
}

~Foo() {
    Console.WriteLine("~Foo");
    this.Dispose(false);
}
public void Dispose() {
    this.Dispose(true);
    GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing) {
    if (disposed) return;
    if (disposing) external.Dispose();
    disposed = true;
}

}

该示例将按您的预期工作。

现在我的猜测是:

我认为构造函数不是第一个被调用的方法,当一个对象被创建时。看起来我们无法控制的事情是在调用构造函数之前为创建的对象分配空间,或者类似的事情。 这就是我认为 GC 开始工作的地方。它正在获取 ClassInfo 并知道终结器可用。因此它启动 Finalizer-Thread 并创建分配内存的句柄。 知道将调用构造函数,并且将从该句柄创建对象。 但是在调用构造函数(甚至是方法)之前,会检查此代码块中所有引用类型的可用性。这就是引发 FileNotFoundException 的地方。请注意,这是在您看到调试器进入构造函数之前。 现在我们进入 Finalizer-Thread,它查看它的 Foo 句柄,发现这个句柄不再使用(实际上它从未使用过)。它开始最终确定。这是调用终结器的地方。您访问其中的 Dispose(bool) 方法,这就是将引发第二个 FileNotFoundException 的点,因为您正在访问该方法中的 Bar 类。该异常将在调用它之前引发该晦涩的方法检查。我认为它与一些优化有关,可能引用的类型是延迟加载的。

奇怪的是,当构造函数不会直接抛出异常时,就像在我的例子中一样,GC 会检查这个并且不会调用该对象的终结器。也许我错了,该对象仍在使用中,没有被 GC 收集。

编辑:

最好的解决方案相当简单。从您的课程中删除终结器。您没有使用非托管资源,因此您根本不需要终结器或 Dispose(bool)。

【讨论】:

  • 当然,但是如果 Foo 是一个抽象基类(见我的评论 #3),并且是 API 的一部分。需要 IDisposable 模式的完整实现,因为您不能对非托管资源的使用情况做出任何假设。
【解决方案5】:

我认为你将不得不重构一些东西。如果不在您的座位上,我们很难知道它在哪里是最合适的 :-) 例如,向所有终结器添加 try catch 对我来说似乎没什么大不了的(例如,您可以使用正则表达式搜索终结器),但它可能是给你的。

.NET Framework 确实假定您引用的程序集和您使用的类型在运行时就在那里。如果您想要一个更动态的系统,一个插件类型的架构,您需要以不同的方式构建您的程序集和您的类型,例如使用 System.Addin 命名空间或其他库(如 MEF)(参见 SO:Choosing between MEF and MAF (System.AddIn)

因此,在您的情况下,您可以像这样解决 Foo 中的问题:

public class Foo : IDisposable
{
    // use another specific interface here, like some IBar,
    // this is a sample, so I use IDisposable which I know is implemented by Bar
    private readonly IDisposable external;
    public Foo()
    {
        Console.WriteLine("Foo");
        external = Activator.CreateInstance(Type.GetType("AssemblyB.Bar, AssemblyB")) as IDisposable;
    }

    ... same code    
}

但这也意味着重构......

【讨论】:

  • 真实系统已经使用MEF等,没有问题。如前所述,上面的示例是可以重现异常的最小示例。困扰我的是,我希望运行时能够检查到位,就像我在备注 #3 中的代码一样。既然运行时必须接收(来自融合)构造函数无法执行的反馈,为什么在不可能创建非托管资源的情况下调用终结器??
  • @m0sa - 您可以完美地创建仍需要完成的未初始化(例如使用 FormatterServices.GetUninitializedObject)对象。
  • 我不知道。您能否对未初始化的对象进行更多说明?为什么它们有效?它们在哪里需要(显然在序列化中除外)?
  • @m0sa - 当然,它们主要用于序列化:标准 .NET 序列化(格式化程序)或 WCF 序列化(DataContractSerializer)。在这种情况下,如果在类上定义了具有特定名称的方法(例如“OnDeserializing”),则可以使用它们来完成初始化。
猜你喜欢
  • 1970-01-01
  • 2011-04-22
  • 1970-01-01
  • 2011-10-31
  • 1970-01-01
  • 1970-01-01
  • 2015-04-15
  • 2015-11-18
  • 1970-01-01
相关资源
最近更新 更多