Skip to content
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

feat(core): Add ability to "ref" and "unref" pending ops #12889

Merged
merged 18 commits into from
Nov 25, 2021

Conversation

bartlomieju
Copy link
Member

@bartlomieju bartlomieju commented Nov 24, 2021

This commit adds an ability to "ref" or "unref" pending ops.

Up to this point Deno had a notion of "async ops" and "unref async ops";
the former keep event loop alive, while the latter do not block event loop
from finishing. It was not possible to change between op types after
dispatching, one had to decide which type to use before dispatch.

Instead of storing ops in two separate "FuturesUnordered" collections,
now ops are stored in a single collection, with supplemental "HashSet"
storing ids of promises that were "unrefed".

Two APIs were added to "Deno.core":

  • "Deno.core.refOp(promiseId)" which allows to mark promise id
    to be "refed" and keep event loop alive (the default behavior)
  • "Deno.core.unrefOp(promiseId)" which allows to mark promise
    id as "unrefed" which won't block event loop from exiting

Closes #12745

TODO:

  • metrics for unrefed ops (I think that unrefing ops should decrement ops_dispatched_async and increment ops_dispatched_async_unref, though it seems overly complicated)
  • cleanup handling of finished ops (remove AsyncOpIterator?)
  • add id field to promises returned from Deno.core.opcallAsync
  • add Deno.core.ref(...promiseIds) and Deno.core.unref(...promiseIds)

core/01_core.js Outdated
Comment on lines 133 to 141
function opAsync(opName, arg1 = null, arg2 = null, unref = false) {
const promiseId = nextPromiseId++;
const maybeError = opcallAsync(opsCache[opName], promiseId, arg1, arg2);
const maybeError = opcallAsync(
opsCache[opName],
promiseId,
arg1,
arg2,
unref,
);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was done so that ops that are unref by default don't have to call into Rust immediately after dispatch to mark them as "unrefed"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I probably would keep the old signature. The only "unref-by-default" ops are signal listeners and they're not a performance concern.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted.

core/runtime.rs Outdated Show resolved Hide resolved
@bartlomieju bartlomieju changed the title [WIP] refactor(core): handling of unref ops refactor(core): handling of unref ops Nov 24, 2021
@bartlomieju bartlomieju changed the title refactor(core): handling of unref ops feat(core): Add ability to "ref" and "unref" pending ops Nov 24, 2021
core/01_core.js Outdated
return PromisePrototypeThen(setPromise(promiseId), unwrapOpResult);
const p = PromisePrototypeThen(setPromise(promiseId), unwrapOpResult);
// Save the id on the promise so it can be latered ref'ed or unref'ed
p.id = promiseId;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered using a symbol here or a reverse WeakMap?
(I am not sure what that would do for performance, we'd have to measure that)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not, but using a symbol might be preferable, that way we can better communicate that users shouldn't use that value. I'll explore that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used Symbol.for("Deno.core.internalPromiseId") instead, let's see if performance is better or worse

/** Mark following promises as "ref", ie. event loop won't exit
* until they are resolved. All async ops are "ref" by default. */
function refOps(
...promiseIds: number[]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does it take multiple promiseIds?
I do understand that it is faster to coalesce multiple refOp calls into a single call, but I wonder how common that is, and also note that passing a single int into the binding layer is faster than passing an array.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In some resources we might have multiple pending ops associated with it. So instead of crossing boundary multiple times I thought it's better to do it in one go. Keep in mind that this is spread operator so you're passing multiple integers and not an array (Deno.core.refOps(1, 2, 3, 4)). Also is there a situation where this API would be used in a hot path?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep in mind that this is spread operator so you're passing multiple integers and not an array (Deno.core.refOps(1, 2, 3, 4))

I see, in the binding layer you're just iterating over FunctionCallbackArguments, that's pretty efficient and I have no concerns about it.

In some resources we might have multiple pending ops associated with it.

You'll find that this almost never happens in practice but oh well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you prefer to have it accept single ID that is fine for me as well. Your call

core/runtime.rs Outdated Show resolved Hide resolved
Copy link
Member

@piscisaureus piscisaureus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty good, some questions though.

@AaronO
Copy link
Contributor

AaronO commented Nov 24, 2021

Do we want all op(calls) to be unrefable ? I feel like it should be opt-in and take its distinct path (e.g: opUnref instead of overloading opAsync)

@piscisaureus
Copy link
Member

Do we want all op(calls) to be unrefable ? I feel like it should be opt-in and take its distinct path (e.g: opUnref instead of overloading opAsync)

I don't see a reason why we would be more restrictive about this than necessary.

@AaronO
Copy link
Contributor

AaronO commented Nov 24, 2021

Do we want all op(calls) to be unrefable ? I feel like it should be opt-in and take its distinct path (e.g: opUnref instead of overloading opAsync)

I don't see a reason why we would be more restrictive about this than necessary.

Well if in practice there's only a very small subset of ops/opcalls that may be unref'd then I think it's cleaner to have its own distinct implementation path so as not to overload the simple async opcall path

@bartlomieju
Copy link
Member Author

Do we want all op(calls) to be unrefable ? I feel like it should be opt-in and take its distinct path (e.g: opUnref instead of overloading opAsync)

I don't see a reason why we would be more restrictive about this than necessary.

Well if in practice there's only a very small subset of ops/opcalls that may be unref'd then I think it's cleaner to have its own distinct implementation path so as not to overload the simple async opcall path

I agree with Bert, I also believe it makes it more confusing to have distinct type of op for that purpose. Besides having different API there would be no upside IMO as we won't be able to validate if you are unrefing an op of that type. I'll be happy to revisit the topic after we add .ref()/.unref() to several APIs.

@AaronO
Copy link
Contributor

AaronO commented Nov 24, 2021

Do we want all op(calls) to be unrefable ? I feel like it should be opt-in and take its distinct path (e.g: opUnref instead of overloading opAsync)

I don't see a reason why we would be more restrictive about this than necessary.

Well if in practice there's only a very small subset of ops/opcalls that may be unref'd then I think it's cleaner to have its own distinct implementation path so as not to overload the simple async opcall path

I agree with Bert, I also believe it makes it more confusing to have distinct type of op for that purpose. Besides having different API there would be no upside IMO as we won't be able to validate if you are unrefing an op of that type. I'll be happy to revisit the topic after we add .ref()/.unref() to several APIs.

I'm not proposing a different kind of op rust-side just that the opCall JS side is explicit (killing op_async_unref is good IMO).

Do we want all op(calls) to be unrefable ? I feel like it should be opt-in and take its distinct path (e.g: opUnref instead of overloading opAsync)

I don't see a reason why we would be more restrictive about this than necessary.

It all depends on our call expectations but IMO:

  1. Deno.core.opAsync("op_foo", A, B, unref=true/false) makes most sense if unref is derived from end-user args/inputs
  2. Deno.core.opUnref("op_foo", A, B) makes more sense if the implementor decides if the opcall is unrefable

From my understanding unref=... shouldn't really be derived from user inputs, it will mostly be hardcoded by the implementor (ext/*/*.js code)

If that's the case I think Deno.core.opUnref is the better choice, it explicits the intention and is easier to search/scan.

I also think it's more ergonomic, the previous opAsync() sig maps quite naturally to the function sig, one could view it as a curried version of the opcall or Deno.core.ops.foo_bar(A, B) (which would be the API if we did Fast API), tagging an op-layer control value to the end of the args feels out of place

@bartlomieju
Copy link
Member Author

I'm not proposing a different kind of op rust-side just that the opCall JS side is explicit (killing op_async_unref is good IMO).

Okay, I misunderstood then. @AaronO I removed unref argument in Deno.core.opAsync; now you need to manually call Deno.core.unrefOps() afterwards. Is that okay for you?

@bartlomieju
Copy link
Member Author

Also I'd appreciate some feedback on how to tackle metrics - right now there are two uncalled APIs, this needs to be addressed before landing.

@@ -85,12 +85,14 @@ impl OpsTracker {
metrics.ops_completed_async += 1;
}

#[allow(unused)]
pub fn track_unref(&self, id: OpId) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just delete this, I guess?
@AaronO any opinions?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that's fine. Right now "unrefed" ops don't take part in opSanitizer but actually it might be preferable to include them.

core/01_core.js Outdated Show resolved Hide resolved
Copy link
Member

@piscisaureus piscisaureus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, let's land this and see what it does to the benchmarks.

Copy link
Contributor

@AaronO AaronO left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Contributor

@bnoordhuis bnoordhuis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work!

@@ -43,6 +44,7 @@
const RING_SIZE = 4 * 1024;
const NO_PROMISE = null; // Alias to null is faster than plain nulls
const promiseRing = ArrayPrototypeFill(new Array(RING_SIZE), NO_PROMISE);
const promiseIdSymbol = SymbolFor("Deno.core.internalPromiseId");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking but as a possible future improvement: you can use a v8::Private if you're worried about the symbol leaking out to user land code (it's visible to Object.getOwnPropertySymbols() after all.)

Private symbols are like regular symbols except more... er, private. (Great explanation, Ben. +1, would read again.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that construct available in JS land?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet, but I can add it. You may have seen node's getHiddenValue() and setHiddenValue() - it's the same mechanism.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet, but I can add it. You may have seen node's getHiddenValue() and setHiddenValue() - it's the same mechanism.

I haven't seen it. I guess I'll add a TODO and we can change it once the API is added.

core/bindings.rs Show resolved Hide resolved
core/ops_metrics.rs Show resolved Hide resolved
core/runtime.rs Outdated Show resolved Hide resolved
core/runtime.rs Outdated Show resolved Hide resolved
core/01_core.js Outdated Show resolved Hide resolved
}
};

state.unrefed_ops.insert(promise_id);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we check that promise_id is in state.pending_ops?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we'd have to iterate over all pending_ops killing the performance.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait, this is bindings.rs, it's not a hot path. I guess I can add a check here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually not possible to do that, there's no way to inspect promise_id when iterating pending_ops

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be possible once @AaronO moves promise ring to Rust (#12314)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the meantime, what probably can be done is change the unrefOp JS function to check whether the promise ID exists and hasn't resolved, right?

It would be pretty bad if some careless code added an already-resolved promise ID to unrefed_ops, since all that matters is the length compared to pending_ops. But I suppose CI would catch that, though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the meantime, what probably can be done is change the unrefOp JS function to check whether the promise ID exists and hasn't resolved, right?

That would require to adding a wrapper around Deno.core.unrefOp in core/01_core.js just to verify the promise map.

I'm not sure if it's worth doing that at this moment; I think we should try this API in the wild and see if that's a problem.

@bartlomieju bartlomieju merged commit f3c0f05 into denoland:main Nov 25, 2021
@bartlomieju bartlomieju deleted the unref_ops branch November 25, 2021 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add support "unref"ing pending ops
5 participants