Skip to content
This repository has been archived by the owner on Jan 25, 2022. It is now read-only.

WeakRef collection semantics violate encapsulation #19

Closed
lars-t-hansen opened this issue Jan 31, 2018 · 8 comments
Closed

WeakRef collection semantics violate encapsulation #19

lars-t-hansen opened this issue Jan 31, 2018 · 8 comments

Comments

@lars-t-hansen
Copy link

The spec currently states that if the weakref becomes condemned before or at the same time as the target object, then no finalizer is executed. I can see why this is desirable choice for the implementation, but it seems to me that it violates reasonable assumptions about encapsulation.

Suppose a wasm application maintains many objects within its flat heap, which it reifies to JS as JS objects. These JS objects require finalization, so that the finalizer can destruct the wasm resource properly. Suppose that that's all there's to it (ie we don't have to map an address to the JS object representing it so there's no natural external data structure that will reference the weakrefs).

With the current design, we must retain an additional table of weakrefs in the JS heap to ensure that the JS finalizers will be run when the JS objects that are held weakly are condemned, and when an object is finalized the finalizer must ensure that the weakref that caused the finalizer to run is removed from the table, or it must ensure that the table is eventually swept for dead weakrefs.

Furthermore, libraries will themselves need to have their own tables or data structures for ensuring that weakrefs are kept alive long enough (they wouldn't likely share a single table for this), so this problem becomes somewhat non-local.

With a design that does not require the weakref to stay alive, the weakref could be attached to the JS object, or, I suppose, simply dropped on the floor, creating and dropping the weakref thus being equivalent to registering the object for finalization. This puts a larger burden on the implementation but the ergonomics seem to me to be better.

@erights
Copy link
Contributor

erights commented Jan 31, 2018

Hi @lars-t-hansen , could you be more explicit about how the issue you raise is an "encapsulation" issue?

@lars-t-hansen
Copy link
Author

I don't know if I can do much more than restate what I already wrote in slightly different terms:

Suppose I have a JS object that is a proxy for some resource and that, provided my application continues to run long enough, after the JS object is reaped I need to run a finalizer to destruct that resource. In order to do that, the weakref creating the object<->finalizer relationship must stay alive until the finalizer is invoked, for all practical purposes, since it's its liveness that lets the finalizer run at all.

The need to keep the weakref alive means there is a data structure that holds the weakref. In some applications there may be a natural data structure for that. In other applications there's none, so one must be created. My general concern about encapsulation is that this data structure that's created for managing weakrefs will leak out beyond library boundaries because it needs to be, say, scanned periodically to remove dead weakrefs.

(Since we're talking about it, the fact that weakrefs must stay alive longer than the objects they reference for the finalizer to be run is an additional source of incompatibility across implementations since different object management strategies can lead to diverging lifetimes for weakrefs and the objects they reference. Not only does the firing of a finalizer depends on tenuring strategies for the weakly held object, but on the tenuring strategies of the weakref and the relative strategies for managing the two objects.)

@dtribble
Copy link
Contributor

dtribble commented Feb 9, 2018

@lars-t-hansen Thanks for the write-up. I'll take the two issue here in mostly opposite order. I'd also be happy to discuss this in realtime if that's easier to come to closure.

Finalizer lifetime

A design goal for the solution is specifically to support post-mortem finalization. Pre-mortem finalization leads to emergent resurrection bugs that are typically extremely difficult to debug and infeasible to reliably prevent. The only known way to prevent them is to preclude them by using a post-mortem approach. This is discussed more in the proposal.

Fundamentally, post-mortem means that the finalizer necessarily and deliberately must have a longer lifetime than the target object because it must run after the target object is permanently and irrevocably inaccessible.

Reachability of finalizers

I think in practice the concern you are raising does not arise. As scenarios become more concrete, a natural context for the lifetime of finalizers will typically become clear. For wasm host-binding, it's the wasm module instance; for a remote object system, it's the RemoteConnection that manages references for a particular remote process; etc.

I'll illustrate the intended solution in the context of the RemoteConnection example, but it would apply to lots of other scenarios, including the "wasm proxy bridge" being discussed in other issues here.

Subsystem finalization

A basic remote comm system approach has a RemoteConnection for each remote process. Each RemoteConnection manages RemoteRefs, which are local JavaScript objects that each proxy messages over the RemoteConnection to a corresponding object in the associated remote process. If a RemoteRef is gc'd, then finalization will eventually send a dropRef message to the remote process (handled by its RemoteConnection to drop the incoming reference that keeps the remote object around). This is a pretty typical implementation for comm systems and for example enables us to build distributed conservative GC of acyclic object graphs on top of local conservative GC and finalization.

Here's the wrinkle, if the last RemoteRef for a RemoteConnection is dropped, what happens? It's entirely reasonable to just close the network connection and be done. It adds no value to have the finalization system insist that individual RemoteRef finalizers get run and dropRefs get sent. That also unnecessarily creates a layered finalization problem: the RemoteConnection cannot be collected until a later GC phase. Worse, this might cause the RemoteConnection to be promoted and thus unnecessarily survive much longer than needed.

In practice, finalization generally applies within a sub-system, and the maximum lifetime of finalizers is ideally the lifetime of the sub-system. This does require some explicit management of the WeakRefs used in the subsystem, but the appropriate structure is enough different in different scenarios that framework developers need to express their requirements in code. A RemoteConnection uses a map from remoteIds to weakRefs, the DOM iterator needs a set, observers typically use an array, etc.

System retention is a trap

Any element of the GC or finalization framework that deliberately retains objects becomes a source of retention bugs. Continuing the RemoteConnection example, connection services are typically provided by using reserved remote IDs (this technique is used in systems from CORBA to Java's RMI to Midori). Those reserved objects are added to the remotes table at connection time. Thus, remoteId 2 might be the connection GC management object that gets the dropRef messages. The problem is that the RemoteConnection points at those service objects and is the executor for them. If executors are retained by the infrastructure, then the last RemoteRef on a RemoteConnection can never be collected, and so RemoteConnections will never be collected. That bug emerges from entirely reasonable composition of good programming abstractions because the infrastructure retained references.

Similar patterns show up because e.g., modules are retained. A design goal is to have a coherent solution in which nothing is retained by default. If necessary, users could build global collections with controlled retention if they had to, and be in an identical position as if the sysem retained them. But I know of no use case where that is either necessary or desirable.

@erights
Copy link
Contributor

erights commented Feb 12, 2018

A simpler example that perhaps makes the point clearer:

Say we went with the alternative policy: whether or not a weak-ref is strongly reachable, if its target is collected, then its executor must eventually be invoked. Now consider this program:

function executor(...) {...}

function subsystem(obj, i) {
    const wr = makeWeakRef(obj, executor, i);
    ...
    // The objects made above are reachable only from the returned s
    return s;
}

for (const i = 0; i < N; i++) {
    const s = subsystem(Object, i);
}

Assume each call to subsystem creates a new isolated graph of objects that are only reachable from the returned s. Under the originally proposed policy, each subsystem can be collected once its s goes out of scope. Assuming that Object never becomes unreachable, the executor is never called. But if Object does somehow become unreachable, the executor is called for some non-deterministic set of non-collected subsystems.

Under the alternative policy, assuming the engine has no proof that Object will never become unreachable, none of these weak-refs can be collected. As long as it is possible that Object might be collected, the engine must be prepared to call executor for all of these otherwise-garbage weak-refs. In other words, the alternative proposal effectively demands a strong reference from the target back to all weak-refs for whom it is a target. One irony is that this causes leakage that would not be leaked if we had used a strong reference instead.

@lars-t-hansen
Copy link
Author

@erights, yes indeed - that's how it breaks down. Either you have a guarantee of executor execution when the object is gc'd (if it is gc'd), in which case you maintain state to track that, or you have the opposite problem, that there's uncertainty about whether your executor will be invoked. Which one of these you favor plausibly depends on usage patterns. If you're using your weakref for managing an external resource you really want the guarantee, so you'll end up anchoring the weakref in some external data structure to guarantee that it is reaped after the object it protects. I'm not completely sure which use case is well-served by the lack of the guarantee, but I don't doubt that there are some.

@littledan
Copy link
Member

I believe the current specification is based on the design that @lars-t-hansen started the thread with.

@erights
Copy link
Contributor

erights commented Apr 18, 2019

I believe the current specification is based on the design that @lars-t-hansen started the thread with.

When and how did that change? Do we now have the ironic cost I mentioned:

One irony is that this causes leakage that would not be leaked if we had used a strong reference instead.

Why is this guarantee useful in a system in which there is never any guarantee that any particular object is collected, or that the process will survive long enough for the eventually-called executor to actually be called? Does this extra utility pay for the extra cost?

@littledan
Copy link
Member

Sorry, I had a typo above--I meant, I don't believe the current specification is based on the design that @lars-t-hansen responded to. WeakRefs are not coupled to finalizers, and a WeakRef dying doesn't cause any sort of finalizer to be cancelled. So I think this issue is resolved; please reopen or file an other issue if there are remaining problems.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants