Node.js 작동방식

I/O

I/O는 컴퓨터의 기본적인 동작들 중에서 가장 느리다.

블로킹 I/O

전통적인 블로킹 I/O 프로그래밍에서는 I/O를 요청하는 함수의 호출은 작업이 완료될 때까지 스레드의 실행을 차단한다.

블로킹 I/O를 사용하여 구현된 웹서버가 같은 스레드 내에서 여러 연결을 처리하지 못하는 것은 소켓의 각각의 I/O 작업이 다른 연결의 처리를 차단하기 때문이다.

→ 이를 해결하기 위한 전통적인 접근 방법은 각각의 동시 연결을 처리하기 위해서 개별의 스레드 또는 프로스세를 사용하는 것이다.

해당 방법은 I/O 작업이 각각의 스레드에서 처리되므로 I/O 작업으로 인해 블로킹된 스레드가 다른 연결들의 가용성에 영향을 미치지 않는다.

→ 예를 들어, 데이터베이스나 파일시스템과 상호작용할 때와 같이 모든 유형의 I/O가 요청의 처리를 차단할 수 있다는 것을 생각해보면 I/O 작업의 결과를 위해서 스레드가 꽤 많이 블로킹된다는 것을 알 수 있다.

✔︎ 여기에서 블로킹 I/O의 단점이 드러나는데, 스레드는 시스템 리소스 측면에서 비용이 저렴하지 않기 때문에 메모리를 소모하고 컨텍스트 전환을 유발하여 대부분의 시간 동안 사용하지 않는 장시간 실행 스레드를 가지게 됨으로써 귀중한 메모리와 CPU 사이클을 낭비하게 된다.

논블로킹 I/O

논블로킹 I/O, 이 운영모드에서 시스템 호출은 데이터가 읽혀지거나 쓰여지기를 기다리지 않고 즉시 반환된다.

→ 호출 순간에 사용 가능한 결과가 없는 경우, 함수는 단순히 미리 정의된 상수를 반환하여 그 순간에 사용 가능한 데이터가 없다는 것을 알린다.

→ 예를 들어, Unix 운영체제에서 운영모드를 논블로킹 (O_NONBLOCK)으로 변경하기 위해 기존 파일 디스크립터를 조작하는 fcntl() 함수가 사용된다. 우선 리소스가 논 블로킹 모드에 있고 리소스가 읽힐 준비가 된 데이터를 가지고 있지 않다면 모든 읽기 작업은 실패함과 동시에 코드 EAGAIN을 반환한다.

이러한 논 블로킹 I/O를 다루는 가장 기본적인 패턴은 실제 데이터가 반환될 때까지 루프 내에서 리소스를 적극적으로 폴링하는 것이다. → 바쁜 대기 (busy-waiting)

논 블로킹 I/O와 폴링 루프를 사용하여 여러 리소스로부터 읽어드리는 것이 어떻게 가능한지 보여주는 코드

resources = [socketA, socketB, fileA]
while (!resources.isEmpty()) {
	for (resource of resources) {
		// 읽기 시도
		data = resource.read()
		if (data === NO_DATA_AVAILABLE) {
			// 이 순간에는 읽을 데이터가 없음
			continue
		}
		if (data === RESOURCE_CLOSED) {
			// 리소스가 닫히고 리스트에서 삭제
			resources.remove(i)
		} else {
			// 데이터를 받고 처리
			consumeData(data)
		}
	}
}

이와 같이 간단한 기법으로 서로 다른 리소스를 같은 스레드 내에서 처리할 수 있지만 여전히 효율적이지 않다.

해당 폴링 알고리즘은 엄청난 CPU 시간의 낭비를 초래한다.

이벤트 디멀티플렉싱

바쁜 대기(Busy-waiting)는 논 블로킹 리소스 처리를 위한 이상적인 기법이 아니다. 다행히도 대부분의 운영체제는 논 블로킹 리소스를 효율적인 방법으로 처리하기 위한 기본적인 메커니즘을 제공한다. 이 메커니즘을 동기 이벤트 디멀티플렉서 또는 이벤트 통지 인터페이스라고 한다.

🧐 멀티플렉싱: 여러 신호들을 하나로 합성하여 제한된 수용범위 내에서 매개체를 통하여 쉽게 전달하는 방법을 나타낸다.

🧐 디멀티플렉싱: 신호가 원래의 구성요소로 다시 분할되는 작업

동기 이벤트 디멀티플렉서는 여러 리소스를 관찰하고 이 리소스들 중에 읽기 또는 쓰기 연산의 실행이 완료되었을 때 새로운 이벤트를 반환한다.

동기 이벤트 디멀티플랙서가 처리하기 위한 새로운 이벤트가 있을 때까지 블로킹된다는 것이 장점이다.

watchedList.add(socketA, FOR_READ) // 1️⃣
watchedList.add(fileB, FOR_READ)
while (events = demultiplexer.watch(watchedList)) { // 2️⃣
	// 이벤트 루프
	for (event of events) { // 3️⃣
		// 블로킹하지 않으면 항상 데이터를 반환
		data = event.resource.read()
		if (data === RESOURCE_CLOSED) {
			// 리소스가 닫히고 관찰되는 리스트에서 삭제
			demultiplexer.unwatch(event.resource)
		} else {
			// 실제 데이터를 받으면 처리
			consumeData(data)
		}
	}
}

1️⃣ 각 리소스가 데이터 구조에 추가된다. 각 리소스를 특정 연산과 연결한다.

2️⃣ 디멀티플렉서가 관찰될 리소스 그룹과 함께 설정된다. demultiplexer.watch()는 동기식으로 관찰되는 리소스들 중에서 읽을 준비가 된 리소스가 있을 때까지 블로킹된다. 준비된 리소스가 생기면, 이벤트 디멀티플렉서는 처리를 위한 새로운 이벤트 세트를 반환한다.

3️⃣ 이벤트 디멀티플렉서에서 반환된 각 이벤트가 처리된다. 이 시점에서 각 이벤트와 관련된 리소스는 읽을 준비 및 차단되지 않는 것이 보장된다. 모든 이벤트가 처리되고 나면, 이 흐름은 다시 이벤트 디멀티플렉서가 처리 가능한 이벤트를 반환하기 전까지 블로킹된다. 이를 이벤트 루프 라고 한다.

이 패턴을 이용하면 바쁜 대기 기술을 이용하지 않고도 여러 I/O 작업을 단일 스레드 내에서 다룰 수 있다.

하나의 스레드만을 사용하는 것이 동시적 다중 I/O 사용 작업에 나쁜 영향을 미치지 않는다. 작업은 여러 스레드에 분산되는 대신에 시간에 따라 분산된다.

리액터 패턴

리액터 패턴의 이면에 있는 주된 아이디어는 각 I/O 작업에 연관된 핸들러를 갖는다는 것이다.

Node.js에서의 핸들러는 콜백 함수에 해당한다.

이 핸들러는 이벤트가 생성되고 이벤트 루프에 의해 처리되는 즉시 호출되게 된다.

image

  1. 애플리케이션은 이벤트 디멀티플렉서에 요청을 전달함으로써 새로운 I/O 작업을 생성한다. 애플리케이션은 작업이 완료되었을 때 호출될 핸들러를 명시한다. 이벤트 디멀티플렉서에 새 요청을 전달하는 것은 논 블로킹 호출이며, 제어권은 애플리케이션으로 즉시 반환된다.
  2. 일련의 I/O 작업들이 완료되면 이벤트 디멀티플렉서는 대응하는 이벤트 작업들을 이벤트 큐에 집어 넣는다.
  3. 이 시점에서 이벤트 루프이벤트 큐의 항목들을 순환한다.
  4. 각 이벤트와 관련된 핸들러가 호출된다.
  5. 애플리케이션 코드의 일부인 핸들러의 실행이 완료되면 제어권은 이벤트 루프에 되돌려준다(5a). 핸들러 실행 중에 다른 비동기 작업을 요청할 수 있으며(5b), 이는 이벤트 디멀티플렉서에 새로운 항목을 추가하는 것이다(1).
  6. 이벤트 큐의 모든 항목이 처리되고 나면 이벤트 루프는 이벤트 디멀티플렉서에서 블로킹되며 처리 가능한 새 이벤트가 있을 경우 이 과정이 다시 트리거된다.

Reactor 패턴은 Node.js의 핵심에 있는 패턴으로서, 일련의 관찰 대상 리소스에서 새 이벤트를 사용할 수 있을 때까지 블로킹하여 I/O를 처리하고, 각 이벤트를 관련된 핸들러에 전달함으로써 반응한다.

Libuv, Node.js의 I/O 엔진

서로 다른 운영체제 간의 불일치성은 이벤트 디멀티플레서를 위해 보다 높은 레벨의 추상화를 필요로 하게 되었다.

따라서 Node.js 코어 팀이 Node.js를 주요 운영체제에서 호환되게 해주며 서로 다른 리소스 유형의 논 블로킹 동작을 표준화하기 위해 libuv라고 불리는 C 라이브러리를 만들었다.

Libuv는 Node.js의 하위 수준의 I/O 엔진을 대표하며 아마도 Node.js의 구성요소 중에서 가장 중요하다고 말할 수 있다.

Libuv는 기본 시스템 호출을 추상화하는 것 외에도 리액터 패턴을 구현하고 있으므로 이벤트 루프의 생성, 이벤트 큐의 관리, 비동기 I/O 작업의 실행 및 작업의 실행 및 다른 유형의 작업을 큐에 담기 위한 API들을 제공한다.

(http://nikhilm.github.io/uvbook/ 참고)

Node.js를 위한 구성

리액터 패턴과 libuv는 Node.js는 기본 구성 요소이지만 전체 플랫폼의 구축을 위해서는 3개의 구성이 더 필요하다.

  • libuv와 다른 저수준 기능들을 랩핑하고 표출시키기 위한 바인딩 세트
  • V8, 크롬 브라우저를 위해 구글이 개발한 JavaScript 엔진으로 Node.js가 매우 빠르고 효율적인 이유 중 하나이기도 한다. V8은 혁신적인 설계와 속도 그리고 효율적인 메모리 관리로 높은 평가를 받고 있다.
  • 고수준 Node.js API를 구현하고 있는 코어 JavaScript 라이브러리

image