Bug in WeakAction in case of Closure Action

前端 未结 3 2227
你的背包
你的背包 2021-02-18 18:39

In one of the projects I take part in there is a vast use of WeakAction. That\'s a class that allows to keep reference to an action instance without causing its tar

3条回答
  •  有刺的猬
    2021-02-18 19:07

    After some more research and after collecting all the useful bits of information from the answers that were posted here, I realized that there is not going to be an elegant and sealed solution to the problem. Since this is a real life problem we went with the pragmatic approach, trying to at least reduce it by handling as many scenarios as possible, so I wanted to post what we did.

    Deeper investigation of the Action object that is passed to the constructor of the WeakEvent, and especially the Action.Target property, showed that there are effectively 2 different cases of closure objects.

    The first case is when the Lambda uses local variables from the scope of the calling function, but does not use any information from the instance of the A class. In the following example, assume EventAggregator.Register is a method that takes an action and stores a WeakAction that wraps it.

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

    The lambda created here uses the num variable, which is a local variable defined in the scope of the _createListenAction function. So the compiler has to wrap it with a closure class in order maintain the closure variables. However, since the lambda does not access any of class A members, there is no need to store a reference to A. The target of the action will therefore not include any reference to the A instance and there is absolutely no way for the WeakAction constructor to reach it.

    The second case is illustrated in the following example:

    public class A 
    {
       int _num = 10;
    
        public void Listen() 
        {
            EventAggregator.Register(_createListenAction());
        }
    
        public Action _createListenAction() 
        {
            return new Action(() => 
            {
                if (_num > 10) MessageBox.Show("This is a large number");
            });
        }
    }
    

    Now _num is not provided as parameter to the function, it comes from the class A instance. Using reflection to learn about the structure of the Target object reveals that the last field the the compiler defines holds a reference to the A class instance. This case also applies when the lambda contains calls to member methods, as in the following example:

    public class A 
    {
        private void _privateMethod() 
        {
           // do something here
        }        
    
        public void Listen() 
        {
            EventAggregator.Register(_createListenAction());
        }
    
        public Action _createListenAction() 
        {
            return new Action(() => 
            {
                 _privateMethod();
            });
        }
    }
    

    _privateMethod is a member function, so it is called in the context of the class A instance, so the closure must keep a reference to it in order to invoke the lambda in the right context.

    So the first case is a Closure that only contains functions local variable, the second contains reference to the parent A instance. In both cases, there are no hard references to the Closure instance, so if the WeakAction constructor just leaves things the way they are, the WeakAction will "die" instantly despite the fact that the class A instance is still alive.

    We are faced here with 3 different problems:

    1. How to identify that the target of the action is a nested closure class instance, and not the original A instance?
    2. How to obtain a reference to the original class A instance?
    3. How to extend the life span of the closure instance so that it lives as long as the A instance live, but not beyond that?

    The answer to the first question is that we rely on 3 characteristics of the closure instance: - It is private (to be more accurate, it is not "Visible". When using C# compiler, the reflected type has IsPrivate set to true but with VB it does not. In all cases, the IsVisible property is false). - It is nested. - As @DarkFalcon mentioned in his answer, It is decorated with the [CompilerGenerated] attribute.

    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;
    }
    

    While this is not a 100% sealed predicate (a malicious programmer may generate a nested private class and decorate it with the CompilerGenerated attribute), in real life scenarios this is accurate enough, and again, we are building a pragmatic solution, not an academic one.

    So problem number 1 is solved. The weak action constructor identifies situations where the action target is a closure and responds to that.

    Problem 3 is also easily solvable. As @usr wrote in his answer, once we get a hold of the A class instance, adding a ConditionalWeakTable with a single entry where the A class instance is the key and the closure instance is the target, solves the problem. The garbage collector knows not to collect the closure instance as long as the A class instance lives. So that's ok.

    The only non - solvable problem is the second one, how to obtain a reference to the class A instance? As I said, there are 2 cases of closures. One where the compiler creates a member that holds this instance, and one where it doesn't. In the second case, there is simply no way to get it, so the only thing we can do is to create a hard reference to the closure instance in order to save it from being instantly garbage collected. This means that it may out live the class A instance (in fact it will live as long as the WeakAction instance lives, which may be forever). But this is not such a terrible case after all. The closure class in this case only contains a few local variables, and in 99.9% of the cases it is a very small structure. While this is still a memory leak, it is not a substantial one.

    But just in order to allow users to avoid even that memory leak, we have now added an additional constructor to the WeakAction class, as follows:

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

    And when this constructor is called, we add a ConditionalWeakTable entry where the target is the key and the actions target is the value. We also hold a weak reference to both the target and the actions target and if any of them die, we clear both. So that the actions target lives no less and no more than the provided target. This basically allows the user of the WeakAction to tell it to hold on to the closure instance as long as the target lives. So new users will be told to use it in order to avoid memory leaks. But in existing projects, where this new constructor is not used, this at least minimizes the memory leaks to closures that have no reference to the class A instance.

    The case of closures that reference the parent are more problematic because they affect garbase collection. If we hold a hard reference to the closure, we cause a much more drastic memory leak becuase the class A instance will also never be cleared. But this case is also easier to treat. Since the compiler adds a last member that holds a reference to the class A instance, we simply use reflection to extract it and do exactly what we do when the user provides it in the constructor. We identify this case when the last member of the closure instance is of the same type as the declaring type of the closure nested class. (Again, its not 100% accurate, but for real life cases its close enough).

    To summarize, the solution I presented here is not 100% sealed solution, simply because there does not seem to be a such solution. But since we have to provide SOME answer to this annoying bug, this solution at least reduces the problem substantially.

提交回复
热议问题