From d77c6232d37238013dd96f3c37b7e4f77384e0f9 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 8 Aug 2019 16:18:05 -0700 Subject: [PATCH] [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 --- .../src/__tests__/ReactUpdates-test.js | 18 +- packages/scheduler/src/Scheduler.js | 304 +++++------------- packages/scheduler/src/SchedulerMinHeap.js | 91 ++++++ 3 files changed, 171 insertions(+), 242 deletions(-) create mode 100644 packages/scheduler/src/SchedulerMinHeap.js diff --git a/packages/react-dom/src/__tests__/ReactUpdates-test.js b/packages/react-dom/src/__tests__/ReactUpdates-test.js index 66a3a899072c3..8e23a6d5c1615 100644 --- a/packages/react-dom/src/__tests__/ReactUpdates-test.js +++ b/packages/react-dom/src/__tests__/ReactUpdates-test.js @@ -1631,6 +1631,7 @@ describe('ReactUpdates', () => { ReactDOM.render(, container); while (error === null) { Scheduler.unstable_flushNumberOfYields(1); + Scheduler.unstable_clearYields(); } expect(error).toContain('Warning: Maximum update depth exceeded.'); expect(stack).toContain('in NonTerminating'); @@ -1653,9 +1654,9 @@ describe('ReactUpdates', () => { React.useEffect(() => { if (step < LIMIT) { setStep(x => x + 1); - Scheduler.unstable_yieldValue(step); } }); + Scheduler.unstable_yieldValue(step); return step; } @@ -1663,24 +1664,11 @@ describe('ReactUpdates', () => { act(() => { ReactDOM.render(, container); }); - - // Verify we can flush them asynchronously without warning - for (let i = 0; i < LIMIT * 2; i++) { - Scheduler.unstable_flushNumberOfYields(1); - } expect(container.textContent).toBe('50'); - - // Verify restarting from 0 doesn't cross the limit act(() => { _setStep(0); - // flush once to update the dom - Scheduler.unstable_flushNumberOfYields(1); - expect(container.textContent).toBe('0'); - for (let i = 0; i < LIMIT * 2; i++) { - Scheduler.unstable_flushNumberOfYields(1); - } - expect(container.textContent).toBe('50'); }); + expect(container.textContent).toBe('50'); }); it('can have many updates inside useEffect without triggering a warning', () => { diff --git a/packages/scheduler/src/Scheduler.js b/packages/scheduler/src/Scheduler.js index cdb9930d4c973..ea0961bec342f 100644 --- a/packages/scheduler/src/Scheduler.js +++ b/packages/scheduler/src/Scheduler.js @@ -18,6 +18,7 @@ import { forceFrameRate, requestPaint, } from './SchedulerHostConfig'; +import {push, pop, peek} from './SchedulerMinHeap'; // TODO: Use symbols? var ImmediatePriority = 1; @@ -40,9 +41,12 @@ var LOW_PRIORITY_TIMEOUT = 10000; // Never times out var IDLE_PRIORITY = maxSigned31BitInt; -// Tasks are stored as a circular, doubly linked list. -var firstTask = null; -var firstDelayedTask = null; +// Tasks are stored on a min heap +var taskQueue = []; +var timerQueue = []; + +// Incrementing id counter. Used to maintain insertion order. +var taskIdCounter = 0; // Pausing the scheduler is useful for debugging. var isSchedulerPaused = false; @@ -72,35 +76,16 @@ function scheduler_flushTaskAtPriority_Idle(callback, didTimeout) { return callback(didTimeout); } -function flushTask(task, currentTime) { - // Remove the task from the list before calling the callback. That way the - // list is in a consistent state even if the callback throws. - const next = task.next; - if (next === task) { - // This is the only scheduled task. Clear the list. - firstTask = null; - } else { - // Remove the task from its position in the list. - if (task === firstTask) { - firstTask = next; - } - const previous = task.previous; - previous.next = next; - next.previous = previous; - } - task.next = task.previous = null; - - // Now it's safe to execute the task. - var callback = task.callback; +function flushTask(task, callback, currentTime) { var previousPriorityLevel = currentPriorityLevel; var previousTask = currentTask; currentPriorityLevel = task.priorityLevel; currentTask = task; - var continuationCallback; try { var didUserCallbackTimeout = task.expirationTime <= currentTime; // Add an extra function to the callstack. Profiling tools can use this // to infer the priority of work that appears higher in the stack. + var continuationCallback; switch (currentPriorityLevel) { case ImmediatePriority: continuationCallback = scheduler_flushTaskAtPriority_Immediate( @@ -133,76 +118,32 @@ function flushTask(task, currentTime) { ); break; } - } catch (error) { - throw error; + return typeof continuationCallback === 'function' + ? continuationCallback + : null; } finally { currentPriorityLevel = previousPriorityLevel; currentTask = previousTask; } - - // A callback may return a continuation. The continuation should be scheduled - // with the same priority and expiration as the just-finished callback. - if (typeof continuationCallback === 'function') { - var expirationTime = task.expirationTime; - var continuationTask = task; - continuationTask.callback = continuationCallback; - - // Insert the new callback into the list, sorted by its timeout. This is - // almost the same as the code in `scheduleCallback`, except the callback - // is inserted into the list *before* callbacks of equal timeout instead - // of after. - if (firstTask === null) { - // This is the first callback in the list. - firstTask = continuationTask.next = continuationTask.previous = continuationTask; - } else { - var nextAfterContinuation = null; - var t = firstTask; - do { - if (expirationTime <= t.expirationTime) { - // This task times out at or after the continuation. We will insert - // the continuation *before* this task. - nextAfterContinuation = t; - break; - } - t = t.next; - } while (t !== firstTask); - if (nextAfterContinuation === null) { - // No equal or lower priority task was found, which means the new task - // is the lowest priority task in the list. - nextAfterContinuation = firstTask; - } else if (nextAfterContinuation === firstTask) { - // The new task is the highest priority task in the list. - firstTask = continuationTask; - } - - const previous = nextAfterContinuation.previous; - previous.next = nextAfterContinuation.previous = continuationTask; - continuationTask.next = nextAfterContinuation; - continuationTask.previous = previous; - } - } } 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 - ); + let timer = peek(timerQueue); + while (timer !== null) { + if (timer.callback === null) { + // Timer was cancelled. + pop(timerQueue); + } else if (timer.startTime <= currentTime) { + // Timer fired. Transfer to the task queue. + pop(timerQueue); + timer.sortIndex = timer.expirationTime; + push(taskQueue, timer); + } else { + // Remaining timers are pending. + return; + } + timer = peek(timerQueue); } } @@ -211,24 +152,19 @@ function handleTimeout(currentTime) { advanceTimers(currentTime); if (!isHostCallbackScheduled) { - if (firstTask !== null) { + if (peek(taskQueue) !== null) { isHostCallbackScheduled = true; requestHostCallback(flushWork); - } else if (firstDelayedTask !== null) { - requestHostTimeout( - handleTimeout, - firstDelayedTask.startTime - currentTime, - ); + } else { + const firstTimer = peek(timerQueue); + if (firstTimer !== null) { + requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); + } } } } function flushWork(hasTimeRemaining, initialTime) { - // Exit right away if we're currently paused - if (enableSchedulerDebugging && isSchedulerPaused) { - return; - } - // We'll need a host callback the next time work is scheduled. isHostCallbackScheduled = false; if (isHostTimeoutScheduled) { @@ -237,47 +173,44 @@ function flushWork(hasTimeRemaining, initialTime) { cancelHostTimeout(); } - let currentTime = initialTime; - advanceTimers(currentTime); - isPerformingWork = true; try { - if (!hasTimeRemaining) { - // Flush all the expired callbacks without yielding. - // TODO: Split flushWork into two separate functions instead of using - // a boolean argument? - while ( - firstTask !== null && - firstTask.expirationTime <= currentTime && - !(enableSchedulerDebugging && isSchedulerPaused) + let currentTime = initialTime; + advanceTimers(currentTime); + let task = peek(taskQueue); + while (task !== null && !(enableSchedulerDebugging && isSchedulerPaused)) { + if ( + task.expirationTime > currentTime && + (!hasTimeRemaining || shouldYieldToHost()) ) { - flushTask(firstTask, currentTime); + // This task hasn't expired, and we've reached the deadline. + break; + } + const callback = task.callback; + if (callback !== null) { + task.callback = null; + const continuation = flushTask(task, callback, currentTime); + if (continuation !== null) { + task.callback = continuation; + } else { + if (task === peek(taskQueue)) { + pop(taskQueue); + } + } currentTime = getCurrentTime(); advanceTimers(currentTime); + } else { + pop(taskQueue); } - } else { - // Keep flushing callbacks until we run out of time in the frame. - if (firstTask !== null) { - do { - flushTask(firstTask, currentTime); - currentTime = getCurrentTime(); - advanceTimers(currentTime); - } while ( - firstTask !== null && - !shouldYieldToHost() && - !(enableSchedulerDebugging && isSchedulerPaused) - ); - } + task = peek(taskQueue); } // Return whether there's additional work - if (firstTask !== null) { + if (task !== null) { return true; } else { - if (firstDelayedTask !== null) { - requestHostTimeout( - handleTimeout, - firstDelayedTask.startTime - currentTime, - ); + let firstTimer = peek(timerQueue); + if (firstTimer !== null) { + requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); } return false; } @@ -388,18 +321,19 @@ function unstable_scheduleCallback(priorityLevel, callback, options) { var expirationTime = startTime + timeout; var newTask = { + id: taskIdCounter++, callback, priorityLevel, startTime, expirationTime, - next: null, - previous: null, + sortIndex: -1, }; if (startTime > currentTime) { // This is a delayed task. - insertDelayedTask(newTask, startTime); - if (firstTask === null && firstDelayedTask === newTask) { + newTask.sortIndex = startTime; + push(timerQueue, newTask); + if (peek(taskQueue) === null && newTask === peek(timerQueue)) { // All tasks are delayed, and this is the task with the earliest delay. if (isHostTimeoutScheduled) { // Cancel an existing timeout. @@ -411,7 +345,8 @@ function unstable_scheduleCallback(priorityLevel, callback, options) { requestHostTimeout(handleTimeout, startTime - currentTime); } } else { - insertScheduledTask(newTask, expirationTime); + newTask.sortIndex = expirationTime; + push(taskQueue, newTask); // Schedule a host callback, if needed. If we're already performing work, // wait until the next time we yield. if (!isHostCallbackScheduled && !isPerformingWork) { @@ -423,74 +358,6 @@ function unstable_scheduleCallback(priorityLevel, callback, options) { 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 - if (firstTask === null) { - // This is the first task in the list. - firstTask = newTask.next = newTask.previous = newTask; - } else { - var next = null; - var task = firstTask; - do { - if (expirationTime < task.expirationTime) { - // The new task times out before this one. - next = task; - break; - } - task = task.next; - } while (task !== firstTask); - - if (next === null) { - // No task with a later timeout was found, which means the new task has - // the latest timeout in the list. - next = firstTask; - } else if (next === firstTask) { - // The new task has the earliest expiration in the entire list. - firstTask = newTask; - } - - var previous = next.previous; - previous.next = next.previous = newTask; - newTask.next = next; - newTask.previous = previous; - } -} - -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; - } -} - function unstable_pauseExecution() { isSchedulerPaused = true; } @@ -504,34 +371,14 @@ function unstable_continueExecution() { } function unstable_getFirstCallbackNode() { - return firstTask; + return peek(taskQueue); } function unstable_cancelCallback(task) { - var next = task.next; - if (next === null) { - // Already cancelled. - return; - } - - if (task === next) { - if (task === firstTask) { - firstTask = null; - } else if (task === firstDelayedTask) { - firstDelayedTask = null; - } - } else { - if (task === firstTask) { - firstTask = next; - } else if (task === firstDelayedTask) { - firstDelayedTask = next; - } - var previous = task.previous; - previous.next = next; - next.previous = previous; - } - - task.next = task.previous = null; + // Null out the callback to indicate the task has been canceled. (Can't remove + // from the queue because you can't remove arbitrary nodes from an array based + // heap, only the first one.) + task.callback = null; } function unstable_getCurrentPriorityLevel() { @@ -541,9 +388,12 @@ function unstable_getCurrentPriorityLevel() { function unstable_shouldYield() { const currentTime = getCurrentTime(); advanceTimers(currentTime); + const firstTask = peek(taskQueue); return ( - (currentTask !== null && + (firstTask !== currentTask && + currentTask !== null && firstTask !== null && + firstTask.callback !== null && firstTask.startTime <= currentTime && firstTask.expirationTime < currentTask.expirationTime) || shouldYieldToHost() diff --git a/packages/scheduler/src/SchedulerMinHeap.js b/packages/scheduler/src/SchedulerMinHeap.js new file mode 100644 index 0000000000000..e74d5d6853b14 --- /dev/null +++ b/packages/scheduler/src/SchedulerMinHeap.js @@ -0,0 +1,91 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +type Heap = Array; +type Node = { + id: number, + sortIndex: number, +}; + +export function push(heap: Heap, node: Node): void { + const index = heap.length; + heap.push(node); + siftUp(heap, node, index); +} + +export function peek(heap: Heap): Node | null { + const first = heap[0]; + return first === undefined ? null : first; +} + +export function pop(heap: Heap): Node | null { + const first = heap[0]; + if (first !== undefined) { + const last = heap.pop(); + if (last !== first) { + heap[0] = last; + siftDown(heap, last, 0); + } + return first; + } else { + return null; + } +} + +function siftUp(heap, node, index) { + while (true) { + const parentIndex = Math.floor((index - 1) / 2); + const parent = heap[parentIndex]; + if (parent !== undefined && compare(parent, node) > 0) { + // The parent is larger. Swap positions. + heap[parentIndex] = node; + heap[index] = parent; + index = parentIndex; + } else { + // The parent is smaller. Exit. + return; + } + } +} + +function siftDown(heap, node, index) { + const length = heap.length; + while (index < length) { + const leftIndex = (index + 1) * 2 - 1; + const left = heap[leftIndex]; + const rightIndex = leftIndex + 1; + const right = heap[rightIndex]; + + // If the left or right node is smaller, swap with the smaller of those. + if (left !== undefined && compare(left, node) < 0) { + if (right !== undefined && compare(right, left) < 0) { + heap[index] = right; + heap[rightIndex] = node; + index = rightIndex; + } else { + heap[index] = left; + heap[leftIndex] = node; + index = leftIndex; + } + } else if (right !== undefined && compare(right, node) < 0) { + heap[index] = right; + heap[rightIndex] = node; + index = rightIndex; + } else { + // Neither child is smaller. Exit. + return; + } + } +} + +function compare(a, b) { + // Compare sort index first, then task id. + const diff = a.sortIndex - b.sortIndex; + return diff !== 0 ? diff : a.id - b.id; +}