Scheduler.yield() 및 장기 작업: 메인 스레드 잠금 해제
브라우저는 다음과 같은 방식으로 JavaScript를 실행합니다. 실행부터 완료까지: 언제 JavaScript 작업을 시작하고 다른 작업을 수행하기 전에 완료합니다. 사용자 입력에 대한 응답, 애니메이션 실행, UI 업데이트 등이 포함됩니다. 300ms 동안 지속되는 작업은 브라우저를 300ms 동안 차단합니다. 사용자가 버튼을 클릭하고, 클릭이 즉시 등록되지 않습니다. 이것은 입력 지연, 높은 NPI의 근본 원인입니다.
해결책은 JavaScript를 적게 작성하는 것이 아니라 긴 작업을 더 많은 덩어리로 나누는 것입니다.
작아서 한 청크와 다른 청크 사이에서 브라우저에 대한 제어권을 포기합니다. 2023년까지 입니다
해킹이 필요하다 setTimeout(fn, 0) o MessageChannel.
와 함께 scheduler.yield() — 2024년부터 모든 최신 브라우저에서 사용 가능
— 브라우저에 제어권을 넘기는 것은 한 줄의 코드가 됩니다.
무엇을 배울 것인가
- 실행 완료 모델과 마찬가지로 메인 스레드를 차단하고 INP에 불이익을 줍니다.
- 장기 작업: PerformanceObserver 및 Chrome DevTools로 식별
- Scheduler.yield(): 브라우저에 제어권을 넘겨주는 새로운 API
- 작업 분할: 급진적인 리팩토링 없이 무거운 알고리즘 깨기
- Scheduler.postTask(): 작업 우선순위(사용자 차단, 사용자 표시, 백그라운드)
- isInputPending(): 보류 중인 입력이 없는 경우에만 작업을 실행합니다.
- 현실 세계의 실용적인 패턴: 가상 목록, 일괄 계산, 작업자 스레드
문제: 실행에서 완료까지 및 입력 지연
메인 스레드는 JavaScript, 레이아웃, 페인팅, 입력 이벤트 등 모든 것을 처리합니다. 언제 하나의 JavaScript 작업이 실행 중이고 다른 모든 작업(입력 이벤트 포함) 그들은 줄을 서서 기다린다. 500ms 작업은 500ms의 입력 지연을 생성합니다. 사용자가 클릭하고, 스크롤하고 입력하지만 작업이 완료될 때까지 브라우저가 응답하지 않습니다.
// Identificare i Long Tasks con PerformanceObserver
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
// Task oltre 50ms: "Long Task" per definizione
console.warn('Long Task detected:', {
duration: Math.round(entry.duration),
startTime: Math.round(entry.startTime),
// attribution: quale script/funzione e responsabile
attribution: entry.attribution?.map((attr) => ({
name: attr.name,
containerType: attr.containerType,
containerSrc: attr.containerSrc,
})),
});
}
}
});
// Osserva sia longtask che LoAF (Long Animation Frame)
observer.observe({ type: 'longtask', buffered: true });
observer.observe({ type: 'long-animation-frame', buffered: true });
// Calcola il Total Blocking Time (TBT) - metrica Lighthouse
let totalBlockingTime = 0;
const tbtObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Solo la parte eccedente i 50ms contribuisce al TBT
const blockingTime = entry.duration - 50;
if (blockingTime > 0) {
totalBlockingTime += blockingTime;
}
}
});
tbtObserver.observe({ type: 'longtask', buffered: true });
Scheduler.yield(): 브라우저에 제어권 부여
scheduler.yield() 나중에 해결되는 Promise를 반환합니다.
브라우저가 입력을 처리하고 UI를 업데이트할 수 있는 기회가 있다는 것입니다.
이는 긴 작업을 별도의 세부 작업으로 나누는 가장 쉬운 방법입니다.
브라우저가 한 청크와 다른 청크 사이에서 "호흡"할 수 있도록 합니다.
// PRIMA: task monolitico da 400ms che blocca il main thread
async function processLargeDatasetBlocking(items: DataItem[]): Promise {
// Questo loop gira per ~400ms senza mai cedere il controllo
for (const item of items) {
await expensiveTransformation(item); // 0.4ms per item, 1000 items = 400ms
}
updateUI(items);
}
// DOPO: stesso algoritmo con scheduler.yield() ogni N item
async function processLargeDatasetYielding(items: DataItem[]): Promise {
const CHUNK_SIZE = 50; // Processa 50 item per chunk (~20ms per chunk)
for (let i = 0; i < items.length; i++) {
await expensiveTransformation(items[i]);
// Cedi il controllo al browser ogni CHUNK_SIZE item
if (i % CHUNK_SIZE === 0) {
await scheduler.yield();
// Il browser ora puo:
// - processare input events (click, scroll, keyboard)
// - eseguire animazioni pendenti
// - aggiornare la UI
// Poi il nostro task riprende dalla prossima iterazione
}
}
updateUI(items);
}
// Utilita: yield con priorita specifica
async function yieldToMain(): Promise {
// Fallback per browser senza scheduler.yield()
if ('scheduler' in globalThis && 'yield' in scheduler) {
await scheduler.yield();
} else {
// Fallback: setTimeout 0 + MessageChannel
await new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => resolve();
channel.port2.postMessage(undefined);
});
}
}
Scheduler.postTask(): 작업 우선순위
scheduler.postTask() 우선순위에 따라 작업 일정을 계획할 수 있습니다.
명시적이다. 세 가지 수준: user-blocking (사용자에게 직접 응답,
최우선 순위), user-visible (기본값, 중요하지 않은 렌더링의 경우)
background (긴급하지 않은 작업, 낮은 우선순위).
// scheduler.postTask(): schedulazione con priorita
// Task ad alta priorita: risposta diretta all'input utente
async function handleSearchInput(query: string): Promise {
// user-blocking: processa immediatamente, prima degli altri task
await scheduler.postTask(
() => filterResults(query),
{ priority: 'user-blocking' }
);
}
// Task a priorita normale: aggiornamento UI visibile
async function renderSearchResults(results: SearchResult[]): Promise {
await scheduler.postTask(
() => renderToDOM(results),
{ priority: 'user-visible' }
);
}
// Task a bassa priorita: analytics, prefetch, cleanup
async function sendAnalytics(event: AnalyticsEvent): Promise {
await scheduler.postTask(
() => analyticsService.track(event),
{ priority: 'background' }
);
}
// Abort controller: cancella task se non piu necessario
// Esempio: l'utente digita velocemente, cancella la ricerca precedente
let searchController: AbortController | null = null;
async function handleInput(query: string): Promise {
// Cancella la ricerca precedente
searchController?.abort();
searchController = new AbortController();
try {
const results = await scheduler.postTask(
() => searchDatabase(query),
{
priority: 'user-blocking',
signal: searchController.signal,
}
);
renderResults(results);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Task cancellato: normale, non loggare come errore
return;
}
throw error;
}
}
isInputPending(): 적응형 수율
navigator.scheduling.isInputPending() 확인할 수 있게 해준다
제어를 포기하기 전에 대기 중인 입력 이벤트가 있는 경우. 이를 통해
필요한 경우에만 양보하여 작업 처리량을 극대화합니다.
// isInputPending(): yield solo quando ci sono input in attesa
async function processWithAdaptiveYield(items: DataItem[]): Promise {
const CHUNK_SIZE = 100;
let startTime = performance.now();
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
// Strategia 1: yield se ci sono input pending
if (
i % CHUNK_SIZE === 0 &&
navigator.scheduling?.isInputPending()
) {
await scheduler.yield();
startTime = performance.now();
continue;
}
// Strategia 2: yield se il chunk ha impiegato troppo (>16ms = 1 frame)
if (i % 10 === 0) {
const elapsed = performance.now() - startTime;
if (elapsed > 16) {
await scheduler.yield();
startTime = performance.now();
}
}
}
}
// Combinazione ottimale per produzione
async function processOptimal(items: DataItem[]): Promise {
const DEADLINE_MS = 5; // Yield ogni 5ms per mantenere frame rate fluido
let lastYield = performance.now();
for (const item of items) {
processItem(item);
const now = performance.now();
if (now - lastYield >= DEADLINE_MS || navigator.scheduling?.isInputPending()) {
await scheduler.yield();
lastYield = performance.now();
}
}
}
CPU 집약적인 작업을 위한 웹 작업자
일부 작업은 단순히 "중단"될 수 없습니다. 이는 순차 알고리즘입니다. 완전히 돌아가야 합니다. 이에 대한 해결책은 작품을 옮기는 것이다. 에 웹 작업자, 메인 스레드를 입력용으로 남겨둡니다.
// worker.ts: algoritmo pesante fuori dal main thread
self.onmessage = async (event: MessageEvent) => {
const { items, taskId } = event.data;
try {
// Questo algoritmo puo durare anche 2 secondi
// ma non blocca il main thread
const result = await heavyComputation(items);
self.postMessage({ taskId, result, success: true });
} catch (error) {
self.postMessage({
taskId,
error: (error as Error).message,
success: false
});
}
};
async function heavyComputation(items: DataItem[]): Promise {
// Sorting, filtering, aggregation complessa
return items
.filter((item) => item.value > 0)
.sort((a, b) => b.score - a.score)
.reduce((acc, item) => {
acc[item.category] = (acc[item.category] ?? 0) + item.value;
return acc;
}, {} as ProcessedData);
}
// main.ts: usa il Worker senza bloccare il main thread
class ComputationWorkerPool {
private worker: Worker;
private pendingTasks = new Map void;
reject: (error: Error) => void;
}>();
constructor() {
this.worker = new Worker(new URL('./worker.ts', import.meta.url));
this.worker.onmessage = this.handleMessage.bind(this);
}
process(items: DataItem[]): Promise {
return new Promise((resolve, reject) => {
const taskId = crypto.randomUUID();
this.pendingTasks.set(taskId, { resolve, reject });
this.worker.postMessage({ items, taskId });
});
}
private handleMessage(event: MessageEvent): void {
const { taskId, result, error, success } = event.data;
const task = this.pendingTasks.get(taskId);
if (!task) return;
this.pendingTasks.delete(taskId);
if (success) {
task.resolve(result);
} else {
task.reject(new Error(error));
}
}
}
// Uso: il main thread rimane completamente libero
const pool = new ComputationWorkerPool();
const result = await pool.process(largeDataset);
// Durante la computazione nel worker, l'UI rimane perfettamente reattiva
체크리스트: INP에 대한 메인 스레드 최적화
- PerformanceObserver(장기 작업 유형) 또는 Chrome DevTools 성능 탭을 사용하여 장기 작업 식별
- 배치 알고리즘의 경우: 5-16ms마다 Scheduler.yield()로 중단합니다.
- 분할 불가능한 순차 알고리즘의 경우: Web Worker로 이동
- 예약된 작업의 경우: 적절한 우선순위로 Scheduler.postTask()를 사용하세요.
- 영향 측정: CrUX 또는 web-vitals.js 사용 전후의 INP P75 비교
결론
scheduler.yield() e scheduler.postTask() 그들은 대표한다
성능이 뛰어난 JavaScript를 작성하는 방법에 대한 근본적인 변화: 해킹에서
완전한 브라우저 지원을 통해 의미론적으로 API를 정리하는 setTimeout. 와 결합
CPU 집약적인 작업을 위한 웹 워커를 사용하면 INP를 200ms 미만으로 유지할 수 있습니다.
복잡한 JavaScript 애플리케이션에서도 마찬가지입니다.







