[Scheduler] Integrated timers (#15911)

Adds a `delay` option to `scheduleCallback`. When specified, the task is
not scheduled until after the delay has elapsed.

Delayed tasks are scheduled on a timer queue maintained by Scheduler,
instead of directly with the browser. The main benefit is to reduce the
number of native browser timers that Scheduler's `message` event handler
has to contend with; so, after yielding to the browser at the end of the
frame, Scheduler will more quickly regain control of the main thread.
Because we're able to flush the timer queue without yielding to browser
timer events, there's also less task switching overhead (though in the
absence of `isInputPending`, this is mostly a theoretical win since we
yield every frame regardless).

If the queue of non-delayed tasks is non-empty — that is, if there is
pending CPU bound work — Scheduler is able to avoid a browser timer
entirely by periodically checking its own timer queue while flushing
tasks (inside the `message` event handler). Once the CPU-bound work is
complete, if there are still pending delayed tasks, Scheduler will
schedule a single browser timer that fires once the earliest delay
has elapsed.
This commit is contained in:
Andrew Clark
2019-06-19 16:05:07 -07:00
committed by GitHub
parent 3af91eb8ce
commit 35ef78de3e
4 changed files with 406 additions and 40 deletions
+168 -40
View File
@@ -11,6 +11,8 @@
import {enableSchedulerDebugging} from './SchedulerFeatureFlags';
import {
requestHostCallback,
requestHostTimeout,
cancelHostTimeout,
shouldYieldToHost,
getCurrentTime,
forceFrameRate,
@@ -39,6 +41,7 @@ var IDLE_PRIORITY = maxSigned31BitInt;
// Tasks are stored as a circular, doubly linked list.
var firstTask = null;
var firstDelayedTask = null;
// Pausing the scheduler is useful for debugging.
var isSchedulerPaused = false;
@@ -50,6 +53,7 @@ var currentPriorityLevel = NormalPriority;
var isPerformingWork = false;
var isHostCallbackScheduled = false;
var isHostTimeoutScheduled = false;
function flushTask(task, currentTime) {
// Remove the task from the list before calling the callback. That way the
@@ -135,14 +139,62 @@ function flushTask(task, currentTime) {
}
}
function advanceTimers(currentTime) {
// Check for tasks that are no longer delayed and add them to the queue.
if (firstDelayedTask !== null && firstDelayedTask.startTime <= currentTime) {
do {
const task = firstDelayedTask;
const next = task.next;
if (task === next) {
firstDelayedTask = null;
} else {
firstDelayedTask = next;
const previous = task.previous;
previous.next = next;
next.previous = previous;
}
task.next = task.previous = null;
insertScheduledTask(task, task.expirationTime);
} while (
firstDelayedTask !== null &&
firstDelayedTask.startTime <= currentTime
);
}
}
function handleTimeout(currentTime) {
isHostTimeoutScheduled = false;
advanceTimers(currentTime);
if (!isHostCallbackScheduled) {
if (firstTask !== null) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
} else if (firstDelayedTask !== null) {
requestHostTimeout(
handleTimeout,
firstDelayedTask.startTime - currentTime,
);
}
}
}
function flushWork(hasTimeRemaining, initialTime) {
// Exit right away if we're currently paused
if (enableSchedulerDebugging && isSchedulerPaused) {
return;
}
// We'll need a new host callback the next time work is scheduled.
// We'll need a host callback the next time work is scheduled.
isHostCallbackScheduled = false;
if (isHostTimeoutScheduled) {
// We scheduled a timeout but it's no longer needed. Cancel it.
isHostTimeoutScheduled = false;
cancelHostTimeout();
}
let currentTime = initialTime;
advanceTimers(currentTime);
isPerformingWork = true;
try {
@@ -150,7 +202,6 @@ function flushWork(hasTimeRemaining, initialTime) {
// Flush all the expired callbacks without yielding.
// TODO: Split flushWork into two separate functions instead of using
// a boolean argument?
let currentTime = initialTime;
while (
firstTask !== null &&
firstTask.expirationTime <= currentTime &&
@@ -158,14 +209,15 @@ function flushWork(hasTimeRemaining, initialTime) {
) {
flushTask(firstTask, currentTime);
currentTime = getCurrentTime();
advanceTimers(currentTime);
}
} else {
// Keep flushing callbacks until we run out of time in the frame.
let currentTime = initialTime;
if (firstTask !== null) {
do {
flushTask(firstTask, currentTime);
currentTime = getCurrentTime();
advanceTimers(currentTime);
} while (
firstTask !== null &&
!shouldYieldToHost() &&
@@ -174,7 +226,17 @@ function flushWork(hasTimeRemaining, initialTime) {
}
}
// Return whether there's additional work
return firstTask !== null;
if (firstTask !== null) {
return true;
} else {
if (firstDelayedTask !== null) {
requestHostTimeout(
handleTimeout,
firstDelayedTask.startTime - currentTime,
);
}
return false;
}
} finally {
isPerformingWork = false;
}
@@ -242,34 +304,41 @@ function unstable_wrapCallback(callback) {
};
}
function unstable_scheduleCallback(priorityLevel, callback, options) {
var startTime = getCurrentTime();
function timeoutForPriorityLevel(priorityLevel) {
switch (priorityLevel) {
case ImmediatePriority:
return IMMEDIATE_PRIORITY_TIMEOUT;
case UserBlockingPriority:
return USER_BLOCKING_PRIORITY;
case IdlePriority:
return IDLE_PRIORITY;
case LowPriority:
return LOW_PRIORITY_TIMEOUT;
case NormalPriority:
default:
return NORMAL_PRIORITY_TIMEOUT;
}
}
function unstable_scheduleCallback(priorityLevel, callback, options) {
var currentTime = getCurrentTime();
var startTime;
var timeout;
if (
typeof options === 'object' &&
options !== null &&
typeof options.timeout === 'number'
) {
timeout = options.timeout;
} else {
switch (priorityLevel) {
case ImmediatePriority:
timeout = IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
timeout = USER_BLOCKING_PRIORITY;
break;
case IdlePriority:
timeout = IDLE_PRIORITY;
break;
case LowPriority:
timeout = LOW_PRIORITY_TIMEOUT;
break;
case NormalPriority:
default:
timeout = NORMAL_PRIORITY_TIMEOUT;
if (typeof options === 'object' && options !== null) {
var delay = options.delay;
if (typeof delay === 'number' && delay > 0) {
startTime = currentTime + delay;
} else {
startTime = currentTime;
}
timeout =
typeof options.timeout === 'number'
? options.timeout
: timeoutForPriorityLevel(priorityLevel);
} else {
timeout = timeoutForPriorityLevel(priorityLevel);
startTime = currentTime;
}
var expirationTime = startTime + timeout;
@@ -283,6 +352,34 @@ function unstable_scheduleCallback(priorityLevel, callback, options) {
previous: null,
};
if (startTime > currentTime) {
// This is a delayed task.
insertDelayedTask(newTask, startTime);
if (firstTask === null && firstDelayedTask === newTask) {
// All tasks are delayed, and this is the task with the earliest delay.
if (isHostTimeoutScheduled) {
// Cancel an existing timeout.
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
// Schedule a timeout.
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
insertScheduledTask(newTask, expirationTime);
// Schedule a host callback, if needed. If we're already performing work,
// wait until the next time we yield.
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
}
}
return newTask;
}
function insertScheduledTask(newTask, expirationTime) {
// Insert the new task into the list, ordered first by its timeout, then by
// insertion. So the new task is inserted after any other task the
// same timeout
@@ -315,15 +412,39 @@ function unstable_scheduleCallback(priorityLevel, callback, options) {
newTask.next = next;
newTask.previous = previous;
}
}
// Schedule a host callback, if needed. If we're already performing work, wait
// until the next time we yield.
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
function insertDelayedTask(newTask, startTime) {
// Insert the new task into the list, ordered by its start time.
if (firstDelayedTask === null) {
// This is the first task in the list.
firstDelayedTask = newTask.next = newTask.previous = newTask;
} else {
var next = null;
var task = firstDelayedTask;
do {
if (startTime < task.startTime) {
// The new task times out before this one.
next = task;
break;
}
task = task.next;
} while (task !== firstDelayedTask);
if (next === null) {
// No task with a later timeout was found, which means the new task has
// the latest timeout in the list.
next = firstDelayedTask;
} else if (next === firstDelayedTask) {
// The new task has the earliest expiration in the entire list.
firstDelayedTask = newTask;
}
var previous = next.previous;
previous.next = next.previous = newTask;
newTask.next = next;
newTask.previous = previous;
}
return newTask;
}
function unstable_pauseExecution() {
@@ -349,13 +470,17 @@ function unstable_cancelCallback(task) {
return;
}
if (next === task) {
// This is the only scheduled task. Clear the list.
firstTask = null;
if (task === next) {
if (task === firstTask) {
firstTask = null;
} else if (task === firstDelayedTask) {
firstDelayedTask = null;
}
} else {
// Remove the task from its position in the list.
if (task === firstTask) {
firstTask = next;
} else if (task === firstDelayedTask) {
firstDelayedTask = next;
}
var previous = task.previous;
previous.next = next;
@@ -370,9 +495,12 @@ function unstable_getCurrentPriorityLevel() {
}
function unstable_shouldYield() {
const currentTime = getCurrentTime();
advanceTimers(currentTime);
return (
(currentTask !== null &&
firstTask !== null &&
firstTask.startTime <= currentTime &&
firstTask.expirationTime < currentTask.expirationTime) ||
shouldYieldToHost()
);
@@ -395,4 +395,201 @@ describe('Scheduler', () => {
ImmediatePriority,
]);
});
describe('delayed tasks', () => {
it('schedules a delayed task', () => {
scheduleCallback(NormalPriority, () => Scheduler.yieldValue('A'), {
delay: 1000,
});
// Should flush nothing, because delay hasn't elapsed
expect(Scheduler).toFlushAndYield([]);
// Advance time until right before the threshold
Scheduler.advanceTime(999);
// Still nothing
expect(Scheduler).toFlushAndYield([]);
// Advance time past the threshold
Scheduler.advanceTime(1);
// Now it should flush like normal
expect(Scheduler).toFlushAndYield(['A']);
});
it('schedules multiple delayed tasks', () => {
scheduleCallback(NormalPriority, () => Scheduler.yieldValue('C'), {
delay: 300,
});
scheduleCallback(NormalPriority, () => Scheduler.yieldValue('B'), {
delay: 200,
});
scheduleCallback(NormalPriority, () => Scheduler.yieldValue('D'), {
delay: 400,
});
scheduleCallback(NormalPriority, () => Scheduler.yieldValue('A'), {
delay: 100,
});
// Should flush nothing, because delay hasn't elapsed
expect(Scheduler).toFlushAndYield([]);
// Advance some time.
Scheduler.advanceTime(200);
// Both A and B are no longer delayed. They can now flush incrementally.
expect(Scheduler).toFlushAndYieldThrough(['A']);
expect(Scheduler).toFlushAndYield(['B']);
// Advance the rest
Scheduler.advanceTime(200);
expect(Scheduler).toFlushAndYield(['C', 'D']);
});
it('interleaves normal tasks and delayed tasks', () => {
// Schedule some high priority callbacks with a delay. When their delay
// elapses, they will be the most important callback in the queue.
scheduleCallback(
UserBlockingPriority,
() => Scheduler.yieldValue('Timer 2'),
{delay: 300},
);
scheduleCallback(
UserBlockingPriority,
() => Scheduler.yieldValue('Timer 1'),
{delay: 100},
);
// Schedule some tasks at default priority.
scheduleCallback(NormalPriority, () => {
Scheduler.yieldValue('A');
Scheduler.advanceTime(100);
});
scheduleCallback(NormalPriority, () => {
Scheduler.yieldValue('B');
Scheduler.advanceTime(100);
});
scheduleCallback(NormalPriority, () => {
Scheduler.yieldValue('C');
Scheduler.advanceTime(100);
});
scheduleCallback(NormalPriority, () => {
Scheduler.yieldValue('D');
Scheduler.advanceTime(100);
});
// Flush all the work. The timers should be interleaved with the
// other tasks.
expect(Scheduler).toFlushAndYield([
'A',
'Timer 1',
'B',
'C',
'Timer 2',
'D',
]);
});
it('interleaves delayed tasks with time-sliced tasks', () => {
// Schedule some high priority callbacks with a delay. When their delay
// elapses, they will be the most important callback in the queue.
scheduleCallback(
UserBlockingPriority,
() => Scheduler.yieldValue('Timer 2'),
{delay: 300},
);
scheduleCallback(
UserBlockingPriority,
() => Scheduler.yieldValue('Timer 1'),
{delay: 100},
);
// Schedule a time-sliced task at default priority.
const tasks = [['A', 100], ['B', 100], ['C', 100], ['D', 100]];
const work = () => {
while (tasks.length > 0) {
const task = tasks.shift();
const [label, ms] = task;
Scheduler.advanceTime(ms);
Scheduler.yieldValue(label);
if (tasks.length > 0 && shouldYield()) {
return work;
}
}
};
scheduleCallback(NormalPriority, work);
// Flush all the work. The timers should be interleaved with the
// other tasks.
expect(Scheduler).toFlushAndYield([
'A',
'Timer 1',
'B',
'C',
'Timer 2',
'D',
]);
});
it('schedules callback with both delay and timeout', () => {
scheduleCallback(
NormalPriority,
() => {
Scheduler.yieldValue('A');
Scheduler.advanceTime(100);
},
{delay: 100, timeout: 900},
);
Scheduler.advanceTime(99);
// Does not flush because delay has not elapsed
expect(Scheduler).toFlushAndYield([]);
// Delay has elapsed but task has not expired
Scheduler.advanceTime(1);
expect(Scheduler).toFlushExpired([]);
// Still not expired
Scheduler.advanceTime(899);
expect(Scheduler).toFlushExpired([]);
// Now it expires
Scheduler.advanceTime(1);
expect(Scheduler).toHaveYielded(['A']);
});
it('cancels a delayed task', () => {
// Schedule several tasks with the same delay
const options = {delay: 100};
scheduleCallback(
NormalPriority,
() => Scheduler.yieldValue('A'),
options,
);
const taskB = scheduleCallback(
NormalPriority,
() => Scheduler.yieldValue('B'),
options,
);
const taskC = scheduleCallback(
NormalPriority,
() => Scheduler.yieldValue('C'),
options,
);
// Cancel B before its delay has elapsed
expect(Scheduler).toFlushAndYield([]);
cancelCallback(taskB);
// Cancel C after its delay has elapsed
Scheduler.advanceTime(500);
cancelCallback(taskC);
// Only A should flush
expect(Scheduler).toFlushAndYield(['A']);
});
});
});
@@ -15,6 +15,8 @@
export let requestHostCallback;
export let cancelHostCallback;
export let requestHostTimeout;
export let cancelHostTimeout;
export let shouldYieldToHost;
export let getCurrentTime;
export let forceFrameRate;
@@ -88,6 +90,7 @@ if (
// If this accidentally gets imported in a non-browser environment, e.g. JavaScriptCore,
// fallback to a naive implementation.
let _callback = null;
let _timeoutID = null;
const _flushCallback = function() {
if (_callback !== null) {
try {
@@ -113,6 +116,12 @@ if (
cancelHostCallback = function() {
_callback = null;
};
requestHostTimeout = function(cb, ms) {
_timeoutID = setTimeout(cb, ms);
};
cancelHostTimeout = function() {
clearTimeout(_timeoutID);
};
shouldYieldToHost = function() {
return false;
};
@@ -141,6 +150,8 @@ if (
let isAnimationFrameScheduled = false;
let timeoutID = -1;
let frameDeadline = 0;
// We start out assuming that we run at 30fps but then the heuristic tracking
// will adjust this value to a faster fps if we get more frequent animation
@@ -269,4 +280,15 @@ if (
scheduledHostCallback = null;
isMessageEventScheduled = false;
};
requestHostTimeout = function(callback, ms) {
timeoutID = localSetTimeout(() => {
callback(getCurrentTime());
}, ms);
};
cancelHostTimeout = function() {
localClearTimeout(timeoutID);
timeoutID = -1;
};
}
@@ -9,6 +9,8 @@
let currentTime: number = 0;
let scheduledCallback: ((boolean, number) => void) | null = null;
let scheduledTimeout: (number => void) | null = null;
let timeoutTime: number = -1;
let yieldedValues: Array<mixed> | null = null;
let expectedNumberOfYields: number = -1;
let didStop: boolean = false;
@@ -22,6 +24,16 @@ export function cancelHostCallback(): void {
scheduledCallback = null;
}
export function requestHostTimeout(callback: number => void, ms: number) {
scheduledTimeout = callback;
timeoutTime = currentTime + ms;
}
export function cancelHostTimeout(): void {
scheduledTimeout = null;
timeoutTime = -1;
}
export function shouldYieldToHost(): boolean {
if (
expectedNumberOfYields !== -1 &&
@@ -49,6 +61,8 @@ export function reset() {
}
currentTime = 0;
scheduledCallback = null;
scheduledTimeout = null;
timeoutTime = -1;
yieldedValues = null;
expectedNumberOfYields = -1;
didStop = false;
@@ -159,6 +173,11 @@ export function yieldValue(value: mixed): void {
export function advanceTime(ms: number) {
currentTime += ms;
if (!isFlushing) {
if (scheduledTimeout !== null && timeoutTime <= currentTime) {
scheduledTimeout(currentTime);
timeoutTime = -1;
scheduledTimeout = null;
}
unstable_flushExpired();
}
}