Description
After https://dart-review.googlesource.com/c/sdk/+/354902, we will allow sending deeply immutable Finalizable
s to other isolates in the same group. However, if a NativeFinalizer
lives in an isolate that exits, but deeply immutable Finalizable
s are still alive in other isolates, the native resources are prematurely finalized. Therefore, we should enable sending NativeFinalizer
s to another isolate via Isolate.exit
.
Currently, sending NativeFinalizers
s is disallowed:
sdk/runtime/vm/object_graph_copy.cc
Line 873 in 83ba591
Line 2389 in 83ba591
Sending a NativeFinalizer
to another isolate should have the effect of changing the isolate pointer to the receiving isolate.
Some open questions:
- What is the state in between that the message is send and that it is received? Running finalizers has the side effect of cleaning up the entries in the Dart heap via a message to the isolate. If there is temporarily no isolate while the finalizer is in flight, where should the clean up message be sent? (Presumably we can run the native finalizer while it is in flight from one isolate to another, we do have access to the heap to access the address of the native callback and the token.)
- What is the state if the exit message is never handled?
If we do decide to support this, maybe we should add a NativeFinalizer.adopt(NativeFinalizer other)
that will check if the native callback function is equal and then merge all the entries from the other finalizer. This will enable storing a finalizer in a static field and on isolate shutdown sending it to another isolate and merging it into the finalizer that's in the "same" static field already. (The alternative is that one might have a collection of adopted finalizers and can never clean up that collection.)
If we do not decide to support it, probably the best practise is that NativeFinalizer
s should live on, and Finalizable
s should be created on, an isolate that will stay alive until the end of the program (the main isolate or platform isolate). cc @liamappelbe @HosseinYousefi Do you have use cases where you want to create a Finalizable
on a helper isolate that is subsequently going to exit after you've sent the Finalizable
to the main isolate?
(Things would be much easier if we could just mark the NativeFinalizer
static field as shared
across all isolates in the group, a la dart-lang/language#3531, shared final NativeFinalizer = ...
. We would not need to worry about sending it to another isolate on exit.)
Activity
lrhn commentedon Feb 28, 2024
If you have a finalizer attached to an object, and you send that object through
Isolate.exit
, will the finalizer stay alive too (being implemented by heap merging would suggest so, but I can see arguments in either direction).If so, that effectively keeps its callbacks alive, and allows them to run in the remaining isolate.
If not, I also assume the finalizer won't run, because cleaning up a reachable object is bad.
(I should check.)
But this is about native finalizers, which only do callouts to native functions, right?
Here it also matters what happens on
Isolate.exit
, even if you don't send the finalizer. Will it trigger, stay alive, or quietly go away with its isolate?Sending (merging) the native finalizer to another isolate with
Isolate.exit
shoulder change how it behaves much, over not sending it, but it does allow you to interact and update the object during sending.(Why does a native finalizer need an isolate reference? Is the native code run in a way that makes it being to that isolate?)
Whether an exit message is handled shouldn't matter. It exists, and is delivered. Whether someone looks at it after that is up to them. If it languishes in the buffer of a paused
SteamSubscription
, that's what someone asked for.The
adopt
functionality seems reasonable. Biggest question is whether later additions to the adoptee should be forwarded to the adopter too.dcharkes commentedon Feb 29, 2024
Thanks for all the good questions @lrhn! I think I have a slightly better idea flashed out now. See my answers to your questions below.
Heap merging yes, but if that finalizer is not saved in a global variable or something, it will be GCed itself. Finalizers that are GCed will never be run.
Conceptually only a native function is called.
Implementation detail is that the finalizer entries need to be removed from the Dart heap, otherwise we would leak memory by accumulating entry objecs. So currently, a message is sent to the isolate to do so. If we were able to remove entries from a Dart
Set
in C++ code in the VM, or we would change to a different data structure than aSet
, we would not need to execute any Dart code after aNativeFinalizer
has run.sdk/sdk/lib/_internal/vm/lib/internal_patch.dart
Lines 268 to 289 in 3ef08e1
It will trigger. We don't want it to quietly go away, it would lead to leaking native resources.
I don't think we can achieve that sending or not sending doesn't change the behavior. We have specified for both
Finalizer
andNativeFinalizer
that if the finalizer object gets GCed that the callbacks never get run not sending is not an option. To prevent native resources from leaking, aNativeFinalizer
that is reachable in an isolate that gets shut down is run eagerly.So sending the native finalizer to a new isolate prevents the native callbacks from being run eagerly. So the behavior is different between sending and not sending.
(Having
shared
variables from the shared memory multithreading would do what you want! Then it would be the same sending or not sending it.)Set
in C++ VM code or by not using aSet
but still keeping all the operations O(1). Thinking about it, maybe_allEntries
could be converted into a doubly linked list, I'd need to think about it a bit more.I guess if a message is not handled, but stays in flight forever, it does keep all the objects in such message alive, including the
NativeFinalizer
. So any objects attached that are GCed will have their native callback run.If we change the
_allEntries
data structure to a doubly linked list, then we could change the isolate pointer on an entry (and add the finalizer to the isolate.finalizers linked list) to the target isolate immediately. This should have the effect that if a message is not handled before the receiving isolate shutdown, the native callbacks are run on the receiving isolate shutdown. (I'd need to double check that the not handled messages stay alive until the finalizers are run on isolate shutdown.)That is a good question. Alternatively, calling
attach
ordetach
on aNativeFinalizer
that has been adopted should throw. I don't really have a strong opinion about this.(Note we can't really add this to normal
Finalizer
s, as we can't check that the callback closures are equal. Or would it be enough to check that the signatures are equal?)High level idea then:
_allEntries
to be a doubly linked list, then we don't have to worry about having to clean up the data structure in the Dart heap anymore.Isolate.exit
the isolate pointer is immediately set to the target isolate (and the finalizer is immediately added to the target isolate finalizers and removed from the sending isolate finalizers).adopt
toNativeFinalizer
API.dcharkes commentedon Feb 29, 2024
From a discussion with @HosseinYousefi:
JObject
properly. Users would need to know about theNativeFinalizer
and send it to the longest living isolate withIsolate.exit
. It is undesirable to burden users with this.SendPort.send
not delivered. This can also not be encapsulated inpackage:jni
andpackage:jnigen
. The user would need to write the native finalizer argument in theSendPort.send
, or we would need to provideJObject.send(SendPort)
. Both don’t enable writing abstractions (larger object graphs containing JObjects).interface class MessageNotDeliveredNativeFinalizer
with aget NativeFinalizerFunction callback
. Then if the message is not delivered we have a O(N) traversal of the full graph reachable from the message to see which objects implement this. (We cannot do this on message send, because deeply immutables on send because are O(1).)@pragma('vm:sharable')
and allow users to mark static fields with@pragma(‘vm:shared’)
. Basically we would need to make all operations onNativeFinalizer
s concurrency safe. (We have someSet
andExpando
operations internally.)shared
), we cannot say a native finalizer in an isolate group is going to stay alive forever, because a Dart code writer would not be able to say that this native finalizer object itself can be GCed. With ashared
/@pragma('vm:shared')
we can.@pragma('vm:sharable')
.Finalizables
with@pragma('vm:deeply-immutable')
only if theNativeFinalizer
is in a static field marked@pragma('vm:shared')
.NativeFinalizer
s live in an isolate group instead of isolate. #55062mkustermann commentedon Feb 29, 2024
If we have an object sharable across isolates, that object doesn't have an owning isolate. If this sharable object represents a resource, the resource is owned by the isolate group - not by an individual isolate.
=> We may want to disallow attaching native finalizers (with run-eagerly-on-isolate-shutdown semantics) to sharable objects.
=> We may want to introduce
NativeFinalizer.sharable
instead, which would have the semantics of run-eagerly-on-isolate-group-shutdown and one can only attach those to sharable objects.mkustermann commentedon Feb 29, 2024
This is somewhat of a separate issue, but may be worthwhile fixing.
Already today one can create deeply immutable objects (dart constants, but also deeply immutable typed data arrays). Attaching finalizers to them seems not a good idea. @dcharke could you make a CL to disallow that?
NativeFinalizer
s to deeply immutable objects #55067dcharkes commentedon Mar 4, 2024
Okay, I think the conclusion is that we do not want to support sending
NativeFinalizer
s to other isolates. Rather we should support #55062.2 remaining items