Skip to content

Delegates and their troubles

Adam ? edited this page May 20, 2022 · 2 revisions

Introduction

So, why are delegates bad? Why is it that I always seem to state that they have all sorts of problems? What are these problems and why do they exist? Hopefully this will answer most of those questions.

Background

Before we get too ahead of ourselves, we need to understand how custom delegates are made in the first place. In order to create a custom delegate, we need to create an instance of the delegate type we wish to make, which we do by providing some metadata in the constructor.

Specifically:

  • The instance
  • A boxed MethodInfo

In typical C# programs, delegates can be created as easily as implicitly wrapping a C# method. For example:

event Action<int> myDelegate;
...
void test(int x) { ... }
void static test2(int x) { ... }
...
myDelegate += test;
myDelegate += test2;

In this case, test is an instance method, yet it can be added to a delegate of type Action<int> all the same. This is because each delegate instance in C# holds both the C# method info (for the method it is holding) as well as an instance field, which holds the actual instance. For static methods, this instance field is null, whereas for instance methods, this instance field serves as a way of calling the instance method while still making the delegate of a proper form (Action<int> and not Action<MyClass, int>).

In the modding world, we would like to follow this same logic. In essence, we want to be able to create delegates from both static and instance methods. However, there are a few caveats here. First and foremost is the concept of metadata that we talked about earlier. Secondly is how we can actually call our instance methods.

Creation and Issues

When we make a delegate (see: il2cpp_utils::MakeDelegate and its many overloads), at the simplest, we are essentially taking a function pointer (and optionally an instance) and using it to create our delegate instance. In doing so, we need to create our boxed MethodInfo. This is something that needs to have lifetime equivalent to the delegate we are making-- specifically, we need to ensure that any time the delegate is invoked, the MethodInfo exists. Once the delegate is destructed, the MethodInfo need not exist.

So, we need to create this metadata, and we need it to exist for awhile. We can't allocate it as a stack local, since it'll go out of scope almost immediately (certainly before the delegate is actually used). We can't (easily) place it in static without changing out a lot of logic and increasing ELF size (there is more to this that I will not cover in this post), so it seems we will have to place it on heap.

Okay, so, we can place the metadata on the heap, but now we have no good way of cleaning it up after it is done being used. This is the first issue that plagues custom delegates-- we have no way of trivially knowing when a given delegate instance has been destroyed, and thus to destroy our attached metadata.

The next issue plaguing delegates is related to wrapping. Specifically, creating delegates that wrap lambdas that cannot decay to function pointers (ex, lambdas that have captures) or instance methods.

In order to handle captured lambdas, we take advantage of the fact that there is an instance parameter for our delegates. We use this instance parameter to hold additional metadata, such that when we call our special method pointer, it redirects to call the contextual invoke. Specifically, our instance parameter holds an std::function of correct type and when we create our delegate, we provide instead a wrapper function that forwards to calling the held std::function on the instance of the type we provide. Pseudocode, with some simplifications made for readability:

template<class R, class... TArgs>
struct InstanceWrapper {
  std::function<R (TArgs...)> func;
};
template<class R, class... TArgs>
R wrapper_function(InstanceWrapper<R, TArgs...>* inst, TArgs... args) {
  return inst->func(args...);
}

A similar InstanceWrapper is made for instance methods, where, in addition to the std::function, we also hold a field for the instance (if it is a pointer, we copy the pointer value, otherwise, we copy the entire value type).

These InstanceWrapper types are allocated on the GC heap in an incredibly cursed way (see: __AllocateUnsafe), which results in another issue-- because of how these are essentially allocated on the GC heap, they do not have any custom C# destruction logic, which means that the held std::function does not get cleaned up. This is only an issue when the std::function instance itself is not trivially destructible, which unfortunately is the case for contextual lambdas.

Thus, there are two fundamental issues with delegates, both related to leakage of memory:

  1. The MethodInfo instance holding the metadata of the function being wrapped by the delegate is not properly cleared when the delegate dies.
  2. If the delegate was made wrapping a C++ instance method and/or is a contextual lambda, the std::function's internal fields are not deallocated when the delegate dies, which in turn causes this temporary instance to die.

Potential Solutions

One of the most obvious solutions here is to simply move delegate creation and management logic to custom types entirely. The two core problems with delegates are trivially solved by creating several custom types that would hold the std::function, potentially the instance, and potentially the metadata. On destruction of the owning delegate, this instance would be subsequently destroyed, and its destructor would run, allowing proper and efficient cleanup of this data.

Another solution, at least, to the metadata case, is to move some of the metadata creation to live in the static space of the module, as opposed to living on the heap. This has the potential to increase ELF size significantly, but would also allow there to be no need in cleaning up at all, at least, for trivial delegates, specifically those that are not contextual lambdas.

Yet another solution would be to arbitrarily hook delegate destructors and deallocate the data then, though this comes with its own set of problems.

Conclusion

As it stands, there is no clear solution for the complexities that are involved in delegates and their lifetime management. There are some solutions, but many of these solutions come with their own set of problems or are otherwise infeasible. Perhaps in the 4.x release, delegates will be removed entirely from bs-hook proper, instead being entirely wrapped in another library, say, custom types.

Clone this wiki locally