From a912eca96a1b1936284b8b68136168788e305ddf Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 1 Nov 2025 20:59:35 -0700 Subject: [PATCH] Add event loop architecture documentation (#24300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds comprehensive documentation explaining how Bun's event loop works, including task draining, microtasks, process.nextTick, and I/O polling integration. ## What does this document? - **Task draining algorithm**: Shows the exact flow for processing each task (run β†’ release weak refs β†’ drain microtasks β†’ deferred tasks) - **Process.nextTick ordering**: Explains batching behavior - all nextTick callbacks in current batch run, then microtasks drain - **Microtask integration**: How JavaScriptCore's microtask queue and Bun's nextTick queue interact - **I/O polling**: How uSockets epoll/kqueue events integrate with the event loop - **Timer ordering**: Why setImmediate runs before setTimeout - **Enter/Exit mechanism**: How the counter prevents excessive microtask draining ## Visual aids Includes ASCII flowcharts showing: - Main tick flow - autoTick flow (with I/O polling) - Per-task draining sequence ## Code references All explanations include specific file paths and line numbers for verification: - `src/bun.js/event_loop/Task.zig` - `src/bun.js/event_loop.zig` - `src/bun.js/bindings/ZigGlobalObject.cpp` - `src/js/builtins/ProcessObjectInternals.ts` - `packages/bun-usockets/src/eventing/epoll_kqueue.c` ## Examples Includes JavaScript examples demonstrating: - nextTick vs Promise ordering - Batching behavior when nextTick callbacks schedule more nextTicks - setImmediate vs setTimeout ordering πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Bot Co-authored-by: Claude --- src/event_loop/README.md | 344 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 src/event_loop/README.md diff --git a/src/event_loop/README.md b/src/event_loop/README.md new file mode 100644 index 0000000000..52b3f13e42 --- /dev/null +++ b/src/event_loop/README.md @@ -0,0 +1,344 @@ +# Bun Event Loop Architecture + +This document explains how Bun's event loop works, including task draining, microtasks, process.nextTick, setTimeout ordering, and I/O polling integration. + +## Overview + +Bun's event loop is built on top of **uSockets** (a cross-platform event loop based on epoll/kqueue) and integrates with **JavaScriptCore's** microtask queue and a custom **process.nextTick** queue. The event loop processes tasks in a specific order to ensure correct JavaScript semantics while maximizing performance. + +## Core Components + +### 1. Task Queue (`src/bun.js/event_loop/Task.zig`) +A tagged pointer union containing various async task types (file I/O, network requests, timers, etc.). Tasks are queued by various subsystems and drained by the main event loop. + +### 2. Immediate Tasks (`event_loop.zig:14-15`) +Two separate queues for `setImmediate()`: +- **`immediate_tasks`**: Tasks to run on the current tick +- **`next_immediate_tasks`**: Tasks to run on the next tick + +This prevents infinite loops when `setImmediate` is called within a `setImmediate` callback. + +### 3. Concurrent Task Queue (`event_loop.zig:17`) +Thread-safe queue for tasks enqueued from worker threads or async operations. These are moved to the main task queue before processing. + +### 4. Deferred Task Queue (`src/bun.js/event_loop/DeferredTaskQueue.zig`) +For operations that should be batched and deferred until after microtasks drain (e.g., buffered HTTP response writes, file sink flushes). This avoids excessive system calls while maintaining responsiveness. + +### 5. Process.nextTick Queue (`src/bun.js/bindings/JSNextTickQueue.cpp`) +Node.js-compatible implementation of `process.nextTick()`, which runs before microtasks but after each task. + +### 6. Microtask Queue (JavaScriptCore VM) +Built-in JSC microtask queue for promises and queueMicrotask. + +## Event Loop Flow + +### Main Tick Flow (`event_loop.zig:477-513`) + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 1. Tick concurrent tasks β”‚ ← Move tasks from concurrent queue +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 2. Process GC timer β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 3. Drain regular task queue β”‚ ← tickQueueWithCount() +β”‚ For each task: β”‚ +β”‚ - Run task β”‚ +β”‚ - Release weak refs β”‚ +β”‚ - Drain microtasks β”‚ +β”‚ (See detailed flow below) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 4. Handle rejected promises β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### autoTick Flow (`event_loop.zig:349-401`) + +This is called when the event loop is active and needs to wait for I/O: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 1. Tick immediate tasks β”‚ ← setImmediate() callbacks +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 2. Update date header timer β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 3. Process GC timer β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 4. Poll I/O via uSockets β”‚ ← epoll_wait/kevent with timeout +β”‚ (epoll_kqueue.c:251-320) β”‚ +β”‚ - Dispatch ready polls β”‚ +β”‚ - Each I/O event treated as taskβ”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 5. Drain timers (POSIX) β”‚ ← setTimeout/setInterval callbacks +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 6. Call VM.onAfterEventLoop() β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 7. Handle rejected promises β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Task Draining Algorithm + +### For Regular Tasks (`Task.zig:97-512`) + +For each task dequeued from the task queue: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ FOR EACH TASK in task queue: β”‚ +β”‚ β”‚ +β”‚ 1. RUN THE TASK (Task.zig:135-506) β”‚ +β”‚ └─> Execute task.runFromJSThread() or equivalent β”‚ +β”‚ β”‚ +β”‚ 2. DRAIN MICROTASKS (Task.zig:508) β”‚ +β”‚ └─> drainMicrotasksWithGlobal() β”‚ +β”‚ β”‚ β”‚ +β”‚ β”œβ”€> RELEASE WEAK REFS (event_loop.zig:129) β”‚ +β”‚ β”‚ └─> VM.releaseWeakRefs() β”‚ +β”‚ β”‚ β”‚ +β”‚ β”œβ”€> CALL JSC__JSGlobalObject__drainMicrotasks() β”‚ +β”‚ β”‚ (ZigGlobalObject.cpp:2793-2840) β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”œβ”€> IF nextTick queue exists and not empty: β”‚ +β”‚ β”‚ β”‚ └─> Call processTicksAndRejections() β”‚ +β”‚ β”‚ β”‚ (ProcessObjectInternals.ts:295-335) β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ └─> DO-WHILE loop: β”‚ +β”‚ β”‚ β”‚ β”œβ”€> Process ALL nextTick callbacks β”‚ +β”‚ β”‚ β”‚ β”‚ (with try/catch & async ctx) β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ └─> drainMicrotasks() β”‚ +β”‚ β”‚ β”‚ (promises, queueMicrotask) β”‚ +β”‚ β”‚ β”‚ WHILE queue not empty β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ └─> ALWAYS call vm.drainMicrotasks() again β”‚ +β”‚ β”‚ (safety net for any remaining microtasks) β”‚ +β”‚ β”‚ β”‚ +β”‚ └─> RUN DEFERRED TASK QUEUE (event_loop.zig:136-138)β”‚ +β”‚ └─> deferred_tasks.run() β”‚ +β”‚ (buffered writes, file sink flushes, etc.) β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Key Points + +#### Process.nextTick Ordering (`ZigGlobalObject.cpp:2818-2829`) + +The process.nextTick queue is special: +- It runs **before** microtasks +- After processing **all** nextTick callbacks in the current batch, microtasks are drained +- This creates batched processing with interleaving between nextTick generations and promises: + +```javascript +Promise.resolve().then(() => console.log('promise 1')); +process.nextTick(() => { + console.log('nextTick 1'); + Promise.resolve().then(() => console.log('promise 2')); +}); +process.nextTick(() => console.log('nextTick 2')); + +// Output: +// nextTick 1 +// nextTick 2 +// promise 1 +// promise 2 +``` + +If a nextTick callback schedules another nextTick, it goes to the next batch: + +```javascript +process.nextTick(() => { + console.log('nextTick 1'); + process.nextTick(() => console.log('nextTick 3')); + Promise.resolve().then(() => console.log('promise 2')); +}); +process.nextTick(() => console.log('nextTick 2')); +Promise.resolve().then(() => console.log('promise 1')); + +// Output: +// nextTick 1 +// nextTick 2 +// promise 1 +// promise 2 +// nextTick 3 +``` + +The implementation (`ProcessObjectInternals.ts:295-335`): +```typescript +function processTicksAndRejections() { + var tock; + do { + while ((tock = queue.shift()) !== null) { + // Run the callback with async context + try { + callback(...args); + } catch (e) { + reportUncaughtException(e); + } + } + + drainMicrotasks(); // ← Drain promises after each batch + } while (!queue.isEmpty()); +} +``` + +#### Deferred Task Queue (`DeferredTaskQueue.zig:44-61`) + +Runs after microtasks to batch operations: +- Used for buffered HTTP writes, file sink flushes +- Prevents re-entrancy issues +- Balances latency vs. throughput + +The queue maintains a map of `(pointer, task_fn)` pairs and runs each task. If a task returns `true`, it remains in the queue for the next drain; if `false`, it's removed. + +## I/O Polling Integration + +### uSockets Event Loop (`epoll_kqueue.c:251-320`) + +The I/O poll is integrated into the event loop via `us_loop_run_bun_tick()`: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ us_loop_run_bun_tick(): β”‚ +β”‚ β”‚ +β”‚ 1. EMIT PRE-CALLBACK (us_internal_loop_pre) β”‚ +β”‚ β”‚ +β”‚ 2. CALL Bun__JSC_onBeforeWait(jsc_vm) β”‚ +β”‚ └─> Notify VM we're about to block β”‚ +β”‚ β”‚ +β”‚ 3. POLL I/O β”‚ +β”‚ β”œβ”€> epoll_pwait2() [Linux] β”‚ +β”‚ └─> kevent64() [macOS/BSD] β”‚ +β”‚ └─> Block with timeout until I/O ready β”‚ +β”‚ β”‚ +β”‚ 4. FOR EACH READY POLL: β”‚ +β”‚ β”‚ β”‚ +β”‚ β”œβ”€> Check events & errors β”‚ +β”‚ β”‚ β”‚ +β”‚ └─> us_internal_dispatch_ready_poll() β”‚ +β”‚ β”‚ β”‚ +β”‚ └─> This enqueues tasks or callbacks that will: β”‚ +β”‚ - Add tasks to the concurrent task queue β”‚ +β”‚ - Eventually trigger drainMicrotasks β”‚ +β”‚ β”‚ +β”‚ 5. EMIT POST-CALLBACK (us_internal_loop_post) β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### I/O Events Handling + +When I/O becomes ready (socket readable/writable, file descriptor ready): +1. The poll is dispatched via `us_internal_dispatch_ready_poll()` or `Bun__internal_dispatch_ready_poll()` +2. This triggers the appropriate callback **synchronously during the I/O poll phase** +3. The callback may: + - Directly execute JavaScript (must use `EventLoop.enter()/exit()`) + - Enqueue a task to the concurrent task queue for later processing + - Update internal state and return (e.g., `FilePoll.onUpdate()`) +4. If JavaScript is called via `enter()/exit()`, microtasks are drained when `entered_event_loop_count` reaches 0 + +**Important**: I/O callbacks don't automatically get the microtask draining behavior - they must explicitly wrap JS calls in `enter()/exit()` or use `runCallback()` to ensure proper microtask handling. This is why some I/O operations enqueue tasks to the concurrent queue instead of running JavaScript directly. + +## setTimeout and setInterval Ordering + +Timers are handled differently based on platform: + +### POSIX (`event_loop.zig:396`) +```zig +ctx.timer.drainTimers(ctx); +``` + +Timers are drained after I/O polling. Each timer callback: +1. Is wrapped in `enter()`/`exit()` +2. Triggers microtask draining after execution +3. Can enqueue new tasks + +### Windows +Uses the uv_timer_t mechanism integrated into the uSockets loop. + +### Timer vs. setImmediate Ordering + +```javascript +setTimeout(() => console.log('timeout'), 0); +setImmediate(() => console.log('immediate')); + +// Output is typically: +// immediate +// timeout +``` + +This is because: +- `setImmediate` runs in `tickImmediateTasks()` before I/O polling +- `setTimeout` fires after I/O polling (even with 0ms) +- However, this can vary based on timing and event loop state + +## Enter/Exit Mechanism + +The event loop uses a counter to track when to drain microtasks: + +```zig +pub fn enter(this: *EventLoop) void { + this.entered_event_loop_count += 1; +} + +pub fn exit(this: *EventLoop) void { + const count = this.entered_event_loop_count; + if (count == 1 and !this.virtual_machine.is_inside_deferred_task_queue) { + this.drainMicrotasksWithGlobal(this.global, this.virtual_machine.jsc_vm) catch {}; + } + this.entered_event_loop_count -= 1; +} +``` + +This ensures microtasks are only drained once per top-level event loop task, even if JavaScript calls into native code which calls back into JavaScript multiple times. + +## Summary + +The Bun event loop processes work in this order: + +1. **Immediate tasks** (setImmediate) +2. **I/O polling** (epoll/kqueue) +3. **Timer callbacks** (setTimeout/setInterval) +4. **Regular tasks** from the task queue + - For each task: + - Run the task + - Release weak references + - Check for nextTick queue + - If active: Run nextTick callbacks, drain microtasks after each + - If not: Just drain microtasks + - Drain deferred task queue +5. **Handle rejected promises** + +This architecture ensures: +- βœ… Correct Node.js semantics for process.nextTick vs. promises +- βœ… Efficient batching of I/O operations +- βœ… Minimal microtask latency +- βœ… Prevention of infinite loops from self-enqueueing tasks +- βœ… Proper async context propagation