Node는 원래 고성능 웹 서버를 구축하기 위해 만들어졌으며 JavaScript용 서버 측 런타임으로 이벤트 기반, 비동기 I/O, 단일 스레딩과 같은 기능을 갖추고 있습니다. 이벤트 루프를 기반으로 한 비동기 프로그래밍 모델을 통해 Node는 높은 동시성을 처리하고 서버 성능을 크게 향상시키는 동시에 JavaScript의 단일 스레드 특성을 유지하므로 Node에서 상태 동기화 및 문제를 처리할 필요가 없습니다. 다중 스레드에서 교착 상태가 발생합니다. 스레드 컨텍스트 전환으로 인한 성능 오버헤드가 없습니다. 이러한 특성을 바탕으로 Node는 고성능, 높은 동시성이라는 본질적인 장점을 갖고 있으며, 이를 기반으로 고속, 확장 가능한 다양한 네트워크 응용 플랫폼을 구축할 수 있습니다.
이 기사는 Node의 비동기 및 이벤트 루프의 기본 구현 및 실행 메커니즘에 대해 자세히 설명합니다.
Node가 핵심 프로그래밍 모델로 비동기식을 사용하는 이유는 무엇입니까?
앞서 언급했듯이 Node는 원래 고성능 웹 서버를 구축하기 위해 만들어졌습니다. 비즈니스 시나리오에서 완료해야 할 여러 가지 관련 없는 작업 집합이 있다고 가정하면
단일 스레드 직렬 실행이라는 두 가지 최신 주류 솔루션이 있습니다.
여러 스레드와 병렬로 완료됩니다.
단일 스레드 직렬 실행은 동기식 프로그래밍 모델이지만, I/O를 동기적으로 실행하기 때문에 프로그래머의 순차적 사고 방식에 더 가깝고 더 편리한 코드를 작성하기가 더 쉽습니다. 동시에 단일 요청으로 인해 서버가 느리게 응답하고 동시성이 높은 애플리케이션 시나리오에 적용할 수 없습니다. 또한 I/O를 차단하기 때문에 CPU는 항상 I/O가 완료될 때까지 기다리며 이를 수행할 수 없습니다. CPU의 처리 능력을 완전히 활용하려면 결국 효율성이 낮아지게 되며,
멀티 스레드 프로그래밍 모델은 프로그래밍의 상태 동기화 및 교착 상태와 같은 문제로 인해 개발자에게 골치 아픈 문제를 안겨주기도 합니다. 멀티스레딩은 멀티코어 CPU에서 CPU 활용도를 효과적으로 향상시킬 수 있습니다.
단일 스레드 직렬 실행과 다중 스레드 병렬 실행의 프로그래밍 모델은 나름대로 장점이 있지만 성능과 개발 난이도 측면에서 단점도 있습니다.
또한 클라이언트 요청에 대한 응답 속도부터 시작하여 클라이언트가 동시에 두 개의 리소스를 획득하는 경우 동기 방식의 응답 속도는 두 리소스의 응답 속도와 클라이언트의 응답 속도를 합한 값이 됩니다. 비동기식 방법은 둘 중 가장 큰 방법이며, 동기화에 비해 성능 이점이 매우 분명합니다. 애플리케이션 복잡성이 증가함에 따라 이 시나리오는 동시에 n개의 요청에 응답하도록 발전할 것이며 동기화에 비해 비동기의 장점이 강조될 것입니다.
요약하면 Node는 다음과 같이 대답합니다. 단일 스레드를 사용하여 다중 스레드 교착 상태, 상태 동기화 및 기타 문제를 방지하고, 비동기 I/O를 사용하여 단일 스레드가 차단되는 것을 방지하여 CPU를 더 잘 사용합니다. 이것이 Node가 핵심 프로그래밍 모델로 비동기식을 사용하는 이유입니다.
또한, 멀티코어 CPU를 활용하지 못하는 단일 스레드의 단점을 보완하기 위해 Node에서는 브라우저에 Web Workers와 유사한 하위 프로세스도 제공하여 작업자 프로세스를 통해 CPU를 효율적으로 활용할 수 있습니다.
비동기식을 사용해야 하는 이유에 대해 이야기한 후 비동기식을 구현하는 방법은 무엇입니까?
우리가 일반적으로 호출하는 비동기 작업에는 두 가지 유형이 있습니다. 하나는 파일 I/O 및 네트워크 I/O와 같은 I/O 관련 작업이고, 다른 하나는 setTimeOut
및 setInterval
과 같은 I/O와 관련 없는 작업입니다. 분명히 우리가 논의하고 있는 비동기식은 I/O와 관련된 작업, 즉 비동기식 I/O를 의미합니다.
비동기식 I/O는 I/O 호출로 인해 후속 프로그램의 실행이 차단되지 않고 I/O가 완료될 때까지 기다리는 원래 시간이 실행을 위해 필요한 다른 비즈니스에 할당되기를 바라면서 제안되었습니다. 이 목표를 달성하려면 비차단 I/O를 사용해야 합니다.
I/O 차단은 CPU가 I/O 호출을 시작한 후 I/O가 완료될 때까지 차단된다는 의미입니다. 차단 I/O를 알면 비차단 I/O는 이해하기 쉽습니다. CPU는 차단하고 기다리는 대신 I/O 호출을 시작한 후 즉시 반환됩니다. 분명히 차단 I/O에 비해 비차단 I/O의 성능이 더 향상되었습니다.
그렇다면 비차단 I/O가 사용되고 CPU는 I/O 호출을 시작한 후 즉시 반환할 수 있으므로 I/O가 완료되었음을 어떻게 알 수 있습니까? 정답은 여론조사입니다.
I/O 호출 상태를 적시에 얻기 위해 CPU는 I/O 작업이 완료되었는지 확인하기 위해 지속적으로 I/O 작업을 반복적으로 호출합니다. 이러한 작업이 완료되었는지 확인하기 위해 반복 호출하는 기술을 폴링이라고 합니다. .
당연히 폴링으로 인해 CPU는 상태 판단을 반복적으로 수행하게 되며 이는 CPU 리소스를 낭비하게 됩니다. 또한, 폴링 간격을 제어하기가 어렵습니다. 간격이 너무 길면 I/O 작업 완료 시 적시에 응답을 받지 못하므로 간격이 너무 짧으면 간접적으로 애플리케이션의 응답 속도가 느려집니다. 필연적으로 폴링에 CPU가 소비됩니다. 시간이 더 오래 걸리고 CPU 리소스 활용도가 줄어듭니다.
따라서 폴링은 비차단 I/O가 후속 프로그램의 실행을 차단하지 않는다는 요구 사항을 충족하지만 응용 프로그램에서는 여전히 I/O를 기다려야 하기 때문에 일종의 동기화로만 간주될 수 있습니다. O 완전히 돌아오려면 아직도 많은 시간을 기다려야 합니다.
우리가 기대하는 완벽한 비동기 I/O는 애플리케이션이 비차단 호출을 시작하는 것입니다. 대신 폴링을 통해 I/O 호출 상태를 지속적으로 쿼리할 필요가 없습니다. I/O가 완료되었습니다. 세마포어나 콜백을 통해 애플리케이션에 데이터를 전달하면 됩니다.
이 비동기 I/O를 어떻게 구현하나요? 답은 스레드 풀입니다.
이 글에서는 항상 Node가 단일 스레드에서 실행된다고 언급했지만, 여기서 단일 스레드는 JavaScript 코드가 단일 스레드에서 실행된다는 것을 의미합니다. I/O 작업과 같이 주요 비즈니스 로직과 관련이 없는 부분에 대해서는 스레드 형태로 다른 구현에서 실행하면 메인 스레드의 실행에 영향을 주거나 차단하지 않습니다. 반대로 메인 스레드의 실행 효율성을 향상시키고 비동기 I/O를 실현할 수 있습니다.
스레드 풀을 통해 메인 스레드는 I/O 호출만 수행하고 다른 스레드는 차단 I/O 또는 비차단 I/O와 폴링 기술을 수행하여 데이터 수집을 완료한 다음 스레드 간 통신을 사용하여 I/O를 완료합니다. /O 획득한 데이터가 전달되어 비동기 I/O를 쉽게 구현합니다.
메인 스레드는 I/O 호출을 수행하고, 스레드 풀은 I/O 작업을 수행하고 데이터 획득을 완료한 후 스레드 간 통신을 통해 데이터를 메인 스레드로 전달하여 I/O 호출을 완료하고, 메인 스레드는 재사용 콜백 함수는 사용자에게 데이터를 공개하고, 사용자는 데이터를 사용하여 비즈니스 로직 수준에서 작업을 완료합니다.
사용자는 기본 레이어의 번거로운 구현 세부 사항에대해
걱정할 필요가 없습니다. 아래와 같이 Node에 캡슐화된 비동기 API를 호출하고 비즈니스 로직을 처리하는 콜백 함수를 전달하기만 하면 됩니다.
("fs") ; fs.readFile('example.js', (데이터) => { // 비즈니스 로직 처리});
Nodejs의 비동기 기본 구현 메커니즘은 플랫폼마다 다릅니다. Windows에서 IOCP는 주로 시스템 커널에 I/O 호출을 보내고 커널에서 완료된 I/O 작업을 얻는 데 사용됩니다. 비동기 I/O 프로세스를 완료하기 위한 이벤트 루프를 사용하면 이 프로세스는 Linux에서는 epoll을 통해, FreeBSD에서는 kqueue를 통해, Solaris에서는 이벤트 포트를 통해 구현됩니다. 스레드 풀은 Windows에서 커널(IOCP)에 의해 직접 제공되는 반면 *nix
시리즈는 libuv 자체에 의해 구현됩니다.
Windows 플랫폼과 *nix
플랫폼의 차이로 인해 Node는 libuv를 추상 캡슐화 계층으로 제공하므로 모든 플랫폼 호환성 판단이 이 계층에서 완료되어 상위 계층 Node와 하위 계층 사용자 정의 스레드 풀 및 IOCP가 보장됩니다. 서로 독립적입니다. 노드는 컴파일 중에 플랫폼 조건을 결정하고 unix 디렉터리 또는 win 디렉터리의 소스 파일을 대상 프로그램으로 선택적으로 컴파일합니다.
위는 Node의 비동기 구현입니다.
(스레드 풀의 크기는 환경 변수 UV_THREADPOOL_SIZE
를 통해 설정할 수 있습니다. 기본값은 4입니다. 이 값의 크기는 실제 상황에 따라 사용자가 조정할 수 있습니다.)
그러면 문제는, 스레드 풀, 메인 스레드는 어떻게 콜백 함수가 호출됩니까? 답은 이벤트 루프입니다.
I/O 데이터 처리를 위해 콜백 함수를 사용하기 때문에 콜백 함수를 언제, 어떻게 호출할 것인가의 문제가 불가피하다. 실제 개발에서는 다중 및 다중 유형 비동기 I/O 호출 시나리오가 종종 포함됩니다. 이러한 비동기 I/O 콜백 호출을 합리적으로 정렬하고 비동기 콜백의 질서 있는 진행을 보장하는 방법은 또한 어려운 문제입니다. 비동기 I/O /O 외에도 타이머와 같은 비I/O 비동기 호출도 있습니다. 이러한 API는 실시간이며 그에 따라 우선순위가 더 높은 콜백을 예약하는 방법은 무엇입니까?
따라서 이러한 작업이 기본 스레드에서 순서대로 실행되도록 하기 위해 다양한 우선 순위와 유형의 비동기 작업을 조정하는 예약 메커니즘이 있어야 합니다. 브라우저와 마찬가지로 Node는 이러한 무거운 작업을 수행하기 위해 이벤트 루프를 선택했습니다.
노드는 작업 유형과 우선 순위에 따라 타이머, 보류 중, 유휴, 준비, 폴링, 확인 및 닫기 등 7가지 범주로 작업을 나눕니다. 각 작업 유형마다 작업과 해당 콜백을 저장하는 선입 선출 작업 대기열이 있습니다(타이머는 작은 상단 힙에 저장됨). 이 7가지 유형을 기반으로 Node는 이벤트 루프의 실행을 다음 7가지 단계로 나눕니다.
이단계의 실행 우선순위
가 가장 높습니다.이 단계에서 이벤트 루프는 타이머를 저장하는 데이터 구조(최소 힙)를 확인하고 타이머를 순회하며 현재 시간과 만료 시간을 하나씩 비교하고 타이머가 만료되었는지 확인합니다. , 타이머는 콜백 함수가 꺼내지고 실행됩니다.
단계에서는 네트워크, IO 및 기타 예외가 발생할 때 콜백을 실행합니다. *nix
에서 보고된 일부 오류는 이 단계에서 처리됩니다. 또한 이전 주기의 폴 단계에서 실행되어야 하는 일부 I/O 콜백이 이 단계로 연기됩니다.
단계는 이벤트 루프 내에서만 사용됩니다.
새로운 I/O 이벤트를 검색하고 I/O 관련 콜백을 실행합니다(종료 콜백, 타이머 예약 콜백 및 setImmediate()
를 제외한 거의 모든 콜백). 노드는 적절한 시간에 여기에서 차단됩니다.
Poll, 즉 폴링 단계는 이벤트 루프의 가장 중요한 단계로 네트워크 I/O 및 파일 I/O에 대한 콜백이 주로 이 단계에서 처리됩니다. 이 단계에는 두 가지 주요 기능이 있습니다. 즉,
이 단계가 I/O를 차단하고 폴링해야 하는 기간을 계산하는 것입니다.
I/O 대기열에서 콜백을 처리합니다.
이벤트 루프가 폴 단계에 들어가고 타이머가 설정되지 않은 경우:
폴 큐가 비어 있지 않으면 이벤트 루프는 큐를 순회하며 큐가 비어 있거나 실행될 수 있는 최대 수에 도달할 때까지 동기적으로 실행합니다.
폴링 대기열이 비어 있으면 다른 두 가지 중 하나가 발생합니다.
실행해야 할 setImmediate()
콜백이 있으면 폴 단계가 즉시 종료되고 콜백을 실행하기 위해 확인 단계로 들어갑니다.
실행할 setImmediate()
콜백이 없으면 이벤트 루프는 콜백이 대기열에 추가될 때까지 대기한 후 즉시 실행합니다. 이벤트 루프는 제한 시간이 만료될 때까지 기다립니다. 여기에서 멈추기로 한 이유는 Node가 주로 IO를 처리하기 때문에 보다 시기적절하게 IO에 대응할 수 있기 때문입니다.
폴링 큐가 비면 이벤트 루프는 시간 임계값에 도달한 타이머를 확인합니다. 하나 이상의 타이머가 시간 임계값에 도달하면 이벤트 루프는 타이머 단계로 돌아가 이러한 타이머에 대한 콜백을 실행합니다.
단계에서는 setImmediate()
의 콜백을 순서대로 실행합니다.
이 단계에서는 socket.on('close', ...)
과 같은 리소스를 닫기 위해 일부 콜백을 실행합니다. 이 단계의 실행 지연은 영향이 거의 없으며 우선순위가 가장 낮습니다.
노드 프로세스가 시작되면 이벤트 루프를 초기화하고, 사용자의 입력 코드를 실행하고, 해당 비동기 API 호출, 타이머 스케줄링 등을 수행한 다음 이벤트 루프에 들어가기 시작합니다.
┌───────── ── ────────────────┐ ┌─>│ 타이머 │ │ └─────────────┬─────────────┘ │ ┌─────────────┴────────────┐ │ │ 보류 중인 콜백 │ │ └─────────────┬─────────────┘ │ ┌─────────────┴────────────┐ │ │ 유휴, 준비 │ │ └─────────────┬────────────┘ ┌───────────────┐ │ ┌─────────────┴────────────┐ │ 수신: │ │ │ 설문조사 │<─────┤ 연결, │ │ └─────────────┬─────────────┘ │ 데이터 등 │ │ ┌─────────────┴────────────┐ └───────────────┘ │ │ 확인 │ │ └─────────────┬─────────────┘ │ ┌─────────────┴────────────┐ └──┤ 콜백 닫기 │ └─────────────────────────────┘
이벤트 루프(종종 틱이라고 함)의 각 반복은 위와 같습니다. order는 7개의 실행 단계에 들어갑니다. 각 단계에서는 대기열에 있는 특정 개수의 콜백이 실행됩니다. 특정 개수만 실행되고 모두 실행되지 않는 이유는 현재 단계의 실행 시간이 너무 길어지는 것을 방지하기 위한 것입니다. 다음 단계의 실패를 피하십시오. 실행되지 않습니다.
자, 위는 이벤트 루프의 기본 실행 흐름입니다. 이제 또 다른 질문을 살펴보겠습니다.
다음 시나리오의 경우:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
서비스가 포트 8000에 성공적으로 바인딩되면, 즉 listen()
성공적으로 호출되면 listening
이벤트의 콜백이 아직 바인딩되지 않았으므로 포트가 성공적으로 바인딩된 후에는 전달한 listening
이벤트의 콜백이 실행되지 않습니다.
또 다른 질문을 생각해보면, 개발 중에 오류 처리, 불필요한 리소스 정리, 우선순위가 낮은 기타 작업과 같은 몇 가지 요구 사항이 있을 수 있습니다. 이러한 논리가 동기식으로 실행되면 현재 작업의 효율성에 영향을 미칠 것입니다. 콜백 등의 형태로 setImmediate()
비동기적으로 전달하는 경우 실행 시점을 보장할 수 없으며 실시간 성능도 높지 않습니다. 그렇다면 이러한 논리를 어떻게 처리해야 할까요?
이러한 문제를 기반으로 Node는 브라우저에서 참조를 가져와 일련의 마이크로 작업 메커니즘을 구현했습니다. Node에서는 new Promise().then()
호출하는 것 외에도 전달된 콜백 함수가 마이크로태스크에 캡슐화됩니다. process.nextTick()
의 콜백도 마이크로태스크에 캡슐화되며, 후자가 전자보다 높을 것이다.
마이크로태스크의 경우 이벤트 루프의 실행 프로세스는 무엇입니까? 즉, 마이크로태스크는 언제 실행되는가?
노드 11 이상 버전에서는 단계의 작업이 실행되면 마이크로 작업 대기열이 즉시 실행되고 대기열이 지워집니다.
마이크로태스크 실행은 node11 이전 단계가 실행된 후에 시작됩니다.
따라서 마이크로태스크를 사용하면 이벤트 루프의 각 주기는 먼저 타이머 단계에서 작업을 실행한 다음 process.nextTick()
및 new Promise().then()
의 마이크로태스크 대기열을 순서대로 지운 다음 계속 실행합니다. 타이머 단계 또는 다음 단계의 다음 작업, 즉 보류 단계의 작업 등 이 순서대로 진행됩니다.
process.nextTick()
사용하여 Node는 위의 포트 바인딩 문제를 해결할 수 있습니다. 다음 의사에 표시된 것처럼 listen()
메서드 내에서 listening
이벤트의 발행이 콜백으로 캡슐화되어 process.nextTick()
으로 전달됩니다. 코드:
함수 청취() { // 수신 포트 작업을 수행합니다... // `listening` 이벤트의 발행을 콜백으로 캡슐화하고 process.nextTick(()의 `process.nextTick()`에 전달합니다 => { 방출('듣기'); }); };
현재 코드가 실행된 후 마이크로태스크가 실행되기 시작하여 listening
이벤트가 발생하고 이벤트 콜백 호출이 트리거됩니다.
비동기 자체의 예측 불가능성과 복잡성으로 인해 Node에서 제공하는 비동기 API를 사용하는 과정에서 이벤트 루프의 실행 원리를 마스터했지만 여전히 직관적이지 않거나 예상하지 못한 현상이 있을 수 있습니다. .
예를 들어 타이머( setTimeout
, setImmediate
)의 실행 순서는 호출되는 컨텍스트에 따라 달라집니다. 둘 다 최상위 컨텍스트에서 호출되는 경우 실행 시간은 프로세스나 시스템의 성능에 따라 달라집니다.
다음 예를 살펴보겠습니다.
setTimeout(() => { console.log('타임아웃'); }, 0); setImmediate(() => { console.log('즉시'); });
위 코드의 실행 결과는 무엇입니까? 지금 이벤트 루프에 대한 설명에 따르면 다음과 같은 대답을 얻을 수 있습니다. 타이머 단계가 확인 단계 전에 실행되므로 setTimeout()
의 콜백이 먼저 실행되고 그 다음 setImmediate()
의 콜백이 실행됩니다. 실행.
실제로 이 코드의 출력 결과는 불확실합니다. Timeout이 먼저 출력될 수도 있고, Immediate가 먼저 출력될 수도 있습니다. 이는 두 타이머가 모두 전역 컨텍스트에서 호출되기 때문입니다. 이벤트 루프가 실행을 시작하고 타이머 단계까지 실행되면 머신의 실행 성능에 따라 현재 시간이 1ms보다 클 수도 있고 1ms보다 작을 수도 있습니다. , 첫 번째 타이머 단계에서 setTimeout()
실제로 불확실하므로 다른 출력 결과가 나타납니다.
( delay
값( setTimeout
의 두 번째 매개변수)이 2147483647
보다 크거나 1
보다 작을 경우, delay
1
로 설정됩니다.)
다음 코드를 살펴보겠습니다:
const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('타임아웃'); }, 0); setImmediate(() => { console.log('즉시'); }); });
이 코드에서는 두 타이머가 모두 콜백 함수로 캡슐화되어 readFile
로 전달되는 것을 볼 수 있습니다. 콜백이 호출될 때 현재 시간은 1ms보다 커야 하므로 setTimeout
의 콜백은 setImmediate
콜백이 먼저 호출되므로 인쇄된 결과는 timeout immediate
입니다.
이상은 Node.js를 사용할 때 주의해야 할 타이머 관련 내용입니다. 또한 process.nextTick()
, new Promise().then()
및 setImmediate()
의 실행 순서에도 주의해야 합니다. 이 부분은 비교적 간단하므로 이전에 언급했으므로 반복하지 않겠습니다. .
: 비동기가 필요한 이유와 비동기를 구현하는 방법이라는 두 가지 관점에서 Node 이벤트 루프의 구현 원리를 보다 자세히 설명하는 것으로 시작하여 관련 주의 사항을 언급하는 것이 도움이 되기를 바랍니다. 너.