Skip to content

[JSC] The microtask queue aborts the process at 2^25 pending tasks - #667

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/5f417ff6/microtask-queue-segments
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/5f417ff6/microtask-queue-segments

Conversation

@robobun

@robobun robobun commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • The queue stores its tasks in a linked list of 16 KB segments. No allocation grows with the queue, so the only limit is memory. A segment is freed when its last task leaves.
  • A throwing enqueue() is not an option. 33 call sites are in JSPromise.cpp, in void paths (settle, await) that cannot throw or undo a half-queued settle.
  • The marking cursor is the same count as before. visitAggregate() skips that many tasks by walking segments.
  • Verified: new stress tests microtask-queue-segments.js and microtask-queue-more-than-2-25-tasks.js (aborts before), and 3,655 runs of the promise and microtask stress tests. 13 timing patterns are within noise. A burst of 16M tasks is 33% faster at half the peak RSS.

Background

  • QueuedTask is the record of one microtask (a promise reaction or a queueMicrotask() callback): 40 bytes in Bun.
  • MicrotaskQueue owns two of these deques and swaps them at the end of each checkpoint.
  • The collector visits the queue as a root, possibly several times in one cycle. m_markedBefore counts the tasks at the front that this cycle has visited.
  • A queue that drains keeps one segment and starts over at its first slot, so a shallow queue reuses the same cache lines.
Notes

Repros. Bun 1.4.3 canary (09bb54630, linux x64): exit 134 after 1.6 s, peak RSS 1.5 GB. Node v26.3.0 queues and drains the same count.

const f = () => {};
for (let i = 0; i < 33554431; i++) queueMicrotask(f);

jsc (this is JSTests/stress/microtask-queue-more-than-2-25-tasks.js):

const p = Promise.resolve();
const f = () => {};
for (let i = 0; i < 2 ** 25 + 1; i++) p.then(f);
drainMicrotasks();

The same abort is reachable where one call queues many tasks: resolve() on a promise with 2^25 reactions, and a WritableStream with 2^25 pending writer.write() promises that errors (writableStreamFinishErroring rejects them in one loop).

Why not a fallible Deque::tryAppend. oven-sh/bun#42648 lists it as a follow-up, and it is right for the web streams queues that oven-sh/bun#42649 and oven-sh/bun#42659 fixed, because those callers throw a RangeError at the call. The microtask queue is different: settling a promise fans out one task per reaction from triggerPromiseReactions, Promise.all and Promise.race queue one per element, and a WritableStream that errors rejects every pending write in one loop. None of these can stop halfway. JSC does the same for every other allocation on that path (the reaction cells, the result promises): it succeeds or the process ends.

Why not a bigger ring. V8 uses one ring buffer that doubles with no cap. That would fix the abort with the smallest diff, but it keeps two costs the Deque already had. Growth from 2^25 to 2^26 tasks needs a new 2.7 GB buffer while the old 1.3 GB one is still live. The buffer is never given back: the measurements below show 2.2 GB retained after two bursts of 16 million tasks.

Time. jsc release builds (the flags of the bun-webkit-linux-amd64 lane, no LTO) of this branch and of its base, 11 alternating runs each, minimum wall time in ms. The machine is shared, and run-to-run noise is about 3%.

pattern base this PR
one await loop, 20M awaits (queue depth 1) 547.3 533.4 -2.5%
2 concurrent await loops 545.1 542.1 -0.6%
10 loops 543.4 545.3 +0.3%
100 loops 550.9 555.3 +0.8%
1,000 loops 556.3 561.2 +0.9%
10,000 loops 597.0 582.5 -2.4%
bursts of 10 p.then(f), then drain 958.7 949.8 -0.9%
bursts of 100 778.3 778.1 0.0%
bursts of 1,000 754.7 751.9 -0.4%
bursts of 100,000 788.4 799.0 +1.3%
bursts of 1,000,000 949.9 936.6 -1.4%
one burst of 16,000,000 1167.0 780.2 -33.1%
then chain of 500,000, 20 times 532.7 490.9 -7.8%
Promise.all of 1,000, 10,000 times 367.6 358.4 -2.5%

A standalone copy of both containers, with mimalloc as in Bun, times the queue alone (ns per enqueue plus dequeue, best of 7, 100M tasks):

pattern Deque 4 KB segments 16 KB segments
depth 1 3.99 1.93 1.94
steady depth 10 4.76 4.31 4.21
steady depth 1,000 4.77 4.22 3.86
steady depth 100,000 4.78 4.53 4.13
bursts of 100 3.30 3.03 3.02
bursts of 1,000 2.61 3.48 3.15
bursts of 100,000 3.64 4.23 3.73
bursts of 1,000,000 6.66 8.09 7.28
  • The burst rows compare against a Deque that has already grown, which allocates nothing. The segments pay one malloc and one free per 409 tasks: about 80 ns per pair for mimalloc when thousands of blocks are live, which is 0.2 ns per task. That is why the segment is 16 KB and not 4 KB.
  • A cache of freed segments removes that cost, but only by keeping the memory: with 4 KB segments the 1M-burst row of the first table was +5.5% without a cache and -2.9% with an unbounded one. I left it out.

Memory. Three bursts of 16 million p.then(f), each drained and followed by fullGC(). RSS in MB:

base this PR
first burst queued 1541 1301
second burst queued 2703 1444
after the third burst and a GC 2262 1309
peak 2730 1444

The base keeps both Deque buffers (2^24 tasks each). This PR gives the segments back to the allocator, and the next burst reuses them.

Tests.

  • microtask-queue-segments.js checks FIFO order at depths around the segment boundaries for both task sizes (409 and 511 per segment), keeps the queue at a constant depth while it walks through segments with full and eden collections in between, and collects a queue of 20,000 tasks whose payloads only the queue references. A build that visits only the head segment fails it 3 of 3.
  • In debug builds forEachTaskAfter asserts that it visits exactly size() - toSkip tasks, so every collection checks the segment walk.
  • microtask-queue-more-than-2-25-tasks.js needs about 3 GB, so it has //@ skip if $memoryLimited, which CI passes. Locally: base exits 134 after 2.8 s, this branch passes in 3.1 s.

#427 (drain scopes) adds prepend() and takeLast() on top of the Deque. On top of this PR, endDrainScope() can do the same without them: move what is in m_queue to the end of deferred with dequeue() and enqueue(), then m_queue.swap(deferred). Both deques are visited the whole time.

Bun PR that pins the preview build of this branch and adds the Bun test: to follow.

MarkedMicrotaskDeque kept its tasks in a WTF::Deque<QueuedTask>. A Deque doubles one Vector buffer, and a Vector
buffer holds at most 2^31 - 1 bytes, so the step from 2^25 to 2^26 tasks is not a valid capacity and
Deque::expandCapacity() calls CRASH(). Script reaches that count with 1.3 GB of queue: 2^25 reactions on one promise,
or 2^25 queueMicrotask() calls in Bun. enqueue() has no failure channel, and most of its callers (settling a promise,
await) cannot throw.

The queue now stores its tasks in a singly linked list of 16 KB segments. No allocation grows with the queue, so
the only limit is memory. A task never moves. A segment is freed when its last task is dequeued, and a queue that
drains starts over at the first slot of the segment it keeps, so a shallow queue stays in the same cache lines.
The old queue never gave its buffer back: after two bursts of 16 million tasks it kept two 640 MB buffers, one in
each of the deques that performMicrotaskCheckpoint() swaps.

The marking cursor is unchanged. It is still the count of tasks at the front that the collector has visited in
this cycle, dequeue() still decrements it, and visitAggregate() skips that many tasks, now by walking segments.

* JSTests/stress/microtask-queue-segments.js: Added. Order, segment boundaries, and collections while the queue is
  the only reference to its payloads.
* JSTests/stress/microtask-queue-more-than-2-25-tasks.js: Added. Aborts without the change. It needs about 3 GB, so
  it is skipped when memory is limited.
* Source/JavaScriptCore/runtime/MicrotaskQueue.cpp:
* Source/JavaScriptCore/runtime/MicrotaskQueue.h:
* Source/JavaScriptCore/runtime/MicrotaskQueueInlines.h:
@github-actions

Copy link
Copy Markdown

Preview build of b321195: autobuild-preview-pr-667-b3211955

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #674 fixes the abort with a much smaller change (it lifts the Vector byte limit for Deque<QueuedTask> and leaves the queue as it is). This segment queue replaces the container on the path of every await and conflicts with #427, which is too much for this abort. The branch and the measurements above stay here in case the memory behaviour of the queue becomes worth a change of its own.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant