Skip to content

Commit 6edaf6f

Browse files
authored
Detect and prevent render starvation, per lane (#18864)
* Detect and prevent render starvation, per lane If an update is CPU-bound for longer than expected according to its priority, we assume it's being starved by other work on the main thread. To detect this, we keep track of the elapsed time using a fixed-size array where each slot corresponds to a lane. What we actually store is the event time when the lane first became CPU-bound. Then, when receiving a new update or yielding to the main thread, we check how long each lane has been pending. If the time exceeds a threshold constant corresponding to its priority, we mark it as expired to force it to synchronously finish. We don't want to mistake time elapsed while an update is IO-bound (waiting for data to resolve) for time when it is CPU-bound. So when a lane suspends, we clear its associated event time from the array. When it receives a signal to try again, either a ping or an update, we assign a new event time to restart the clock. * Store as expiration time, not start time I originally stored the start time because I thought I could use this in the future to also measure Suspense timeouts. (Event times are currently stored on each update object for this purpose.) But that won't work because in the case of expiration times, we reset the clock whenever the update becomes IO-bound. So to replace the per-update field, I'm going to have to track those on the room separately from expiration times.
1 parent 6207743 commit 6edaf6f

16 files changed

+719
-130
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.new.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import {
112112
SyncLane,
113113
OffscreenLane,
114114
DefaultHydrationLane,
115+
NoTimestamp,
115116
includesSomeLane,
116117
laneToLanes,
117118
removeLanes,
@@ -2301,7 +2302,9 @@ function updateDehydratedSuspenseComponent(
23012302
// is one of the very rare times where we mutate the current tree
23022303
// during the render phase.
23032304
suspenseState.retryLane = attemptHydrationAtLane;
2304-
scheduleUpdateOnFiber(current, attemptHydrationAtLane);
2305+
// TODO: Ideally this would inherit the event time of the current render
2306+
const eventTime = NoTimestamp;
2307+
scheduleUpdateOnFiber(current, attemptHydrationAtLane, eventTime);
23052308
} else {
23062309
// We have already tried to ping at a higher priority than we're rendering with
23072310
// so if we got here, we must have failed to hydrate at those levels. We must

packages/react-reconciler/src/ReactFiberClassComponent.new.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ const classComponentUpdater = {
202202
}
203203

204204
enqueueUpdate(fiber, update);
205-
scheduleUpdateOnFiber(fiber, lane);
205+
scheduleUpdateOnFiber(fiber, lane, eventTime);
206206
},
207207
enqueueReplaceState(inst, payload, callback) {
208208
const fiber = getInstance(inst);
@@ -222,7 +222,7 @@ const classComponentUpdater = {
222222
}
223223

224224
enqueueUpdate(fiber, update);
225-
scheduleUpdateOnFiber(fiber, lane);
225+
scheduleUpdateOnFiber(fiber, lane, eventTime);
226226
},
227227
enqueueForceUpdate(inst, callback) {
228228
const fiber = getInstance(inst);
@@ -241,7 +241,7 @@ const classComponentUpdater = {
241241
}
242242

243243
enqueueUpdate(fiber, update);
244-
scheduleUpdateOnFiber(fiber, lane);
244+
scheduleUpdateOnFiber(fiber, lane, eventTime);
245245
},
246246
};
247247

packages/react-reconciler/src/ReactFiberHooks.new.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1720,7 +1720,7 @@ function dispatchAction<S, A>(
17201720
warnIfNotCurrentlyActingUpdatesInDev(fiber);
17211721
}
17221722
}
1723-
scheduleUpdateOnFiber(fiber, lane);
1723+
scheduleUpdateOnFiber(fiber, lane, eventTime);
17241724
}
17251725
}
17261726

packages/react-reconciler/src/ReactFiberHotReloading.new.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
} from './ReactFiberWorkLoop.new';
2121
import {updateContainer} from './ReactFiberReconciler.new';
2222
import {emptyContextObject} from './ReactFiberContext.new';
23-
import {SyncLane} from './ReactFiberLane';
23+
import {SyncLane, NoTimestamp} from './ReactFiberLane';
2424
import {
2525
ClassComponent,
2626
FunctionComponent,
@@ -319,7 +319,7 @@ function scheduleFibersWithFamiliesRecursively(
319319
fiber._debugNeedsRemount = true;
320320
}
321321
if (needsRemount || needsRender) {
322-
scheduleUpdateOnFiber(fiber, SyncLane);
322+
scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
323323
}
324324
if (child !== null && !needsRemount) {
325325
scheduleFibersWithFamiliesRecursively(

packages/react-reconciler/src/ReactFiberLane.js

+113-11
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export opaque type LanePriority =
2929
| 16;
3030
export opaque type Lanes = number;
3131
export opaque type Lane = number;
32+
export opaque type LaneMap<T> = Array<T>;
3233

3334
import invariant from 'shared/invariant';
3435

@@ -66,7 +67,7 @@ const IdleLanePriority: LanePriority = 2;
6667

6768
const OffscreenLanePriority: LanePriority = 1;
6869

69-
const NoLanePriority: LanePriority = 0;
70+
export const NoLanePriority: LanePriority = 0;
7071

7172
const TotalLanes = 31;
7273

@@ -117,6 +118,8 @@ const IdleUpdateRangeEnd = 30;
117118

118119
export const OffscreenLane: Lane = /* */ 0b1000000000000000000000000000000;
119120

121+
export const NoTimestamp = -1;
122+
120123
// "Registers" used to "return" multiple values
121124
// Used by getHighestPriorityLanes and getNextLanes:
122125
let return_highestLanePriority: LanePriority = DefaultLanePriority;
@@ -365,6 +368,63 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
365368
return nextLanes;
366369
}
367370

371+
function computeExpirationTime(lane: Lane, currentTime: number) {
372+
// TODO: Expiration heuristic is constant per lane, so could use a map.
373+
getHighestPriorityLanes(lane);
374+
const priority = return_highestLanePriority;
375+
if (priority >= InputContinuousLanePriority) {
376+
// User interactions should expire slightly more quickly.
377+
return currentTime + 1000;
378+
} else if (priority >= TransitionLongLanePriority) {
379+
return currentTime + 5000;
380+
} else {
381+
// Anything idle priority or lower should never expire.
382+
return NoTimestamp;
383+
}
384+
}
385+
386+
export function markStarvedLanesAsExpired(
387+
root: FiberRoot,
388+
currentTime: number,
389+
): void {
390+
// TODO: This gets called every time we yield. We can optimize by storing
391+
// the earliest expiration time on the root. Then use that to quickly bail out
392+
// of this function.
393+
394+
const pendingLanes = root.pendingLanes;
395+
const suspendedLanes = root.suspendedLanes;
396+
const pingedLanes = root.pingedLanes;
397+
const expirationTimes = root.expirationTimes;
398+
399+
// Iterate through the pending lanes and check if we've reached their
400+
// expiration time. If so, we'll assume the update is being starved and mark
401+
// it as expired to force it to finish.
402+
let lanes = pendingLanes;
403+
while (lanes > 0) {
404+
const index = ctrz(lanes);
405+
const lane = 1 << index;
406+
407+
const expirationTime = expirationTimes[index];
408+
if (expirationTime === NoTimestamp) {
409+
// Found a pending lane with no expiration time. If it's not suspended, or
410+
// if it's pinged, assume it's CPU-bound. Compute a new expiration time
411+
// using the current time.
412+
if (
413+
(lane & suspendedLanes) === NoLanes ||
414+
(lane & pingedLanes) !== NoLanes
415+
) {
416+
// Assumes timestamps are monotonically increasing.
417+
expirationTimes[index] = computeExpirationTime(lane, currentTime);
418+
}
419+
} else if (expirationTime <= currentTime) {
420+
// This lane expired
421+
root.expiredLanes |= lane;
422+
}
423+
424+
lanes &= ~lane;
425+
}
426+
}
427+
368428
// This returns the highest priority pending lanes regardless of whether they
369429
// are suspended.
370430
export function getHighestPriorityPendingLanes(root: FiberRoot) {
@@ -555,6 +615,10 @@ export function higherPriorityLane(a: Lane, b: Lane) {
555615
return a !== NoLane && a < b ? a : b;
556616
}
557617

618+
export function createLaneMap<T>(initial: T): LaneMap<T> {
619+
return new Array(TotalLanes).fill(initial);
620+
}
621+
558622
export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
559623
root.pendingLanes |= updateLane;
560624

@@ -570,16 +634,33 @@ export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
570634

571635
// Unsuspend any update at equal or lower priority.
572636
const higherPriorityLanes = updateLane - 1; // Turns 0b1000 into 0b0111
637+
573638
root.suspendedLanes &= higherPriorityLanes;
574639
root.pingedLanes &= higherPriorityLanes;
575640
}
576641

577642
export function markRootSuspended(root: FiberRoot, suspendedLanes: Lanes) {
578643
root.suspendedLanes |= suspendedLanes;
579644
root.pingedLanes &= ~suspendedLanes;
645+
646+
// The suspended lanes are no longer CPU-bound. Clear their expiration times.
647+
const expirationTimes = root.expirationTimes;
648+
let lanes = suspendedLanes;
649+
while (lanes > 0) {
650+
const index = ctrz(lanes);
651+
const lane = 1 << index;
652+
653+
expirationTimes[index] = NoTimestamp;
654+
655+
lanes &= ~lane;
656+
}
580657
}
581658

582-
export function markRootPinged(root: FiberRoot, pingedLanes: Lanes) {
659+
export function markRootPinged(
660+
root: FiberRoot,
661+
pingedLanes: Lanes,
662+
eventTime: number,
663+
) {
583664
root.pingedLanes |= root.suspendedLanes & pingedLanes;
584665
}
585666

@@ -600,6 +681,8 @@ export function markRootMutableRead(root: FiberRoot, updateLane: Lane) {
600681
}
601682

602683
export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
684+
const noLongerPendingLanes = root.pendingLanes & ~remainingLanes;
685+
603686
root.pendingLanes = remainingLanes;
604687

605688
// Let's try everything again
@@ -608,6 +691,18 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
608691

609692
root.expiredLanes &= remainingLanes;
610693
root.mutableReadLanes &= remainingLanes;
694+
695+
const expirationTimes = root.expirationTimes;
696+
let lanes = noLongerPendingLanes;
697+
while (lanes > 0) {
698+
const index = ctrz(lanes);
699+
const lane = 1 << index;
700+
701+
// Clear the expiration time
702+
expirationTimes[index] = -1;
703+
704+
lanes &= ~lane;
705+
}
611706
}
612707

613708
export function getBumpedLaneForHydration(
@@ -671,18 +766,25 @@ export function getBumpedLaneForHydration(
671766

672767
const clz32 = Math.clz32 ? Math.clz32 : clz32Fallback;
673768

674-
// Taken from:
769+
// Count leading zeros. Only used on lanes, so assume input is an integer.
770+
// Based on:
675771
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32
676772
const log = Math.log;
677773
const LN2 = Math.LN2;
678-
function clz32Fallback(x) {
679-
// Let n be ToUint32(x).
680-
// Let p be the number of leading zero bits in
681-
// the 32-bit binary representation of n.
682-
// Return p.
683-
const asUint = x >>> 0;
684-
if (asUint === 0) {
774+
function clz32Fallback(lanes: Lanes | Lane) {
775+
if (lanes === 0) {
685776
return 32;
686777
}
687-
return (31 - ((log(asUint) / LN2) | 0)) | 0; // the "| 0" acts like math.floor
778+
return (31 - ((log(lanes) / LN2) | 0)) | 0;
779+
}
780+
781+
// Count trailing zeros. Only used on lanes, so assume input is an integer.
782+
function ctrz(lanes: Lanes | Lane) {
783+
let bits = lanes;
784+
bits |= bits << 16;
785+
bits |= bits << 8;
786+
bits |= bits << 4;
787+
bits |= bits << 2;
788+
bits |= bits << 1;
789+
return 32 - clz32(~bits);
688790
}

packages/react-reconciler/src/ReactFiberNewContext.new.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from './ReactWorkTags';
2323
import {
2424
NoLanes,
25+
NoTimestamp,
2526
isSubsetOfLanes,
2627
includesSomeLane,
2728
mergeLanes,
@@ -209,7 +210,7 @@ export function propagateContextChange(
209210
if (fiber.tag === ClassComponent) {
210211
// Schedule a force update on the work-in-progress.
211212
const update = createUpdate(
212-
-1,
213+
NoTimestamp,
213214
pickArbitraryLane(renderLanes),
214215
null,
215216
);

packages/react-reconciler/src/ReactFiberReconciler.new.js

+13-8
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
SyncLane,
7777
InputDiscreteHydrationLane,
7878
SelectiveHydrationLane,
79+
NoTimestamp,
7980
getHighestPriorityPendingLanes,
8081
higherPriorityLane,
8182
} from './ReactFiberLane';
@@ -307,7 +308,7 @@ export function updateContainer(
307308
}
308309

309310
enqueueUpdate(current, update);
310-
scheduleUpdateOnFiber(current, lane);
311+
scheduleUpdateOnFiber(current, lane, eventTime);
311312

312313
return lane;
313314
}
@@ -352,7 +353,8 @@ export function attemptSynchronousHydration(fiber: Fiber): void {
352353
}
353354
break;
354355
case SuspenseComponent:
355-
flushSync(() => scheduleUpdateOnFiber(fiber, SyncLane));
356+
const eventTime = requestEventTime();
357+
flushSync(() => scheduleUpdateOnFiber(fiber, SyncLane, eventTime));
356358
// If we're still blocked after this, we need to increase
357359
// the priority of any promises resolving within this
358360
// boundary so that they next attempt also has higher pri.
@@ -389,8 +391,9 @@ export function attemptUserBlockingHydration(fiber: Fiber): void {
389391
// Suspense.
390392
return;
391393
}
394+
const eventTime = requestEventTime();
392395
const lane = InputDiscreteHydrationLane;
393-
scheduleUpdateOnFiber(fiber, lane);
396+
scheduleUpdateOnFiber(fiber, lane, eventTime);
394397
markRetryLaneIfNotHydrated(fiber, lane);
395398
}
396399

@@ -402,8 +405,9 @@ export function attemptContinuousHydration(fiber: Fiber): void {
402405
// Suspense.
403406
return;
404407
}
408+
const eventTime = requestEventTime();
405409
const lane = SelectiveHydrationLane;
406-
scheduleUpdateOnFiber(fiber, lane);
410+
scheduleUpdateOnFiber(fiber, lane, eventTime);
407411
markRetryLaneIfNotHydrated(fiber, lane);
408412
}
409413

@@ -413,8 +417,9 @@ export function attemptHydrationAtCurrentPriority(fiber: Fiber): void {
413417
// their priority other than synchronously flush it.
414418
return;
415419
}
420+
const eventTime = requestEventTime();
416421
const lane = requestUpdateLane(fiber, null);
417-
scheduleUpdateOnFiber(fiber, lane);
422+
scheduleUpdateOnFiber(fiber, lane, eventTime);
418423
markRetryLaneIfNotHydrated(fiber, lane);
419424
}
420425

@@ -497,7 +502,7 @@ if (__DEV__) {
497502
// Shallow cloning props works as a workaround for now to bypass the bailout check.
498503
fiber.memoizedProps = {...fiber.memoizedProps};
499504

500-
scheduleUpdateOnFiber(fiber, SyncLane);
505+
scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
501506
}
502507
};
503508

@@ -507,11 +512,11 @@ if (__DEV__) {
507512
if (fiber.alternate) {
508513
fiber.alternate.pendingProps = fiber.pendingProps;
509514
}
510-
scheduleUpdateOnFiber(fiber, SyncLane);
515+
scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
511516
};
512517

513518
scheduleUpdate = (fiber: Fiber) => {
514-
scheduleUpdateOnFiber(fiber, SyncLane);
519+
scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
515520
};
516521

517522
setSuspenseHandler = (newShouldSuspendImpl: Fiber => boolean) => {

packages/react-reconciler/src/ReactFiberRoot.new.js

+8-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import type {RootTag} from './ReactRootTags';
1212

1313
import {noTimeout} from './ReactFiberHostConfig';
1414
import {createHostRootFiber} from './ReactFiber.new';
15-
import {NoLanes} from './ReactFiberLane';
15+
import {
16+
NoLanes,
17+
NoLanePriority,
18+
NoTimestamp,
19+
createLaneMap,
20+
} from './ReactFiberLane';
1621
import {
1722
enableSchedulerTracing,
1823
enableSuspenseCallback,
@@ -33,8 +38,8 @@ function FiberRootNode(containerInfo, tag, hydrate) {
3338
this.hydrate = hydrate;
3439
this.callbackNode = null;
3540
this.callbackId = NoLanes;
36-
this.callbackIsSync = false;
37-
this.expiresAt = -1;
41+
this.callbackPriority_new = NoLanePriority;
42+
this.expirationTimes = createLaneMap(NoTimestamp);
3843

3944
this.pendingLanes = NoLanes;
4045
this.suspendedLanes = NoLanes;

0 commit comments

Comments
 (0)