1ilsang

Developer who will be a Legend

Node.js Event-loop Architecture

2019-09-23 1ilsangNode.js

node architecture image

Node.js 의 이벤트 루프를 이해하고 아키텍처를 알아보자.
본 내용은 https://medium.com/preezma/node-js-event-loop-architecture-go-deeper-node-core-c96b4cec7aa4 를 번역한 내용입니다.

개요

 만약 너가 Node.js 개발자라면 주니어, 중간 혹은 시니어 레벨에 상관없이, 너는 이미 Node.js의 코어, 이벤트 루프 혹은 싱글쓰레드, “setTimeout” 혹은 “setImmediate” 함수가 어떻게 처리되는지에 대해 많이 알고 있을 것이다.

 주로, 너는 Node.js 가 논블로킹 I/O 모델을 사용하고 비동기 프로그래밍 스타일이라는 것을 알고 있다. 이 주제에 대해 수 많은 유명한 전문가들의 기사나 블로그 포스트가 존재한다. 그러나 나는 그들중 많은 것들이 미묘하게 틀렸거나 완전히 오도되어 구글의 첫 페이지에 나타나, 많은 이들을 쉽게 오해시키고 있다고 감히 말할 수 있다. 그럼에도 매우 슬프게, 그들은 여러분이 올바른 지식을 가졌다고 착각하게 만들지도 모른다.

그렇다면, 이벤트 루프가 무엇인가? Node.js 는 싱글쓰레드인가 멀티쓰레드인가?

baby

 나는 직장이나 웹에서 서로 다른 토론을 하는동안 이 질문에 분명하지않고 잘못된 답변들로 끊임없이 놀란다. 심지어 한번은 내 답이 면접관의 답변과 일치하지 않아 인터뷰에서 떨어진적이 있었는데, 그는 이 주제에 대해 잘 알고 있다고 생각하고 있었다.

따라서 이 글의 컨셉은 너의 Node.js 코어의 개념과 이것이 어떻게 구현되어있는지, 어떻게 동작하는지를 명확히 하는 것이다. 왜냐하면, Node.js는 단순한 “서버 상의 자바스크립트” 이상의 것이기 때문이다. 게다가 약 30%는 C++이다. JS가 아니다! 우리는 이 C++ 부분이 Node.js 에서 실제로 무슨 역할을 하는지 알아볼 것이다.

| Node.js 는 싱글 쓰레드인가?

  • 맞아 싱글쓰레드야! 정답이야.
  • 아니 싱글쓰레드가 아니야! 정답이야!

What?

…What?

 또한 사람들은 멀티태스킹, 싱글 쓰레드, 멀티 쓰레드, 쓰레드 풀, epoll loop, event loop 같은 많은 표현들을 사용한다.
자 그러면 이제 Node.js 코어에서 무슨일이 일어나고 있는지 처음부터 자세히 알아보도록 하자.

  • 프로세서는 한 번에 한 개 또는 한 개 이상의 작업(프로그램)을 병렬적으로 실행 할 수 있다(멀티태스킹).

multi tasking spongebob

 싱글 코어 프로세스가 있고 이 프로세스가 한번에 하나의 일만 처리할 수 있을 때, 프로세스는 어플리케이션 호출을 완료한 다음 JavaScript 의 생성자 함수처럼 다음 작업을 시작하도록 프로세스에게 통지하고, 그렇지 않을 경우 현재 작업을 다시 실행한다. 멀지 않은 과거에, 단순한 어플리케이션이나 게임이 yield 를 호출할 수 없게될 때, 어플리케이션 자체에 닿을 수 없게 되기 때문에 컴퓨터는 종료 될 것이다.(*역자: yield 는 자신이 할당받은 CPU 시간을 포기하고 다른 쓰레드가 실행될 수 있도록 한다. 즉, 과거의 프로그램들이 yield 를 할 수 없을 떄 프로세스 양도가 안되고 그로인해 컴퓨터가 다른 프로그램에 접근할 수 없게 되는 것을 뜻하는 듯 하다)

socket image

 소켓의 포인트는 가상 “인터페이스” 를 가지고있는 커널 객체라는 점이다(read/write/pool/close/etc).

 시스템 소켓은 TCP 소켓처럼 동작한다. 그들은 데이터를 버퍼로 변환한 다음 보낸다. 우리는 두개의 프로세스간 통신을 할때 자바스크립트를 사용하므로 JSON.stringify 를 많이 불러야한다. 따라서 우리는 이 과정이 상당히 성능 저하를 불러올 것을 알 수 있다.

하지만! 우리에겐 쓰레드가 있다!

thread handle

그럼 이제, 이것이 뭔지 그리고 어떻게 두개의 쓰레드가 통신하게 만들 수 있는지 살펴보자.

  • 실행 쓰레드는 스케쥴러가 독립적으로 관리할 수 있는 프로그래밍된 명령의 가장 작은 단위이다.

프로세스에서 쓰레드가 실행된다. 하나의 프로세스는 많은 쓰레드들을 가질 수 있다. 그리고 그들은 같은 프로세스에서 메모리를 서로 공유할 수 있다.

멋지다!!!

 이는 만약 우리가 두 쓰레드간 커뮤니케이션을 필요로 할 때 우리는 아무것도 할 필요가 없다는 뜻이 된다. 만약 하나의 쓰레드에서 글로벌 변수를 사용한다면, 우리는 다른 쓰레드에서 바로 변수에 접근할 수 있다.(모든 쓰레드가 같은 메모리에서 참조를 공유한다. 따라서 이는 굉장히 효과적인 통신 방법이다)

 하지만 우리가 “foo”라는 변수에 값을 대입하고, 다른 쓰레드에서 이것을 읽는다고 하자. 그럼 이제 무슨일이 일어날까?

what happened?

 실제로, 우리는 알 수 없다. 아마도 다른 쓰레드가 읽기 전에 메모리에 쓸 수 있는 첫 번째 쓰레드일 수도 있고, 아닐수도 있다.(역자: 쓰레드 경쟁)

즉, 값이 바뀐 foo 변수를 얻을 수도 있고 아닐 수도 있다.

이것이 왜 멀티 쓰레딩 환경에서 코드를 작성하는게 더 힘든지를 알려준다. 그럼 Node.js 는 뭐라하는지 할펴보자.

| Node.js: 나는 하나의 쓰레드만 가지고있어

Excuse me, was you saying something?

 실제로, Node.js 는 내부적으로 V8 을 가지고있다. 그리고 이벤트 루프가 실행된 메인 쓰레드에서 코드를 실행한다.(이것이 우리가 Node.js가 싱글 쓰레드라고 말하는 주된 이유이다.)

 하지만 우리가 알고 있듯이, Node.js가 단순히 V8은 아니다. 여기에는 많은 API(C++)들이 존재하고, 이 모든 것들은 libuv(C++)를 통해 구현된 이벤트 루프에의해 관리된다.

 C++은 자바스크립트 코드 뒤에서 동작하고 있으며 쓰레드에 접근할 수 있다. 만약 Node.js 에서 자바스크립트 동기 메서드를 실행하면 이것은 항상 메인 쓰레드에서 실행된다. 하지만 만약 너가 어떤 비동기 동작을 실행한다면, 이것은 메인 쓰레드에서 실행되지 않을 수 있다. 너가 어떤 메소드를 사용하는지 따라 이벤트 루프가 API 들 중 하나로 라우팅하고 그 작업은 다른 쓰레드에서 처리될 수 있다.

 하나의 예로 CRYPTO 를 살펴 보자. CRYPTO 는 많은 CPU 집중 메소드들을 가지고있다. 어떤 것들은 동기고 어떤 것들은 비동기로 되어있다. pbkdf2()메서드를 보자. 만약 우리가 2코어 프로세서에서 동기 버전을 실행해 4개의 콜을 만들고, 1회의 콜 실행 시간이 2ms일 경우, 4회의 콜 실행 시간은 모두 4 * ppkdf2() 실행 시간(8ms)이 된다.

 하지만 만약 우리가 비동기 버전으로 이 메서드를 실행시킨다고 해보자. 같은 CPU 일 때, 실행 시간은 2 * pbkdf2() 이 될 것이다. 왜냐하면 프로세서가 기본적으로 4개의 쓰레드(너는 아래에서 왜, 어떻게 이런지 이해하게 될 것이다)를 취하기 때문이다. 두 가지 프로세스로 이것을 호스트하고 그 안에서 pbkdf2() 를 처리한다.

async event loop

따라서 너가 기회를 준다면, Node.js 는 너를 위해 병렬적으로 실행한다. “그러니 비동기 메서드를 사용해라!!”

Node.js 는 쓰레드 풀이라는 미리 할당된 쓰레드 셋을 사용하며, 우리가 얼마나 많은 쓰레드를 오픈한다고 정의하지 않으면, 이것은 기본적으로 4개의 쓰레드를 오픈한다. 우리는 기본 쓰레드 개수를 세팅으로 증가시킬 수 있다.

UV_THREADPOOL_SIZE=110&&node index.js

or

process.env.UV_THREADPOOL_SIZE=62 from code.

| 그럼 노드는 멀티 쓰레드인가?

  • 이봐!! 노드는 멀티 쓰레드로 동작해! 맞아! 노드는 멀티 쓰레드야!

 따라서 사람들이 너에게 노드가 멀티쓰레드인지 싱글쓰레드인지물어볼 때, 너는 반드시 이 질문을 물어봐라: “언제?”.

excellent question

TCP 연결을 살펴보자.

쓰레드당 연결

Thread per connection

TCP 서버를 만드는 간단한 방법은 소켓을 만들고, 이 소켓을 포트에 바인딩하고, 그 위에 “listen”을 호출하는 것이다.

int server = socket();
bind(server, 8080);
listen(server);

 우리가 “listen”으로 포트를 열고 있는 동안, 소켓은 커넥션을 만들거나 커넥션을 수락하기 위해 사용된다. 우리가 “listen”을 호출할 때, 우리는 커넥션을 수락할 수 있도록 준비된다.

while(int conn = accept(server)) {
  pthread_create(echo, conn)
}
void echo(int conn) {
  char buf(4096);
  while(int size = read(conn, buffer, sizeof buf)) {
    write(conn, buffer,size);
  }
}

 커넥션이 도착해 그곳에 무언가 작성 할 때, 다 쓰기 전까지 우리는 다른 커넥션을 수락할 수 없다. 이것이 바로 다른 쓰레드로 작업을 밀어넣는 이유이다. 따라서 우리는 소켓 설명자와 함수 포인터를 다른 쓰레드로 전달한다.

 이제 시스템은 몇 천개의 쓰레드를 쉽게 처리 할 수 있지만, 이 경우 매 커넥션마다 많은 양의 데이터를 쓰레드로 보내야 하고 20~40 만 개의 동시 연결로 확장되지 않는다. 하지만 조금만 생각해보자.

 우리가 실제로 필요로 하는 것은 소켓 설명자뿐이다. 그리고 그것을 가지고 무엇을 해야 하는지를 기억해야한다. 따라서 여기엔 Epoll(UNIX), Kqueue(BSD)라는 더 좋은 방법이 있다.

Epoll loop

Epoll loop

Epoll 에 집중해보자.

 이것이 우리에게 무엇을 줄까? 이것을 사용하는 이유가 무엇일까? Epoll을 사용하면 우리가 흥미로워하는 이벤트들에 대해 커널에게 질문 할 수 있고 커널은 우리가 질문했던 일들이 언제 일어났는지 알려준다. 우리의 경우에서는 TCP 커넥션이 왔을 때다. Epoll descriptor 를 만들고 Epoll loop 에 “wait” 상태로 추가해 준다. 이것은 TCP 연결이 들어왔을 때 깨어난 다음, Epoll loop에 추가하고 데이터가 오기를 기다린다. 이것이 Event loop 가 우리를 위해 하는 것들이다.

하나의 예제를 보자:

 우리가 동일한 2코어 프로세스를 통해 HTTP를 경유해 무엇 인가를 다운받으려고 할 때, 4, 6 또는 8개의 요청에서 동일한 시간이 소요될 것이다. 이게 무슨 뜻인가? 이는 우리가 가진 쓰레드 풀의 한계과 같지 않다는 것을 뜻한다.

이유는 OS 가 다운로드를 처리하기 때문이다. 우리는 단지 다운로드가 다 되었는지 묻기만 하면 된다.(Epoll 이 “data” 이벤트를 청취한다)

APIs

 그렇다면 어떤 API 가 어떤 기능에 반응하는가? fs.* 의 모든 것들은 uv 쓰레드 풀을 사용한다(동기가 아닌 이상). 블로킹 호출은 쓰레드에 의해 만들어진다. 그리고 완료될 때, 이벤트 루프에게 신호를 보낸다. 우리는 Epoll에게 직접적으로 “wait” 할 수 없다. 하지만 우리는 그들에게 연결(pipe)할 수 있다. Pipe는 2가지 끝을 가지고 있다. 한 쪽은 쓰레드이다. 작업이 끝나면, 데이터를 파이프에 쓴다. 다른 한 쪽 끝은 Epoll 루프에서 기다리고 있다. 쓰레드가 데이터를 얻게 될 경우 Epoll 은 깨어난다. 따라서 Epoll 은 pipe에 반응한다.

이에 대응하는 주요 기능과 API는 다음과 같다:

EPOLL, KQUEUE, ASYNC, etc. 운영체제에 달려있다.

  • TCP/UDP 서버와 클라이언트
  • pipe
  • dns.resolve

NGINX

  • nginx 신호(sigterm)
  • 자식 프로세스(exec, spawn)
  • TTY 입력(console)

THREAD POOL

  • fs.
  • dns.lookup

 그리고 이벤트 루프는 결과의 송수신 등을 처리하므로, C++ API로 요청을 라우팅하고 감독처럼 결과를 자바스크립트로 되돌려 보내는 일종의 중앙 파견이다.

Event Loop

 그럼 이벤트 루프가 무엇인가? 이것은 무한한 while loop 로써, Epoll(kqueue)을 “wait” 혹은 “pool”이라 부르고 callback, event, fs 등의 흥미로운 이벤트가 일어났을 때 Node.js 로 전달되고, Epoll에서 기다릴 것이 없을 때 종료된다. 이것이 Node.js 에서 어떻게 비동기가 동작하고 왜 우리가 이것을 event-driven 이라 부르는지 이다. 이벤트 루프는 자바스크립트가 가능할 때마다 시스템 커널로 작업을 오프로드하여, 싱글 쓰레드 기반으로 처리된다는 사실에도 불구하고, Node.js 가 non-blocking I/O 작업을 할 수 있도록 해준다.

Node.js 이벤트 루프의 1회 반복을 Tick 이라 하며, 그 단계가 있다.

Evernt loop tick

이벤트 루프 단계, 타이머, process.nextTick()의 더 자세한 내용은 Node.js 공식 문서를 참고하라.

https://nodejs.org/ko/docs/guides/event-loop-timers-and-nexttick/

노드 10.5 버전이 릴리즈된 이래로, 새로운 worker_thread 모듈을 사용할 수 있다.

 워커 쓰레드 모듈은 자바스크립트를 병렬적으로 실행하는 쓰레드의 사용을 허락한다. 워커들(쓰레드들)은 CPU 집약적인 JavaScript 작업을 수행할 때 유용하다. 이들이 I/O 집약적인 작업에는 큰 도움이 되지 않을 것이다. Node.js의 내장된 비동기 I/O 조작들이 더 효과적이다.

워커 쓰레드는 child_process나 클러스터와 달리 메모리를 공유할 수 있다. 이들은 ArrayBuffer 인스턴스를 전송하거나 SharedArrayBuffer 인스턴스를 공유함으로써 이러한 작업을 수행한다.

워커 쓰레드에 더 자세히 알고 싶으면 Node.js 공식 문서를 참고하라:

https://nodejs.org/api/worker_threads.html


허접한 번역 읽어주셔서 감사합니다!

그럼 이만!