Open dcharkes opened 4 months ago
@mkustermann: name option NativeFinalizale
.
(I misremembered how this works in today's meeting)
The current setup is:
Finalizer
API with any dart object (no restriction) - under the assumption that invoked finalizer callbacks are delayed in event loop, delayed enough to ensure no finalization-before-use issues occur (**)NativeFinalizer
API only with Finalizable
sI think the main reasons we currently disallow Finalizer
s to be shared across isolates is that:
NativeFinalizer
object is GCed then the finalizers are silently / don't trigger anymore
=> That is IMHO a mistake - if C code needs to free up something, we should invoke it, whether the NativeFinalizer
object is still alive or not
=> It also leads to non-determinism as an unreachable NativeFinalizer
may or may not be collected before the collection of Finalizable
s. So whether or not the callback is inovked is dependent on how GC happens to be implemented.NativeFinalizer
s haven't been collected yet)
=> Any other isolate using the objects after isolate shutdown would have use-after-free error@pragma('vm:deeply-immutable')
now for that.So marking a class as extends Finalizable
and marking it as @pragma('vm:deeply-immutable')
allows sending/sharing across isolates but
NativeFinalizer
s and therefore possible create use-after-free scenarios.Dart_NewFinalizableHandle
as those are a) tied to an isolate group and are b) guaranteed to run (neither of which are NativeFinalizer
s are not - see above)Eventually (with shared multi threading) we want a finalizer API that works across isolates (just like our existing C API works across isolates). So one way would be to have
But this may require two different marker interfaces or otherwise leaves room for user bugs where objects that are isolate-local are shared.
We could go instead the other direction:
NativeFinalizer
object was GCed=> This would effectively align the NativeFinalizer
behavior with that of the C API.
=> This would make it safe to allow sharing Finalizable
& deeply immutable objects across isolates.
So the main question is: Do we really care about early reclamation of isolate-local resources on unexpected isolate shutdown? This is only important if isolates are short lived and native resources aren't eagerly freed up by user code (or e.g. isolates get often killed) and we cannot wait for the GC to eventually run them a bit later.
/cc @mraleph wdyt?
(**) This assumption only makes sense for synchronous code. In asynchronous code one can easily have
foo() async {
final obj = Foo(field);
finalizer.attach(foo, ...., () => ...);
final field = obj.field;
await <...>
// ^^^^
// - takes long time
// - may cause GC
// - may collect `obj` (as it's not used anymore & it's not `Finalizable)
// - GC may enqueue finalizer,
// - may run finalizer
useField(field); // <-- use field after finalizer has run
}
=> This would effectively align the
NativeFinalizer
behavior with that of the C API.
That's not entirely true, canceling finalizers from another isolate doesn't work, from a previous discussion of ours:
- However, detach would not work shared. Trying to detach from a non-shared static nativeFinalizer would lead to a no-op on another isolate. Because it would be a different native finalizer (that happens to have the same callback address.)
Do we really care about early reclamation of isolate-local resources on unexpected isolate shutdown?
I don't know of any such use cases. So, I like the idea of trying to make NativeFinalizer
shared always instead of introducing a SharedNativeFinalizer
later, from another previous discussion of ours:
I believe we could make the existing NativeFinalizer
sharable (and thus make detach work from any isolate) by making it's backing store for entries (a map), concurrency safe. (We would need to do the same work for introducing a SharedNativeFinalizer
later anyway.)
That's not entirely true, canceling finalizers from another isolate doesn't work, from a previous discussion of ours:
The code may not care about detachment at all, it may only detach on the main isolate anyway, helper isolates could ask main isolate to detach or it may (in future) use static shared NativeFinalizer = ...
(in which case one gets the same semantics as the C api)
Trying to detach from a non-shared static nativeFinalizer would lead to a no-op
Wouldn't it throw a nice exception: "You cannot detach object foo
from this finalizer as it was not attached" ?
I believe we could make the existing NativeFinalizer sharable (and thus make detach work from any isolate) by making it's backing store for entries (a map), concurrency safe. (We would need to do the same work for introducing a SharedNativeFinalizer later anyway.)
Making NativeFinalizer
shared (i.e. make them work in upcoming shared multithreading shared isolates) isn't necessary for this.
What is necessary is committing to the semantic changes of NativeFinalizer
: Make them always run (even if NativeFinalizer
object is GCed), make them not run on isolate shutdown. As those two semantic changes will prevent use-after-free errors.
Right, if you cannot send the NativeFinalizer
to another isolate, you cannot try to detach there. š
ā For changing NativeFinalizer
s to be run on isolate group shutdown, and not GCable themselves if they still have attachments.
Wouldn't it throw a nice exception: "You cannot detach object foo from this finalizer as it was not attached" ?
It does not. https://github.com/dart-lang/sdk/issues/56632
We currently have a
Finalizable
interface that serves two purposes:NativeFinalizer
s attached.Due to (2), we disallow sending objects implementing
Finalizable
to other isolates. As that that will most likely be an error.However, when attaching finalizers via the
dart_api.h
, we also need the behavior of (1), and we actually want to send objects to other isolates (that's why we resorted to using finalizers from thedart_api.h
in the first place.)We should introduce another marker interface that only has behavior 1. Maybe the interfaces should even be related.
Ready for bike shedding on the name:
a.
KeepAlive
b.IsolateGroupFinalizable
The marker interface should probably live in
dart:ffi
as well. Because it's mostly used when sending objects via FFI withHandle
s. Also it would enable relating the two interfaces, helping with documentation. But I can be convinced of other options.Use case:
cc @HosseinYousefi @mkustermann