Scheduling in Unison.js
Introduction
In JavaScript, understanding the call stack, event loop, microtasks, and the microtask queue is essential for knowing how asynchronous code is handled. Let’s go through each of these and their relationships:
1. Call Stack
The call stack is a stack data structure that keeps track of function calls. JavaScript is single-threaded, so it can only do one thing at a time. When a function is invoked, it’s pushed onto the call stack. When the function completes, it’s popped off the stack. If a function calls another function, the called function is pushed onto the stack, and so on. The call stack executes in a Last In, First Out (LIFO) order.
-
Example: If
functionA
callsfunctionB
,functionB
is pushed onto the stack afterfunctionA
.function functionA() { functionB(); } function functionB() { console.log('Inside functionB'); } functionA();
In summary: The call stack is where synchronous code is executed.
2. Event Loop
The event loop is responsible for coordinating the execution of code, handling events, and processing asynchronous tasks. It checks the call stack and the task queues (macrotask queue and microtask queue), ensuring JavaScript executes tasks in the right order.
- How it works:
- The event loop continuously monitors the call stack.
- When the call stack is empty (i.e., all synchronous code has executed), the event loop checks the microtask queue.
- If the microtask queue has tasks, they are moved to the call stack and executed before moving on to any other tasks.
- After all microtasks are complete, the event loop checks the macrotask queue (often just called the task queue) for tasks.
In summary: The event loop manages and coordinates between the call stack, microtasks, and macrotasks.
3. Microtasks and the Microtask Queue
Microtasks are a type of asynchronous task that are given higher priority than other tasks (macrotasks). They’re used for operations that need to happen immediately after the current operation, before any new rendering or other events.
- Examples of Microtasks:
Promise
resolutions (via.then()
or.catch()
)MutationObserver
callbacks
- Microtask Queue:
- The microtask queue holds microtasks to be executed as soon as the call stack is clear.
- When the call stack is empty, the event loop first processes all tasks in the microtask queue before moving to the macrotask queue.
In summary: Microtasks have higher priority and are executed right after the call stack is empty, before any macrotasks.
4. Relationship between Call Stack, Event Loop, Microtask Queue, and Macrotask Queue
-
Call Stack: Holds and executes synchronous code. When asynchronous operations are encountered, they don’t block the call stack. Instead, they are sent to either the microtask queue or macrotask queue, depending on their type.
-
Microtask Queue: Microtasks (like resolved Promises) go here and are given high priority. The event loop checks the microtask queue immediately after the call stack is empty. Only when the microtask queue is empty does it proceed to macrotasks.
-
Event Loop: The event loop manages when code is executed. It continually checks the call stack and decides what to execute based on the availability of tasks in the call stack and the microtask and macrotask queues.
-
Macrotask Queue (or simply the task queue): Contains tasks like
setTimeout
callbacks,setInterval
callbacks, and I/O tasks. The event loop only checks this queue after the call stack and microtask queue are both clear.
5. Interactive Demo
Example to Illustrate Relationships
console.log('Script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise resolved');
});
console.log('Script end');
Execution Breakdown:
-
Call Stack:
- “Script start” is printed immediately because it’s synchronous.
setTimeout
is encountered, so it’s placed in the macrotask queue to run after the current execution context.Promise.resolve().then(...)
creates a microtask to be executed after the call stack is empty.- “Script end” is printed immediately because it’s also synchronous.
-
Event Loop:
- After the synchronous code completes, the call stack is empty.
- The event loop now checks the microtask queue (which has the resolved Promise).
- “Promise resolved” is printed.
- Once the microtask queue is empty, the event loop checks the macrotask queue (which has the
setTimeout
callback). - “setTimeout” is printed.
Final Output:
Script start
Script end
Promise resolved
setTimeout
In short:
- Synchronous code runs first.
- The microtask queue is processed after synchronous code but before macrotasks.
- Macrotasks are processed only after synchronous code and microtasks are fully handled.
This mechanism helps JavaScript handle asynchronous operations efficiently while maintaining predictable and organized execution behavior.
Vue scheduler
In Vue, a job represents a unit of work, often a function that needs to run in response to changes in reactive data. Jobs are typically updates or side effects triggered when reactive data is modified, such as re-rendering a component or updating DOM elements.
How Vue’s Job Scheduling Works
Vue’s scheduling mechanism for jobs is similar to JavaScript’s asynchronous scheduling in a single-threaded environment, but with some custom rules to optimize performance and responsiveness. Vue maintains two distinct job queues, which we’ll call the “pre” queue and the “post” queue, each with different timings for when they are processed.
-
Pre Queue:
- Jobs in the “pre” queue are flushed first, immediately after the current JavaScript call stack clears.
- Typical jobs in the “pre” queue include watchers and computed property re-evaluations that need to run before other tasks.
-
Post Queue:
- Jobs in the “post” queue are always flushed after the “pre” queue, regardless of when they were added.
- Common examples of “post” queue jobs include cleanup tasks or DOM update effects that don’t need immediate execution.
The strict ordering of these queues ensures that “pre” jobs always run before “post” jobs in any given cycle, preserving the correct order of updates.
How Job Queuing and Flushing Work
- When a job is triggered by a reactive change, it’s added to the appropriate queue. Before adding, Vue’s scheduler checks if a flush is already scheduled.
- If no flush is scheduled, Vue schedules one to run in the near future, using a microtask (such as
Promise.then
) to ensure the flush occurs as soon as the current call stack is clear. - Each job can only be queued once per cycle, meaning duplicate jobs are ignored until the next flush cycle.
This mechanism prevents redundant reactivity updates and ensures that jobs are processed in batches, only after synchronous code has completed.
Example of Job Scheduling with Pre and Post Queues
Consider the following:
import { ref, watchEffect, nextTick } from 'vue';
const count = ref(0);
watchEffect(() => {
console.log('Count changed:', count.value);
// This is a "pre" queue job and will run as soon as the reactive data changes.
});
// Simulating a reactive data change
count.value++;
console.log('Synchronous log');
Output:
Synchronous log
Count changed: 1
In this example:
count.value++
triggers a change in reactive data.- The watcher is queued in the “pre” queue.
- The “pre” queue flushes immediately after the current call stack (so “Synchronous log” logs first).
- Only after the call stack clears does the watcher output “Count changed: 1”.
How nextTick
Works in Vue
nextTick
is a utility in Vue that allows you to schedule code to run after the current flush is complete. It returns a Promise
that resolves when all currently pending updates are applied. If no flush is scheduled, nextTick
returns a promise that is already resolved.
count.value++;
nextTick().then(() => {
console.log('Flushed updates');
});
// Logs 'Flushed updates' after 'Count changed: 1'
Summary of Key Points
- Pre Queue: Runs tasks that need to be handled immediately after reactive data changes (like watchers).
- Post Queue: Runs cleanup tasks and effects that can wait until after “pre” queue jobs.
- Job Deduplication: Ensures that each job is only queued once per cycle.
- Flush Scheduling: Uses microtasks to flush all queued jobs once the call stack is clear.
- nextTick: Allows for code to run after all updates are flushed, returning a promise.
This system ensures that all reactive updates in Vue are handled efficiently, preserving consistency and minimizing unnecessary re-renders.
Connecting the Vue scheduler with React (manual flushing mode)
How the @unisonjs/core
Library Coordinates Scheduling in Vue and React
In JavaScript frameworks, Vue and React each handle scheduling independently, both using microtasks. However, they differ in the order and timing of flushes, which can cause issues when trying to synchronize updates across both. Specifically, Vue flushes occur before React flushes by default. This means that, without intervention, Vue’s job queues are usually empty by the time React processes updates, causing potential misalignment between the states of the two libraries.
The @unisonjs/core
library addresses this by allowing the Vue scheduler to operate in a manual mode, enabling controlled coordination of job flushing across both frameworks.
Key Features and Mechanisms of @unisonjs/core
-
Switching Vue Scheduler to Manual Mode:
- When
@unisonjs/core
initiates manual mode in Vue, a controller counter is incremented to keep track of how many components or parts of the application are using manual scheduling. - This allows Vue jobs to be queued without being immediately flushed, letting React and Vue jobs accumulate and sync before processing.
- When
-
Tracking Job Positions:
- Each job that is queued in Vue (via
@unisonjs/core
) returns an index indicating its position in the queue. - This enables components to track the specific jobs (or “effects”) they’ve declared, allowing them to precisely flush their jobs at the right time.
- Components can then manage job timing across frameworks, ensuring that each effect runs in a synchronized manner.
- Each job that is queued in Vue (via
-
Flushing Specific Jobs:
@unisonjs/core
offers specific functions for controlled flushing:flushJobAt(index: number)
: Flushes a single job at a specific position in the “pre” queue.flushPostJobAt(index: number)
: Flushes a single job at a specific position in the “post” queue.flushJobsUntil(index: number)
: Flushes all jobs up to a specific position in the “pre” queue, ideal for batch processing.flushPostJobsUntil(index: number)
: Flushes all jobs up to a specific position in the “post” queue.
- By using these functions, components can precisely control when jobs are executed, allowing for smooth coordination with React’s scheduling.
-
Reverting to Automatic Mode:
- Once manual mode is no longer required, components can request a return to automatic mode. Each time a component requests to exit manual mode, the controller counter is decremented.
- When the controller counter reaches zero,
@unisonjs/core
automatically returns Vue’s scheduler to auto mode, and all remaining jobs are flushed in the usual order.
-
Manual Notification of Flush Completion:
- Since
nextTick
relies on job flushing to be complete, in manual mode,@unisonjs/core
requires explicit notification when flushing ends. - This notification triggers the nextTick promise resolution, ensuring that subsequent actions dependent on the flush can proceed correctly.
- Since
Example of Using @unisonjs/core
to Control Job Flushing
Let’s consider a scenario where we have a Vue scheduler and a React component that need to stay synchronized:
import { flushJobAt, flushJobsUntil, nextTick, switchToManual, switchToAuto } from '@unisonjs/core';
function Counter() {
const [count, setCount] = useState(0);
const jobIndex = useRef(null);
function increment() {
setCount(count + 1);
// Switch scheduler to manual mode
switchToManual();
jobIndex.current = queueJob(() => {
console.log('log count :', count);
});
}
useEffect(() => {
// Flush the job and all the previous
flushJobsUntil(jobIndex.current);
// After manual control is complete, switch scheduling back to auto mode
switchToAuto();
});
// Use nextTick to act after flushing completes
nextTick().then(() => {
console.log('All jobs flushed');
});
return <button>count is {count}</button>;
}
Summary of @unisonjs/core
’s Key Benefits
- Manual Mode Control: Switches Vue’s scheduler to manual mode to align job flushing with React, avoiding premature flushes.
- Precise Job Tracking: Enables specific jobs or groups of jobs to be flushed as needed, reducing desynchronization risks.
- Automatic Mode Reversion: Ensures Vue’s scheduler returns to normal when manual control is no longer needed, flushing any remaining jobs.
- NextTick Compatibility: Allows
nextTick
to work correctly by manually signaling when flushes are complete, so code dependent on Vue’s reactive cycle can operate seamlessly.
This approach allows @unisonjs/core
to effectively synchronize Vue and React, offering fine-grained control over job flushing and maintaining consistent application state across both libraries.