-
Notifications
You must be signed in to change notification settings - Fork 53
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
[Java.Interop] Add JavaPeerableRegistrationScope #1238
Conversation
var typeface1 = Typeface.Create("Family", …);
var typeface2 = Typeface.Create("Family", …);
typeface1.Dispose ();
Console.WriteLine (typeface2.Weight);
Ideally it would "just work" without additional knowledge or effort, but perhaps we just can't get there from where we are today? |
4f0ccbb
to
1013e93
Compare
Fixes: #4 Context: #426 Context: a666a6f Alternate names? * JavaScope * JavaPeerableCleanupPool * JavaPeerCleanup * JavaReferenceCleanup * JniPeerRegistrationScope Issue #426 is an idea to implement a *non*-GC-Bridged `JniRuntime.JniValueManager` type, primarily for use with .NET Core. This was begun in a666a6f. What's missing is the answer to a question: what to do about `JniRuntime.JniValueManager.CollectPeers()`? With a Mono-style GC bridge, `CollectPeers()` is `GC.Collect()`. In a666a6f with .NET Core, `CollectPeers()` calls `IJavaPeerable.Dispose()` on all registered instances, which is "extreme". @jonpryor thought that if there were a *scope-based* way to selectively control which instances were disposed, that might be "better" and more understandable. Plus, this is issue #4! Which brings us to the background for Issue #4, which touches upon [bugzilla 25443][0] and [Google issue 37034307][1]: Java.Interop attempts to provide referential identity for Java objects: if two separate Java methods invocations return the same Java instance, then the bindings of those methods should also return the same instance. This is "fine" on the surface, but has a few related issues: 1. JNI Global References are a limited resource: Android only allows ~52000 JNI Global References to exist, which sounds like a lot, but might not be. 2. Because of (1), it is not uncommon to want to use `using` blocks to invoke `IJavaPeerable.Dispose()` to release JNI Global References. 3. However, because of object identity, you could unexpectedly introduce *cross-thread sharing* of `IJavaPeerable` instances. This might not be at all obvious; for example, in the Android 5 timeframe, [`Typeface.create()`][2] wouldn't necessarily return new Java instances, but may instead return cached instances. Meaning that this: var t1 = new Thread(() => { using var typeface = Typeface.Create("Family", …); // use typeface… }); var t2 = new Thread(() => { using var typeface = Typeface.Create("Family", …); // use typeface… }); t1.Start(); t2.Start(); t1.Join(); t2.Join(); could plausibly result in `ObjectDisposedException`s (or worse), as the threads could be unexpectedly sharing the same bound instance. Which *really* means that you can't reliably call `Dispose()`, unless you *know* you created that instance: using var safe = new Java.Lang.Double(42.0); // I created this, therefore I control all access and can Dispose() it using var unsafe = Java.Lang.Double.ValueOf(42.0); // I have no idea who else may be using this instance! Attempt to address both of these scenarios -- a modicum of .NET Core support, and additional sanity around JNI Global Reference lifetimes -- by introducing a new `JavaPeerableRegistrationScope` type, which introduces a scope-based mechanism to control when `IJavaPeerable` instances are cleaned up: public enum JavaPeerableRegistrationScopeCleanup { RegisterWithManager, Dispose, Release, } public ref struct JavaPeerableRegistrationScope { public JavaPeerableRegistrationScope(JavaPeerableRegistrationScopeCleanup cleanup); public void Dispose(); } `JavaPeerableRegistrationScope` is a [`ref struct`][3], which means it can only be allocated on the runtime stack, ensuring that cleanup semantics are *scope* semantics. TODO: is that actually a good idea? If a `JavaPeerableRegistrationScope` is created using `JavaPeerableRegistrationScopeCleanup.RegisterWithManager`, existing behavior is followed. This is useful for nested scopes, should instances need to be registered with `JniRuntime.ValueManager`. If a `JavaPeerableRegistrationScope` is created using `JavaPeerableRegistrationScopeCleanup.Dispose` or `JavaPeerableRegistrationScopeCleanup.Release`, then: 1. `IJavaPeerable` instances created within the scope are "thread-local": they can be *used* by other threads, but `JniRuntime.JniValueManager.PeekPeer()` will only find the value on the creating thread. 2. At the end of a `using` block / when `JavaPeerableRegistrationScope.Dispose()` is called, all collected instances will be `Dispose()`d (with `.Dispose`) or released (with `.Release`), and left to the GC to eventually finalize. For example: using (new JavaPeerableRegistrationScope (JavaPeerableRegistrationScopeCleanup.Dispose)) { var singleton = JavaSingleton.Singleton; // use singleton // If other threads attempt to access `JavaSingleton.Singleton`, // they'll create their own peer wrapper } // `singleton.Dispose()` is called at the end of the `using` block However, if e.g. the singleton instance is already accessed, then it won't be added to the registration scope and won't be disposed: var singleton = JavaSingleton.Singleton; // singleton is registered with the ValueManager // later on the same thread or some other threa… using (new JavaPeerableRegistrationScope (JavaPeerableRegistrationScopeCleanup.Dispose)) { var anotherSingleton = JavaSingleton.Singleton; // use anotherSingleton… } then `anotherSingleton` will *not* disposed, as it already existed. *Only newly created instances* are added to the current scope. By default, only `JavaPeerableRegistrationScopeCleanup.RegisterWithManager` is supported. To support the other cleanup modes, `JniRuntime.JniValueManager.SupportsPeerableRegistrationScopes` must return `true`, which in turn requires that: * `JniRuntime.JniValueManager.AddPeer()` calls `TryAddPeerToRegistrationScope()`, * `JniRuntime.JniValueManager.RemovePeer()` calls `TryRemovePeerFromRegistrationScopes()` * `JniRuntime.JniValueManager.PeekPeer()` calls `TryPeekPeerFromRegistrationScopes()`. See `ManagedValueManager` for an example implementation. Additionally, add the following methods to `JniRuntime.JniValueManager` to help further assist peer management: partial class JniRuntime.JniValueManager { public virtual bool CanCollectPeers { get; } public virtual bool CanReleasePeers { get; } public virtual void ReleasePeers (); } Finally, many of the new members are marked with the [`[Experimental]` attribute][4], which indicates that an API is experimental and could change. In order to use the new APIs, then the `JI9999` warning must be disabled, e.g. via [`#pragma warning disable JI9999`][5]. This will allow us to break API and semantics, should that be necessary. TODO: docs? TODO: *nested* scopes, and "bound" vs. "unbound" instance construction around `JniValueManager.GetValue<T>()` or `.CreateValue<T>()`, and *why* they should be treated differently. TODO: Should `CreateValue<T>()` be *removed*? name implies it always "creates" a new value, but implementation will return existing instances, so `GetValue<T>()` alone may be better. One related difference is that` `CreateValue<T>()` uses `PeekBoxedObject()`, while `GetValue<T>()` doesn't. *Should* it? [0]: https://web.archive.org/web/20211106214514/https://bugzilla.xamarin.com/25/25443/bug.html#c63 [1]: https://issuetracker.google.com/issues/37034307 [2]: https://developer.android.com/reference/android/graphics/Typeface#create(java.lang.String,%20int) [3]: https://docs.microsoft.com/dotnet/csharp/language-reference/builtin-types/struct#ref-struct [4]: https://learn.microsoft.com/dotnet/api/system.diagnostics.codeanalysis.experimentalattribute?view=net-8.0 [5]: https://learn.microsoft.com/dotnet/csharp/language-reference/preprocessor-directives#pragma-warning
1013e93
to
f0ae3fc
Compare
You're correct that threading doesn't per-se matter here, it's just that threading increases the "spooky action at a distance" meme. Sheer code size can also increase "spooky action at a distance".
It is in fact not okay, for the reason I attempted to explain in the commit message: even though the method name says "create", it does not in fact necessarily create a new instance! In this case, Console.WriteLine ($"# jonp: typeface1={typeface1.PeerReference}, typeface2={typeface2.PeerReference}");
Console.WriteLine ($"# jonp: typeface1==typeface2: {typeface1 == typeface2}"); prints to logcat:
which in turn means that Which in turn means that
As they say, "Boom goes the dynamite!"
While #426 *says "Reference Counting, non-bridged backend", I've already mentally dropped the "reference counting" bit as it doesn't really make sense, as there's no way in normal C# use to automatically increment or decrement a reference count. Instead, what we already have is a peer has a "ref count" of 1 or 0: it has a This is what we see in the preceding Which is to say, I cannot conceive of what an "automatic reference counting solution" means or looks like while retaining existing |
I probably used the wrong terminology to describe what I was thinking. All peerable wrapper creation is presumably centralized to one location ( Similarly, all wrapper destruction is centralized to Could we store a "reference count" in the wrapper that denotes how many times we have returned the wrapper via For example: var typeface1 = Typeface.Create ("Family", …); // Returns new wrapper with "ref count" of 1
var typeface2 = Typeface.Create ("Family", …); // Returns same wrapper with "ref count" incremented to 2
typeface1.Dispose (); // Wrapper "ref count" decremented to 1, wrapper not disposed
Console.WriteLine (typeface2.Weight); // This succeeds because the wrapper still exists
typeface2.Dispose (); // Wrapper "ref count" decremented to 0, wrapper is disposed
Console.WriteLine (typeface2.Weight); // This fails because the wrapper is disposed This would not be the "true" "reference counting" which isn't easily supportable by C#. That is, the following would still fail, but the failure would be consistent with all other object instances in C#. var typeface1 = Typeface.Create ("Family", …); // Returns new wrapper with "ref count" of 1
var typeface2 = typeface1;
typeface1.Dispose (); // Wrapper "ref count" decremented to 0, wrapper is disposed
Console.WriteLine (typeface2.Weight); // This fails because the wrapper is disposed |
This is an idea that hadn't occurred to me, and I'm not at all sure that I like it. For starters, it means that I also think that this won't actually work the way we think it will, due to properties that return collections. Consider the namespace Java.IO {
public partial class BufferedInputStream : Java.IO.FilterInputStream {
// .NET for Android
protected IList<byte>? Buf {
get {
const string __id = "buf.[B";
var __v = _members.InstanceFields.GetObjectValue (__id, this);
return global::Android.Runtime.JavaArray<byte>.FromJniHandle (__v.Handle, JniHandleOwnership.TransferLocalRef);
}
// Java.Base
protected global::Java.Interop.JavaSByteArray? Buf {
get {
const string __id = "buf.[B";
var __v = _members.InstanceFields.GetObjectValue (__id, this);
return global::Java.Interop.JniEnvironment.Runtime.ValueManager.GetValue<global::Java.Interop.JavaSByteArray? >(ref __v, JniObjectReferenceOptions.Copy);
}
}
}
} Next, consider usage of this property: class Wat : Java.IO.BufferedInputStream
{
public Wat(System.IO.Stream? @in, int size) : base (@in, size)
{
}
void Hm()
{
for (int i =0; i < Buf.Count; ++i) {
Console.WriteLine (Buf[i]);
}
}
} What does the repeated access of the .locals init (int32 V_0,
bool V_1)
IL_0000: nop
IL_0001: ldc.i4.0
IL_0002: stloc.0
IL_0003: br.s IL_001d
IL_0005: nop
IL_0006: ldarg.0
IL_0007: call instance [System.Runtime]System.Collections.Generic.IList`1<uint8> [Mono.Android]Java.IO.BufferedInputStream::get_Buf()
IL_000c: ldloc.0
IL_000d: callvirt instance !0 [System.Runtime]System.Collections.Generic.IList`1<uint8>::get_Item(int32)
IL_0012: call void [System.Console]System.Console::WriteLine(int32)
IL_0017: nop
IL_0018: nop
IL_0019: ldloc.0
IL_001a: ldc.i4.1
IL_001b: add
IL_001c: stloc.0
IL_001d: ldloc.0
IL_001e: ldarg.0
IL_001f: call instance [System.Runtime]System.Collections.Generic.IList`1<uint8> [Mono.Android]Java.IO.BufferedInputStream::get_Buf()
IL_0024: callvirt instance int32 [System.Runtime]System.Collections.Generic.ICollection`1<uint8>::get_Count()
IL_0029: clt
IL_002b: stloc.1
IL_002c: ldloc.1
IL_002d: brtrue.s IL_0005
IL_002f: ret Notice there are two With a " Additionally, updating things so that your proposed code works: var typeface1 = Typeface.Create ("Family", …); // Returns new wrapper with "ref count" of 1
var typeface2 = Typeface.Create ("Family", …); // Returns same wrapper with "ref count" incremented to 2
typeface1.Dispose (); // Wrapper "ref count" decremented to 1, wrapper not disposed
Console.WriteLine (typeface2.Weight); // This succeeds because the wrapper still exists makes my brain hurt: on the one hand. On .NET for Android, would the refcount be considered by the GC bridge code, i.e. if we hit the GC bridge and an instance has a non-zero refcount, will the instance still be eligible for collection? If it will still be eligible for collection, then the refcount becomes "weird" ("my object has a refcount of 2, yet it was collected!"). If it won't be eligible for collection, then I don't want to imagine how many memory leaks this will plausibly introduce, as This might be plausible for Desktop bindings, but I want abstractions that abstract over CoreCLR & MonoVM, and this feels like a hugely leaky abstraction. |
While discussing this with @jonathanpeppers, one "foot gun" with this idea is that you can't use it "indiscriminately". Consider partial class Activity {
public override void OnCreate(Bundle? savedInstanceState)
{
using (new JavaPeerableRegistrationScope (JavaPeerableRegistrationScopeCleanup.Dispose)) {
base.OnCreate(savedInstanceState);
Xamarin.Essentials.Platform.Init(this, savedInstanceState);
SetContentView(Resource.Layout.activity_main);
Android.Support.V7.Widget.Toolbar toolbar = FindViewById<Android.Support.V7.Widget.Toolbar>(Resource.Id.toolbar);
SetSupportActionBar(toolbar);
FloatingActionButton fab = FindViewById<FloatingActionButton>(Resource.Id.fab);
fab.Click += FabOnClick;
}
}
} What will happen is that things will almost certainly not work. In particular, Some of that could actually be useful: That's potentially obvious here, but if the |
One idea that comes to mind to address the above foot gun comment would be to cache recently used |
Yet another fundamental problem with this idea: caching. Issue #1243 proposes caching all global::Java.Lang.Thread.State? _Runnable_cache;
public static global::Java.Lang.Thread.State? Runnable {
get {
if (_Runnable_cache != null) return _Runnable_cache;
const string __id = "RUNNABLE.Ljava/lang/Thread$State;";
var __v = _members.StaticFields.GetObjectValue (__id);
return _Runnable_cache = global::Java.Interop.JniEnvironment.Runtime.ValueManager.GetValue<global::Java.Lang.Thread.State? >(ref __v, JniObjectReferenceOptions.Copy);
}
} Now, imagine such caching intermixed with using (new JavaPeerableRegistrationScope(JavaPeerableRegistrationScopeCleanup.Dispose)) {
var r = Java.Lang.Thread.State.Runnable;
} Accessing Additionally, such caching is invisible and unknowable, at any level of the stack. (See also: dotnet/maui#24248 ) Which feels like a final nail against this idea. |
Closing, as I don't see a way to preserve this idea that wouldn't break other things; see #1238 (comment). |
Fixes: #4
Context: #426
Context: a666a6f
Alternate names?
Issue #426 is an idea to implement a non-GC-Bridged
JniRuntime.JniValueManager
type, primarily for use with .NET Core. This was begun in a666a6f.What's missing is the answer to a question: what to do about
JniRuntime.JniValueManager.CollectPeers()
? With a Mono-style GC bridge,CollectPeers()
isGC.Collect()
. In a666a6f with .NET Core,CollectPeers()
callsIJavaPeerable.Dispose()
on all registered instances, which is "extreme".@jonpryor thought that if there were a scope-based way to selectively control which instances were disposed, that might be "better" and more understandable. Plus, this is issue #4!
Which brings us to the background for Issue #4, which touches upon bugzilla 25443 and Google issue 37034307: Java.Interop attempts to provide referential identity for Java objects: if two separate Java methods invocations return the same Java instance, then the bindings of those methods should also return the same instance.
This is "fine" on the surface, but has a few related issues:
JNI Global References are a limited resource: Android only allows ~52000 JNI Global References to exist, which sounds like a lot, but might not be.
Because of (1), it is not uncommon to want to use
using
blocks to invokeIJavaPeerable.Dispose()
to release JNI Global References.However, because of object identity, you could unexpectedly introduce cross-thread sharing of
IJavaPeerable
instances. This might not be at all obvious; for example, in the Android 5 timeframe,Typeface.create()
wouldn't necessarily return new Java instances, but may instead return cached instances.Meaning that this:
could plausibly result in
ObjectDisposedException
s (or worse), as the threads could be unexpectedly sharing the same bound instance.Which really means that you can't reliably call
Dispose()
, unless you know you created that instance:Attempt to address both of these scenarios -- a modicum of .NET Core support, and additional sanity around JNI Global Reference lifetimes -- by introducing a new
JavaPeerableRegistrationScope
type, which introduces a scope-based mechanism to control whenIJavaPeerable
instances are cleaned up:JavaPeerableRegistrationScope
is aref struct
, which means it can only be allocated on the runtime stack, ensuring that cleanup semantics are scope semantics.TODO: is that actually a good idea?
If a
JavaPeerableRegistrationScope
is created usingJavaPeerableRegistrationScopeCleanup.RegisterWithManager
, existing behavior is followed. This is useful for nested scopes, should instances need to be registered withJniRuntime.ValueManager
.If a
JavaPeerableRegistrationScope
is created usingJavaPeerableRegistrationScopeCleanup.Dispose
orJavaPeerableRegistrationScopeCleanup.Release
, then:IJavaPeerable
instances created within the scope are "thread-local": they can be used by other threads, butJniRuntime.JniValueManager.PeekPeer()
will only find the value on the creating thread.At the end of a
using
block / whenJavaScope.Dispose()
is called, all collected instances will beDispose()
d (with.Dispose
) or released (with.Release
), and left to the GC to eventually finalize.For example:
However, if e.g. the singleton instance is already accessed, then it won't be added to the registration scope and won't be disposed:
then
anotherSingleton
will not disposed, as it already existed. Only newly created instances are added to the current scope.By default, only
JavaPeerableRegistrationScopeCleanup.RegisterWithManager
is supported. To support the other cleanup modes,JniRuntime.JniValueManager.SupportsPeerableRegistrationScopes
must returntrue
, which in turn requires that:JniRuntime.JniValueManager.AddPeer()
callsTryAddPeerToRegistrationScope()
,JniRuntime.JniValueManager.RemovePeer()
callsTryRemovePeerFromRegistrationScopes()
JniRuntime.JniValueManager.PeekPeer()
callsTryPeekPeerFromRegistrationScopes()
.See
ManagedValueManager
for an example implementation.Finally, add the following methods to
JniRuntime.JniValueManager
to help further assist peer management:TODO: docs?
TODO: nested scopes, and "bound" vs. "unbound" instance construction around
JniValueManager.GetValue<T>()
or.CreateValue<T>()
, and why they should be treated differently.TODO: Should
CreateValue()
CreateValue<T>()
be removed? name implies it always "creates" a new value, but implementation will return existing instances, soGetValue<T>()
alone may be better. One related difference is thatuses
PeekBoxedObject(), while
GetValue()` doesn't. Should it?