@samitouri / QOS-React-2 / commits / d77c6232d3

[Scheduler] Store Tasks on a Min Binary Heap (#16245)

* [Scheduler] Store Tasks on a Min Binary Heap Switches Scheduler's priority queue implementation (for both tasks and timers) to an array-based min binary heap. This replaces the naive linked-list implementation that was left over from the queue we once used to schedule React roots. A list was arguably fine when it was only used for roots, since the total number of roots is usually small, and is only 1 in the common case of a single-page app. Since Scheduler is now used for many types of JavaScript tasks (e.g. including timers), the total number of tasks can be much larger. Binary heaps are the standard way to implement priority queues. Insertion is O(1) in the average case (append to the end) and O(log n) in the worst. Deletion is O(log n). Peek is O(1). * Sophie nits

Andrew Clark committed Aug 8, 2019 at 16:18 UTC d77c6232d37238013dd96f3c37b7e4f77384e0f9
3 files changed +171 -242
packages/react-dom/src/__tests__/ReactUpdates-test.js
+3 -15
@@ -1631,6 +1631,7 @@ describe('ReactUpdates', () => {
1631 ReactDOM.render(<App />, container);
1632 while (error === null) {
1633 Scheduler.unstable_flushNumberOfYields(1);
1634 + Scheduler.unstable_clearYields();
1635 }
1636 expect(error).toContain('Warning: Maximum update depth exceeded.');
1637 expect(stack).toContain('in NonTerminating');
@@ -1653,9 +1654,9 @@ describe('ReactUpdates', () => {
1654 React.useEffect(() => {
1655 if (step < LIMIT) {
1656 setStep(x => x + 1);
1656 - Scheduler.unstable_yieldValue(step);
1657 }
1658 });
1659 + Scheduler.unstable_yieldValue(step);
1660 return step;
1661 }
1662
@@ -1663,24 +1664,11 @@ describe('ReactUpdates', () => {
1664 act(() => {
1665 ReactDOM.render(<Terminating />, container);
1666 });
1666 -
1667 - // Verify we can flush them asynchronously without warning
1668 - for (let i = 0; i < LIMIT * 2; i++) {
1669 - Scheduler.unstable_flushNumberOfYields(1);
1670 - }
1667 expect(container.textContent).toBe('50');
1672 -
1673 - // Verify restarting from 0 doesn't cross the limit
1668 act(() => {
1669 _setStep(0);
1676 - // flush once to update the dom
1677 - Scheduler.unstable_flushNumberOfYields(1);
1678 - expect(container.textContent).toBe('0');
1679 - for (let i = 0; i < LIMIT * 2; i++) {
1680 - Scheduler.unstable_flushNumberOfYields(1);
1681 - }
1682 - expect(container.textContent).toBe('50');
1670 });
1671 + expect(container.textContent).toBe('50');
1672 });
1673
1674 it('can have many updates inside useEffect without triggering a warning', () => {
packages/scheduler/src/Scheduler.js
+77 -227
@@ -18,6 +18,7 @@ import {
18 forceFrameRate,
19 requestPaint,
20 } from './SchedulerHostConfig';
21 +import {push, pop, peek} from './SchedulerMinHeap';
22
23 // TODO: Use symbols?
24 var ImmediatePriority = 1;
@@ -40,9 +41,12 @@ var LOW_PRIORITY_TIMEOUT = 10000;
41 // Never times out
42 var IDLE_PRIORITY = maxSigned31BitInt;
43
43 -// Tasks are stored as a circular, doubly linked list.
44 -var firstTask = null;
45 -var firstDelayedTask = null;
44 +// Tasks are stored on a min heap
45 +var taskQueue = [];
46 +var timerQueue = [];
47 +
48 +// Incrementing id counter. Used to maintain insertion order.
49 +var taskIdCounter = 0;
50
51 // Pausing the scheduler is useful for debugging.
52 var isSchedulerPaused = false;
@@ -72,35 +76,16 @@ function scheduler_flushTaskAtPriority_Idle(callback, didTimeout) {
76 return callback(didTimeout);
77 }
78
75 -function flushTask(task, currentTime) {
76 - // Remove the task from the list before calling the callback. That way the
77 - // list is in a consistent state even if the callback throws.
78 - const next = task.next;
79 - if (next === task) {
80 - // This is the only scheduled task. Clear the list.
81 - firstTask = null;
82 - } else {
83 - // Remove the task from its position in the list.
84 - if (task === firstTask) {
85 - firstTask = next;
86 - }
87 - const previous = task.previous;
88 - previous.next = next;
89 - next.previous = previous;
90 - }
91 - task.next = task.previous = null;
92 -
93 - // Now it's safe to execute the task.
94 - var callback = task.callback;
79 +function flushTask(task, callback, currentTime) {
80 var previousPriorityLevel = currentPriorityLevel;
81 var previousTask = currentTask;
82 currentPriorityLevel = task.priorityLevel;
83 currentTask = task;
99 - var continuationCallback;
84 try {
85 var didUserCallbackTimeout = task.expirationTime <= currentTime;
86 // Add an extra function to the callstack. Profiling tools can use this
87 // to infer the priority of work that appears higher in the stack.
88 + var continuationCallback;
89 switch (currentPriorityLevel) {
90 case ImmediatePriority:
91 continuationCallback = scheduler_flushTaskAtPriority_Immediate(
@@ -133,76 +118,32 @@ function flushTask(task, currentTime) {
118 );
119 break;
120 }
136 - } catch (error) {
137 - throw error;
121 + return typeof continuationCallback === 'function'
122 + ? continuationCallback
123 + : null;
124 } finally {
125 currentPriorityLevel = previousPriorityLevel;
126 currentTask = previousTask;
127 }
142 -
143 - // A callback may return a continuation. The continuation should be scheduled
144 - // with the same priority and expiration as the just-finished callback.
145 - if (typeof continuationCallback === 'function') {
146 - var expirationTime = task.expirationTime;
147 - var continuationTask = task;
148 - continuationTask.callback = continuationCallback;
149 -
150 - // Insert the new callback into the list, sorted by its timeout. This is
151 - // almost the same as the code in `scheduleCallback`, except the callback
152 - // is inserted into the list *before* callbacks of equal timeout instead
153 - // of after.
154 - if (firstTask === null) {
155 - // This is the first callback in the list.
156 - firstTask = continuationTask.next = continuationTask.previous = continuationTask;
157 - } else {
158 - var nextAfterContinuation = null;
159 - var t = firstTask;
160 - do {
161 - if (expirationTime <= t.expirationTime) {
162 - // This task times out at or after the continuation. We will insert
163 - // the continuation *before* this task.
164 - nextAfterContinuation = t;
165 - break;
166 - }
167 - t = t.next;
168 - } while (t !== firstTask);
169 - if (nextAfterContinuation === null) {
170 - // No equal or lower priority task was found, which means the new task
171 - // is the lowest priority task in the list.
172 - nextAfterContinuation = firstTask;
173 - } else if (nextAfterContinuation === firstTask) {
174 - // The new task is the highest priority task in the list.
175 - firstTask = continuationTask;
176 - }
177 -
178 - const previous = nextAfterContinuation.previous;
179 - previous.next = nextAfterContinuation.previous = continuationTask;
180 - continuationTask.next = nextAfterContinuation;
181 - continuationTask.previous = previous;
182 - }
183 - }
128 }
129
130 function advanceTimers(currentTime) {
131 // Check for tasks that are no longer delayed and add them to the queue.
188 - if (firstDelayedTask !== null && firstDelayedTask.startTime <= currentTime) {
189 - do {
190 - const task = firstDelayedTask;
191 - const next = task.next;
192 - if (task === next) {
193 - firstDelayedTask = null;
194 - } else {
195 - firstDelayedTask = next;
196 - const previous = task.previous;
197 - previous.next = next;
198 - next.previous = previous;
199 - }
200 - task.next = task.previous = null;
201 - insertScheduledTask(task, task.expirationTime);
202 - } while (
203 - firstDelayedTask !== null &&
204 - firstDelayedTask.startTime <= currentTime
205 - );
132 + let timer = peek(timerQueue);
133 + while (timer !== null) {
134 + if (timer.callback === null) {
135 + // Timer was cancelled.
136 + pop(timerQueue);
137 + } else if (timer.startTime <= currentTime) {
138 + // Timer fired. Transfer to the task queue.
139 + pop(timerQueue);
140 + timer.sortIndex = timer.expirationTime;
141 + push(taskQueue, timer);
142 + } else {
143 + // Remaining timers are pending.
144 + return;
145 + }
146 + timer = peek(timerQueue);
147 }
148 }
149
@@ -211,24 +152,19 @@ function handleTimeout(currentTime) {
152 advanceTimers(currentTime);
153
154 if (!isHostCallbackScheduled) {
214 - if (firstTask !== null) {
155 + if (peek(taskQueue) !== null) {
156 isHostCallbackScheduled = true;
157 requestHostCallback(flushWork);
217 - } else if (firstDelayedTask !== null) {
218 - requestHostTimeout(
219 - handleTimeout,
220 - firstDelayedTask.startTime - currentTime,
221 - );
158 + } else {
159 + const firstTimer = peek(timerQueue);
160 + if (firstTimer !== null) {
161 + requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
162 + }
163 }
164 }
165 }
166
167 function flushWork(hasTimeRemaining, initialTime) {
227 - // Exit right away if we're currently paused
228 - if (enableSchedulerDebugging && isSchedulerPaused) {
229 - return;
230 - }
231 -
168 // We'll need a host callback the next time work is scheduled.
169 isHostCallbackScheduled = false;
170 if (isHostTimeoutScheduled) {
@@ -237,47 +173,44 @@ function flushWork(hasTimeRemaining, initialTime) {
173 cancelHostTimeout();
174 }
175
240 - let currentTime = initialTime;
241 - advanceTimers(currentTime);
242 -
176 isPerformingWork = true;
177 try {
245 - if (!hasTimeRemaining) {
246 - // Flush all the expired callbacks without yielding.
247 - // TODO: Split flushWork into two separate functions instead of using
248 - // a boolean argument?
249 - while (
250 - firstTask !== null &&
251 - firstTask.expirationTime <= currentTime &&
252 - !(enableSchedulerDebugging && isSchedulerPaused)
178 + let currentTime = initialTime;
179 + advanceTimers(currentTime);
180 + let task = peek(taskQueue);
181 + while (task !== null && !(enableSchedulerDebugging && isSchedulerPaused)) {
182 + if (
183 + task.expirationTime > currentTime &&
184 + (!hasTimeRemaining || shouldYieldToHost())
185 ) {
254 - flushTask(firstTask, currentTime);
186 + // This task hasn't expired, and we've reached the deadline.
187 + break;
188 + }
189 + const callback = task.callback;
190 + if (callback !== null) {
191 + task.callback = null;
192 + const continuation = flushTask(task, callback, currentTime);
193 + if (continuation !== null) {
194 + task.callback = continuation;
195 + } else {
196 + if (task === peek(taskQueue)) {
197 + pop(taskQueue);
198 + }
199 + }
200 currentTime = getCurrentTime();
201 advanceTimers(currentTime);
202 + } else {
203 + pop(taskQueue);
204 }
258 - } else {
259 - // Keep flushing callbacks until we run out of time in the frame.
260 - if (firstTask !== null) {
261 - do {
262 - flushTask(firstTask, currentTime);
263 - currentTime = getCurrentTime();
264 - advanceTimers(currentTime);
265 - } while (
266 - firstTask !== null &&
267 - !shouldYieldToHost() &&
268 - !(enableSchedulerDebugging && isSchedulerPaused)
269 - );
270 - }
205 + task = peek(taskQueue);
206 }
207 // Return whether there's additional work
273 - if (firstTask !== null) {
208 + if (task !== null) {
209 return true;
210 } else {
276 - if (firstDelayedTask !== null) {
277 - requestHostTimeout(
278 - handleTimeout,
279 - firstDelayedTask.startTime - currentTime,
280 - );
211 + let firstTimer = peek(timerQueue);
212 + if (firstTimer !== null) {
213 + requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
214 }
215 return false;
216 }
@@ -388,18 +321,19 @@ function unstable_scheduleCallback(priorityLevel, callback, options) {
321 var expirationTime = startTime + timeout;
322
323 var newTask = {
324 + id: taskIdCounter++,
325 callback,
326 priorityLevel,
327 startTime,
328 expirationTime,
395 - next: null,
396 - previous: null,
329 + sortIndex: -1,
330 };
331
332 if (startTime > currentTime) {
333 // This is a delayed task.
401 - insertDelayedTask(newTask, startTime);
402 - if (firstTask === null && firstDelayedTask === newTask) {
334 + newTask.sortIndex = startTime;
335 + push(timerQueue, newTask);
336 + if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
337 // All tasks are delayed, and this is the task with the earliest delay.
338 if (isHostTimeoutScheduled) {
339 // Cancel an existing timeout.
@@ -411,7 +345,8 @@ function unstable_scheduleCallback(priorityLevel, callback, options) {
345 requestHostTimeout(handleTimeout, startTime - currentTime);
346 }
347 } else {
414 - insertScheduledTask(newTask, expirationTime);
348 + newTask.sortIndex = expirationTime;
349 + push(taskQueue, newTask);
350 // Schedule a host callback, if needed. If we're already performing work,
351 // wait until the next time we yield.
352 if (!isHostCallbackScheduled && !isPerformingWork) {
@@ -423,74 +358,6 @@ function unstable_scheduleCallback(priorityLevel, callback, options) {
358 return newTask;
359 }
360
426 -function insertScheduledTask(newTask, expirationTime) {
427 - // Insert the new task into the list, ordered first by its timeout, then by
428 - // insertion. So the new task is inserted after any other task the
429 - // same timeout
430 - if (firstTask === null) {
431 - // This is the first task in the list.
432 - firstTask = newTask.next = newTask.previous = newTask;
433 - } else {
434 - var next = null;
435 - var task = firstTask;
436 - do {
437 - if (expirationTime < task.expirationTime) {
438 - // The new task times out before this one.
439 - next = task;
440 - break;
441 - }
442 - task = task.next;
443 - } while (task !== firstTask);
444 -
445 - if (next === null) {
446 - // No task with a later timeout was found, which means the new task has
447 - // the latest timeout in the list.
448 - next = firstTask;
449 - } else if (next === firstTask) {
450 - // The new task has the earliest expiration in the entire list.
451 - firstTask = newTask;
452 - }
453 -
454 - var previous = next.previous;
455 - previous.next = next.previous = newTask;
456 - newTask.next = next;
457 - newTask.previous = previous;
458 - }
459 -}
460 -
461 -function insertDelayedTask(newTask, startTime) {
462 - // Insert the new task into the list, ordered by its start time.
463 - if (firstDelayedTask === null) {
464 - // This is the first task in the list.
465 - firstDelayedTask = newTask.next = newTask.previous = newTask;
466 - } else {
467 - var next = null;
468 - var task = firstDelayedTask;
469 - do {
470 - if (startTime < task.startTime) {
471 - // The new task times out before this one.
472 - next = task;
473 - break;
474 - }
475 - task = task.next;
476 - } while (task !== firstDelayedTask);
477 -
478 - if (next === null) {
479 - // No task with a later timeout was found, which means the new task has
480 - // the latest timeout in the list.
481 - next = firstDelayedTask;
482 - } else if (next === firstDelayedTask) {
483 - // The new task has the earliest expiration in the entire list.
484 - firstDelayedTask = newTask;
485 - }
486 -
487 - var previous = next.previous;
488 - previous.next = next.previous = newTask;
489 - newTask.next = next;
490 - newTask.previous = previous;
491 - }
492 -}
493 -
361 function unstable_pauseExecution() {
362 isSchedulerPaused = true;
363 }
@@ -504,34 +371,14 @@ function unstable_continueExecution() {
371 }
372
373 function unstable_getFirstCallbackNode() {
507 - return firstTask;
374 + return peek(taskQueue);
375 }
376
377 function unstable_cancelCallback(task) {
511 - var next = task.next;
512 - if (next === null) {
513 - // Already cancelled.
514 - return;
515 - }
516 -
517 - if (task === next) {
518 - if (task === firstTask) {
519 - firstTask = null;
520 - } else if (task === firstDelayedTask) {
521 - firstDelayedTask = null;
522 - }
523 - } else {
524 - if (task === firstTask) {
525 - firstTask = next;
526 - } else if (task === firstDelayedTask) {
527 - firstDelayedTask = next;
528 - }
529 - var previous = task.previous;
530 - previous.next = next;
531 - next.previous = previous;
532 - }
533 -
534 - task.next = task.previous = null;
378 + // Null out the callback to indicate the task has been canceled. (Can't remove
379 + // from the queue because you can't remove arbitrary nodes from an array based
380 + // heap, only the first one.)
381 + task.callback = null;
382 }
383
384 function unstable_getCurrentPriorityLevel() {
@@ -541,9 +388,12 @@ function unstable_getCurrentPriorityLevel() {
388 function unstable_shouldYield() {
389 const currentTime = getCurrentTime();
390 advanceTimers(currentTime);
391 + const firstTask = peek(taskQueue);
392 return (
545 - (currentTask !== null &&
393 + (firstTask !== currentTask &&
394 + currentTask !== null &&
395 firstTask !== null &&
396 + firstTask.callback !== null &&
397 firstTask.startTime <= currentTime &&
398 firstTask.expirationTime < currentTask.expirationTime) ||
399 shouldYieldToHost()
packages/scheduler/src/SchedulerMinHeap.js new
+91
@@ -0,0 +1,91 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +type Heap = Array<Node>;
11 +type Node = {
12 + id: number,
13 + sortIndex: number,
14 +};
15 +
16 +export function push(heap: Heap, node: Node): void {
17 + const index = heap.length;
18 + heap.push(node);
19 + siftUp(heap, node, index);
20 +}
21 +
22 +export function peek(heap: Heap): Node | null {
23 + const first = heap[0];
24 + return first === undefined ? null : first;
25 +}
26 +
27 +export function pop(heap: Heap): Node | null {
28 + const first = heap[0];
29 + if (first !== undefined) {
30 + const last = heap.pop();
31 + if (last !== first) {
32 + heap[0] = last;
33 + siftDown(heap, last, 0);
34 + }
35 + return first;
36 + } else {
37 + return null;
38 + }
39 +}
40 +
41 +function siftUp(heap, node, index) {
42 + while (true) {
43 + const parentIndex = Math.floor((index - 1) / 2);
44 + const parent = heap[parentIndex];
45 + if (parent !== undefined && compare(parent, node) > 0) {
46 + // The parent is larger. Swap positions.
47 + heap[parentIndex] = node;
48 + heap[index] = parent;
49 + index = parentIndex;
50 + } else {
51 + // The parent is smaller. Exit.
52 + return;
53 + }
54 + }
55 +}
56 +
57 +function siftDown(heap, node, index) {
58 + const length = heap.length;
59 + while (index < length) {
60 + const leftIndex = (index + 1) * 2 - 1;
61 + const left = heap[leftIndex];
62 + const rightIndex = leftIndex + 1;
63 + const right = heap[rightIndex];
64 +
65 + // If the left or right node is smaller, swap with the smaller of those.
66 + if (left !== undefined && compare(left, node) < 0) {
67 + if (right !== undefined && compare(right, left) < 0) {
68 + heap[index] = right;
69 + heap[rightIndex] = node;
70 + index = rightIndex;
71 + } else {
72 + heap[index] = left;
73 + heap[leftIndex] = node;
74 + index = leftIndex;
75 + }
76 + } else if (right !== undefined && compare(right, node) < 0) {
77 + heap[index] = right;
78 + heap[rightIndex] = node;
79 + index = rightIndex;
80 + } else {
81 + // Neither child is smaller. Exit.
82 + return;
83 + }
84 + }
85 +}
86 +
87 +function compare(a, b) {
88 + // Compare sort index first, then task id.
89 + const diff = a.sortIndex - b.sortIndex;
90 + return diff !== 0 ? diff : a.id - b.id;
91 +}