From 0e2c76a0179ac7bb5ae4285fece5ddadc7981c47 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 29 Jul 2019 15:08:01 -0700 Subject: [PATCH] [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). --- packages/scheduler/src/Scheduler.js | 274 ++++++--------------- packages/scheduler/src/SchedulerMinHeap.js | 91 +++++++ 2 files changed, 169 insertions(+), 196 deletions(-) create mode 100644 packages/scheduler/src/SchedulerMinHeap.js diff --git a/packages/scheduler/src/Scheduler.js b/packages/scheduler/src/Scheduler.js index cdb9930d4c973..4c8ae4f6046d3 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; @@ -73,25 +77,13 @@ function scheduler_flushTaskAtPriority_Idle(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; + if (callback === null) { + // The task was canceled. + return; + } + // Clearing the callback marks it as ready for removal from the task queue. + task.callback = null; var previousPriorityLevel = currentPriorityLevel; var previousTask = currentTask; currentPriorityLevel = task.priorityLevel; @@ -133,76 +125,34 @@ function flushTask(task, currentTime) { ); break; } - } catch (error) { - throw error; } 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. + // A callback may return a continuation. 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; - } + task.callback = continuationCallback; } } 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,14 +161,14 @@ 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); + } } } } @@ -246,38 +196,53 @@ 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 task = peek(taskQueue); while ( - firstTask !== null && - firstTask.expirationTime <= currentTime && + task !== null && + task.expirationTime <= currentTime && !(enableSchedulerDebugging && isSchedulerPaused) ) { - flushTask(firstTask, currentTime); + flushTask(task, currentTime); + // If the task completed, remove it from the queue. Need to confirm + // that it's still the first task in the queue, in case additional + // tasks were scheduled. + if (task === peek(taskQueue) && task.callback === null) { + pop(taskQueue); + } currentTime = getCurrentTime(); advanceTimers(currentTime); + task = peek(taskQueue); } } else { // Keep flushing callbacks until we run out of time in the frame. - if (firstTask !== null) { + let task = peek(taskQueue); + if (task !== null) { do { - flushTask(firstTask, currentTime); + flushTask(task, currentTime); + // If the task completed, remove it from the queue. Need to confirm + // that it's still the first task in the queue, in case additional + // tasks were scheduled. + if (task === peek(taskQueue) && task.callback === null) { + pop(taskQueue); + } currentTime = getCurrentTime(); advanceTimers(currentTime); + task = peek(taskQueue); } while ( - firstTask !== null && + task !== null && !shouldYieldToHost() && !(enableSchedulerDebugging && isSchedulerPaused) ); } } // Return whether there's additional work + let firstTask = peek(taskQueue); if (firstTask !== 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 +353,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 +377,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 +390,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 +403,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 +420,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..9f1d693d41794 --- /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 !== undefined && last !== first) { + heap[0] = last; + siftDown(heap, last, 0); + } + return first; + } else { + return null; + } +} + +function siftUp(heap, node, index) { + while (index > 0) { + const parentIndex = Math.floor((index + 1) / 2) - 1; + 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; +}