【问题标题】:Bug in WeakAction in case of Closure Action在关闭操作的情况下,WeakAction 中的错误
【发布时间】:2014-09-08 18:12:14
【问题描述】:

在我参与的一个项目中,大量使用了WeakAction。这是一个允许保持对动作实例的引用而不导致其目标不被垃圾收集的类。它的工作方式很简单,它对构造函数执行一个操作,并保持对操作目标和方法的弱引用,但丢弃对操作本身的引用。到了执行动作的时候,它会检查目标是否还活着,如果是,就调用目标上的方法。

除了一种情况 - 当动作在闭包中实例化时,一切都很好。考虑以下示例:

public class A 
{
     WeakAction action = null;

     private void _register(string msg) 
     {
         action = new WeakAction(() => 
         {
             MessageBox.Show(msg);
         }
     }
}

由于 lambda 表达式使用 msg 局部变量,C# 编译器会自动生成一个嵌套类来保存所有闭包变量。动作的目标是嵌套类的实例而不是 A 的实例。一旦构造函数完成,传递给 WeakAction 构造函数的动作就不会被引用,因此垃圾收集器可以立即处理它。稍后,如果WeakAction 被执行,它将无法工作,因为目标不再是活动的,即使A 的原始实例是活动的。

现在我不能改变WeakAction 的调用方式(因为它被广泛使用),但我可以改变它的实现。我正在考虑尝试找到一种方法来访问 A 的实例并强制嵌套类的实例在 A 的实例仍然存在时保持活跃,但我不知道如何获取它。

有很多关于A 与任何事情的关系的问题,并且建议改变A 创建弱动作的方式(我们不能这样做)所以这里是一个澄清:

A 类的实例希望B 类的实例在发生某事时通知它,因此它使用Action 对象提供回调。 A 不知道B 使用弱动作,它只是提供了一个Action 作为回调。 B 使用 WeakAction 的事实是未公开的实现细节。 B 需要存储这个动作并在需要时使用它。但是B 的寿命可能比A 长得多,并且持有对正常Action 的强引用(它本身持有生成它的A 实例的强引用)导致A 永远不会被垃圾收集.如果A 是不再存在的项目列表的一部分,我们希望A 被垃圾回收,并且因为B 持有Action 的引用,它本身指向A ,我们有内存泄漏。

所以B 不是持有A 提供的Action,而是B 将其包装成WeakAction 并仅存储弱操作。到了调用它的时候,B 只有在WeakAction 还活着的情况下才会这样做,只要A 还活着就应该如此。

A 在方法中创建该操作,并且不会自行保留对它的引用 - 这是给定的。由于Action 是在A 的特定实例的上下文中构造的,因此该实例是A 的目标,当A 死亡时,对它的所有弱引用变为null 所以B 知道不要调用它并处理 WeakAction 对象。

但有时生成Action 的方法会使用在该函数中本地定义的变量。在这种情况下,动作运行的上下文不仅包括A 的实例,还包括方法内部的局部变量的状态(称为“闭包”)。 C# 编译器通过创建一个隐藏的嵌套类来保存这些变量(我们称之为A__closure)来做到这一点,而成为Action 目标的实例是A__closure 的实例,而不是A 的实例。这是用户不应该意识到的事情。除了A__closure 的这个实例只被Action 对象引用。而且由于我们创建了对目标的弱引用,并且不持有对动作的引用,因此没有对 A__closure 实例的引用,垃圾收集器可能(并且通常会)立即处理它。所以A 活着,A__closure 死了,尽管A 仍然期待回调被调用,B 做不到。

这就是错误。

我的问题是,如果有人知道 WeakAction 构造函数(实际保存原始 Action 对象的唯一一段代码)可以以某种神奇的方式从 @ 中提取 A 的原始实例。在ActionTarget 中找到的987654377@ 实例。如果是这样,我或许可以延长 A__Closure 的生命周期以匹配 A 的生命周期。

【问题讨论】:

  • 在 C# 的更高版本中是否已修复此问题?

标签: c# weak-references


【解决方案1】:

经过更多研究并从此处发布的答案中收集了所有有用的信息后,我意识到不会有一个优雅而密封的解决方案来解决这个问题。由于这是一个现实生活中的问题,我们采用了务实的方法,试图至少通过处理尽可能多的场景来减少它,所以我想发布我们所做的。

对传递给 WeakEvent 构造函数的 Action 对象,尤其是 Action.Target 属性的深入调查表明,实际上存在 2 种不同的闭包对象情况。

第一种情况是 Lambda 使用调用函数范围内的局部变量,但不使用 A 类实例中的任何信息。在下面的示例中,假设 EventAggregator.Register 是一个方法,它接受一个操作并存储一个包装它的 WeakAction。

public class A 
{
    public void Listen(int num) 
    {
        EventAggregator.Register<SomeEvent>(_createListenAction(num));
    }

    public Action _createListenAction(int num) 
    {
        return new Action(() => 
        {
            if (num > 10) MessageBox.Show("This is a large number");
        });
    }
}

这里创建的 lambda 使用了num 变量,它是定义在_createListenAction 函数范围内的局部变量。所以编译器必须用一个闭包类来包装它以维护闭包变量。但是,由于 lambda 不访问任何类 A 成员,因此无需存储对 A 的引用。因此,动作的目标将不包含对 A 实例的任何引用,并且 WeakAction 构造函数绝对没有办法到达它。

第二种情况如下例所示:

public class A 
{
   int _num = 10;

    public void Listen() 
    {
        EventAggregator.Register<SomeEvent>(_createListenAction());
    }

    public Action _createListenAction() 
    {
        return new Action(() => 
        {
            if (_num > 10) MessageBox.Show("This is a large number");
        });
    }
}

现在_num 没有作为参数提供给函数,它来自A 类实例。使用反射来了解 Target 对象的结构表明编译器定义的最后一个字段包含对 A 类实例的引用。当 lambda 包含对成员方法的调用时,这种情况也适用,如下例所示:

public class A 
{
    private void _privateMethod() 
    {
       // do something here
    }        

    public void Listen() 
    {
        EventAggregator.Register<SomeEvent>(_createListenAction());
    }

    public Action _createListenAction() 
    {
        return new Action(() => 
        {
             _privateMethod();
        });
    }
}

_privateMethod 是一个成员函数,因此它是在类 A 实例的上下文中调用的,因此闭包必须保留对它的引用才能在正确的上下文中调用 lambda。

所以第一种情况是只包含函数局部变量的闭包,第二种情况包含对父 A 实例的引用。在这两种情况下,都没有对 Closure 实例的硬引用,因此如果 WeakAction 构造函数只是保持原样,尽管 A 类实例仍然存在,但 WeakAction 将立即“死亡”。

我们在这里面临 3 个不同的问题:

  1. 如何识别动作的目标是嵌套闭包 类实例,而不是原始的 A 实例?
  2. 如何获取对原始A类实例的引用?
  3. 如何延长闭包实例的生命周期,使其能够存活 只要 A 实例存在,但不超出此范围?

第一个问题的答案是我们依赖闭包实例的 3 个特征: - 它是私有的(更准确地说,它不是“可见的”。使用 C# 编译器时,反射类型的 IsPrivate 设置为 true,但在 VB 中则没有。在所有情况下,IsVisible 属性都是 false)。 - 它是嵌套的。 - 正如@DarkFalcon 在他的回答中提到的,它用 [CompilerGenerated] 属性装饰。

private static bool _isClosure(Action a)
{
    var typ = a.Target.GetType();
    var isInvisible = !typ.IsVisible;
    var isCompilerGenerated = Attribute.IsDefined(typ, typeof(CompilerGeneratedAttribute));
    var isNested = typ.IsNested && typ.MemberType == MemberTypes.NestedType;


    return isNested && isCompilerGenerated && isInvisible;
}

虽然这不是一个 100% 密封的谓词(恶意程序员可能会生成一个嵌套的私有类并使用 CompilerGenerated 属性对其进行修饰),但在现实生活中这已经足够准确了,而且我们正在构建一个实用的解决方案,不是学术的。

所以问题 1 解决了。弱动作构造函数识别动作目标是闭包的情况并对其做出响应。

问题 3 也很容易解决。正如@usr 在他的回答中所写,一旦我们获得了 A 类实例,添加一个带有单个条目的 ConditionalWeakTable,其中 A 类实例是键,闭包实例是目标,就可以解决问题。只要 A 类实例存在,垃圾收集器就知道不收集闭包实例。所以没关系。

唯一不可解决的问题是第二个问题,如何获得对A类实例的引用?正如我所说,闭包有两种情况。一种是编译器创建一个包含此实例的成员,另一种是没有。在第二种情况下,根本没有办法得到它,所以我们唯一能做的就是创建一个对闭包实例的硬引用,以防止它立即被垃圾收集。这意味着它可能会在 A 类实例中存活(事实上,只要 WeakAction 实例存活,它就会存活,这可能是永远的)。 但是这毕竟不是一个可怕的案例。这种情况下的闭包类只包含几个局部变量,并且在 99.9% 的情况下它是一个非常小的结构。虽然这仍然是内存泄漏,但它不是一个实质性的泄漏。

但为了让用户避免内存泄漏,我们现在在 WeakAction 类中添加了一个额外的构造函数,如下所示:

public WeakAction(object target, Action action) {...}

当调用这个构造函数时,我们添加一个 ConditionalWeakTable 条目,其中目标是键,动作目标是值。我们还持有对目标和动作目标的弱引用,如果其中任何一个死亡,我们都会清除它们。因此,动作目标的生命不会少于或多于提供的目标。这基本上允许 WeakAction 的用户告诉它只要目标存在就保持闭包实例。因此,将告知新用户使用它以避免内存泄漏。但是在不使用这个新构造函数的现有项目中,这至少可以最大限度地减少内存泄漏到没有引用 A 类实例的闭包。

闭包引用父级的情况更成问题,因为它们会影响垃圾收集。如果我们持有对闭包的硬引用,则会导致更严重的内存泄漏,因为 A 类实例也永远不会被清除。但这种情况也比较容易治疗。由于编译器添加了一个包含对 A 类实例的引用的最后一个成员,因此我们只需使用反射来提取它,并完全按照用户在构造函数中提供它时所做的事情。当闭包实例的最后一个成员与闭包嵌套类的声明类型相同时,我们会识别这种情况。 (同样,它不是 100% 准确,但对于现实生活中的案例来说已经足够接近了)。

总而言之,我在这里提出的解决方案不是 100% 密封的解决方案,只是因为似乎没有这样的解决方案。但是由于我们必须为这个烦人的错误提供一些答案,所以这个解决方案至少大大减少了这个问题。

【讨论】:

    【解决方案2】:

    您希望延长闭包类实例的生命周期,使其与A 实例的生命周期完全相同。 CLR 有一个特殊的 GC 句柄类型:the Ephemeron,实现为internal struct DependentHandle

    1. 此结构仅作为ConditionalWeakTable 类的一部分公开。您可以为每个 WeakAction 创建一个这样的表,其中只有一个项目。键是A 的实例,值是闭包类实例。
    2. 或者,您可以使用私有反射来撬开DependentHandle
    3. 或者,您可以使用一个全局共享的ConditionalWeakTable 实例。它可能需要同步才能使用。查看文档。

    考虑打开一个连接问题以公开DependentHandle 并链接到此问题以提供用例。

    【讨论】:

    • 谢谢,这些似乎都是很好的建议,只是我不知道如何获得 A 实例。有没有办法用嵌套类做到这一点?
    • @KobiHari 您可以依赖 C# 编译器内部,并使用反射从闭包中提取引用。反编译一些程序集以了解它们如何生成名称。更好:让new WeakAction 的调用者传入他想用作“GC 根”的项目数组。调用者必须传入A
    • 我很想听听有关第一个选项的更多详细信息。至于第二个,我无法更改弱动作签名。
    • A 引用肯定只是闭包中的一个字段。提取其价值。我不知道闭包类的结构是什么。
    • 按照您的建议,我打印了闭包类型具有的字段信息对象列表,这实际上很有趣。有2例。第一种情况是动作 lambda 实际上引用了任何 A 类成员。在这种情况下,编译器会植入一个包含对 A 实例的引用的字段。但是在第二种情况下,当 lambda 是“纯”并且只访问闭包变量时,则没有成员指向 A 实例。
    【解决方案3】:

    a.Target 提供对包含 lambda 参数的对象的访问。对此执行GetType 将返回编译器生成的类型。一种选择是检查此类型的自定义属性 System.Runtime.CompilerServices.CompilerGeneratedAttribute 并在这种情况下保持对该对象的强引用。

    Now I can't change the way the WeakAction is called, (since it's in wide use) but I can change it's implementation. 请注意,这是迄今为止唯一可以使其保持活动状态而无需修改 WeakAction 的构造方式的方法。它也没有达到使 lambda 与 A 对象一样长的目标(它会保持它与 WeakAction 对象一样长)。我不相信如果不改变WeakAction 的构造方式,就像其他答案中所做的那样,这是不可能实现的。 WeakAction 至少需要获得对 A 对象的引用,而您目前不提供。

    【讨论】:

    • 确实目标具有编译器生成的属性,但如果我持有对它的强引用,我可能会导致内存泄漏。例如,如果动作从 A 调用方法,那么它实际上会保留对 A 实例的引用,然后这个实例将永远不会被垃圾回收。
    • 那你别无选择,你必须改变WeakAction的构造方式。我认为ConditionalWeakTable 是一个好方法(让WeakAction 使用一个。)
    • 这是未来使用的好方法,但我们必须支持弱动作的向后使用而不更改签名。否则,这不是可接受的解决方案。
    • 您如何看待 IL 修改,无论是通过调试或分析 API 的运行时还是静态到磁盘上的调用程序集?两者都非常丑陋,但这似乎是以通用方式完成您想做的事情的唯一方法。
    • 我还不清楚 A 的实例与 lambda 有什么关联。 lambda 是否总是从A 的实例方法构造的?
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-08-19
    相关资源
    最近更新 更多