간단히 요약하면..

구성 요소

V8은 ECMAScript 명세를 구현한 JavaScript 실행 엔진으로, 자체적으로 힙(heap), 스택(stack), 실행 컨텍스트(Execution Context) 구조를 가진다. 즉, V8만으로도 JavaScript 언어 자체 기능(변수, 함수, 클래스, 프로미스 등) 은 실행할 수 있다.

Node.js는 V8을 임베드(embed) 하면서 파일 시스템, 타이머, 네트워크 요청 등 V8에는 없는 기능을 C++로 구현한 API를 JS 환경에 노출한다. 또한 이러한 API들은 비동기 처리를 위해 libuv를 사용하며, libuv는 OS의 비동기 I/O 를 다룬다.

주요 기능

  1. Node.js가 초기화될 때, C++ 계층에서 V8에 JS 함수 이름과 C++ 함수 포인터를 바인딩한다. 예를 들어 fs.readFile() JS 함수는 실제로 C++의 FSReqWrap::ReadFile()을 가리킨다.
  2. JS 코드에서 함수를 호출하면, Node는 이 객체를 감싸는(wrap) C++ 객체(*)(예: TCPWrap) 를 생성하여 이 내부에 libuv가 제공하는 구조체(uv_req_t 또는 uv_handle_t) 포함시킨다(이 구조체의 내부 data 필드에 다시 Wrap을 할당하여 서로 참조 가능하다). 이 객체(*)는 JS 콜백에 대한 포인터도 가지고 있어, libuv가 I/O 완료 시 C++ 계층에서 V8에 해당 콜백을 실행할 수있도록 요청을 보낸다.

흐름 제어 

V8 바인딩을 설정하는 등의 초기화 이후, 노드의 메인 스레드는 이벤트루프 로직을 반복한다(libuv의 uv_run (...) { while(..) {...} })

uv_run은 while문을 반복하며 poll. check, timer 등의 phase를 실행하고, 각 phase는 해당하는 큐가 비어있지 않는동안 그 안의 로직을 반복한다.

종류

노드 인스턴스의 종류를 메인 인스턴스와 워커 인스턴스(스레드풀과는 다르다)로 나눌 수 있는데, 각각의 V8 isolate와 이벤트루프를 갖는다.


코드로 살펴보기!

node app.js 와 같은 명령어를 통해 Node 로 JS 파일을 실행했을 때 Node.js 내부에서는 어떤 것들이 실행되는지, main함수부터 따라가 보았다. SEA(단독으로 실행가능한 애플리케이션) 모드가 아닌 표준 모드로, unix 기준으로 살펴보았다.

src/node_main.cc

1. V8 바인딩 설정

main()

   └─ node::Start()

       └─ StartInternal()

           └─ InitializeOncePerProcessInternal()

               └─ InitializeNodeWithArgsInternal()

                   └─ binding::RegisterBuiltinBindings()

           └─ ...

 

main 함수에서 시작해서 위와 같은 과정을 통해 ResiterBuiltinBindings 함수가 실행되는데, 이 함수는 X-Macro 패턴을 사용하여 그 다음의 코드를 자동생성한다.

node_binding.cc

void RegisterBuiltinBindings() {
  _register_fs();
  _register_tcp_wrap();
  _register_timer_wrap();
  // ... 기타
}

_register_XXX 함수들은 node.js 소스코드가 컴파일 되는 시점에 NODE_BINDING_CONTEXT_AWARE_INTERNAL 매크로가 호출한 NODE_BINDING_CONTEXT_AWARE_CPP 매크로에 의해 생성되어 있는 상태이다. 예를 들어, src에 tcp_wrap.cc가 포함된 상태로 node.js를 컴파일하면 TCP::Initialize 함수의 구현부로 _register_tcp_wrap 함수가 생성되고, 노드가 초기화될 때 이 함수가 실행되게 된다.

tcp_wrap.cc
node_binding.h

TCPWrap::Initialize함수는 JS에서 호출 가능한 TCP 관련 메서드들을 Node.js의 C++ 구현과 연결한다. 

이를 위해 V8의 FunctionTemplate을 사용하여 JS 함수의 설계도(블루프린트)를 만들고, 각 메서드 이름에 대응하는 C++ 콜백 함수를 등록한다. 이렇게 등록된 함수는 JS 코드에서 해당 메서드가 호출될 때 V8이 내부적으로 C++ 콜백을 실행하도록 동작한다.

tcp_wrap.cc

아래의 TCPWrap::Listen 함수는 TCPWrap 객체를 만든다음 libuv의 uv_listen() 을 호출해서 uv_tcp_t 핸들을 OS 이벤트루프에 “리스닝 소켓”으로 등록하고, 새 연결이 들어오면 OnConnection 콜백을 호출하도록 설정한 다음, 그 결과 코드를 JS로 돌려준다.

2. uv loop 설정 및 생성, V8 Isolate 생성

main()

   └─ node::Start()

       └─ StartInternal()

          └─ ... (1번)

           └─ NodeMainInstance()

node.cc
node_main_instance.cc

3. Environment (노드의 메인 객체) 생성

main()

   └─ node::Start()

       └─ StartInternal()

           └─ ... (1번)

           └─ NodeMainInstance()

                └─ ... (2번)

           └─ NodeMainInstance::Run()

               └─ CreateMainEnvironment()

                    └─ CreateEnvironment()

 

4. JS 엔트리 파일을 실행

main()

   └─ node::Start()

       └─ StartInternal()

           └─ ... (1번)

           └─ NodeMainInstance()

                └─ ... (2번)

           └─ NodeMainInstance::Run()

               └─ CreateMainEnvironment()

                    └─ ... (3번)

                   └─ LoadEnvironment()

                       └─ StartExecution()

                           └─ Realm::ExecuteBootstrapper("internal/main/run_main_module")

                               └─ BuiltinLoader::CompileAndCall()

                                    └─ Function::Call()

                                        └─ [lib/internal/main/run_main_module.js 실행]

                                            └─ Module.runMain(mainEntry)

                                                └─ executeUserEntryPoint()

main 함수에서 시작해서 위와 같은 과정을 통해 executeUserEntryPoint 함수가 실행된다. 이 때 아래 코드가 실행된다고 가정해보자.

// app.js
const net = require('net');

const server = net.createServer((socket) => {
  console.log('client connected');
  socket.write('hello from server!\n');
  socket.end();
});

server.listen(8080, () => {
  console.log('server listening on port 8080');
});

server.listen(...)이 실행되면 net.js의 해당 부분을 통해

src/lib/net.js

JS: server.listen()
 └─ Server.prototype.listen()
     └─ listenInCluster()                           
         └─ server._listen2() (= setupListenHandle)  
             └─ createServerHandle()                          
             └─ this._handle.bind(address, port)
             └─ this._handle.listen(backlog)

 

이러한 함수들을 지나서. 위에서 나온 TCPWrap::Listen 함수가 실행되게 된다.

5. libuv 이벤트루프로 들어간다. 

다음으로 아래의 함수를 따라 libuv의 uv_run이 node.js 프로세스에서 실행되어, I/O 작업과 그 콜백 작업의 실행 흐름을 제어한다. 

main()

   └─ node::Start()

       └─ StartInternal()

           └─ NodeMainInstance::Run()

               └─ ... (3번)

               └─ NodeMainInstance::Run(ExitCode*, Environment*)

                   └─ SpinEventLoopInternal()

                       └─ uv_run()

 

이 내용은 별도의 포스팅으로 다루겠다.

 

Node.js 딥다이브 (2) - libuv 의 I/O 멀티플렉싱 살펴보기

서론ibuv는 Node.js와 독립적인 비동기 I/O 추상화 계층이지만, Node.js의 이벤트 루프와 비동기 작업 흐름을 담당하므로 함께 정리하였다.I/O 멀티플렉싱이란..I/O 작업은 유저 스페이스에서 할 수 없

9436188.tistory.com

 


메모

V8 핸들

TCPWrap.cc 코드에 Local<XXX> 타입이 있다. 이것은 V8 핸들이다.

JS 객체(obj)는 V8 이 관리하는 힙에 존재하며 GC에 의해 이동하거나 해제할 수 있기 때문에 직접 포인터로 잡아두면 위험하다. 대신 V8이 “핸들 테이블(handle table)”에 객체를 등록해주고 그 인덱스를 통해 안전하게 접근하도록 하는 목적이다.

빌트인 스크립트

네이티브 빌트인 스크립트는 노드에 내장된 파일로, 노드 사용 시 접근할 수는 없으며 미리 컴파일 되어있어 내장로더로 실행시 초기화 성능을 최적화할 수 있다.

가장 처음에 app.js를 찾고 읽는 것을 C++레벨에서 직접 구현할 줄 알았는데, 이 스크립트를 실행하도록 하여 일관성과 유연성 측면에서 강점을 가진다고 한다.

 

+ Recent posts