Home About Eric Topics SourceGear

2023-01-31 13:00:00

A "gotcha" with object handles

This is part of a series on Native AOT.
Previous -- Top -- Next

In the previous chapter, we talked about GCHandle as a way to pass object references into native code. Let's dive a little deeper and talk about a problem that can happen with these object handles in the context of Native AOT.

As we said in the previous chapter, the IntPtr from a GCHandle is "opaque", the only thing we can do with it is "give it back to the .NET code and ask it to do something". But we need to be a bit more precise. That GCHandle is owned by one particular instance of the .NET GC, and we can only "give it back" to that particular instance. If there happen to be multiple copies of the GC around, we must not ... cross the strands.

Let's illustrate this by taking the code from last chapter's sample and breaking it into two libraries.

All we've done here is split one library into two. We moved the functions, but we made no changes to their code.

We can leave the C++ code unchanged. We just need to change its little build script to reference both libraries.

When we run the program, we now get an error:

Unhandled Exception: System.InvalidCastException: Specified cast is not valid.
   at System.Runtime.TypeCast.CheckCastClass(MethodTable*, Object) + 0x6b
   at NativeExports.GetStringLength(IntPtr) + 0x70

For convenience and review, here's the code for get_string_length:

[UnmanagedCallersOnly(EntryPoint = "get_string_length")]
public static int GetStringLength(IntPtr v)
    GCHandle h = GCHandle.FromIntPtr(v);
    object ob = h.Target;
    string s = (string) ob;
    int len = s.Length;
    return len;

The exception is being thrown on the attempted cast, but the core problem is that the GCHandle is ... foreign.

Remember in the previous chapter when we said that the GC keeps track of everything? Our transgression here is that we allocated a string in one library and tried to use it in a different library. That's not okay. The GC can't keep track of stuff outside of its library.

In other words: Each library has its own GC.

We can see from the size of the two shared libraries that each .so is large enough to suggest that it has its own copy of all the dependencies it needs:

$ ls -l cs_make_string/bin/Debug/net7.0/linux-x64/publish/
total 15448
-rw-r--r-- 1 eric eric 15814944 Jan 31 10:19 make_string.so
$ ls -l cs_use_string/bin/Debug/net7.0/linux-x64/publish/
total 15452
-rw-r--r-- 1 eric eric 15820792 Jan 31 10:33 use_string.so

Strictly speaking, this "foreign handle" problem is as old as GCHandle itself, but it has been relatively uncommon. Before Native AOT libraries, you kinda had to go out of your way to have multiple instances of the GC and pass a GCHandle across them.

Now that Native AOT (by default) bundles up all the dependencies with each library, the result is a potentially bigger limitation in practice: When multiple Native AOT libraries are in play, we cannot share objects between them.

This has ramifications for the use cases that can be addressed with Native AOT libraries.

For example, suppose that Carole, Monica, and Kate each have a C# library that they want to make available as a package for C++ developers. Such a package might contain the compiled library (built with Native AOT) plus a C++ wrapper. Carole's library is more foundational, and each of the other two libraries have a dependency on it.

With the current limitations of Native AOT, that scenario is difficult or impossible.

One possible solution here is "just don't share objects". For some cases, that might be okay. For example, with a string, we could convert to/from a C++ representation that no longer relies on the object handle. But this is not a general solution. What if the object is a network socket?

How did we end up here?

I suspect the .NET team has simply prioritized ease over power. Building a Native AOT library is really elegant. Just type dotnet publish -r RID and the tooling will figure out everything you need, warn you about incompatibilities, and get it done. The resulting shared library doesn't have any weird dependencies -- everything it needs has been included, and everything else has been trimmed out.

An alternative approach, one which prioritized power-user scenarios, would have been possible, but it would have taken longer to develop, and it would have made the feature much harder to use. Instead of being self-contained, the resulting shared library would depend on other shared libraries, probably quite a few of them.

(I should note here that the early support for static libraries does offer cause for optimism, although not yet a clean solution. When Native AOT builds a static library, it does not include a copy of the GC (and such things). Rather, (as mentioned in a previous chapter), the necessary runtime dependencies need to be added at link time. However, each static library build by Native AOT does get its own copy of certain other things, so using two static libraries results in duplicate symbols.)

In my opinion, the team made the right choice for the early stages of Native AOT. Nonetheless, I do hope the feature gets more flexibility in this area later.

The code for this blog entry is available at: