-
Notifications
You must be signed in to change notification settings - Fork 212
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
change liveslots to use WeakRefs to track objects and report their disuse #2660
Comments
Liveslots now uses WeakRefs and a FinalizationRegistry to track the state of each import: UNKNOWN -> REACHABLE -> UNREACHABLE -> COLLECTED -> FINALIZED -> UNKNOWN. Reintroduction can move it from UNREACHABLE/COLLECTED/FINALIZED back to REACHABLE at any time. Liveslots maintains a local `deadSet` that contains all the vrefs which are in the FINALIZED state. They will remain in that state (and in `deadSet`) until a later change which uses `syscall.dropImports` to inform the kernel, and remove them from `deadSet`. Promises are retained until resolved+retired, even if userspace somehow drops all references to them. We might do better in the future, but the story is a lot more complicated than it is for Presences. Exported Remotables are still retained indefinitely. A later change (#2664) will wire `dropExports()` up to drop them. refs #2660
Liveslots now uses WeakRefs and a FinalizationRegistry to track the state of each import: UNKNOWN -> REACHABLE -> UNREACHABLE -> COLLECTED -> FINALIZED -> UNKNOWN. Reintroduction can move it from UNREACHABLE/COLLECTED/FINALIZED back to REACHABLE at any time. Liveslots maintains a local `deadSet` that contains all the vrefs which are in the FINALIZED state. They will remain in that state (and in `deadSet`) until a later change which uses `syscall.dropImports` to inform the kernel, and remove them from `deadSet`. Promises are retained until resolved+retired, even if userspace somehow drops all references to them. We might do better in the future, but the story is a lot more complicated than it is for Presences. Exported Remotables are still retained indefinitely. A later change (#2664) will wire `dropExports()` up to drop them. refs #2660
Liveslots now uses WeakRefs and a FinalizationRegistry to track the state of each import: UNKNOWN -> REACHABLE -> UNREACHABLE -> COLLECTED -> FINALIZED -> UNKNOWN. Reintroduction can move it from UNREACHABLE/COLLECTED/FINALIZED back to REACHABLE at any time. Liveslots maintains a local `deadSet` that contains all the vrefs which are in the FINALIZED state. They will remain in that state (and in `deadSet`) until a later change which uses `syscall.dropImports` to inform the kernel, and remove them from `deadSet`. Promises are retained until resolved+retired, even if userspace somehow drops all references to them. We might do better in the future, but the story is a lot more complicated than it is for Presences. Exported Remotables are still retained indefinitely. A later change (#2664) will wire `dropExports()` up to drop them. refs #2660
Modify xsnap.c to add a `gc()` function to the globals of the initial ("start") Compartment. This function should trigger an immediate, synchronous, full GC sweep. As a non-standard global, the `gc()` function will be filtered out of the globals in all child Compartments by SES as usual. Note that this changes the snapshot format: heap snapshots written before this change cannot be read by code after this change. This happens because `gc()` (which is implemented in C) is a new "callback" (a C function made available to JS code), which is an "exit" from the reference graph. It must be recognized during serialization, and re-attached during reload, and xsnap cannot handle loading snapshots with a different set of exits, even purely additive changes. closes #2682 refs #2660 refs #2615
Modify xsnap.c to add a `gc()` function to the globals of the initial ("start") Compartment. This function should trigger an immediate, synchronous, full GC sweep. As a non-standard global, the `gc()` function will be filtered out of the globals in all child Compartments by SES as usual. Note that this changes the snapshot format: heap snapshots written before this change cannot be read by code after this change. This happens because `gc()` (which is implemented in C) is a new "callback" (a C function made available to JS code), which is an "exit" from the reference graph. It must be recognized during serialization, and re-attached during reload, and xsnap cannot handle loading snapshots with a different set of exits, even purely additive changes. closes #2682 refs #2660 refs #2615
Ok so the second part of this is going to look rather different to work with XS. In Node, finalizer callbacks are either pushed onto the promise/micro-task queue, or they're pushed onto some other queue that's higher priority than whatever In XS, finalizer callbacks aren't queued. Instead, they're called inline from a function named
where So XS finalizers obey the JS rules that they run in their own turn, but it doesn't schedule those turns in a place where JS code can wait for them: they only run as the engine is returning control back to the host application (after the outermost invocation has finished: in fact as it is finishing). So, that kind of scuttles the plan to let liveslots drive things. I can think of a couple of approaches:
The downside of the last approach is latency, we're sending an extra (although small) message over the pipe for each crank. The downside of the last two approaches is the lack of parallelism with Node.js, and it might be tempting to use |
Is it an option to add a downcall from JS to say "send me your finalization notes now"? |
The spec says that the finalizers are called in separate turns. There was a part of the proposed API for enable some finalization during a turn, but IIRC that was dropped from the proposal before it advanced in tc39. I just checked my Node v14 and it does not implement that extra API. |
That's the part of our original WeakRef proposal that did not survive tc39. |
I've been chatting with Peter, he suggested a simple two-line change that would run the finalizers every time the promise queue is drained, which would let us stick with the simpler approach. Each time the kernel process sends over a message on the pipe, we execute that message (evaluate its string in the right context), then call a function named agoric-sdk/packages/xsnap/src/xsnap.c Lines 1047 to 1090 in b5bda04
It basically does: while True:
while not promiseQueueIsEmpty:
processPromiseQueue()
if not someTimerJobsAreReady:
break
while someTimerJobsAreReady:
processReadyTimers() What we're looking at is to let all finalizers run at the beginning of the loop, before we examine the promise queue. (His original suggestion was to run them after processing the promise queue but before the timers.. I figure earlier is better, in case a finalizer callback pushes something onto the promise queue): while True:
processAllFinalizationRegistries() # new
while not promiseQueueIsEmpty:
processPromiseQueue()
if not someTimerJobsAreReady:
break
while someTimerJobsAreReady:
processReadyTimers() That will run finalizers before running promises, which is aggressive and .. different, but I think still within spec, and I don't think it would cause us any problems. And it would allow a simple The specific change to xsBeginHost(the);
xsEndHost(the); pair at the top of the This wouldn't trigger GC by itself: we still have to make an explicit I don't know what sort of impact this will have on performance. The code I'm looking at is: and it looks like it walks through every registered object every time it is polled, testing each |
Liveslots now uses WeakRefs and a FinalizationRegistry to track the state of each import: UNKNOWN -> REACHABLE -> UNREACHABLE -> COLLECTED -> FINALIZED -> UNKNOWN. Reintroduction can move it from UNREACHABLE/COLLECTED/FINALIZED back to REACHABLE at any time. Liveslots maintains a local `deadSet` that contains all the vrefs which are in the FINALIZED state. They will remain in that state (and in `deadSet`) until a later change which uses `syscall.dropImports` to inform the kernel, and remove them from `deadSet`. We remove imported objects from the deadSet if/when they are re-introduced. Promises are retained until resolved+retired, even if userspace somehow drops all references to them. We might do better in the future, but the story is a lot more complicated than it is for Presences. Exported Remotables are still retained indefinitely. A later change (#2664) will wire `dropExports()` up to drop them. We only register finalizers for imported objects: not imported promises, and not exports of any flavor. Liveslots is not yet calling syscall.dropImports, but by mocking WeakRef and FinalizationRegistry, we can test to make sure it updates the deadSet correctly. refs #2660
Liveslots now uses WeakRefs and a FinalizationRegistry to track the state of each import: UNKNOWN -> REACHABLE -> UNREACHABLE -> COLLECTED -> FINALIZED -> UNKNOWN. Reintroduction can move it from UNREACHABLE/COLLECTED/FINALIZED back to REACHABLE at any time. Liveslots maintains a local `deadSet` that contains all the vrefs which are in the FINALIZED state. They will remain in that state (and in `deadSet`) until a later change which uses `syscall.dropImports` to inform the kernel, and remove them from `deadSet`. We remove imported objects from the deadSet if/when they are re-introduced. Promises are retained until resolved+retired, even if userspace somehow drops all references to them. We might do better in the future, but the story is a lot more complicated than it is for Presences. Exported Remotables are still retained indefinitely. A later change (#2664) will wire `dropExports()` up to drop them. We only register finalizers for imported objects: not imported promises, and not exports of any flavor. Liveslots is not yet calling syscall.dropImports, but by mocking WeakRef and FinalizationRegistry, we can test to make sure it updates the deadSet correctly. refs #2660
Liveslots now uses WeakRefs and a FinalizationRegistry to track the state of each import: UNKNOWN -> REACHABLE -> UNREACHABLE -> COLLECTED -> FINALIZED -> UNKNOWN. Reintroduction can move it from UNREACHABLE/COLLECTED/FINALIZED back to REACHABLE at any time. Liveslots maintains a local `deadSet` that contains all the vrefs which are in the FINALIZED state. They will remain in that state (and in `deadSet`) until a later change which uses `syscall.dropImports` to inform the kernel, and remove them from `deadSet`. We remove imported objects from the deadSet if/when they are re-introduced. Promises are retained until resolved+retired, even if userspace somehow drops all references to them. We might do better in the future, but the story is a lot more complicated than it is for Presences. Exported Remotables are still retained indefinitely. A later change (#2664) will wire `dropExports()` up to drop them. We only register finalizers for imported objects: not imported promises, and not exports of any flavor. Liveslots is not yet calling syscall.dropImports, but by mocking WeakRef and FinalizationRegistry, we can test to make sure it updates the deadSet correctly. refs #2660
Liveslots now uses WeakRefs and a FinalizationRegistry to track the state of each import: UNKNOWN -> REACHABLE -> UNREACHABLE -> COLLECTED -> FINALIZED -> UNKNOWN. Reintroduction can move it from UNREACHABLE/COLLECTED/FINALIZED back to REACHABLE at any time. Liveslots maintains a local `deadSet` that contains all the vrefs which are in the FINALIZED state. They will remain in that state (and in `deadSet`) until a later change which uses `syscall.dropImports` to inform the kernel, and remove them from `deadSet`. We remove imported objects from the deadSet if/when they are re-introduced. Promises are retained until resolved+retired, even if userspace somehow drops all references to them. We might do better in the future, but the story is a lot more complicated than it is for Presences. Exported Remotables are still retained indefinitely. A later change (#2664) will wire `dropExports()` up to drop them. We only register finalizers for imported objects: not imported promises, and not exports of any flavor. Liveslots is not yet calling syscall.dropImports, but by mocking WeakRef and FinalizationRegistry, we can test to make sure it updates the deadSet correctly. refs #2660
`dispatch()`, the low-level interface to each vat (generally provided by liveslots), is now async. Vats are responsible for not resolving the promise returned by `dispatch()` until the user-level code has finished running and the crank is complete. Vats are given `waitUntilQuiescent` in their `gcTools` argument to facilitate this. This will make it possible for liveslots to run `gc()` and wait long enough to give finalizers a chance to run (and then call `dropImports`) before the crank is considered complete. closes #2671 refs #2660
`dispatch()`, the low-level interface to each vat (generally provided by liveslots), is now async. Vats are responsible for not resolving the promise returned by `dispatch()` until the user-level code has finished running and the crank is complete. Vats are given `waitUntilQuiescent` in their `gcTools` argument to facilitate this. This will make it possible for liveslots to run `gc()` and wait long enough to give finalizers a chance to run (and then call `dropImports`) before the crank is considered complete. closes #2671 refs #2660
Peter suggested a different fix a few weeks later. He found out that for (;;) {
while (the->promiseJobs) {
the->promiseJobs = 0;
fxRunPromiseJobs(the);
}
c_gettimeofday(&tv, NULL);
... can become: for (;;) {
while (the->promiseJobs) {
while (the->promiseJobs) {
the->promiseJobs = 0;
fxRunPromiseJobs(the);
}
fxEndJob(the);
}
c_gettimeofday(&tv, NULL);
... I think this will behave like: while True:
while not promiseQueueIsEmpty:
while not promiseQueueIsEmpty:
processPromiseQueue()
runFinalizers()
if not someTimerJobsAreReady:
break
processFirstReadyTimer() Our front-of-crank sequence is to queue up the Then our mid-crank pause happens. I found a async function gcAndFinalize() {
if (typeof gc !== 'function') {
console.log(`unable to gc(), skipping`);
return;
}
// on Node.js, GC seems to work better if the promise queue is empty first
await new Promise(setImmediate);
// on xsnap, we must do it twice for some reason
await new Promise(setImmediate);
gc();
// this gives finalizers a chance to run
await new Promise(setImmediate);
} This drains the promise queue (twice, without that xsnap didn't work), the last queue entry will call Once that is complete, the end-of-crank sequence can proceed, which looks at the finalizer results and makes a bunch of GC syscalls. |
This adds a platform-specific `gcAndFinalize()` function, which returns a Promise that resolves when GC has been provoked and FinalizationRegistry callbacks have had a chance to run. On Node.js, the application must be run with --expose-gc . On `xsnap`, a small change was made to the run loop to let finalizers run before after the promise queue is empty and before timer events run. refs #2660
This adds a platform-specific `gcAndFinalize()` function, which returns a Promise that resolves when GC has been provoked and FinalizationRegistry callbacks have had a chance to run. On Node.js, the application must be run with --expose-gc . On `xsnap`, a small change was made to the run loop to let finalizers run before after the promise queue is empty and before timer events run. refs #2660
This adds a platform-specific `gcAndFinalize()` function, which returns a Promise that resolves when GC has been provoked and FinalizationRegistry callbacks have had a chance to run. On Node.js, the application must be run with --expose-gc . On `xsnap`, a small change was made to the run loop to let finalizers run before after the promise queue is empty and before timer events run. refs #2660
This adds a platform-specific `gcAndFinalize()` function, which returns a Promise that resolves when GC has been provoked and FinalizationRegistry callbacks have had a chance to run. On Node.js, the application must be run with --expose-gc . On `xsnap`, a small change was made to the run loop to let finalizers run before after the promise queue is empty and before timer events run. refs #2660
This changes the xsnap.c run loop to give finalizers a chance to run just after the promise queue drains. With this change, userspace can do a combination of `gc()` and `setImmediate` that lets it provoke a full GC sweep, and wait until finalizers have run. SwingSet will use this during a crank, after userspace has become idle, and before end-of-crank GC processing takes place. This combination is implemented in a function named `gcAndFinalize()`. We copy this function from its normal home in SwingSet so the xsnap.c behavior it depends upon can be tested locally. refs #2660
This adds a platform-specific `gcAndFinalize()` function, which returns a Promise that resolves when GC has been provoked and FinalizationRegistry callbacks have had a chance to run. On Node.js, the application must be run with --expose-gc . refs #2660
This changes the xsnap.c run loop to give finalizers a chance to run just after the promise queue drains. With this change, userspace can do a combination of `gc()` and `setImmediate` that lets it provoke a full GC sweep, and wait until finalizers have run. SwingSet will use this during a crank, after userspace has become idle, and before end-of-crank GC processing takes place. This combination is implemented in a function named `gcAndFinalize()`. We copy this function from its normal home in SwingSet so the xsnap.c behavior it depends upon can be tested locally. refs #2660
This adds a platform-specific `gcAndFinalize()` function, which returns a Promise that resolves when GC has been provoked and FinalizationRegistry callbacks have had a chance to run. On Node.js, the application must be run with --expose-gc . refs #2660
This changes the xsnap.c run loop to give finalizers a chance to run just after the promise queue drains. With this change, userspace can do a combination of `gc()` and `setImmediate` that lets it provoke a full GC sweep, and wait until finalizers have run. SwingSet will use this during a crank, after userspace has become idle, and before end-of-crank GC processing takes place. This combination is implemented in a function named `gcAndFinalize()`. We copy this function from its normal home in SwingSet so the xsnap.c behavior it depends upon can be tested locally. refs #2660
This adds a platform-specific `gcAndFinalize()` function, which returns a Promise that resolves when GC has been provoked and FinalizationRegistry callbacks have had a chance to run. On Node.js, the application must be run with --expose-gc . refs #2660
This changes the xsnap.c run loop to give finalizers a chance to run just after the promise queue drains. With this change, userspace can do a combination of `gc()` and `setImmediate` that lets it provoke a full GC sweep, and wait until finalizers have run. SwingSet will use this during a crank, after userspace has become idle, and before end-of-crank GC processing takes place. This combination is implemented in a function named `gcAndFinalize()`. We copy this function from its normal home in SwingSet so the xsnap.c behavior it depends upon can be tested locally. refs #2660
This adds a platform-specific `gcAndFinalize()` function, which returns a Promise that resolves when GC has been provoked and FinalizationRegistry callbacks have had a chance to run. On Node.js, the application must be run with --expose-gc . refs #2660
This changes the xsnap.c run loop to give finalizers a chance to run just after the promise queue drains. With this change, userspace can do a combination of `gc()` and `setImmediate` that lets it provoke a full GC sweep, and wait until finalizers have run. SwingSet will use this during a crank, after userspace has become idle, and before end-of-crank GC processing takes place. This combination is implemented in a function named `gcAndFinalize()`. We copy this function from its normal home in SwingSet so the xsnap.c behavior it depends upon can be tested locally. refs #2660
This adds a platform-specific `gcAndFinalize()` function, which returns a Promise that resolves when GC has been provoked and FinalizationRegistry callbacks have had a chance to run. On Node.js, the application must be run with --expose-gc . refs #2660
Consensus mode will depend upon GC being deterministic, but solo mode does not. Solo mode requires GC be "sufficiently deterministic", which means a finalizer may or may not run in any given crank. To support this, we must not record the GC-related syscalls (dropImport, retireImport, retireExport) in the transcript. When replaying a transcript, we ignore these syscalls as well. closes #3146 refs #2615 refs #2660 refs #2724
Consensus mode will depend upon GC being deterministic, but solo mode does not. Solo mode requires GC be "sufficiently deterministic", which means a finalizer may or may not run in any given crank. To support this, we must not record the GC-related syscalls (dropImport, retireImport, retireExport) in the transcript. When replaying a transcript, we ignore these syscalls as well. closes #3146 refs #2615 refs #2660 refs #2724
Consensus mode will depend upon GC being deterministic, but solo mode does not. Solo mode requires GC be "sufficiently deterministic", which means a finalizer may or may not run in any given crank. To support this, we must not record the GC-related syscalls (dropImport, retireImport, retireExport) in the transcript. When replaying a transcript, we ignore these syscalls as well. closes #3146 refs #2615 refs #2660 refs #2724
Change liveslots to provoke GC at mid-crank, then process the "dead set" and emit `syscall.dropImports` for everything we can. For now, we conservatively assume that everything remains recognizable, so we do *not* emit `syscall.retireImport` for anything. The vref might be used as a WeakMap/WeakSet key, and the VOM isn't yet tracking those. When #3161 is done, we'll change liveslots to ask the VOM about each vref, and retire the ones it does not know how to recognize (which will hopefully be the majority of them). closes #3147 refs #2615 refs #2660
Change liveslots to provoke GC at mid-crank, then process the "dead set" and emit `syscall.dropImports` for everything we can. For now, we conservatively assume that everything remains recognizable, so we do *not* emit `syscall.retireImport` for anything. The vref might be used as a WeakMap/WeakSet key, and the VOM isn't yet tracking those. When #3161 is done, we'll change liveslots to ask the VOM about each vref, and retire the ones it does not know how to recognize (which will hopefully be the majority of them). closes #3147 refs #2615 refs #2660
What is the Problem Being Solved?
The next phase of #2615 is to enhance liveslots to be able to sense imports becoming unused. This requires changes to
slotToVal
andvalToSlot
, to use WeakRefs and FinalizationRegistries. We need to not retain a strong reference to imports. And we must notice (and report) dropped imports at the right time.Description of the Design
There are two parts to this. The first part is to change the tables to use a WeakRef, which requires the creation of an
exports
table to keep a strong reference to exports now thatslotToVal
is no longer doing so. The notes in #1872 (comment) (and the subsequent comments about Promises) apply, as well as the task list in #1872 (comment), however I think we no longer need a counter. This first part only needs to continue to behave correctly: it does not need to actually create a FinalizationRegistry, or at least it does not need to pay attention to the results.The second part is to use the FR to report a
syscall.dropImports
at the right time. This will require changes to the way liveslots is run. First, we need to provide agc()
function to liveslots, so it can provoke engine-level GC towards the end of the crank. We can live without this (e.g. in a non-consensus solo machine, under Node.js, we might not give itgc()
, and merely rely upon organic GC calls), at the expense of non-deterministicdropImports
calls, and also that vats won't be able to drop their imports until the next time they're called after GC happens (which might take hours or days). Liveslots must tolerategc()
being empty, and we'll need higher-level flags to indicate whether we're supposed to be in a deterministic consensus mode (related to #2519) or not.Previously, liveslots was not given
setImmediate
orwaitForQuiescent
, and the vat supervisor gets control as soon as liveslots+userspace becomes idle. In the new approach, liveslots needs to get control when userspace becomes idle, so it can callgc()
and then deliberately allow the IO queue to be serviced so that finalizers get a chance to run. This means we'll need to passwaitForQuiescent
into liveslots. I think this means the calls into liveslots (dispatch.deliver
/etc) should return a Promise, and the supervisor should simply wait for that Promise to fire. But we need to look carefully at the supervisor used byxsnap
and make sure whatever it is waiting for is compatible with this, as I think it waits directly for the underlying engine to become idle, rather than approximating that check withsetImmediate
.Security Considerations
Test Plan
Testing the execution of
gc()
is thorny. Under XS it will probably be determinisic (and we're certainly depending upon it to be so), but under Node.js it's no so clear.I have a test from an earlier branch that seemed to work correctly about 50% of the time, and silently failed to do anything useful the other 50%, which is 100% better than nothing. I'll start with that.
The text was updated successfully, but these errors were encountered: