CPU 집약적 작업에서 이벤트 루프가 멈추는 이유
차단을 일으키는 계산을 식별하고 단일 스레드 JavaScript가 병렬 처리를 위해 실제 스레드를 필요로 하는 이유를 이해합니다.
CPU 집약적 작업에서 이벤트 루프가 멈추는 이유은(는) CoddyKit의 무료 Node.js Backend Development Bootcamp 강의입니다. 이것은 4개 중 1번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Node.js Backend Development Bootcamp 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Node.js Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.
이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.
One Thread to Run Them All
Node.js runs your JavaScript on a single thread. Every request handler, timer callback, and promise continuation takes turns executing on that one thread, coordinated by the event loop.
This design is brilliant for I/O-bound work: while you wait for a database query or an HTTP response, the thread is free to handle other requests. The waiting happens elsewhere (in the OS and libuv's thread pool), not in your JavaScript.
But there is a catch. If a single callback decides to do heavy computation, that one thread is busy crunching numbers and nothing else can run until it finishes. In this lesson we explore exactly why CPU-bound work stalls the event loop.
The Event Loop in One Picture
The event loop is a simple idea: a loop that repeatedly pulls the next ready callback from a queue and runs it to completion before pulling the next one.
- A timer fires → run its callback.
- A socket has data → run its callback.
- A promise resolves → run its continuation.
Crucially, callbacks are cooperative: the loop cannot interrupt a callback in the middle. JavaScript has no preemption. Each callback must voluntarily return to give the loop a chance to do anything else.
const server = require('http').createServer((req, res) => {
// This callback must RETURN quickly so the loop can serve
// the next request. The loop won't interrupt it midway.
res.end('ok');
});
server.listen(3000, () => {
console.log('Listening on http://localhost:3000');
});I/O-Bound vs CPU-Bound
Understanding the stall starts with classifying work:
- I/O-bound: most time is spent waiting for an external resource — disk, network, database. The CPU is mostly idle.
- CPU-bound: most time is spent computing — hashing, image resizing, parsing huge JSON, compression, cryptography, big loops. The CPU is pinned at 100%.
Node's single-threaded model shines for I/O-bound workloads because waiting does not occupy the thread. It struggles with CPU-bound workloads because computing does occupy the thread — and there is only one.
Async I/O Does Not Block
Asynchronous I/O is non-blocking precisely because the heavy waiting is delegated. When you call an async file read, Node hands the work to libuv (and the OS), registers a callback, and immediately returns control to the event loop.
The loop is free to handle thousands of other things while the disk does its job. When the read completes, your callback is queued. The thread never sat idle spinning.
This is the key insight: awaiting I/O is free for the event loop. The thread is released back to do other work.
const fs = require('fs/promises');
async function main() {
console.log('before read');
// Control returns to the loop while the disk works.
const data = await fs.readFile(__filename, 'utf8');
console.log('read', data.length, 'bytes');
}
main();
console.log('this prints BEFORE the read finishes');A CPU-Bound Function That Blocks
Now contrast that with computation. A tight loop summing a billion numbers does not wait for anything — it keeps the thread fully occupied from start to finish.
While heavyCompute() runs, the event loop is frozen. No timers fire, no incoming requests are accepted, no promise continuations run. The whole process appears hung until the function returns.
Run this and notice that the program produces no output at all until the loop finishes — there is no point where control returns to the loop mid-computation.
function heavyCompute() {
let total = 0;
for (let i = 0; i < 2_000_000_000; i++) {
total += i;
}
return total;
}
console.time('compute');
const result = heavyCompute();
console.timeEnd('compute');
console.log('result =', result);Proof: Timers Starve During Computation
Here is a direct demonstration of the stall. We schedule a timer for 100ms, then immediately start a CPU-bound loop that takes much longer.
You might expect the timer to fire after 100ms. It does not. The loop is busy computing and cannot interrupt itself to run the timer callback. The timer only fires after the computation returns — often seconds late.
This is the event loop stall in its purest form: a queued callback that is ready to run but is denied the thread.
const start = Date.now();
setTimeout(() => {
console.log('Timer fired after', Date.now() - start, 'ms (asked for 100)');
}, 100);
// Block the single thread for ~2 seconds.
const end = Date.now() + 2000;
while (Date.now() < end) {
// busy-wait, no I/O, no yielding
}
console.log('Blocking loop done after', Date.now() - start, 'ms');async/await Does NOT Help CPU Work
A common misconception: wrapping CPU work in an async function or adding await will make it non-blocking. It will not.
async/await only yields the thread at an actual await point that suspends on a real asynchronous operation (I/O, a timer, a microtask). A pure computation has no such suspension point — it runs synchronously regardless of the async keyword.
In the snippet, marking the function async changes nothing: the loop still blocks for the full duration of the loop.
async function compute() {
let total = 0;
// No await inside a hot loop = still fully synchronous.
for (let i = 0; i < 2_000_000_000; i++) {
total += i;
}
return total;
}
setTimeout(() => console.log('timer wanted at 50ms'), 50);
console.time('compute');
compute().then((r) => {
console.timeEnd('compute');
console.log('result =', r);
});Why This Wrecks a Backend
On a server, one blocked thread means every concurrent client suffers. While one request runs a 3-second CPU task, all other requests queued on that thread wait the full 3 seconds before they are even read.
- Latency spikes for unrelated endpoints.
- Health-check pings time out, and orchestrators may kill the "unresponsive" process.
- Throughput collapses: one core, one task at a time.
The server is not crashed — it is simply monopolized. This is why a single heavy synchronous handler can take down an entire Node service under load.
const http = require('http');
http.createServer((req, res) => {
if (req.url === '/heavy') {
let t = 0;
for (let i = 0; i < 5_000_000_000; i++) t += i; // blocks everyone
return res.end('done ' + t);
}
// /ping cannot respond while /heavy is running on the same thread
res.end('pong');
}).listen(3000);Chunking Helps a Little, Not Enough
One partial mitigation is to break the work into chunks and setImmediate between them, letting the loop breathe between slices.
This keeps the server responsive — pings can be answered between chunks — but it does not add parallelism. The computation still runs on the one thread, now interleaved with other callbacks, so the total wall-clock time for the heavy task usually gets longer, not shorter.
Chunking trades latency-for-others against throughput-for-the-task. It cannot use a second CPU core. For true parallelism you need real threads.
function computeChunked(total, i, end, done) {
const sliceEnd = Math.min(i + 10_000_000, end);
for (; i < sliceEnd; i++) total += i;
if (i < end) {
// Yield to the loop, then continue next tick.
setImmediate(() => computeChunked(total, i, end, done));
} else {
done(total);
}
}
computeChunked(0, 0, 2_000_000_000, (r) => console.log('result =', r));
setInterval(() => console.log('loop still alive'), 200);The Real Fix: Move Off the Main Thread
Because JavaScript on the main thread is single-threaded and cooperative, the only way to run CPU-bound work in parallel — using more than one CPU core — is to move it to a separate thread of execution.
Node gives you options:
- Worker Threads (
worker_threads): real OS threads inside the same process, each with its own event loop and V8 isolate. Ideal for CPU-bound tasks. - Cluster / child processes: separate processes, more isolation, higher overhead.
The main thread offloads the heavy job, stays responsive to I/O, and collects the result via a message when the worker finishes.
A Worker Thread Keeps the Loop Free
Here is the shape of the solution. The main thread spawns a Worker that runs the heavy computation on a different thread and a different core. The main event loop stays free to serve requests and respond to timers.
When the worker finishes, it posts a message back. The main thread's callback runs that result through the event loop — non-blocking, parallel, and scalable across cores.
This is the foundation of CPU-bound parallelism in Node, which the rest of this course builds on.
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
const worker = new Worker(__filename);
worker.on('message', (sum) => console.log('worker result =', sum));
// Main loop is NOT blocked: this timer fires on time.
setInterval(() => console.log('main thread responsive'), 200);
} else {
let total = 0;
for (let i = 0; i < 2_000_000_000; i++) total += i;
parentPort.postMessage(total);
}Quick Check
A Node HTTP handler runs a synchronous 4-second image-hashing loop. During those 4 seconds, what happens to a second client hitting a different, lightweight endpoint?
Recap
Key takeaways from this lesson:
- Node runs JavaScript on a single, cooperative event-loop thread — callbacks run to completion and cannot be interrupted.
- I/O-bound work is non-blocking because waiting is delegated to libuv/OS; the thread is released.
- CPU-bound work occupies the thread the entire time, freezing timers, requests, and promise continuations — the event loop stalls.
async/awaitdoes not help pure computation; it only yields at real asynchronous suspension points.- Chunking with
setImmediaterestores responsiveness but adds no parallelism and cannot use extra cores. - The real solution is Worker Threads: move CPU-bound work to a separate thread/core so the main loop stays free.
자주 묻는 질문
“CPU 집약적 작업에서 이벤트 루프가 멈추는 이유” 강의는 무료인가요?
네 — “CPU 집약적 작업에서 이벤트 루프가 멈추는 이유” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Node.js Backend Development Bootcamp 강의 전체를 잠금 해제할 수 있습니다. Node.js Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.
“CPU 집약적 작업에서 이벤트 루프가 멈추는 이유”에서 뭘 배우나요?
차단을 일으키는 계산을 식별하고 단일 스레드 JavaScript가 병렬 처리를 위해 실제 스레드를 필요로 하는 이유를 이해합니다. 브라우저에서 직접 실행하는 실습 코드로 Node.js Backend Development Bootcamp을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Node.js Backend Development Bootcamp을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Node.js Backend Development Bootcamp은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 1번째 강의입니다.
“CPU 집약적 작업에서 이벤트 루프가 멈추는 이유” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Node.js Backend Development Bootcamp 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Node.js Backend Development Bootcamp 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- CPU 집약적 작업에서 이벤트 루프가 멈추는 이유
- 워커 스레드 생성 및 메시지 전달
- SharedArrayBuffer 및 Atomics를 활용한 메모리 공유
- 처리량 향상을 위한 재사용 가능한 워커 풀 만들기