diff options
author | Matt Mastracci <matthew@mastracci.com> | 2024-03-01 11:15:18 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-03-01 11:15:18 -0700 |
commit | 736b91edd002ae34082c473b37249f9256a6ba32 (patch) | |
tree | d08c1dfde5777fef622750b166bcf4dc947d22ba /ext | |
parent | 7ac040833025bf234dec485ddaa6c459b25d2196 (diff) |
perf(cli): use new deno_core timers (#22569)
Improves #19100
Fixes #20356
Replaces #20428
Changes made in deno_core to support this:
- [x] Errors must be handled in setTimeout callbacks
- [x] Microtask ordering is not-quite-right
- [x] Timer cancellation must be checked right before dispatch
- [x] Timer sanitizer
- [x] Move high-res timer to deno_core
- [x] Timers need opcall tracing
Diffstat (limited to 'ext')
-rw-r--r-- | ext/node/polyfills/timers.ts | 4 | ||||
-rw-r--r-- | ext/web/02_timers.js | 390 | ||||
-rw-r--r-- | ext/web/benches/timers_ops.rs | 3 | ||||
-rw-r--r-- | ext/web/hr_timer_lock.rs | 67 | ||||
-rw-r--r-- | ext/web/lib.rs | 9 | ||||
-rw-r--r-- | ext/web/timers.rs | 85 |
6 files changed, 67 insertions, 491 deletions
diff --git a/ext/node/polyfills/timers.ts b/ext/node/polyfills/timers.ts index d87a8b048..9a7d59ab2 100644 --- a/ext/node/polyfills/timers.ts +++ b/ext/node/polyfills/timers.ts @@ -21,7 +21,7 @@ import * as timers from "ext:deno_web/02_timers.js"; const clearTimeout_ = timers.clearTimeout; const clearInterval_ = timers.clearInterval; -const setTimeoutUnclamped = timers.setTimeoutUnclamped; +const setImmediate_ = timers.setImmediate; export function setTimeout( callback: (...args: unknown[]) => void, @@ -76,7 +76,7 @@ export function setImmediate( cb: (...args: unknown[]) => void, ...args: unknown[] ): Timeout { - return setTimeoutUnclamped(cb, 0, ...args); + return setImmediate_(cb, ...args); } export const clearImmediate = clearTimeout; diff --git a/ext/web/02_timers.js b/ext/web/02_timers.js index 8096c2ab5..e30071971 100644 --- a/ext/web/02_timers.js +++ b/ext/web/02_timers.js @@ -1,33 +1,18 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. import { core, primordials } from "ext:core/mod.js"; -import { - op_now, - op_sleep, - op_sleep_interval, - op_timer_handle, -} from "ext:core/ops"; +import { op_defer, op_now } from "ext:core/ops"; const { - ArrayPrototypePush, - ArrayPrototypeShift, - FunctionPrototypeCall, - MapPrototypeDelete, - MapPrototypeGet, - MapPrototypeHas, - MapPrototypeSet, Uint8Array, Uint32Array, PromisePrototypeThen, - SafeArrayIterator, - SafeMap, TypedArrayPrototypeGetBuffer, TypeError, indirectEval, + ReflectApply, } = primordials; import * as webidl from "ext:deno_webidl/00_webidl.js"; -import { reportException } from "ext:deno_web/02_event.js"; -import { assert } from "ext:deno_web/00_infra.js"; const hrU8 = new Uint8Array(8); const hr = new Uint32Array(TypedArrayPrototypeGetBuffer(hrU8)); @@ -38,371 +23,118 @@ function opNow() { // --------------------------------------------------------------------------- -/** - * The task queue corresponding to the timer task source. - * - * @type { {action: () => void, nestingLevel: number}[] } - */ -const timerTasks = []; - -/** - * The current task's timer nesting level, or zero if we're not currently - * running a timer task (since the minimum nesting level is 1). - * - * @type {number} - */ -let timerNestingLevel = 0; - -function handleTimerMacrotask() { - // We have no work to do, tell the runtime that we don't - // need to perform microtask checkpoint. - if (timerTasks.length === 0) { - return undefined; - } - - const task = ArrayPrototypeShift(timerTasks); - - timerNestingLevel = task.nestingLevel; - - try { - task.action(); - } finally { - timerNestingLevel = 0; +function checkThis(thisArg) { + if (thisArg !== null && thisArg !== undefined && thisArg !== globalThis) { + throw new TypeError("Illegal invocation"); } - return timerTasks.length === 0; } -// --------------------------------------------------------------------------- - /** - * The keys in this map correspond to the key ID's in the spec's map of active - * timers. The values are the timeout's cancel rid. - * - * @type {Map<number, { cancelRid: number, isRef: boolean, promise: Promise<void> }>} + * Call a callback function immediately. */ -const activeTimers = new SafeMap(); - -let nextId = 1; - -/** - * @param {Function | string} callback - * @param {number} timeout - * @param {Array<any>} args - * @param {boolean} repeat - * @param {number | undefined} prevId - * @returns {number} The timer ID - */ -function initializeTimer( - callback, - timeout, - args, - repeat, - prevId, - // TODO(bartlomieju): remove this option, once `nextTick` and `setImmediate` - // in Node compat are cleaned up - respectNesting = true, -) { - // 2. If previousId was given, let id be previousId; otherwise, let - // previousId be an implementation-defined integer than is greater than zero - // and does not already exist in global's map of active timers. - let id; - let timerInfo; - if (prevId !== undefined) { - // `prevId` is only passed for follow-up calls on intervals - assert(repeat); - id = prevId; - timerInfo = MapPrototypeGet(activeTimers, id); - } else { - // TODO(@andreubotella): Deal with overflow. - // https://github.com/whatwg/html/issues/7358 - id = nextId++; - const cancelRid = op_timer_handle(); - timerInfo = { cancelRid, isRef: true, promise: null }; - - // Step 4 in "run steps after a timeout". - MapPrototypeSet(activeTimers, id, timerInfo); +function setImmediate(callback, ...args) { + if (args.length > 0) { + const unboundCallback = callback; + callback = () => ReflectApply(unboundCallback, window, args); } - // 3. If the surrounding agent's event loop's currently running task is a - // task that was created by this algorithm, then let nesting level be the - // task's timer nesting level. Otherwise, let nesting level be zero. - // 4. If timeout is less than 0, then set timeout to 0. - // 5. If nesting level is greater than 5, and timeout is less than 4, then - // set timeout to 4. - // - // The nesting level of 5 and minimum of 4 ms are spec-mandated magic - // constants. - if (timeout < 0) timeout = 0; - if (timerNestingLevel > 5 && timeout < 4 && respectNesting) timeout = 4; - - // 9. Let task be a task that runs the following steps: - const task = { - action: () => { - // 1. If id does not exist in global's map of active timers, then abort - // these steps. - // - // This is relevant if the timer has been canceled after the sleep op - // resolves but before this task runs. - if (!MapPrototypeHas(activeTimers, id)) { - return; - } - - // 2. - // 3. - if (typeof callback === "function") { - try { - FunctionPrototypeCall( - callback, - globalThis, - ...new SafeArrayIterator(args), - ); - } catch (error) { - reportException(error); - } - } else { - indirectEval(callback); - } - - if (repeat) { - if (MapPrototypeHas(activeTimers, id)) { - // 4. If id does not exist in global's map of active timers, then - // abort these steps. - // NOTE: If might have been removed via the author code in handler - // calling clearTimeout() or clearInterval(). - // 5. If repeat is true, then perform the timer initialization steps - // again, given global, handler, timeout, arguments, true, and id. - initializeTimer(callback, timeout, args, true, id); - } - } else { - // 6. Otherwise, remove global's map of active timers[id]. - core.tryClose(timerInfo.cancelRid); - MapPrototypeDelete(activeTimers, id); - } - }, - - // 10. Increment nesting level by one. - // 11. Set task's timer nesting level to nesting level. - nestingLevel: timerNestingLevel + 1, - }; - - // 12. Let completionStep be an algorithm step which queues a global task on - // the timer task source given global to run task. - // 13. Run steps after a timeout given global, "setTimeout/setInterval", - // timeout, completionStep, and id. - runAfterTimeout( - task, - timeout, - timerInfo, - repeat, + return core.queueImmediate( + callback, ); - - return id; } -// --------------------------------------------------------------------------- - -/** - * @typedef ScheduledTimer - * @property {number} millis - * @property { {action: () => void, nestingLevel: number}[] } task - * @property {boolean} resolved - * @property {ScheduledTimer | null} prev - * @property {ScheduledTimer | null} next - */ - -/** - * A doubly linked list of timers. - * @type { { head: ScheduledTimer | null, tail: ScheduledTimer | null } } - */ -const scheduledTimers = { head: null, tail: null }; - /** - * @param { {action: () => void, nestingLevel: number}[] } task Will be run - * after the timeout, if it hasn't been cancelled. - * @param {number} millis - * @param {{ cancelRid: number, isRef: boolean, promise: Promise<void> }} timerInfo - * @param {boolean} repeat + * Call a callback function after a delay. */ -function runAfterTimeout(task, millis, timerInfo, repeat) { - const cancelRid = timerInfo.cancelRid; - const sleepPromise = repeat - ? op_sleep_interval(millis, cancelRid) - : op_sleep(millis, cancelRid); - timerInfo.promise = sleepPromise; - if (!timerInfo.isRef) { - core.unrefOpPromise(timerInfo.promise); - } - - /** @type {ScheduledTimer} */ - const timerObject = { - millis, - resolved: false, - prev: scheduledTimers.tail, - next: null, - task, - }; - - // Add timerObject to the end of the list. - if (scheduledTimers.tail === null) { - assert(scheduledTimers.head === null); - scheduledTimers.head = scheduledTimers.tail = timerObject; - } else { - scheduledTimers.tail.next = timerObject; - scheduledTimers.tail = timerObject; - } - - // 1. - PromisePrototypeThen( - sleepPromise, - (cancelled) => { - if (timerObject.resolved) { - return; - } - - // "op_void_async_deferred" returns null - if (cancelled !== null && !cancelled) { - // The timer was cancelled. - removeFromScheduledTimers(timerObject); - return; - } - // 2. Wait until any invocations of this algorithm that had the same - // global and orderingIdentifier, that started before this one, and - // whose milliseconds is equal to or less than this one's, have - // completed. - // 4. Perform completionSteps. - - // IMPORTANT: Since the sleep ops aren't guaranteed to resolve in the - // right order, whenever one resolves, we run through the scheduled - // timers list (which is in the order in which they were scheduled), and - // we call the callback for every timer which both: - // a) has resolved, and - // b) its timeout is lower than the lowest unresolved timeout found so - // far in the list. - - let currentEntry = scheduledTimers.head; - while (currentEntry !== null) { - if (currentEntry.millis <= timerObject.millis) { - currentEntry.resolved = true; - ArrayPrototypePush(timerTasks, currentEntry.task); - removeFromScheduledTimers(currentEntry); - - if (currentEntry === timerObject) { - break; - } - } - - currentEntry = currentEntry.next; - } - }, - ); -} - -/** @param {ScheduledTimer} timerObj */ -function removeFromScheduledTimers(timerObj) { - if (timerObj.prev !== null) { - timerObj.prev.next = timerObj.next; - } else { - assert(scheduledTimers.head === timerObj); - scheduledTimers.head = timerObj.next; - } - if (timerObj.next !== null) { - timerObj.next.prev = timerObj.prev; - } else { - assert(scheduledTimers.tail === timerObj); - scheduledTimers.tail = timerObj.prev; - } -} - -// --------------------------------------------------------------------------- - -function checkThis(thisArg) { - if (thisArg !== null && thisArg !== undefined && thisArg !== globalThis) { - throw new TypeError("Illegal invocation"); - } -} - function setTimeout(callback, timeout = 0, ...args) { checkThis(this); + // If callback is a string, replace it with a function that evals the string on every timeout if (typeof callback !== "function") { - callback = webidl.converters.DOMString(callback); + const unboundCallback = webidl.converters.DOMString(callback); + callback = () => indirectEval(unboundCallback); + } + if (args.length > 0) { + const unboundCallback = callback; + callback = () => ReflectApply(unboundCallback, window, args); } timeout = webidl.converters.long(timeout); - - return initializeTimer(callback, timeout, args, false); + return core.queueUserTimer( + core.getTimerDepth() + 1, + false, + timeout, + callback, + ); } +/** + * Call a callback function after a delay. + */ function setInterval(callback, timeout = 0, ...args) { checkThis(this); if (typeof callback !== "function") { - callback = webidl.converters.DOMString(callback); + const unboundCallback = webidl.converters.DOMString(callback); + callback = () => indirectEval(unboundCallback); } - timeout = webidl.converters.long(timeout); - - return initializeTimer(callback, timeout, args, true); -} - -// TODO(bartlomieju): remove this option, once `nextTick` and `setImmediate` -// in Node compat are cleaned up -function setTimeoutUnclamped(callback, timeout = 0, ...args) { - checkThis(this); - if (typeof callback !== "function") { - callback = webidl.converters.DOMString(callback); + if (args.length > 0) { + const unboundCallback = callback; + callback = () => ReflectApply(unboundCallback, window, args); } timeout = webidl.converters.long(timeout); - - return initializeTimer(callback, timeout, args, false, undefined, false); + return core.queueUserTimer( + core.getTimerDepth() + 1, + true, + timeout, + callback, + ); } +/** + * Clear a timeout or interval. + */ function clearTimeout(id = 0) { checkThis(this); id = webidl.converters.long(id); - const timerInfo = MapPrototypeGet(activeTimers, id); - if (timerInfo !== undefined) { - core.tryClose(timerInfo.cancelRid); - MapPrototypeDelete(activeTimers, id); - } + core.cancelTimer(id); } +/** + * Clear a timeout or interval. + */ function clearInterval(id = 0) { checkThis(this); - clearTimeout(id); + id = webidl.converters.long(id); + core.cancelTimer(id); } -function refTimer(id) { - const timerInfo = MapPrototypeGet(activeTimers, id); - if (timerInfo === undefined || timerInfo.isRef) { - return; - } - timerInfo.isRef = true; - core.refOpPromise(timerInfo.promise); +/** + * Mark a timer as not blocking event loop exit. + */ +function unrefTimer(id) { + core.unrefTimer(id); } -function unrefTimer(id) { - const timerInfo = MapPrototypeGet(activeTimers, id); - if (timerInfo === undefined || !timerInfo.isRef) { - return; - } - timerInfo.isRef = false; - core.unrefOpPromise(timerInfo.promise); +/** + * Mark a timer as blocking event loop exit. + */ +function refTimer(id) { + core.refTimer(id); } // Defer to avoid starving the event loop. Not using queueMicrotask() // for that reason: it lets promises make forward progress but can // still starve other parts of the event loop. function defer(go) { - // If we pass a delay of zero to op_sleep, it returns at the next event spin - PromisePrototypeThen(op_sleep(0, 0), () => go()); + PromisePrototypeThen(op_defer(), () => go()); } export { clearInterval, clearTimeout, defer, - handleTimerMacrotask, opNow, refTimer, + setImmediate, setInterval, setTimeout, - setTimeoutUnclamped, unrefTimer, }; diff --git a/ext/web/benches/timers_ops.rs b/ext/web/benches/timers_ops.rs index eda6d332e..d39ee4eea 100644 --- a/ext/web/benches/timers_ops.rs +++ b/ext/web/benches/timers_ops.rs @@ -21,9 +21,8 @@ fn setup() -> Vec<Extension> { esm_entry_point = "ext:bench_setup/setup", esm = ["ext:bench_setup/setup" = { source = r#" - import { setTimeout, handleTimerMacrotask } from "ext:deno_web/02_timers.js"; + import { setTimeout } from "ext:deno_web/02_timers.js"; globalThis.setTimeout = setTimeout; - Deno.core.setMacrotaskCallback(handleTimerMacrotask); "# }], state = |state| { diff --git a/ext/web/hr_timer_lock.rs b/ext/web/hr_timer_lock.rs deleted file mode 100644 index e21af5bc8..000000000 --- a/ext/web/hr_timer_lock.rs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -#[cfg(target_os = "windows")] -mod windows { - use std::marker::PhantomData; - use std::sync::atomic::AtomicU32; - - pub(crate) struct HrTimerLock { - pub(super) _unconstructable: PhantomData<()>, - } - - /// Decrease the reference count of the HR timer on drop. - impl Drop for HrTimerLock { - fn drop(&mut self) { - dec_ref(); - } - } - - /// Maintains the HR timer refcount. This should be more than sufficient as 2^32 timers would be - /// an impossible situation, and if it does somehow happen, the worst case is that we'll disable - /// the high-res timer when we shouldn't (and things would eventually return to proper operation). - static TIMER_REFCOUNT: AtomicU32 = AtomicU32::new(0); - - pub(super) fn inc_ref() { - let old = TIMER_REFCOUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - // Overflow/underflow sanity check in debug mode - debug_assert!(old != u32::MAX); - if old == 0 { - lock_hr(); - } - } - - fn dec_ref() { - let old = TIMER_REFCOUNT.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); - // Overflow/underflow sanity check in debug mode - debug_assert!(old != 0); - if old == 1 { - unlock_hr(); - } - } - - /// If the refcount is > 0, we ask Windows for a lower timer period once. While the underlying - /// Windows timeBeginPeriod/timeEndPeriod API can manage its own reference counts, we choose to - /// use it once per process and avoid nesting these calls. - fn lock_hr() { - // SAFETY: We just want to set the timer period here - unsafe { windows_sys::Win32::Media::timeBeginPeriod(1) }; - } - - fn unlock_hr() { - // SAFETY: We just want to set the timer period here - unsafe { windows_sys::Win32::Media::timeEndPeriod(1) }; - } -} - -#[cfg(target_os = "windows")] -pub(crate) fn hr_timer_lock() -> windows::HrTimerLock { - windows::inc_ref(); - windows::HrTimerLock { - _unconstructable: Default::default(), - } -} - -/// No-op on other platforms. -#[cfg(not(target_os = "windows"))] -pub(crate) fn hr_timer_lock() -> (std::marker::PhantomData<()>,) { - Default::default() -} diff --git a/ext/web/lib.rs b/ext/web/lib.rs index 0601aff24..332102fcc 100644 --- a/ext/web/lib.rs +++ b/ext/web/lib.rs @@ -2,7 +2,6 @@ mod blob; mod compression; -mod hr_timer_lock; mod message_port; mod stream_resource; mod timers; @@ -50,10 +49,8 @@ use crate::message_port::op_message_port_recv_message; pub use crate::message_port::JsMessageData; pub use crate::message_port::MessagePort; +use crate::timers::op_defer; use crate::timers::op_now; -use crate::timers::op_sleep; -use crate::timers::op_sleep_interval; -use crate::timers::op_timer_handle; use crate::timers::StartTime; pub use crate::timers::TimersPermission; @@ -85,9 +82,7 @@ deno_core::extension!(deno_web, compression::op_compression_write, compression::op_compression_finish, op_now<P>, - op_timer_handle, - op_sleep, - op_sleep_interval, + op_defer, op_transfer_arraybuffer, stream_resource::op_readable_stream_resource_allocate, stream_resource::op_readable_stream_resource_allocate_sized, diff --git a/ext/web/timers.rs b/ext/web/timers.rs index f01dcb860..8e2f94338 100644 --- a/ext/web/timers.rs +++ b/ext/web/timers.rs @@ -2,18 +2,8 @@ //! This module helps deno implement timers and performance APIs. -use crate::hr_timer_lock::hr_timer_lock; -use deno_core::error::AnyError; use deno_core::op2; -use deno_core::CancelFuture; -use deno_core::CancelHandle; use deno_core::OpState; -use deno_core::Resource; -use deno_core::ResourceId; -use std::borrow::Cow; -use std::cell::RefCell; -use std::rc::Rc; -use std::time::Duration; use std::time::Instant; pub trait TimersPermission { @@ -53,78 +43,5 @@ where buf[1] = subsec_nanos; } -pub struct TimerHandle(Rc<CancelHandle>); - -impl Resource for TimerHandle { - fn name(&self) -> Cow<str> { - "timer".into() - } - - fn close(self: Rc<Self>) { - self.0.cancel(); - } -} - -/// Creates a [`TimerHandle`] resource that can be used to cancel invocations of -/// [`op_sleep`]. -#[op2(fast)] -#[smi] -pub fn op_timer_handle(state: &mut OpState) -> ResourceId { - state - .resource_table - .add(TimerHandle(CancelHandle::new_rc())) -} - -/// Bifurcate the op_sleep op into an interval one we can use for sanitization purposes. #[op2(async(lazy), fast)] -pub async fn op_sleep_interval( - state: Rc<RefCell<OpState>>, - #[smi] millis: u64, - #[smi] rid: ResourceId, -) -> Result<bool, AnyError> { - op_sleep::call(state, millis, rid).await -} - -/// Waits asynchronously until either `millis` milliseconds have passed or the -/// [`TimerHandle`] resource given by `rid` has been canceled. -/// -/// If the timer is canceled, this returns `false`. Otherwise, it returns `true`. -#[op2(async(lazy), fast)] -pub async fn op_sleep( - state: Rc<RefCell<OpState>>, - #[smi] millis: u64, - #[smi] rid: ResourceId, -) -> Result<bool, AnyError> { - // If this timeout is scheduled for 0ms it means we want it to run at the - // end of the event loop turn. Since this is a lazy op, we can just return - // having already spun the event loop. - if millis == 0 { - return Ok(true); - } - - // If the timer is not present in the resource table it was cancelled before - // this op was polled. - let Ok(handle) = state.borrow().resource_table.get::<TimerHandle>(rid) else { - return Ok(false); - }; - - // If a timer is requested with <=100ms resolution, request the high-res timer. Since the default - // Windows timer period is 15ms, this means a 100ms timer could fire at 115ms (15% late). We assume that - // timers longer than 100ms are a reasonable cutoff here. - - // The high-res timers on Windows are still limited. Unfortunately this means that our shortest duration 4ms timers - // can still be 25% late, but without a more complex timer system or spinning on the clock itself, we're somewhat - // bounded by the OS' scheduler itself. - let _hr_timer_lock = if millis <= 100 { - Some(hr_timer_lock()) - } else { - None - }; - - let res = tokio::time::sleep(Duration::from_millis(millis)) - .or_cancel(handle.0.clone()) - .await; - - // We release the high-res timer lock here, either by being cancelled or resolving. - Ok(res.is_ok()) -} +pub async fn op_defer() {} |