On the impossibility of composing finalizers and FFI
wingolog.org
wingolog.org
In absence of finalizers, I suspect, FFI resources could also be garbage-collected.
If looks like traditional GC-based languages (like Java or Lisp) are hurt by absence of static data flow analysis, which would guarantee that a finalizer cannot revive the object being collected (e.g. by creating a new live reference to it elsewhere). Finalizers can likely be made safe enough if their code is more restricted; that would still allow many reasonable finalizers that calmly release external resources.
If a finalizer calls external code to release external resources (a not uncommon use case), there’s no way static data flow analysis can determine that external code doesn’t make a call back into the VM that revives objects, is there?
function blob_contents(blob) -- <- this ensures liveness until past return
local len_out = ffi.new('unsigned int')
local contents = hb.hb_blob_get_data(blob, len_out)
local len = len_out[0];
return ffi.string(contents, len)
endBut it is flat wrong. From the LuaJIT documentation: "All explicitly (ffi.new(), ffi.cast() etc.) or implicitly (accessors) created cdata objects are garbage collected. You need to ensure to retain valid references to cdata objects somewhere on a Lua stack, an upvalue or in a Lua table while they are still in use. Once the last reference to a cdata object is gone, the garbage collector will automatically free the memory used by it (at the end of the next GC cycle)."
The Lua stack in this case includes all the local variables in that function scope. It's a non-issue/straw man and is common sense. If LuaJIT FFI worked the way the author supposed, it would be near impossible to use practically.
“It is perfectly valid to collect blob after its last use”
This is a useless statement. It’s perfectly “valid” for LuaJIT to not even read your source code and exit immediately, but that isn’t what it does because it would be useless. What counts as a reference is both PUC Lua and LuaJIT is defined.
As far as the desirability of finer grained liveness, Lua has block scope (do end), but in practice LuaJIT does well inlining so functions ought to be short anyway.
So everything you quote Lua a saying here is consistent. The thing is that it only considers "used later" as "used later by the Lua program". Or rather, it only considers Lua values as "values". A value stored in non-managed memory is not a value. It's not GCed. The `ffi.new`-created Lua value is, and it's finalizer happens to free the native memory that the pointer refers to.
So non -Lua "values" are not GCed, they are freed as side effects of Lua values being GCed.
It’s not a trick, it’s a documented interface.
> or attempt to manually extend the lifetime of a finalizable object, and then pray the compiler and GC don’t learn new tricks to invalidate your trick.
This is also silly. There is no reason whatsoever that a GC can’t offer an actual API to keep an object alive.
> An existing finalizer can be removed by setting a nil finalizer, e.g. right before explicitly deleting a resource:
ffi.C.free(ffi.gc(p, nil)) -- Manually free the memory.
https://luajit.org/ext_ffi_api.html#ffi_gc local ffi = require"ffi"
local function collect_lots()
for i = 1, 20 do collectgarbage() end
end
local function f(s)
local blob = ffi.new"int[2]"
local interior = blob + 1
interior[0] = 13 -- should become the return value
s:gsub(".", collect_lots)
return interior[0] -- kept alive by blob?
end
for i = 1, 60 do
local str = ("x"):rep(i - 59)
assert(f(str) == 13) -- can fail!!
endFrom the LuaJIT docs: So e.g. if you assign a cdata array to a pointer, you must keep the cdata object holding the array alive as long as the pointer is still in use:
ffi.cdef[[
typedef struct { int *a; } foo_t;
]]
local s = ffi.new("foo_t", ffi.new("int[10]")) -- WRONG!
local a = ffi.new("int[10]") -- OK
local s = ffi.new("foo_t", a)
-- Now do something with 's', but keep 'a' alive until you're
done.
What on earth does "OK" here mean if not the local variable binding? It's the expectation because this is what it says on the tin.This then isn’t a discussion about fundamental issues or "impossibilities" with GC, but with poor language implementations not following their own specifications or not having them.
Since LuaJIT does not have an explicit pinning interface the expectation that a local variable binding remains until the end of scope is pretty basic. If your bug case is expected then even the line: interior[0] = 13 is undefined and so would everything after local s in the documentation, ie you can do absolutely nothing with a pointed to cdata until you pin it in a table. Who would want to use that?
In this particular example, you'd make an object with a finalizer and hide the raw pointer inside of it. Then you can only touch that pointer by going through a Rust object which participates in lifetime analysis, and it'll clean it up when it's done. Any more attempts to touch that object/pointer will fail to compile.
Expressed that way it makes sense that some people call it "GC at compile time".
pub struct OwnedForeignInstance(NonNull<std::ffi::c_void>);
You can then, as you say, implement Drop to give up the foreign object when it's no longer needed. impl Drop for OwnedForeignInstance {
fn drop(&mut self) {
unsafe { /* Destroy self.0.as_ptr() */ }
}
}
You can define an un-owned reference to the owned foreign instance using a newtype like this. pub struct UnownedReference(PhantomData<UnsafeCell<*mut ()>>);
Then, you can hand out zero-cost lifetime-checked references to the owned foreign instance like this. impl Deref for OwnedForeignInstance {
type Target = UnownedReference;
fn deref(&self) -> &Self::Target {
unsafe { &*(self.0.as_ptr() as *mut _) }
}
}
impl DerefMut for OwnedForeignInstance {
fn deref_mut(&mut self) -> &mut Self::Target {
unsafe { &mut *(self.0.as_ptr() as *mut _) }
}
}
Once you've done that, you expose your FFI functionality on UnownedReference, relying on auto-deref. Unless it consumes the receiver, in which case you put it on the OwnedForeignInstance. This way you can't destroy the object while references to it continue to exist.It's not perfect, but it's the best way I've found so far for making FFI wrapper objects that look and feel like Rust objects while respecting the FFI contract.
function blob_contents(blob)
ffi.pin(blob)
-- ...
ffi.unpin(blob)
end
Where pin disables garbage collection of the given object while pin re-enables it, forming the exact region where its lifetime is guaranteed which is apparently what the author needs.It's manual memory management and the code will have to be written carefully if the language has exceptions or other forms of unwinding. It should work though.
Moving garbage collectors also have a concept of pinning objects since code can save pointers to them. Seems like the same problem to me.
The difficulty is getting the behaviors if you're outside of the run-time, writing writing FFI bindings, where you don't have the option of hacking in new ownership behaviors into the target objects, and your FFI may be lacking in expressiveness also.
If it's a bad problem for a certain kind of object, and that object is relatively important (lots of people want to use bindings for it), the way to go may be a lower level extension module rather than FFI, or a wrapper library around it which is more amenable to FFI.
A ForeignPtr is a GC-managed pointer with an associated finalizer. The finalizer runs when the ForeignPtr gets GC'd.
`withForeignPtr` creates a scope (accepting a lambda) in which you can inspect the pointer `(Ptr a -> IO b)`.
This works well in practice, so I do not really understand why "among GC implementors, it is a truth universally acknowledged that a program containing finalizers must be in want of a segfault".
[1]: https://hackage.haskell.org/package/base-4.19.1.0/docs/Forei...
It works well in only the case where you have perfectly well scoped small regions that you can model as your lambda. When you need to actually do anything intricate with the lifetime where you want it to escape (probably into a datastructure), the callback won’t cut it and it’s on you to ensure the foreignptr’s lifetime becomes interlinked with the returned b
> it’s on you to ensure the foreignptr’s lifetime becomes interlinked with the returned b
Yes; the simplest way to do that is to make sure that your data types never contain raw `Ptr`, only `ForeignPtr` -- same as in C++, where seeing `mytype * x` should ring the alarm bells.
You could say "but what if I call another FFI function that needs the Ptr as an argument"? In that case, surely the function needs to document if it takes ownership of the pointer. If it doesn't document that, yes, it'll crash; but that's unrelated to finalizers (the "impossibility of composing" of which which is what the post claimed); it would also crash if no finalizers were involved.
The only viable way to escape all this is to rewrite the software in the host language. A worthy goal but I don't see anyone signing up for that herculean task outside the Rust community.
I think I saw this first in MLton, which has a touch function for this purpose: http://mlton.org/MLtonFinalizable
I'm not convince this is particularly hard to use functionality, all things considered. Supporting explicit deallocation in a safe way is much harder, especially if FFI callbacks are involved.
[1]:https://www.gnu.org/software/guile//manual/html_node/Guardia...
[2]:https://srfi.schemers.org/srfi-246/
[3]:https://www.cs.tufts.edu/comp/250RTS/archive/kent-dybvig/gua...
[1] https://learn.microsoft.com/en-us/dotnet/api/system.gc.keepa...
function blob_contents(blob)
local len_out = ffi.new('unsigned int')
local contents = gc_borrows_from(blob, hb.hb_blob_get_data(blob, len_out))
local len = len_out[0];
return ffi.string(contents, len)
end
This would tell the GC that the data returned by `hb_blob_get_data` is borrowed from blob, and it can't collect `blob` until `contents` is also unreachable. How to implement that would be up to the runtime, but it seems reasonable to have a wrapper type that holds a traceable reference back to blob.https://learn.microsoft.com/en-us/dotnet/api/system.runtime....
And there's an older API that wraps them which I've found quite handy: https://learn.microsoft.com/en-us/dotnet/api/system.runtime....
Essentially, if you want to (externally - in code you wrote) associate two objects with each other without being able to modify the code of either of those objects, dependent handles are the ticket. It only creates a one-way liveness relationship, though, which may not be sufficient for every use case...
Eventually you’ll want an object graph cycle that traverses through the non-GC heap and then you’re really screwed.
That object should take care of it. Even if the parent object is reclaimed, the byte string should independently persist for as long as is necessary. Since byte strings don't contain pointers to anything, reference counting could be used.
The refcount could be in the parent blob, such that when its nonzero, the blob is pinned against reclamation.
Of course you can't just share out the internals of an object, such that GC doesn't know about them. It doesn't matter if it's a foreign object set up with FFI or something built into the run time.
The only nice way to have a GC language interop with another language is if that other language is also GC’d and that language’s GC interops with yours (what I like to call frankengc).
(Once I add GC to Fil-C and give it proper frankengc hooks, you’ll be able to do destructorless FFI to whatever C code Fil-C can run. It’ll be great I promise.)
It may be worth reading that previous sentence again. I've seen it so many times. Even in the comments on the linked article I see people trying to propose solutions to the problem. But it turns out to be one of those problems that looks simple, even super simple ("just" provide a function to do it), but then it turns out that hundreds of smart people have poured years into solving it... and failed, as far as I know, universally.
Fortunately, you need to have a very particular kind of program to get hit hard by the problem, and an even more particular kind of program to not have the ability to mitigate it into an acceptable issue somehow, most often something like being more careful to close files when done with them. I think I brushed the issue once and it was addressed by exactly that, being more careful to close files when done with them because we accidentally were leaning on GC to close them and that become a de facto file handle leak.
That said, the problem may be exposed by GC but it isn't entirely caused by it. Sufficiently complicated manually-managed memory programs can encounter things that stem from the same underlying issue. If the lifetime of objects that need a "finalizer" (or destructor, if you prefer) becomes sufficiently complicated, it can also become very difficult to figure out when they need to be cleaned up. But you get more runway, and you have more options to deal with it when you have the ability to more forcibly descope things and deallocate them at a known point in time. When I was programming with libpurple, the generalized IM library written in C, it used a lot of "closures" (implemented in C), and figuring out when to correctly deallocate and finalize on them was very, very difficult, because their lifetimes were complicated and not terribly well documented. This is the same underlying problem in a lot of ways, it just manifests differently. But in a manually-managed world, it is at least a solvable problem, however hard the problem may be to solve in some languages (while they had no better option at the time, I gotta say in hindsight libpurple would really have been served by Rust, and I'm not thinking the generalized "rewrite everything in Rust" here but specifically that all those closures would have been much safer to work with with lifetime annotations enforced by the compiler), and however difficult you may make the problem on yourself with your own programming practices.
Alas, no silver bullet.
struct S { int s; };
int *i;
{
struct S s;
i = &s.s;
}
*i = 7; // and in C++ you can even write int& j = *i;
No GC or finalizers involved here.
Hope you have ubsan.
The point being that GC is sort of a red herring in the entire discussion. Easily subverted lifetime and ownership semantics are going to be error prone irrespective.