Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[API Proposal]: GCHandle<T> (like GCHandle, but this time it's great™️) #94134

Open
Sergio0694 opened this issue Oct 28, 2023 · 70 comments
Open
Labels
api-approved API was approved in API review, it can be implemented area-System.Runtime.InteropServices
Milestone

Comments

@Sergio0694
Copy link
Contributor

Sergio0694 commented Oct 28, 2023

This API proposal is extracted from #94113 (comment) and supersedes/fixes:

Overview

The GCHandle type currently has a number of issues/inefficiencies, that are impossible to completely fix without introducing some (major) breaking changes, which could cause all sorts of problems for people having a dependency on the current behaviors. It would be beneficial to instead add a new handle type, which solves all of these problems from the start:

  • Unnecessary validation check (null check) when getting/setting targets
  • Pin flag removal every time the handle target is resolved
  • Interlocked operation from Free
  • Not implementing IDisposable

We have a reference implementation which can be a starting point for this.

cc. @jkotas @AaronRobinsonMSFT @tannergooding

API Proposal

namespace System.Runtime.InteropServices;

public struct GCHandle<T> : System.IDisposable
    where T : class
{
    public GCHandle(T? value, GCHandleType type = GCHandleType.Normal);

    public readonly bool IsAllocated { get; }
    public T? Target { readonly get; set; }

    public static GCHandle<T> FromIntPtr(IntPtr value);
    public static IntPtr ToIntPtr(GCHandle<T> value);

    public void Dispose();
}

public struct PinnedGCHandle<T> : System.IDisposable
    where T : class
{
    public PinnedGCHandle(T? value);

    public readonly bool IsAllocated { get; }
    public T? Target { readonly get; set; }

    public static PinnedGCHandle<T> FromIntPtr(IntPtr value);
    public static IntPtr ToIntPtr(PinnedGCHandle<T> value);

    public void Dispose();
}

public static class GCHandleExtensions
{
    public static T* AddrOfArrayData<T>(this PinnedGCHandle<T[]> handle);
    public static char* AddrOfStringData(this PinnedGCHandle<string> handle);
}

Additional caveats

  • Calling Target on a handle that's not allocated will throw NullReferenceException
  • Calling AddrOf* on a handle that's not allocated will throw NullReferenceException
  • Calling AddrOf* when the target is null will return a null pointer
  • Converting between the two handles via FromIntPtr/ToIntPtr if the handle type is mismatched is undefined behavior
  • Dispose is not thread safe (but still no-op after the first call, just not in a thread-safe manner)

API Usage

Same as GCHandle, really:

// In a constructor (or wherever)
this.handle = new GCHandle<MyObject>(myObj);

// Later on
MyObject? target = this.handle.Target;

Alternative Designs

The alternative would be to change GCHandle, but as mentioned, it seems impossible to fix all issues with back-compat.

Risks

Not really any risk, since this would be a brand new type.

@Sergio0694 Sergio0694 added the api-suggestion Early API idea and discussion, it is NOT ready for implementation label Oct 28, 2023
@dotnet-issue-labeler dotnet-issue-labeler bot added the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Oct 28, 2023
@ghost ghost added the untriaged New issue has not been triaged by the area owner label Oct 28, 2023
@SingleAccretion SingleAccretion added area-System.Runtime and removed needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners labels Oct 28, 2023
@ghost
Copy link

ghost commented Oct 28, 2023

Tagging subscribers to this area: @dotnet/area-system-runtime
See info in area-owners.md if you want to be subscribed.

Issue Details

This API proposal is extracted from #94113 (comment) and supersedes/fixes:

Overview

The GCHandle type currently has a number of issues/inefficiencies, that are impossible to completely fix without introducing some (major) breaking changes, which could cause all sorts of problems for people having a dependency on the current behaviors. It would be beneficial to instead add a new handle type, which solves all of these problems from the start:

  • Unnecessary validation check (null check) when getting/setting targets
  • Pin flag removal every time the handle target is resolved
  • Interlocked operation from Free
  • Not implementing IDisposable

We have a reference implementation which can be a starting point for this.

cc. @jkotas @AaronRobinsonMSFT @tannergooding

API Proposal

namespace System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential)]
public struct UnsafeGCHandle : System.IDisposable
{
    public static UnsafeGCHandle Alloc(object? value, GCHandleType type = GCHandleType.Normal);

    public readonly bool IsAllocated { get; }
    public object? Target { readonly get; set; }

    public readonly unsafe IntPtr GetAddrOfPinnedObject();
    public void Dispose();
}

Open questions

  • I'm assuming that the Target getter can be updated to just do *(object*)_handle, hence being as fast as possible already (ie. not needing that internal call that GCHandle has in its current implementation). If that's the case, then there's no need to add a separate GetTargetUnsafe() method — Target would already be as fast as it can be from the start.
  • Should we consider using "better" names, since this is a new API? For instance:
    • The Alloc abbreviation is against the naming convention. Should it be Allocate? Or maybe Create?
    • Same for GetAddrOfPinnedObject(), should it be GetAddressOfPinnedObject()? Or maybe just PinnedObjectAddress?
  • Since we said the type should implement IDisposable, I've just added Dispose(). Do we also want a Free() method?

API Usage

Same as GCHandle, really:

// In a constructor (or wherever)
this.handle = UnsafeGCHandle.Alloc(myObj);

// Later on
object? target = this.handle.Target();

Alternative Designs

The alternative would be to change GCHandle, but as mentioned, it seems impossible to fix all issues with back-compat.

Risks

Not really any risk, since this would be a brand new type.

Author: Sergio0694
Assignees: -
Labels:

api-suggestion, area-System.Runtime, untriaged

Milestone: -

@ghost
Copy link

ghost commented Oct 28, 2023

Tagging subscribers to this area: @dotnet/interop-contrib
See info in area-owners.md if you want to be subscribed.

Issue Details

This API proposal is extracted from #94113 (comment) and supersedes/fixes:

Overview

The GCHandle type currently has a number of issues/inefficiencies, that are impossible to completely fix without introducing some (major) breaking changes, which could cause all sorts of problems for people having a dependency on the current behaviors. It would be beneficial to instead add a new handle type, which solves all of these problems from the start:

  • Unnecessary validation check (null check) when getting/setting targets
  • Pin flag removal every time the handle target is resolved
  • Interlocked operation from Free
  • Not implementing IDisposable

We have a reference implementation which can be a starting point for this.

cc. @jkotas @AaronRobinsonMSFT @tannergooding

API Proposal

namespace System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential)]
public struct UnsafeGCHandle : System.IDisposable
{
    public static UnsafeGCHandle Alloc(object? value, GCHandleType type = GCHandleType.Normal);

    public readonly bool IsAllocated { get; }
    public object? Target { readonly get; set; }

    public readonly unsafe IntPtr GetAddrOfPinnedObject();
    public void Dispose();
}

Open questions

  • I'm assuming that the Target getter can be updated to just do *(object*)_handle, hence being as fast as possible already (ie. not needing that internal call that GCHandle has in its current implementation). If that's the case, then there's no need to add a separate GetTargetUnsafe() method — Target would already be as fast as it can be from the start.
  • Should we consider using "better" names, since this is a new API? For instance:
    • The Alloc abbreviation is against the naming convention. Should it be Allocate? Or maybe Create?
    • Same for GetAddrOfPinnedObject(), should it be GetAddressOfPinnedObject()? Or maybe just PinnedObjectAddress?
  • Since we said the type should implement IDisposable, I've just added Dispose(). Do we also want a Free() method?

API Usage

Same as GCHandle, really:

// In a constructor (or wherever)
this.handle = UnsafeGCHandle.Alloc(myObj);

// Later on
object? target = this.handle.Target();

Alternative Designs

The alternative would be to change GCHandle, but as mentioned, it seems impossible to fix all issues with back-compat.

Risks

Not really any risk, since this would be a brand new type.

Author: Sergio0694
Assignees: -
Labels:

api-suggestion, area-System.Runtime.InteropServices, untriaged

Milestone: -

@jkotas
Copy link
Member

jkotas commented Oct 28, 2023

  • [StructLayout(LayoutKind.Sequential)]

Unnecessary.

  • The Alloc abbreviation is against the naming convention. Should it be Allocate? Or maybe Create?

It should be a constructor (same as what we have done for DependentHandle).

  • GetAddrOfPinnedObject

This method needs to do several checks against the type of the object. It may be better to have GetAddrOfPinnedArray and GetAddrOfPinnedString to go with the spirit of maximally unsafe type.

  • Keep FromIntPtr/ToIntPtr?

@Sergio0694
Copy link
Contributor Author

Updated the proposal integrating your suggestions 🙂

For GetAddrOfPinnedArray, I take it we want to keep that abbreviation for historic reasons?
If not, should we maybe consider naming those methods eg. GetAddressOfPinnedArray or something?

Any thoughts on whether we can just make Target free (*(object*)_handle in release), or if we need GetTargetUnsafe()?
From our previous conversation it seems to me we should be able to just make that faster? But just double checking 😄

@jkotas
Copy link
Member

jkotas commented Oct 28, 2023

should we maybe consider naming those methods eg. GetAddressOfPinnedArray or something?

I assume that this API would be a shorthand for Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference<T>(Unsafe.As<T[]>(handle.Target))), so maybe GetArrayDataAsPointer? Also, make it return a pointer?

Any thoughts on whether we can just make Target free ((object)_handle in release),

Yes. I think the idea is that this is maximally unsafe type.

@tannergooding
Copy link
Member

Also, make it return a pointer?

Should From/ToIntPtr then be FromPointer and take void* instead?

Yes. I think the idea is that this is maximally unsafe type.

Given we have and allow object* now, and this is "maximally unsafe". Do we even need a type? Could this just be a pair of APIs on System.Runtime.CompilerServices.Unsafe? Something like:

public static object* CreateGCHandle(object? value, GCHandleType type = GCHandleType.Normal);
public static void DisposeGCHandle(object* value);

Given these two APIs, a user could trivially create their own unsafe wrapper with whatever name they wanted. They could still convert to and store it as IntPtr or a GCHandle or ...

@Sergio0694
Copy link
Contributor Author

Sergio0694 commented Oct 28, 2023

I was first a bit skeptical of going as far as exposing object* APIs, but (from Discord) Tanner did make a good point that just convinced me: if you're authoring some native object (eg. say a COM one) and eg. wanted some handle for a CoreWindow object (just to name any type you might need), would it be "better" to have an UnsafeGCHandle field, or literally just a CoreWindow* field? And the latter does kinda seem more explicit/intuitive in a way. And we'd just need two new APIs 😄

I'm not sure they belong on Unsafe though. Perhaps:

namespace System.Runtime.InteropServices;

public struct GCHandle
{
    public static object* CreateUnsafe(object? value, GCHandleType type = GCHandleType.Normal);
    public static void DisposeUnsafe(object* value);
}

?

Eg. in ComputeSharp I have this field. Would certainly be much more readable if it could just be Globals*.
And it also wouldn't need any additional Unsafe.As<Globals>(handle.Target!) clunkyness.

Additional benefit: after you initially cast the pointer type, you can also preserve nullability annotations.
Eg. you could have a SomeObject* field, or a SomeObject?* field to inform the rest of your code.

@tannergooding
Copy link
Member

Noting that using object* does limit the implementations the GC could give for the type. It might not be valid on all runtimes. But, it was a passing idea in my head, so I thought I'd share.

Alternative names than UnsafeGCHandle would also include ObjectHandle or some variant thereof. These could likewise just be *Unsafe variants on GCHandle itself. CreateUnsafe, GetTargetUnsafe, etc. There are pros and cons to all of these, of course.

@jkotas
Copy link
Member

jkotas commented Oct 28, 2023

Should From/ToIntPtr then be FromPointer and take void* instead?

From/ToIntPtr returns opaque handle.

Noting that using object* does limit the implementations the GC could give for the type. It might not be valid on all runtimes.

object* happens to work for reading with the current CoreCLR GC, but it does not work for writing. GC handles need a different write barrier than regular object references even today. I do not think we would want to lock ourselves into potential read barriers to match between object references and GC handles.

@alexrp
Copy link
Contributor

alexrp commented Oct 28, 2023

Has there been any consideration to making the handle struct generic, to make it more type-safe and ergonomic?

@Sergio0694
Copy link
Contributor Author

Well, alright then we can ignore the whole object* idea, perfect 😄

"assume that this API would be a shorthand for Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference<T>(Unsafe.As<T[]>(handle.Target))), so maybe GetArrayDataAsPointer? Also, make it return a pointer?"

I've updated the API shape integrating all the new suggestions:

  • Named the methods GetArrayDataPointer/GetStringDataPointer (for consistency with GetArrayDataReference)
  • Changed the return types to void*

Is there anything else we might be missing? The API shape looks pretty nice to me now? 🙂


Random thought: should the to/from methods for IntPtr be called ToIntPtr/FromIntPtr, or would a different name make sense, now that we have numeric IntPtr and that in general we're moving away from using IntPtr explicitly, and rather just using nint/nuint (since they're true type aliases now, so same as never use eg. Int32 directly).

Like, just spitballing here, maybe:

public static UnsafeGCHandle FromOpaqueHandle(nint value);
public static nint ToOpaqueHandle(UnsafeGCHandle value);

Or does this just not matter and it's better to keep that naming scheme for historic reasons maybe?

@jkotas
Copy link
Member

jkotas commented Oct 28, 2023

We have approved and introduced more APIs that follow the FromIntPtr/ToIntPtr pattern just a few months ago #67090 (comment)

@Sergio0694
Copy link
Contributor Author

Ah, cool, well then the API proposal should be pretty much ready 😄

@hamarb123
Copy link
Contributor

On a slightly related note: it's a shame that there's we can't express object pinned from IL in C# for short pinnings (which did seem legal to write last time I read through that section). If we get GetRawData, we could basically do it.

@jkoritzinsky
Copy link
Member

For GetStringDataPointer, should it return char* as that is always the data type?

Also, if we were to go the generic route, then we could limit GetStringDataPointer and GetArrayDataPointer to only being on handles of valid types by implementing them as extension methods.

@Sergio0694
Copy link
Contributor Author

Making GetStringDataPointer return char* makes a lot of sense to me. I'm not entirely sure I see the value in making the type generic considering that it's just meant to be this very low level thing (and would the increased metadata size be an issue for the runtime using this type internally?). Like, DependentHandle also isn't generic. To me it feels like it would be more wrapping classes potentially being generic, like eg. WeakReference<T> does? 🤔

@jkotas
Copy link
Member

jkotas commented Oct 28, 2023

Is GetArrayDataPointer going to work for both single and multidimensional arrays (that comes with a small perf penalty) or is GetArrayDataPointer going to work for single dimensional arrays only?

@Sergio0694
Copy link
Contributor Author

Mmhh... I was assuming that it'd roughly be the same as Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference(Unsafe.As<Array>(*(object*)_handle))), so that it'd still be fast (IIRC the MD version of GetArrayDataReference only has an extra indirection and a couple of mov-s?), while working with all possible array types? It would seem unfortunate otherwise for this not to be usable with MD arrays, for people that might want to use them.

Alternatively, if (1) the overhead for the general MD logic was considered too much for the SZ case, and (2) we did want to have built-in helpers for this in general, should we consider just having a separate API that'd work in all cases?

As in, spitballing:

public readonly void* GetSZArrayDataPointer();
public readonly void* GetArrayDataPointer();

(I will say I can't really come up with a good name for these two here though)

@MichalPetryka
Copy link
Contributor

I think that getting rid of the pointer getters makes the most sense here, the users can already achieve then with Unsafe.As anyway.

@Sergio0694
Copy link
Contributor Author

I assume the intent is to make it a bit nicer. I mean yes you can do it manually but it's a bit ugly:

// String
char* p = (char*)Unsafe.AsPointer(ref Unsafe.AsRef(in Unsafe.As<string>(handle.Target!).GetPinnableReference()));

// SZ array
T* p = (T*)Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference(Unsafe.As<T[]>(handle.Target!)));

// MD array
T* p  = (T*)Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference(Unsafe.As<Array>(handle.Target!)));

I mean it's both ugly and also not really the ideal code to have to write manually due to how "questionable" it is.
Having some convenience APIs on the handle type makes sense to me? Just like GCHandle also does 🤔

Related question — should these APIs return null if the target is null, like GCHandle.AddrOfPinnedObject() also does?

@jkotas
Copy link
Member

jkotas commented Oct 30, 2023

Should these APIs return null if the target is null, like GCHandle.AddrOfPinnedObject() also does?

To follow the idea of maximally unsafe type idea, these APIs should throw NullReferenceException when the target is null. (Same as MemoryMarshal.GetArrayDataReference throws NullReferenceException for null.)

@Sergio0694
Copy link
Contributor Author

Sounds good! So it seems the only open question is the naming? Should I just leave those names as placeholders and then we can just bikeshed those during API review, as long as the API shape itself is fine? To recap, that'd basically be, for those 3:

public readonly void* GetSZArrayDataPointer();
public readonly void* GetArrayDataPointer();
public readonly char* GetStringDataPointer();

Actually I have a question on GetSZArrayDataPointer: because we're not in a generic context (ie. we can't just do that Unsafe.As<T[]> there), how would the SZ version actually work? Is there some magic trick we can do that I'm missing?

Otherwise, perhaps we could just have GetArrayDataPointer, and if people really need to drop that extra indirection they can then manually get the target and do that unsafe blurb to cast to T[] and call the specialized GetArrayDataReference?

@jkotas
Copy link
Member

jkotas commented Oct 30, 2023

Generic wrapper would address this nicely. Note that you can always use UnsafeGCHandle<object> if you do not want to pay overhead for arbitrary T.

@agocke agocke added this to AppModel Sep 11, 2024
@Sergio0694
Copy link
Contributor Author

Going back to this now that .NET 10 planning is starting 🙂

"We also have RuntimeHelpers.OffsetToStringData that would make it UnsafeAddrOfPinnedArrayData/UnsafeAddrOfPinnedStringData."

I personally like this one better, I feel like the "Data" suffix makes it clear that it's giving you a pointer to the raw object data.

Revised API proposal:

namespace System.Runtime.InteropServices;

public struct GCHandle<T> : System.IDisposable
    where T : class
{
    public GCHandle(T? value, GCHandleType type = GCHandleType.Normal);

    public readonly bool IsAllocated { get; }
    public T? Target { readonly get; set; }

    public static GCHandle<T> FromIntPtr(IntPtr value);
    public static IntPtr ToIntPtr(GCHandle<T> value);

    public void Dispose();
}

public static class GCHandleExtensions
{
    public static T* UnsafeAddrOfPinnedArrayData<T>(this GCHandle<T[]> handle);
    public static char* UnsafeAddrOfPinnedStringData(this GCHandle<string> handle);
}

Let me know if this looks reasonable and/or if we need any more tweaks to get this ready to review 🙂
I'll also update the first post so the final proposed API shape is easier to see.
Thank you!

@Sergio0694 Sergio0694 changed the title [API Proposal]: UnsafeGCHandle (like GCHandle, but this time it's great™️) [API Proposal]: GCHandle<T> (like GCHandle, but this time it's great™️) Dec 23, 2024
@Sergio0694
Copy link
Contributor Author

This will also benefit ComWrappers, in particular when #110828 gets merged. If we can switch the RCW cache to use GCHandle<NativeObjectWrapper> we'll get both the perf improvements from the new GC handle type, and be able to skip the type checks we're currently paying for when using pattern matching on the returned target from those handles (without needing to use Unsafe.As<T>(object), which felt not too great to use here).

@tannergooding tannergooding added api-ready-for-review API is ready for review, it is NOT ready for implementation and removed api-suggestion Early API idea and discussion, it is NOT ready for implementation labels Dec 24, 2024
@jkotas
Copy link
Member

jkotas commented Dec 25, 2024

Let me know if this looks reasonable and/or if we need any more tweaks to get this ready to review

  • You may want to specify the error handling. Is the Target property going to throw InvalidOperationException or NullReferenceException when called on a handle that is not allocated? Dtto for UnsafeAddrOfPinnedArrayData/UnsafeAddrOfPinnedStringData.

  • I am still not sure about the Unsafe extension methods as I have mentioned above. It is unclear to me whether they are an improvement over just calling GetPinnableReference/GetArrayDataReference + Unsafe.AsPointer.

How about introducing a dedicated type for pinned GC handle? It would ensure that the extension methods are only ever used on pinned handles:

struct PinnedGCHandle<T>
{
   public PinnedGCHandle(T? value);
   
   // the rest is same as GCHandle<T>
}

class GCHandleExtensions
{
    public static T* AddrOfArrayData<T>(this PinnedGCHandle<T[]> handle);
    public static char* AddrOfStringData(this PinnedGCHandle<string> handle);
}

@Sergio0694
Copy link
Contributor Author

"You may want to specify the error handling. Is the Target property going to throw InvalidOperationException or NullReferenceException when called on a handle that is not allocated?"

I was thinking that since we're going for "maximally unsafe type", that could just be undefined? Eg. I'd imagine on CoreCLR/NAOT it'd throw a NullReferenceException (because of *(object*)_handle), but not sure we want to lock ourselves into necessarily preserving that exact exception both on Mono and/or possibly in future versions of CoreCLR/NAOT?

"Dtto for UnsafeAddrOfPinnedArrayData/UnsafeAddrOfPinnedStringData."

I figured that'd also just be undefined behavior, for the same reason as above.

"How about introducing a dedicated type for pinned GC handle? It would ensure that the extension methods are only ever used on pinned handles:"

I have two questions about this:

  • Are we worried about the "inconsistency" with the fact that GCHandle would be the same type for all handle types, whereas this new type would be split across these two types that would otherwise have the same API surface?
  • If we had this second type, what happens if you create a GCHandle<T> as pinned? Would we make the constructor throw, or would that still work fine but you'd miss out on those additional exceptions? Are we worried about the fact that in this case it might be potentially confusing and we'd be introducing two different APIs to do the same thing? 🤔

@jkotas
Copy link
Member

jkotas commented Dec 25, 2024

I was thinking that since we're going for "maximally unsafe type"

I made the comment about "maximally unsafe type" when the proposal was non-generic UnsafeGCHandle. The generic GCHandle<T> is not really "maximally unsafe type" anymore. Though, I am fine with the behavior to be undefined as long as it is some kind of failure.

"Dtto for UnsafeAddrOfPinnedArrayData/UnsafeAddrOfPinnedStringData."

I figured that'd also just be undefined behavior, for the same reason as above.

UnsafeAddrOfPinnedArrayData/UnsafeAddrOfPinnedStringData has additional concern of Target being null. It does not sound right for that to be undefined.

If we had this second type, what happens if you create a GCHandle as pinned?

It would be still allowed to create a GCHandle as pinned. You would not have the convenience methods to get the pointer to pinned data. Instead, you would have to use GetPinnableReference/GetArrayDataReference + Unsafe.AsPointer to get the pointer to pinned data.

@Sergio0694
Copy link
Contributor Author

"UnsafeAddrOfPinnedArrayData/UnsafeAddrOfPinnedStringData has additional concern of Target being null. It does not sound right for that to be undefined."

Would it be reasonable to say:

  • If you call this against a handle that's not allocated, it's undefined behavior (same as Target)
  • If you call this and the target is null, you get NullReferenceException (same as GetArrayDataReference)

Related question: should we rather have it return a null pointer instead, in this case? Or is a NRE better? 🤔

"It would be still allowed to create a GCHandle as pinned. You would not have the convenience methods to get the pointer to pinned data. Instead, you would have to use GetPinnableReference/GetArrayDataReference + Unsafe.AsPointer to get the pointer to pinned data."

Makes sense. So the revised API shape would be:

namespace System.Runtime.InteropServices;

public struct GCHandle<T> : System.IDisposable
    where T : class
{
    public GCHandle(T? value, GCHandleType type = GCHandleType.Normal);

    public readonly bool IsAllocated { get; }
    public T? Target { readonly get; set; }

    public static GCHandle<T> FromIntPtr(IntPtr value);
    public static IntPtr ToIntPtr(GCHandle<T> value);

    public void Dispose();
}

public struct PinnedGCHandle<T> : System.IDisposable
    where T : class
{
    public PinnedGCHandle(T? value);

    public readonly bool IsAllocated { get; }
    public T? Target { readonly get; set; }

    public static PinnedGCHandle<T> FromIntPtr(IntPtr value);
    public static IntPtr ToIntPtr(PinnedGCHandle<T> value);

    public void Dispose();
}

public static class PinnedGCHandleGCHandleExtensions
{
    public static T* UnsafeAddrOfPinnedArrayData<T>(this PinnedGCHandle<T[]> handle);
    public static char* UnsafeAddrOfPinnedStringData(this PinnedGCHandle<string> handle);
}

Additional caveats:

  • Calling Target on a handle that's not allocated is undefined behavior (but it'll throw some exception)
  • Calling UnsafeAddrOf* on a handle that's not allocated is undefined behavior (same as above)
  • Calling UnsafeAddrOf* when the target is null will throw NullReferenceException (unless we decide null is better)
  • Converting between the two handles via FromIntPtr/ToIntPtr if the handle type is mismatched is undefined behavior
  • Dispose is not thread safe

Does this look good? 🙂

@jkotas
Copy link
Member

jkotas commented Dec 25, 2024

UnsafeAddrOfPinnedArrayData, UnsafeAddrOfPinnedStringData

Unsafe and Pinned are unnecessary baggage in these names. These methods are as unsafe as everything else on PinnedGCHandle, and the data is always pinned.

Calling Target on a handle that's not allocated is undefined behavior (but it'll throw some exception)

Undefined behavior typically includes hard to diagnose runtime crash. If we want to guarantee that it throws an exception, we can as well specify the exception that is going to thrown. I think guaranteeing NullReferenceException would not be a significant burden.

Calling UnsafeAddrOf* when the target is null will throw NullReferenceException (unless we decide null is better)

Convenience methods in interop namespace typically handle nulls gracefully if it makes sense. For example, the existing GCHandle.AddrOfPinnedObject returns null when the target is null.
 

Dispose is not thread safe

It should still follow Dispose contract. Calling Dispose twice (on the same thread) should be allowed. The second call is going to be no-op.

@Sergio0694
Copy link
Contributor Author

Makes perfect sense for the updated name and guaranteeing a NRE, as well as just returning a null pointer 😄

"It should still follow Dispose contract. Calling Dispose twice (on the same thread) should be allowed. The second call is going to be no-op."

Of course! I just meant that it won't have to bother using an interlocked exchange to mark reset its handle value 👍

Updated API surface and caveats:

namespace System.Runtime.InteropServices;

public struct GCHandle<T> : System.IDisposable
    where T : class
{
    public GCHandle(T? value, GCHandleType type = GCHandleType.Normal);

    public readonly bool IsAllocated { get; }
    public T? Target { readonly get; set; }

    public static GCHandle<T> FromIntPtr(IntPtr value);
    public static IntPtr ToIntPtr(GCHandle<T> value);

    public void Dispose();
}

public struct PinnedGCHandle<T> : System.IDisposable
    where T : class
{
    public PinnedGCHandle(T? value);

    public readonly bool IsAllocated { get; }
    public T? Target { readonly get; set; }

    public static PinnedGCHandle<T> FromIntPtr(IntPtr value);
    public static IntPtr ToIntPtr(PinnedGCHandle<T> value);

    public void Dispose();
}

public static class PinnedGCHandleGCHandleExtensions
{
    public static T* AddrOfArrayData<T>(this PinnedGCHandle<T[]> handle);
    public static char* AddrOfStringData(this PinnedGCHandle<string> handle);
}

Additional caveats:

  • Calling Target on a handle that's not allocated will throw NullReferenceException
  • Calling UnsafeAddrOf* on a handle that's not allocated will throw NullReferenceException
  • Calling UnsafeAddrOf* when the target is null will return a null pointer
  • Converting between the two handles via FromIntPtr/ToIntPtr if the handle type is mismatched is undefined behavior
  • Dispose is not thread safe (but still no-op after the first call, just not in a thread-safe manner)

@Sergio0694
Copy link
Contributor Author

Small follow up. I know we can't do where T : Array, so thoughts about adding perhaps these two for convenience?

public static class PinnedGCHandleGCHandleExtensions
{
    public static T* AddrOfArrayData<T>(this PinnedGCHandle<T[,]> handle);
    public static T* AddrOfArrayData<T>(this PinnedGCHandle<T[,,]> handle);
}

@Sergio0694
Copy link
Contributor Author

Another follow up. @jkotas I was looking at the current GCHandle, which has this:

set
{
IntPtr handle = _handle;
ThrowIfInvalid(handle);
if (IsPinned(handle) && !Marshal.IsPinnable(value))
{
throw new ArgumentException(SR.ArgumentException_NotIsomorphic, nameof(value));
}
InternalSet(GetHandleValue(handle), value);
}

We can't do that check in the new type, because the design is we don't want to store the pinned flag in the handle anymore. So when someone assigns Target, we can't validate whether they're trying to set a non-pinnable objects into a pinned handle. Should we just say doing so is undefined behavior, and we can only properly validate the initial object that's used when creating the handle? Or will the runtime imports already fail in this scenario, just with a different exception type? 🤔

@hamarb123
Copy link
Contributor

hamarb123 commented Dec 25, 2024

Just to check, will we be allowing any type for T in PinnedGCHandle<T> and any (valid for the type) object instance for the value? I would prefer this personally, as I find a lot of that IsPinnable stuff to be an annoying legacy concept which doesn't really make sense in the modern day with object*, etc. imo, and occasionally gets in the way. Notably, IL already allows pinning all objects.

I don't think any of the other GCHandleType have checks do they? IIRC, they already work on the other types (but I could be misremembering).

@hamarb123
Copy link
Contributor

hamarb123 commented Dec 26, 2024

Small follow up. I know we can't do where T : Array

Presumably it would not actually be hard for roslyn to support where T : Array - they added both where T : Enum and where T : Delegate recently (or ~6 years ago apparently lol), so it could presumably be done in a similar fashion, that is unless I'm missing something special about how arrays are handled in roslyn. I would think they would be happy to take it to LDM & approve for vNext if the runtime requests it (and can show an example of where it would be useful, i.e., the above API).

I would think this is a better solution if roslyn could consider the feature relatively quickly (by vNext) (obviously, if the feature would take a while for them to get to or something like that, we probably wouldn't want to hold up this feature for that).

@hez2010
Copy link
Contributor

hez2010 commented Dec 26, 2024

Small follow up. I know we can't do where T : Array, so thoughts about adding perhaps these two for convenience?

public static class PinnedGCHandleGCHandleExtensions
{
    public static T* AddrOfArrayData<T>(this PinnedGCHandle<T[,]> handle);
    public static T* AddrOfArrayData<T>(this PinnedGCHandle<T[,,]> handle);
}

How about simply

public static class PinnedGCHandleGCHandleExtensions
{
    public static T* AddrOfArrayData<T>(this PinnedGCHandle<T> handle);
}

then verify the T at runtime?

@Sergio0694
Copy link
Contributor Author

Sergio0694 commented Dec 26, 2024

That method doesn't do the same thing at all. If you call it on a PinnedGCHandle<string>, you want a char* back, not a string*. Same for T[], you want a T* back, not a T[]*.

Edit: misread, but the issue is the same. You don't want a pointer back that's of the same T as the handle. You want the "array-ness" stripped out of the returning T.

@jkotas
Copy link
Member

jkotas commented Dec 26, 2024

Calling UnsafeAddrOf* on a handle that's not allocated will throw NullReferenceException
Calling UnsafeAddrOf* when the target is null will return a null pointer

UnsafeAddrOf* -> AddrOf

public static T* AddrOfArrayData(this PinnedGCHandle<T[,]> handle);
public static T* AddrOfArrayData(this PinnedGCHandle<T[,,]> handle);

We can always add these later if somebody demonstrates that these methods are needed often enough. One can always handle these cases using GetArrayDataReference + Unsafe.AsPointer.

We can't do that check in the new type, because the design is we don't want to store the pinned flag in the handle anymore. So when someone assigns Target, we can't validate whether they're trying to set a non-pinnable objects into a pinned handle. Should we just say doing so is undefined behavior, and we can only properly validate the initial object that's used when creating the handle? Or will the runtime imports already fail in this scenario, just with a different exception type?

The non-pinnable concept has been an artificial limitation in the existing GCHandle. The implementation of GC handles should handle pinning of objects with references just fine.

@hez2010
Copy link
Contributor

hez2010 commented Dec 26, 2024

That method doesn't do the same thing at all. If you call it on a PinnedGCHandle<string>, you want a char* back, not a string*. Same for T[], you want a T* back, not a T[]*.

Edit: misread, but the issue is the same. You don't want a pointer back that's of the same T as the handle. You want the "array-ness" stripped out of the returning T.

Then it can be returning a nint:

public static class PinnedGCHandleGCHandleExtensions
{
    public static nint AddrOfArrayData<T>(this PinnedGCHandle<T> handle);
}

@Sergio0694
Copy link
Contributor Author

That would be much less convenient than the strongly typed version that's currently in the proposed.

What I could potentially see having would rather be something like (in addition to the other two extensions):

public static void* AddrOfObjectData<T>(this PinnedGCHandle<T> handle);

Which could be used in all other cases (not just arrays), and I know stuff like this has been requested before too.
Not sure though if this would be acceptable or "too unsafe". Thoughts?

@hamarb123
Copy link
Contributor

hamarb123 commented Dec 26, 2024

That would be much less convenient than the strongly typed version that's currently in the proposed.

What I could potentially see having would rather be something like (in addition to the other two extensions):

public static void* AddrOfObjectData<T>(this PinnedGCHandle<T> handle);

Which could be used in all other cases (not just arrays), and I know stuff like this has been requested before too.
Not sure though if this would be acceptable or "too unsafe". Thoughts?

Well, I would think it would make sense to get the actual byte& GetRawData(object) api first before we make & expose helpers on top of it.

I personally think this API would be useful, but it only really makes sense if we have the building block (GetRawData) available too imo.

@Sergio0694
Copy link
Contributor Author

Yup, I don't disagree. I did say "potentially", it doesn't have to be in this first proposal. The current API shape is fine I think 🙂

@jkotas
Copy link
Member

jkotas commented Dec 26, 2024

PinnedGCHandleGCHandleExtensions

There is one too many GCHandle in this name. I think it can be just GCHandleExtensions so that it works for GCHandle extensions too if there are some in future.

@Sergio0694
Copy link
Contributor Author

oops, typo, I meant to just say PinnedGCHandleExtensions 😅
I'll change it to just GCHandleExtensions though, sure!

@bartonjs
Copy link
Member

bartonjs commented Jan 7, 2025

Video

  • Rather than taking the GCHandleType parameter in a constructor we separated out the WeakGCHandle concept to a 3rd type.
  • Consider declaring and implementing IEquatable<self>
  • We went with TryGetTarget/SetTarget on WeakGCHandle to mirror WeakReference<T>
  • We changed AddrOf to GetAddressOf as a prefix.
namespace System.Runtime.InteropServices;

public struct GCHandle<T> : System.IDisposable
    where T : class?
{
    public GCHandle(T value);

    public readonly bool IsAllocated { get; }
    public readonly T Target { get; set; }

    public static GCHandle<T> FromIntPtr(IntPtr value);
    public static IntPtr ToIntPtr(GCHandle<T> value);

    public void Dispose();
}

public struct PinnedGCHandle<T> : System.IDisposable
    where T : class?
{
    public PinnedGCHandle(T value);

    public readonly bool IsAllocated { get; }
    public readonly T Target { get; set; }
    public readonly void* GetAddressOfObjectData();

    public static PinnedGCHandle<T> FromIntPtr(IntPtr value);
    public static IntPtr ToIntPtr(PinnedGCHandle<T> value);

    public void Dispose();
}

public struct WeakGCHandle<T> : System.IDisposable
    where T : class?
{
    public WeakGCHandle(T value, bool trackResurrection = false);

    public readonly bool IsAllocated { get; }

    public readonly bool TryGetTarget([NotNullWhen(true)] out T? target);
    public readonly void SetTarget(T target);

    public static WeakGCHandle<T> FromIntPtr(IntPtr value);
    public static IntPtr ToIntPtr(WeakGCHandle<T> value);

    public void Dispose();
}

public static class GCHandleExtensions
{
    public static unsafe T* GetAddressOfArrayData<T>(this PinnedGCHandle<T[]?> handle);
    public static unsafe char* GetAddressOfStringData(this PinnedGCHandle<string?> handle);
}

@bartonjs bartonjs added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for review, it is NOT ready for implementation labels Jan 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-approved API was approved in API review, it can be implemented area-System.Runtime.InteropServices
Projects
Status: No status
Development

No branches or pull requests