이벤트 루프는 가능한 경우 작업을 시스템 커널로 오프로드하여 비차단 I/O 작업(JavaScript가 단일 스레드임에도 불구하고)을 처리하는 Node.js의 메커니즘입니다.
오늘날 대부분의 코어는 멀티스레드이므로 백그라운드에서 다양한 작업을 처리할 수 있습니다. 작업 중 하나가 완료되면 커널은 Node.js에 적절한 콜백 함수를 폴링 대기열에 추가하고 실행 기회를 기다리도록 알립니다. 이 글의 뒷부분에서 자세히 소개하겠습니다.
Node.js가 시작되면 이벤트 루프를 초기화하고 제공된 입력 스크립트를 처리합니다(또는 이 기사에서 다루지 않는 REPL에 이를 처리합니다). 이는 일부 비동기 API, 예약 타이머를 호출할 수 있습니다. 또는 process.nextTick()
호출한 다음 이벤트 루프 처리를 시작합니다.
아래 다이어그램은 이벤트 루프의 작업 순서에 대한 간략한 개요를 보여줍니다.
┌───────────────────────────┐ ┌─>│ 타이머 │ │ └─────────────┬─────────────┘ │ ┌─────────────┴────────────┐ │ │ 보류 중인 콜백 │ │ └─────────────┬─────────────┘ │ ┌─────────────┴────────────┐ │ │ 유휴, 준비 │ │ └─────────────┬────────────┘ ┌───────────────┐ │ ┌─────────────┴────────────┐ │ 수신: │ │ │ 설문조사 │<─────┤ 연결, │ │ └─────────────┬─────────────┘ │ 데이터 등 │ │ ┌─────────────┴────────────┐ └───────────────┘ │ │ 확인 │ │ └─────────────┬─────────────┘ │ ┌─────────────┴────────────┐ └──┤ 콜백 닫기 │ └─────────────────────────────┘
참고: 각 상자를 이벤트 루프 메커니즘의 단계라고 합니다.
각 단계에는 콜백을 실행하기 위한 FIFO 대기열이 있습니다. 각 단계는 특별하지만 일반적으로 이벤트 루프가 특정 단계에 들어갈 때 해당 단계에 특정한 작업을 수행한 다음 대기열이 소진되거나 최대 콜백 수가 실행될 때까지 해당 단계의 대기열에서 콜백을 실행합니다. 대기열이 소진되거나 콜백 제한에 도달하면 이벤트 루프가 다음 단계로 이동합니다.
이러한 작업 중 하나는 폴링 단계에서 처리되도록 커널에 의해 대기열에 추가된 작업과 새 이벤트를 예약할 수 있으므로 폴링 단계에서 이벤트를 처리하는 동안 폴링 이벤트가 대기열에 포함될 수 있습니다. 따라서 장기 실행 콜백을 사용하면 폴링 단계가 타이머의 임계값 시간보다 오래 실행될 수 있습니다. 자세한 내용은 타이머 및 폴링 섹션을 참조하세요.
참고: Windows 구현과 Unix/Linux 구현 사이에는 미묘한 차이가 있지만 이는 데모 목적에서는 중요하지 않습니다. 가장 중요한 부분은 여기에 있습니다. 실제로는 7~8개의 단계가 있지만 우리가 관심을 갖는 것은 Node.js가 실제로 위의 단계 중 일부를 사용한다는 것입니다.
setTimeout()
및 setInterval()
인 스케줄링 콜백 함수를 실행합니다.setImmediate()
에 의해 예약된 폐쇄형 콜백 함수 제외). 다른 경우에는 적절한 경우 노드가 여기에서 차단됩니다.setImmediate()
콜백 함수가 실행됩니다.socket.on('close', ...)
과 같은 일부 닫기 콜백 함수.각 이벤트 루프 실행 사이에 Node.js는 비동기 I/O 또는 타이머를 기다리고 있는지 확인하고 그렇지 않은 경우 완전히 종료됩니다.
단계타이머는 사용자가 콜백을 실행하기를 원하는 정확한 시간이 아니라 제공된 콜백이 실행될 수 있는 임계값을 지정합니다. 지정된 간격이 지나면 타이머 콜백이 최대한 빨리 실행됩니다. 그러나 운영 체제 예약이나 기타 실행 중인 콜백으로 인해 지연될 수 있습니다.
참고 : 폴링 단계는 타이머가 실행되는 시기를 제어합니다.
예를 들어, 100밀리초 후에 시간 초과되는 타이머를 예약한 다음 스크립트가 95밀리초가 걸리는 파일을 비동기적으로 읽기 시작한다고 가정해 보겠습니다.
const fs = require('fs'); 함수 someAsyncOperation(콜백) { // 완료하는 데 95ms가 걸린다고 가정합니다. fs.readFile('/path/to/file', 콜백); } const timeoutScheduled = Date.now(); setTimeout(() => { const 지연 = Date.now() - timeoutScheduled; console.log(`예약된 이후 ${delay}ms가 지났습니다`); }, 100); // 완료하는 데 95ms가 걸리는 someAsyncOperation을 수행합니다. someAsyncOperation(() => { const startCallback = Date.now(); // 10ms가 걸리는 작업을 수행합니다... while (Date.now() - startCallback < 10) { //아무것도 하지 않음 } });
이벤트 루프가 폴링 단계에 들어가면 빈 대기열( fs.readFile()
아직 완료되지 않음)이 있으므로 가장 빠른 타이머 임계값에 도달할 때까지 남은 밀리초 동안 기다립니다. fs.readFile()
이 파일 읽기를 완료할 때까지 95밀리초를 기다리면 완료하는 데 10밀리초가 걸리는 콜백이 폴링 대기열에 추가되어 실행됩니다. 콜백이 완료되면 대기열에 더 이상 콜백이 없으므로 이벤트 루프 메커니즘은 임계값에 가장 빠르게 도달한 타이머를 살펴본 다음 타이머 단계로 돌아가 타이머의 콜백을 실행합니다. 이 예에서는 타이머가 예약된 시점과 콜백이 실행되는 시점 사이의 총 지연 시간이 105밀리초라는 것을 알 수 있습니다.
참고: 폴링 단계로 인해 이벤트 루프가 중단되는 것을 방지하기 위해 libuv(Node.js 이벤트 루프 및 플랫폼의 모든 비동기 동작을 구현하는 C 라이브러리)에도 엄격한 최대값(시스템에 따라 다름)이 있습니다.
이 단계에서는 특정 시스템 작업(예: TCP 오류 유형)에 대한 콜백을 실행합니다. 예를 들어, 일부 *nix 시스템은 연결을 시도할 때 TCP 소켓이 ECONNREFUSED
수신하는 경우 오류 보고를 기다리기를 원합니다. 이는 보류 중인 콜백 단계 동안 실행을 위해 대기열에 추가됩니다.
폴링 단계에는
I/O를 차단하고 폴링해야 하는 기간을 계산하는
그런 다음 폴링 대기열의 이벤트를 처리합니다.
이벤트 루프가 폴링 단계에 들어가고 예약된 타이머가 없으면 두 가지 중 하나가 발생합니다.
폴링 대기열이 비어 있지 않으면
이벤트 루프는 콜백 대기열을 반복하고 대기열이 비워질 때까지 동기적으로 실행합니다. , 또는 시스템 관련 하드 제한에 도달했습니다.
폴링 큐가 비어 있으면 두 가지 일이 더 발생합니다.
스크립트가 setImmediate()
에 의해 예약된 경우 이벤트 루프는 폴링 단계를 종료하고 예약된 스크립트를 실행하기 위해 확인 단계를 계속합니다.
스크립트가 setImmediate()
에 의해 예약 되지 않은 경우 이벤트 루프는 콜백이 대기열에 추가될 때까지 기다린 다음 즉시 실행합니다.
폴 큐가 비어 있으면 이벤트 루프는 시간 임계값에 도달한 타이머를 확인합니다. 하나 이상의 타이머가 준비되면 이벤트 루프는 타이머 단계로 돌아가서 해당 타이머에 대한 콜백을 실행합니다.
이 단계에서는 폴링 단계가 완료된 후 즉시 콜백을 실행할 수 있습니다. 폴링 단계가 유휴 상태가 되고 setImmediate()
사용한 후 스크립트가 대기열에 있으면 이벤트 루프는 기다리는 대신 확인 단계를 계속할 수 있습니다.
setImmediate()
는 실제로 이벤트 루프의 별도 단계에서 실행되는 특수 타이머입니다. libuv API를 사용하여 폴링 단계가 완료된 후 실행될 콜백을 예약합니다.
일반적으로 코드를 실행할 때 이벤트 루프는 결국 폴링 단계에 도달하여 들어오는 연결, 요청 등을 기다립니다. 그러나 setImmediate()
사용하여 콜백을 예약하고 폴링 단계가 유휴 상태가 되면 폴링 이벤트를 계속 기다리는 대신 이 단계를 종료하고 확인 단계를 계속 진행합니다.
소켓이나 핸들러가 갑자기 닫히면(예: socket.destroy()
) 이 단계에서 'close'
이벤트가 발생합니다. 그렇지 않으면 process.nextTick()
통해 방출됩니다.
setImmediate()
및 setTimeout()
은 매우 유사하지만 호출 시점에 따라 다르게 동작합니다.
setImmediate()
는 현재 폴링 단계가 완료되면 스크립트를 실행하도록 설계되었습니다.setTimeout()
최소 임계값(ms 단위)이 경과한 후 스크립트를 실행합니다.타이머가 실행되는 순서는 호출되는 컨텍스트에 따라 달라집니다. 둘 다 기본 모듈 내에서 호출되는 경우 타이머는 프로세스 성능에 따라 제한됩니다(컴퓨터에서 실행 중인 다른 응용 프로그램의 영향을 받을 수 있음).
예를 들어, I/O 주기(예: 기본 모듈) 내부가 아닌 다음 스크립트를 실행하는 경우 두 타이머가 실행되는 순서는 프로세스 성능에 따라 제한되므로 결정적이지 않습니다.
// timeout_vs_immediate.js setTimeout(() => { console.log('타임아웃'); }, 0); setImmediate(() => { console.log('즉시'); }); $ 노드 timeout_vs_immediate.js 시간 초과 즉각적인 $ 노드 timeout_vs_immediate.js 즉각적인 timeout
그러나 이 두 함수를 I/O 루프에 넣고 호출하면 setImmediate가 항상 먼저 호출됩니다.
// timeout_vs_immediate.js const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('타임아웃'); }, 0); setImmediate(() => { console.log('즉시'); }); }); $ 노드 timeout_vs_immediate.js 즉각적인 시간 초과 $ 노드 timeout_vs_immediate.js 즉각적인setTimeout()보다
시간 초과에 setImmediate()를 사용하는 경우의 주요 이점은 I/O 주기 중에 setImmediate()가 예약된 경우
setTimeout()
setImmediate()
setImmediate()
타이머 수에 따라 타이머보다 먼저 실행된다는 것입니다.
process.nextTick()
눈치챘을 것입니다. 이는 process.nextTick()
이 기술적으로 이벤트 루프의 일부가 아니기 때문입니다. 대신 이벤트 루프의 현재 단계에 관계없이 현재 작업이 완료된 후 nextTickQueue
처리합니다. 여기서 작업은 기본 C/C++ 프로세서로부터의 전환으로 간주되며 실행해야 하는 JavaScript 코드를 처리합니다.
다이어그램을 되돌아보면, 주어진 단계에서 process.nextTick()
이 호출될 때마다 process.nextTick()
에 전달된 모든 콜백은 이벤트 루프가 계속되기 전에 해결됩니다. 이는 재귀적인 process.nextTick()
호출을 통해 I/O를 "고갈"시켜 이벤트 루프가 폴링 단계에 도달하는 것을 방지하므로 몇 가지 나쁜 상황을 만들 수 있습니다.
Node.js에 이와 같은 것이 포함된 이유는 무엇입니까? 그 중 일부는 API가 꼭 그럴 필요는 없더라도 항상 비동기식이어야 한다는 디자인 철학입니다. 다음 코드 조각을 예로 들어 보겠습니다.
function apiCall(arg, callback) { if (인수 유형 !== '문자열') 반환 프로세스.nextTick( 콜백, new TypeError('인수는 문자열이어야 합니다.') ); }
매개변수 확인을 위한 코드 조각입니다. 정확하지 않으면 오류가 콜백 함수로 전달됩니다. API는 최근 process.nextTick()
에 인수를 전달할 수 있도록 업데이트되었습니다. 그러면 콜백 함수 위치 뒤에 있는 모든 인수를 허용하고 인수를 콜백 함수에 대한 인수로 전달할 수 있습니다. 기능을 중첩합니다.
우리가 하고 있는 일은 오류를 사용자에게 다시 전달하는 것입니다. 그러나 사용자 코드의 나머지 부분이 실행된 후에만 가능합니다. process.nextTick()
사용하면 apiCall()
항상 사용자 코드의 나머지 부분 이후 그리고 이벤트 루프가 계속되기 전에 콜백 함수를 실행하도록 보장합니다. 이를 달성하기 위해 JS 호출 스택은 해제된 다음 제공된 콜백을 즉시 실행할 수 있도록 허용하여 RangeError: 超过V8 的最大调用堆栈大小
하지 않고 process.nextTick()
에 대한 재귀 호출이 이루어질 수 있도록 합니다.
이 설계 원칙은 몇 가지 잠재적인 문제를 초래할 수 있습니다. 다음 코드 조각을 예로 들어 보겠습니다.
let bar; // 비동기 서명이 있지만 콜백을 동기적으로 호출합니다. 함수 someAsyncApiCall(콜백) { 콜백(); } // `someAsyncApiCall`이 완료되기 전에 콜백이 호출됩니다. someAsyncApiCall(() => { // someAsyncApiCall이 완료되었으므로 bar에는 어떤 값도 할당되지 않았습니다. console.log('bar', bar); // 정의되지 않음 }); bar = 1;
사용자는 someAsyncApiCall()
비동기 서명이 있는 것으로 정의하지만 실제로는 동기적으로 실행됩니다. 호출될 때 someAsyncApiCall()
에 제공된 콜백은 이벤트 루프의 동일한 단계 내에서 호출됩니다. someAsyncApiCall()
실제로 비동기식으로 아무것도 수행하지 않기 때문입니다. 결과적으로 콜백 함수는 bar
참조하려고 시도하지만 스크립트 실행이 아직 완료되지 않았기 때문에 변수가 아직 범위 내에 있지 않을 수 있습니다.
process.nextTick()
에 콜백을 배치하면 스크립트가 완료될 때까지 실행할 수 있으므로 콜백이 호출되기 전에 모든 변수, 함수 등을 초기화할 수 있습니다. 또한 이벤트 루프를 계속 진행하지 않는다는 장점도 있으며, 이벤트 루프를 계속 진행하기 전에 오류가 발생할 때 사용자에게 경고하는 데 적합합니다. 다음은 process.nextTick()
사용한 이전 예제입니다.
let bar; 함수 someAsyncApiCall(콜백) { process.nextTick(콜백); } someAsyncApiCall(() => { console.log('바', 바); // 1 }); bar = 1;
이것은 또 다른 실제 예입니다:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
포트가 전달된 경우에만 포트가 즉시 바인딩됩니다. 따라서 'listening'
콜백을 즉시 호출할 수 있습니다. 문제는 해당 시점에 .on('listening')
콜백이 설정되지 않았다는 것입니다.
이 문제를 해결하기 위해 'listening'
이벤트가 nextTick()
내에서 대기열에 추가되어 스크립트 실행이 완료될 수 있습니다. 이를 통해 사용자는 원하는 이벤트 핸들러를 설정할 수 있습니다.
사용자에 관한 한 두 가지 유사한 호출이 있지만 이름이 혼동됩니다.
process.nextTick()
동일한 단계에서 즉시 실행됩니다.setImmediate()
이벤트 루프의 다음 반복 또는 '틱'에서 실행됩니다.기본적으로 process.nextTick()
setImmediate()
보다 빠르게 실행되기 때문에 두 이름을 바꿔야 하지만 이는 과거의 유산이므로 변경될 가능성이 없습니다. 성급하게 이름 교환을 수행하면 npm에서 대부분의 패키지가 손상됩니다. 매일 더 많은 새로운 모듈이 추가되고 있으며, 이는 매일 기다려야 한다는 것을 의미하며 더 많은 잠재적 피해가 발생할 수 있다는 것을 의미합니다. 이러한 이름은 혼란스럽더라도 이름 자체는 변경되지 않습니다.
이해하기 쉽기 때문에 개발자는 모든 상황에서 setImmediate()
사용하는 것이 좋습니다.
두 가지입니다.
사용자가 오류를 처리하도록 허용하거나, 불필요한 리소스를 정리하거나, 이벤트 루프가 계속되기 전에 요청을 재시도하는 것입니다.
때로는 스택이 펼쳐진 후 이벤트 루프가 계속되기 전에 콜백을 실행해야 하는 경우도 있습니다.
다음은 사용자 기대를 충족하는 간단한 예입니다.
const server = net.createServer(); server.on('연결', (conn) => {}); 서버.듣기(8080); server.on('listening', () => {});
이벤트 루프의 시작 부분에서 listen()
실행되지만 수신 콜백은 setImmediate()
에 배치된다고 가정합니다. 호스트 이름이 전달되지 않으면 포트가 즉시 바인딩됩니다. 이벤트 루프가 계속 진행되려면 폴링 단계에 도달해야 합니다. 즉, 연결이 수신되었고 연결 이벤트가 수신 이벤트 전에 발생했을 가능성이 있음을 의미합니다.
또 다른 예는 EventEmitter
에서 상속하고 생성자를 호출하려는 함수 생성자를 실행합니다.
const EventEmitter = require('events'); const util = require('util'); 함수 MyEmitter() { EventEmitter.call(this); this.emit('이벤트'); } util.inherits(MyEmitter, EventEmitter); const myEmitter = 새로운 MyEmitter(); myEmitter.on('event', () => { console.log('이벤트가 발생했습니다!'); });
사용자가 이벤트에 콜백 함수를 할당하는 지점까지 스크립트가 아직 처리되지 않았기 때문에 생성자에서 이벤트를 즉시 트리거할 수 없습니다. 따라서 생성자 자체에서 process.nextTick()
사용하여 생성자가 완료된 후 이벤트가 발생하도록 콜백을 설정할 수 있습니다. 이는 예상대로입니다.
const EventEmitter = require('events'); const util = require('util'); 함수 MyEmitter() { EventEmitter.call(this); // 핸들러가 할당되면 nextTick을 사용하여 이벤트를 발생시킵니다. process.nextTick(() => { this.emit('이벤트'); }); } util.inherits(MyEmitter, EventEmitter); const myEmitter = 새로운 MyEmitter(); myEmitter.on('event', () => { console.log('이벤트가 발생했습니다!'); })
출처: https://nodejs.org/en/docs/guides/event-loop-timers-and-nextick/