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.
One library will have
get_hello_string
andfree_objecthandle
.The other 2 functions,
get_string_length
andbanish_letter_l
, will go into a separate library..
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 Aborted
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; }
We are given an
IntPtr
,which we convert back to its
GCHandle
,and then grab the original object from the
Target
property,and then cast that object to a
string
.
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:
https://github.com/ericsink/native-aot-samples/tree/main/problem_multiple_libraries