@samitouri / QOS-React-2 / commits / 35ef78de3e

[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.

Andrew Clark committed Jun 19, 2019 at 16:05 UTC 35ef78de3ea988faa2579850667b1c04ec98403e
4 files changed +405 -39
packages/scheduler/src/Scheduler.js
+167 -39
@@ -11,6 +11,8 @@
11 import {enableSchedulerDebugging} from './SchedulerFeatureFlags';
12 import {
13 requestHostCallback,
14 + requestHostTimeout,
15 + cancelHostTimeout,
16 shouldYieldToHost,
17 getCurrentTime,
18 forceFrameRate,
@@ -39,6 +41,7 @@ var IDLE_PRIORITY = maxSigned31BitInt;
41
42 // Tasks are stored as a circular, doubly linked list.
43 var firstTask = null;
44 +var firstDelayedTask = null;
45
46 // Pausing the scheduler is useful for debugging.
47 var isSchedulerPaused = false;
@@ -50,6 +53,7 @@ var currentPriorityLevel = NormalPriority;
53 var isPerformingWork = false;
54
55 var isHostCallbackScheduled = false;
56 +var isHostTimeoutScheduled = false;
57
58 function flushTask(task, currentTime) {
59 // Remove the task from the list before calling the callback. That way the
@@ -135,14 +139,62 @@ function flushTask(task, currentTime) {
139 }
140 }
141
142 +function advanceTimers(currentTime) {
143 + // Check for tasks that are no longer delayed and add them to the queue.
144 + if (firstDelayedTask !== null && firstDelayedTask.startTime <= currentTime) {
145 + do {
146 + const task = firstDelayedTask;
147 + const next = task.next;
148 + if (task === next) {
149 + firstDelayedTask = null;
150 + } else {
151 + firstDelayedTask = next;
152 + const previous = task.previous;
153 + previous.next = next;
154 + next.previous = previous;
155 + }
156 + task.next = task.previous = null;
157 + insertScheduledTask(task, task.expirationTime);
158 + } while (
159 + firstDelayedTask !== null &&
160 + firstDelayedTask.startTime <= currentTime
161 + );
162 + }
163 +}
164 +
165 +function handleTimeout(currentTime) {
166 + isHostTimeoutScheduled = false;
167 + advanceTimers(currentTime);
168 +
169 + if (!isHostCallbackScheduled) {
170 + if (firstTask !== null) {
171 + isHostCallbackScheduled = true;
172 + requestHostCallback(flushWork);
173 + } else if (firstDelayedTask !== null) {
174 + requestHostTimeout(
175 + handleTimeout,
176 + firstDelayedTask.startTime - currentTime,
177 + );
178 + }
179 + }
180 +}
181 +
182 function flushWork(hasTimeRemaining, initialTime) {
183 // Exit right away if we're currently paused
184 if (enableSchedulerDebugging && isSchedulerPaused) {
185 return;
186 }
187
144 - // We'll need a new host callback the next time work is scheduled.
188 + // We'll need a host callback the next time work is scheduled.
189 isHostCallbackScheduled = false;
190 + if (isHostTimeoutScheduled) {
191 + // We scheduled a timeout but it's no longer needed. Cancel it.
192 + isHostTimeoutScheduled = false;
193 + cancelHostTimeout();
194 + }
195 +
196 + let currentTime = initialTime;
197 + advanceTimers(currentTime);
198
199 isPerformingWork = true;
200 try {
@@ -150,7 +202,6 @@ function flushWork(hasTimeRemaining, initialTime) {
202 // Flush all the expired callbacks without yielding.
203 // TODO: Split flushWork into two separate functions instead of using
204 // a boolean argument?
153 - let currentTime = initialTime;
205 while (
206 firstTask !== null &&
207 firstTask.expirationTime <= currentTime &&
@@ -158,14 +209,15 @@ function flushWork(hasTimeRemaining, initialTime) {
209 ) {
210 flushTask(firstTask, currentTime);
211 currentTime = getCurrentTime();
212 + advanceTimers(currentTime);
213 }
214 } else {
215 // Keep flushing callbacks until we run out of time in the frame.
164 - let currentTime = initialTime;
216 if (firstTask !== null) {
217 do {
218 flushTask(firstTask, currentTime);
219 currentTime = getCurrentTime();
220 + advanceTimers(currentTime);
221 } while (
222 firstTask !== null &&
223 !shouldYieldToHost() &&
@@ -174,7 +226,17 @@ function flushWork(hasTimeRemaining, initialTime) {
226 }
227 }
228 // Return whether there's additional work
177 - return firstTask !== null;
229 + if (firstTask !== null) {
230 + return true;
231 + } else {
232 + if (firstDelayedTask !== null) {
233 + requestHostTimeout(
234 + handleTimeout,
235 + firstDelayedTask.startTime - currentTime,
236 + );
237 + }
238 + return false;
239 + }
240 } finally {
241 isPerformingWork = false;
242 }
@@ -242,34 +304,41 @@ function unstable_wrapCallback(callback) {
304 };
305 }
306
307 +function timeoutForPriorityLevel(priorityLevel) {
308 + switch (priorityLevel) {
309 + case ImmediatePriority:
310 + return IMMEDIATE_PRIORITY_TIMEOUT;
311 + case UserBlockingPriority:
312 + return USER_BLOCKING_PRIORITY;
313 + case IdlePriority:
314 + return IDLE_PRIORITY;
315 + case LowPriority:
316 + return LOW_PRIORITY_TIMEOUT;
317 + case NormalPriority:
318 + default:
319 + return NORMAL_PRIORITY_TIMEOUT;
320 + }
321 +}
322 +
323 function unstable_scheduleCallback(priorityLevel, callback, options) {
246 - var startTime = getCurrentTime();
324 + var currentTime = getCurrentTime();
325
326 + var startTime;
327 var timeout;
249 - if (
250 - typeof options === 'object' &&
251 - options !== null &&
252 - typeof options.timeout === 'number'
253 - ) {
254 - timeout = options.timeout;
255 - } else {
256 - switch (priorityLevel) {
257 - case ImmediatePriority:
258 - timeout = IMMEDIATE_PRIORITY_TIMEOUT;
259 - break;
260 - case UserBlockingPriority:
261 - timeout = USER_BLOCKING_PRIORITY;
262 - break;
263 - case IdlePriority:
264 - timeout = IDLE_PRIORITY;
265 - break;
266 - case LowPriority:
267 - timeout = LOW_PRIORITY_TIMEOUT;
268 - break;
269 - case NormalPriority:
270 - default:
271 - timeout = NORMAL_PRIORITY_TIMEOUT;
328 + if (typeof options === 'object' && options !== null) {
329 + var delay = options.delay;
330 + if (typeof delay === 'number' && delay > 0) {
331 + startTime = currentTime + delay;
332 + } else {
333 + startTime = currentTime;
334 }
335 + timeout =
336 + typeof options.timeout === 'number'
337 + ? options.timeout
338 + : timeoutForPriorityLevel(priorityLevel);
339 + } else {
340 + timeout = timeoutForPriorityLevel(priorityLevel);
341 + startTime = currentTime;
342 }
343
344 var expirationTime = startTime + timeout;
@@ -283,6 +352,34 @@ function unstable_scheduleCallback(priorityLevel, callback, options) {
352 previous: null,
353 };
354
355 + if (startTime > currentTime) {
356 + // This is a delayed task.
357 + insertDelayedTask(newTask, startTime);
358 + if (firstTask === null && firstDelayedTask === newTask) {
359 + // All tasks are delayed, and this is the task with the earliest delay.
360 + if (isHostTimeoutScheduled) {
361 + // Cancel an existing timeout.
362 + cancelHostTimeout();
363 + } else {
364 + isHostTimeoutScheduled = true;
365 + }
366 + // Schedule a timeout.
367 + requestHostTimeout(handleTimeout, startTime - currentTime);
368 + }
369 + } else {
370 + insertScheduledTask(newTask, expirationTime);
371 + // Schedule a host callback, if needed. If we're already performing work,
372 + // wait until the next time we yield.
373 + if (!isHostCallbackScheduled && !isPerformingWork) {
374 + isHostCallbackScheduled = true;
375 + requestHostCallback(flushWork);
376 + }
377 + }
378 +
379 + return newTask;
380 +}
381 +
382 +function insertScheduledTask(newTask, expirationTime) {
383 // Insert the new task into the list, ordered first by its timeout, then by
384 // insertion. So the new task is inserted after any other task the
385 // same timeout
@@ -315,15 +412,39 @@ function unstable_scheduleCallback(priorityLevel, callback, options) {
412 newTask.next = next;
413 newTask.previous = previous;
414 }
415 +}
416
319 - // Schedule a host callback, if needed. If we're already performing work, wait
320 - // until the next time we yield.
321 - if (!isHostCallbackScheduled && !isPerformingWork) {
322 - isHostCallbackScheduled = true;
323 - requestHostCallback(flushWork);
324 - }
417 +function insertDelayedTask(newTask, startTime) {
418 + // Insert the new task into the list, ordered by its start time.
419 + if (firstDelayedTask === null) {
420 + // This is the first task in the list.
421 + firstDelayedTask = newTask.next = newTask.previous = newTask;
422 + } else {
423 + var next = null;
424 + var task = firstDelayedTask;
425 + do {
426 + if (startTime < task.startTime) {
427 + // The new task times out before this one.
428 + next = task;
429 + break;
430 + }
431 + task = task.next;
432 + } while (task !== firstDelayedTask);
433
326 - return newTask;
434 + if (next === null) {
435 + // No task with a later timeout was found, which means the new task has
436 + // the latest timeout in the list.
437 + next = firstDelayedTask;
438 + } else if (next === firstDelayedTask) {
439 + // The new task has the earliest expiration in the entire list.
440 + firstDelayedTask = newTask;
441 + }
442 +
443 + var previous = next.previous;
444 + previous.next = next.previous = newTask;
445 + newTask.next = next;
446 + newTask.previous = previous;
447 + }
448 }
449
450 function unstable_pauseExecution() {
@@ -349,13 +470,17 @@ function unstable_cancelCallback(task) {
470 return;
471 }
472
352 - if (next === task) {
353 - // This is the only scheduled task. Clear the list.
354 - firstTask = null;
473 + if (task === next) {
474 + if (task === firstTask) {
475 + firstTask = null;
476 + } else if (task === firstDelayedTask) {
477 + firstDelayedTask = null;
478 + }
479 } else {
356 - // Remove the task from its position in the list.
480 if (task === firstTask) {
481 firstTask = next;
482 + } else if (task === firstDelayedTask) {
483 + firstDelayedTask = next;
484 }
485 var previous = task.previous;
486 previous.next = next;
@@ -370,9 +495,12 @@ function unstable_getCurrentPriorityLevel() {
495 }
496
497 function unstable_shouldYield() {
498 + const currentTime = getCurrentTime();
499 + advanceTimers(currentTime);
500 return (
501 (currentTask !== null &&
502 firstTask !== null &&
503 + firstTask.startTime <= currentTime &&
504 firstTask.expirationTime < currentTask.expirationTime) ||
505 shouldYieldToHost()
506 );
packages/scheduler/src/__tests__/Scheduler-test.js
+197
@@ -395,4 +395,201 @@ describe('Scheduler', () => {
395 ImmediatePriority,
396 ]);
397 });
398 +
399 + describe('delayed tasks', () => {
400 + it('schedules a delayed task', () => {
401 + scheduleCallback(NormalPriority, () => Scheduler.yieldValue('A'), {
402 + delay: 1000,
403 + });
404 +
405 + // Should flush nothing, because delay hasn't elapsed
406 + expect(Scheduler).toFlushAndYield([]);
407 +
408 + // Advance time until right before the threshold
409 + Scheduler.advanceTime(999);
410 + // Still nothing
411 + expect(Scheduler).toFlushAndYield([]);
412 +
413 + // Advance time past the threshold
414 + Scheduler.advanceTime(1);
415 +
416 + // Now it should flush like normal
417 + expect(Scheduler).toFlushAndYield(['A']);
418 + });
419 +
420 + it('schedules multiple delayed tasks', () => {
421 + scheduleCallback(NormalPriority, () => Scheduler.yieldValue('C'), {
422 + delay: 300,
423 + });
424 +
425 + scheduleCallback(NormalPriority, () => Scheduler.yieldValue('B'), {
426 + delay: 200,
427 + });
428 +
429 + scheduleCallback(NormalPriority, () => Scheduler.yieldValue('D'), {
430 + delay: 400,
431 + });
432 +
433 + scheduleCallback(NormalPriority, () => Scheduler.yieldValue('A'), {
434 + delay: 100,
435 + });
436 +
437 + // Should flush nothing, because delay hasn't elapsed
438 + expect(Scheduler).toFlushAndYield([]);
439 +
440 + // Advance some time.
441 + Scheduler.advanceTime(200);
442 + // Both A and B are no longer delayed. They can now flush incrementally.
443 + expect(Scheduler).toFlushAndYieldThrough(['A']);
444 + expect(Scheduler).toFlushAndYield(['B']);
445 +
446 + // Advance the rest
447 + Scheduler.advanceTime(200);
448 + expect(Scheduler).toFlushAndYield(['C', 'D']);
449 + });
450 +
451 + it('interleaves normal tasks and delayed tasks', () => {
452 + // Schedule some high priority callbacks with a delay. When their delay
453 + // elapses, they will be the most important callback in the queue.
454 + scheduleCallback(
455 + UserBlockingPriority,
456 + () => Scheduler.yieldValue('Timer 2'),
457 + {delay: 300},
458 + );
459 + scheduleCallback(
460 + UserBlockingPriority,
461 + () => Scheduler.yieldValue('Timer 1'),
462 + {delay: 100},
463 + );
464 +
465 + // Schedule some tasks at default priority.
466 + scheduleCallback(NormalPriority, () => {
467 + Scheduler.yieldValue('A');
468 + Scheduler.advanceTime(100);
469 + });
470 + scheduleCallback(NormalPriority, () => {
471 + Scheduler.yieldValue('B');
472 + Scheduler.advanceTime(100);
473 + });
474 + scheduleCallback(NormalPriority, () => {
475 + Scheduler.yieldValue('C');
476 + Scheduler.advanceTime(100);
477 + });
478 + scheduleCallback(NormalPriority, () => {
479 + Scheduler.yieldValue('D');
480 + Scheduler.advanceTime(100);
481 + });
482 +
483 + // Flush all the work. The timers should be interleaved with the
484 + // other tasks.
485 + expect(Scheduler).toFlushAndYield([
486 + 'A',
487 + 'Timer 1',
488 + 'B',
489 + 'C',
490 + 'Timer 2',
491 + 'D',
492 + ]);
493 + });
494 +
495 + it('interleaves delayed tasks with time-sliced tasks', () => {
496 + // Schedule some high priority callbacks with a delay. When their delay
497 + // elapses, they will be the most important callback in the queue.
498 + scheduleCallback(
499 + UserBlockingPriority,
500 + () => Scheduler.yieldValue('Timer 2'),
501 + {delay: 300},
502 + );
503 + scheduleCallback(
504 + UserBlockingPriority,
505 + () => Scheduler.yieldValue('Timer 1'),
506 + {delay: 100},
507 + );
508 +
509 + // Schedule a time-sliced task at default priority.
510 + const tasks = [['A', 100], ['B', 100], ['C', 100], ['D', 100]];
511 + const work = () => {
512 + while (tasks.length > 0) {
513 + const task = tasks.shift();
514 + const [label, ms] = task;
515 + Scheduler.advanceTime(ms);
516 + Scheduler.yieldValue(label);
517 + if (tasks.length > 0 && shouldYield()) {
518 + return work;
519 + }
520 + }
521 + };
522 + scheduleCallback(NormalPriority, work);
523 +
524 + // Flush all the work. The timers should be interleaved with the
525 + // other tasks.
526 + expect(Scheduler).toFlushAndYield([
527 + 'A',
528 + 'Timer 1',
529 + 'B',
530 + 'C',
531 + 'Timer 2',
532 + 'D',
533 + ]);
534 + });
535 +
536 + it('schedules callback with both delay and timeout', () => {
537 + scheduleCallback(
538 + NormalPriority,
539 + () => {
540 + Scheduler.yieldValue('A');
541 + Scheduler.advanceTime(100);
542 + },
543 + {delay: 100, timeout: 900},
544 + );
545 +
546 + Scheduler.advanceTime(99);
547 + // Does not flush because delay has not elapsed
548 + expect(Scheduler).toFlushAndYield([]);
549 +
550 + // Delay has elapsed but task has not expired
551 + Scheduler.advanceTime(1);
552 + expect(Scheduler).toFlushExpired([]);
553 +
554 + // Still not expired
555 + Scheduler.advanceTime(899);
556 + expect(Scheduler).toFlushExpired([]);
557 +
558 + // Now it expires
559 + Scheduler.advanceTime(1);
560 + expect(Scheduler).toHaveYielded(['A']);
561 + });
562 +
563 + it('cancels a delayed task', () => {
564 + // Schedule several tasks with the same delay
565 + const options = {delay: 100};
566 +
567 + scheduleCallback(
568 + NormalPriority,
569 + () => Scheduler.yieldValue('A'),
570 + options,
571 + );
572 + const taskB = scheduleCallback(
573 + NormalPriority,
574 + () => Scheduler.yieldValue('B'),
575 + options,
576 + );
577 + const taskC = scheduleCallback(
578 + NormalPriority,
579 + () => Scheduler.yieldValue('C'),
580 + options,
581 + );
582 +
583 + // Cancel B before its delay has elapsed
584 + expect(Scheduler).toFlushAndYield([]);
585 + cancelCallback(taskB);
586 +
587 + // Cancel C after its delay has elapsed
588 + Scheduler.advanceTime(500);
589 + cancelCallback(taskC);
590 +
591 + // Only A should flush
592 + expect(Scheduler).toFlushAndYield(['A']);
593 + });
594 + });
595 });
packages/scheduler/src/forks/SchedulerHostConfig.default.js
+22
@@ -15,6 +15,8 @@
15
16 export let requestHostCallback;
17 export let cancelHostCallback;
18 +export let requestHostTimeout;
19 +export let cancelHostTimeout;
20 export let shouldYieldToHost;
21 export let getCurrentTime;
22 export let forceFrameRate;
@@ -88,6 +90,7 @@ if (
90 // If this accidentally gets imported in a non-browser environment, e.g. JavaScriptCore,
91 // fallback to a naive implementation.
92 let _callback = null;
93 + let _timeoutID = null;
94 const _flushCallback = function() {
95 if (_callback !== null) {
96 try {
@@ -113,6 +116,12 @@ if (
116 cancelHostCallback = function() {
117 _callback = null;
118 };
119 + requestHostTimeout = function(cb, ms) {
120 + _timeoutID = setTimeout(cb, ms);
121 + };
122 + cancelHostTimeout = function() {
123 + clearTimeout(_timeoutID);
124 + };
125 shouldYieldToHost = function() {
126 return false;
127 };
@@ -141,6 +150,8 @@ if (
150
151 let isAnimationFrameScheduled = false;
152
153 + let timeoutID = -1;
154 +
155 let frameDeadline = 0;
156 // We start out assuming that we run at 30fps but then the heuristic tracking
157 // will adjust this value to a faster fps if we get more frequent animation
@@ -269,4 +280,15 @@ if (
280 scheduledHostCallback = null;
281 isMessageEventScheduled = false;
282 };
283 +
284 + requestHostTimeout = function(callback, ms) {
285 + timeoutID = localSetTimeout(() => {
286 + callback(getCurrentTime());
287 + }, ms);
288 + };
289 +
290 + cancelHostTimeout = function() {
291 + localClearTimeout(timeoutID);
292 + timeoutID = -1;
293 + };
294 }
packages/scheduler/src/forks/SchedulerHostConfig.mock.js
+19
@@ -9,6 +9,8 @@
9
10 let currentTime: number = 0;
11 let scheduledCallback: ((boolean, number) => void) | null = null;
12 +let scheduledTimeout: (number => void) | null = null;
13 +let timeoutTime: number = -1;
14 let yieldedValues: Array<mixed> | null = null;
15 let expectedNumberOfYields: number = -1;
16 let didStop: boolean = false;
@@ -22,6 +24,16 @@ export function cancelHostCallback(): void {
24 scheduledCallback = null;
25 }
26
27 +export function requestHostTimeout(callback: number => void, ms: number) {
28 + scheduledTimeout = callback;
29 + timeoutTime = currentTime + ms;
30 +}
31 +
32 +export function cancelHostTimeout(): void {
33 + scheduledTimeout = null;
34 + timeoutTime = -1;
35 +}
36 +
37 export function shouldYieldToHost(): boolean {
38 if (
39 expectedNumberOfYields !== -1 &&
@@ -49,6 +61,8 @@ export function reset() {
61 }
62 currentTime = 0;
63 scheduledCallback = null;
64 + scheduledTimeout = null;
65 + timeoutTime = -1;
66 yieldedValues = null;
67 expectedNumberOfYields = -1;
68 didStop = false;
@@ -159,6 +173,11 @@ export function yieldValue(value: mixed): void {
173 export function advanceTime(ms: number) {
174 currentTime += ms;
175 if (!isFlushing) {
176 + if (scheduledTimeout !== null && timeoutTime <= currentTime) {
177 + scheduledTimeout(currentTime);
178 + timeoutTime = -1;
179 + scheduledTimeout = null;
180 + }
181 unstable_flushExpired();
182 }
183 }