Search

The Internals of Node

Starting with Node Internals
Node의 엔진으로 V8을 사용하며, 모듈은 libuv를 통해 실행된다. 파일 시스템에 대한 접근이나 네트워크에 관련된 접근들이 libuv를 통해서 가능하다. V8은 JavasScript 코드를 C++에게 이해시키는데 있다. (JavaScript 코드를 실행하고 해석시킨다.)
JavasScript 코드를 작성하면 이는 100% JavasScript 코드이다.
반면 JavasScript를 실행하는 런타임인 Node는 50% JavasScript, 50% C++로 돼있다.
Node를 구성하는 V8은 30% JavasScript, 70% C++, libuv는 100% C++로 돼있다.
Node의 역할은 무엇일까? JavasScript로 코드를 작성하면 Interpreting과 Executing을 통해 기기 내에서 C++로 동작할 수 있도록 훌륭한 인터페이스를 제공하는데 있다. 조금 더 풀어서 설명하자면, 다른 언어를 통해 이미 궁극적으로 구현이 완료된 함수들을 JavasScript로 Wrapping하여 JavasScript로 이용할 수 있도록 하는 것이다. (다른데서 구현된 함수들을 호출하는 개념이므로 일종의 API라고 볼 수 있고, 오로지 JavasScript만으로 이용할 수 있는 관점에서 consistent하다.)
Node를 이용하여 JavasScript 코드를 작성하게 되면 Node의 Standard Library Module을 이용하게 되는데 (path, fs, crpyto, http와 같은) 이런 라이브러리들은 V8을 통해 실행되는 consistent한 API들이라고 볼 수 있다. 이런 Standard Library Module을 이용하게 되면, 직접적으로 C++을 사용하지 않더라도 JavasScript 코드를 이용하여 해당 기능들을 모두 이용할 수 있게 되는 것이다.
Module Implementations
Node에 존재하는 Standard Library Module을 하나 잡고, Node의 Source Code로 어떻게 구현되어 있는지 확인해보고, 이걸 구현하는데 있어서 V8과 libuv가 어떻게 적용되었는지 알아보는 과정이 필요하다. (github에서 확인 가능하다.)
Node 깃헙을 통해 소스코드 분류를 보면 lib, src가 존재한다. lib라고 하는 것들이 우리들이 실제 프로젝트에 사용하는 JavaScript로 구현된 함수와 모듈들이고, src가 JavasScript로 작성된 lib에 대한 C++로 구현된 소스 코드들이다.
Node를 사용하면서 Standard Library를 이용하면 lib에 존재하는 JavasScript코드를 호출하게 되고 Node는 V8을 통해 이를 C++로 이해시킨다. (JavaScript 코드 실행 및 C++로의 타입 변환) 또한 libuv로부터 C++ 코드를 실행 시켜주는데 그 부분이 src가 된다.
lib → internal에 Standard Library Module들이 존재한다. lib 내에 존재하는 파일들을 실제 우리가 작성하는 JavasScript 코드와 구조가 굉장히 비슷하다. (상단 require, 중단 내용들, 하단 export)
Node의 Standard Library의 함수를 호출하면, 호출한 함수의 기능과 관련 없는 부분에 대해서 General Error Checking(callback, digest 등)을 하고, _가 붙은 함수를 한 번 더 호출하게 된다. _가 붙은 함수에서는 호출한 함수의 기능과 관련된 부분에 대해서 Error Checking을 하고, Error Checking을 마치면 All Capital 함수를 호출한다. Node는 All Capital 함수에 밀어 넣은 인자들을 C++에 구현된 함수에 전달하게 된다. 즉, All Capital 함수는 C++ 함수인 것이다. 실제로 All Capital 함수들은 process.binding으로부터 require 된 것을 볼 수 있다. process.binding이라고 하는 것이 바로 Node가 C++ 부분을 JavaScript 부분에 합치는 방법이다.
위 과정에 대해서 깔끔한 플로우로 다시 정리를 해보자.
1.
JavaScript 코드를 작성하고 Standard Library Module의 함수를 호출한다.
2.
Node의 JavaScript 부분에 해당하는 lib에서 호출한 함수를 찾는다.
3.
process.binding을 통해서 Node는 JavaScript의 호출한 함수와 C++의 원 함수를 연결하게 된다. (process.binding을 하게 되면 src에서의 해당되는 스크립트에서 env→SetMethod가 실행되고, 이를 통해 JavaScript의 함수와 C++의 함수가 연결되는 것이다.)
4.
V8을 통해 함수 인자의 값들을 C++로 변환한다. (JavaScript와 C++은 존재하는 데이터 타입이 다르다. V8은 JavaScript코드를 실행하고 서로 다른 두 언어에 대해서 타입에 대해서 적절한 변환을 수행해준다.) JavaScript에서 넘어온 타입들을 C++ 타입으로 변환하여 src에서 함수 실행을 정상적으로 수행할 수 있도록 만들어준다.
5.
Node의 C++ 부분에 해당하는 src에서 호출한 함수의 연결된 함수를 찾아 낸다.
6.
libuv를 통해 해당 함수를 실행한다. libuv는 uv_thread와 관련이 있으며, 대체적으로 동시성 (concurrency)을 위해서 사용된다.
The Basic of Threads
프로그램이 실행 되었을 때의 상태를 프로세스라고 한다면, 프로세스를 처리하는 기본 단위는 쓰레드가 된다. 프로그램 혹은 프로그램들의 쓰레드들은 OS의 스케줄러에 의해서 실행 순서가 결정되고, 순서에 맞게 실행하게 되면 쓰레드의 Instruction들은 CPU에 할당되어 연산을 수행하게 된다.
쓰레드들을 실행하여 처리할 때, 좋은 처리를 하기 위해선 다코어 다쓰레드를 지원하는 머신 레벨에서의 향상을 만들거나 좋은 스케줄러를 사용하면 된다.
좋은 스케줄러란 스케줄링을 잘하는 것을 의미하고, 이는 곧 어떤 쓰레드들이 오래 걸릴지 잘 파악하고 그 순서에 맞게 실행 순서를 만들어주는 것을 의미한다. (쓰레드들에 대한 정확한 Detection을 수행해야 한다.)
이벤트 루프는 스케줄러와 달리 싱글 쓰레드에 대해서 코드들을 처리한다. (오래 걸리는 작업들과 같은 비동기 코드들과 동기 코드들을 구분한다고 보면 된다.)
이벤트 루프는 말 그대로 루프이기 때문에 1틱을 돌기 위한 반복 조건이 필요한데, 반복 조건으로는 3가지 요소가 있다.
1.
setTimeout, setInterval, setImmediate (pending Timers)
2.
OS Tasks like http requests or server listening to port (pending OS Tasks)
3.
Long Running Operations like fs module (pending Operations)
위 3가지 같은 요소가 있다면 이벤트 루프는 반복하게 되고 프로그램은 끝나지 않은 상태로 유지 된다. (각 항목 별로 Array가 있다고 치면, 해당 Array들의 길이가 0이 하나라도 아니면 이벤트 루프는 반복을 지속한다.) 즉, JavaScript로 작성한 내 코드들은 구동 되면서 위 3가지에 대해서 지속해서 기록을 하고 있다는 소리고, 이벤트 루프는 이를 늘 주시하고 있는 것이다.
Event Loop Ticks
어떤 반복 조건에 의해서 이벤트 루프가 돌아가는지는 위에서 보았고, 실제로 반복을 하게 되었을 때 이벤트 루프가 어떤 작업을 하는지 확인할 필요가 있다.
이벤트 루프가 1회 마다 반복을 할 때, 위의 1, 2, 3 사항들에 대해서 등록된 작업들이 수행될 수 있는 지 확인하게 된다.
1.
1번 확인 사항에 대해서는 작업을 처리할 수 있도록 호출 준비가 되었는지 Timer를 확인하게 된다. (단, setTImeout, setInterval에 대해서만 확인한다.)
2.
이 후, 2번과 3번 사항에 대해서 확인하게 된다. 각 사항에 해당하는 작업들에 대해 적절한 Callback 함수를 호출할지 여부를 결정하게 된다. (fetched 된 파일을 받는다든가, incoming request를 처리한다든가에 대한 Callback들이 해당된다.)
3.
1, 2, 3번 사항들에 대한 조건 검사가 끝나고 수행할 수 있는 작업들에 대해 선별이 끝났다면, 선별된 작업들이 실행하는 동안 Event Loop는 잠시 실행을 멈췄다가 작업을 재개한다. (즉, 1) pending OS Task를 마치거나 2) pending Operation을 마쳤거나 3) Timer가 완료 되었을 때 다시 재개하게 된다. ) 수행되는 작업들에 대해서 Call Stack이 빌 때까지 기다린다고 보면 된다. 아무리 Event Loop가 최대한 빨리 돌고 작업을 마치려고 하더라도 3번 과정에서 작업을 처리하는 동안은 멈췄다가 돌아가게 한다.
4.
1번 확인 사항과 비슷하게 pending Timer에 대한 작업을 확인하는데, setTimeout과 setInterval은 확인을 했기 때문에 여기서는 setImmediate에 대한 확인을 하게 된다. 조건에 부합하는 setImmediate이 있다면 바로 호출을 하게 된다. (setImmediate은 setTimeout과 setInterval과 유사한 작업이다.)
5.
'Close' Event라는 것을 처리한다. Close Event는 일종의 이벤트 루프가 1회 반복을 마쳤을 때 호출되는 Callback 함수라고 보면 편하다. 즉, 여기서 실행되는 Close Event라는 것은 이벤트 루프가 1회 반복을 마무리 했기 때문에, 마무리 단계에서 실행되는 작업이라고 보면 된다. 주로 Cleaning Up Code를 수행하는 경우가 많다. 이런 Close Event들은 ReadStream이 있을 때, Read Stream에 Close 이벤트를 달고 Close Event에 해당하는 Callback 함수를 등록하게 된다. (Cleaning Up Code를 주로 하게 된다고 했는데 이런 작업을 수행하는 Close Event가 존재하는 이유는 프로그램의 Dangling Loose End의 상황을 피하기 위해서이다.)
Is Node Single Threaded?
Node의 이벤트를 처리하는 이벤트 루프는 싱글 쓰레드가 맞지만, Node 내에서의 Express.js와 같은 몇 Framework나 Standard Library들은 싱글 쓰레드가 아니다.
The libuv Thread Pool
기본적으로는 1개의 Thread Pool 당 4개의 uv_thread_t로 동작하게 되지만, UV_THREADPOOL_SIZE를 조정함에 따라 쓰레드 수를 늘릴 수 있다.
(Node에서 process.env.UV_THREADPOOL_SIZE = 4와 같이 조정 가능하다.)
이에 대해서 예를 들면 Thread Pool의 크기가 4일 때 Thread를 이용하는 수가 5개면, 4개를 사용하고 그 다음에 5번째 작업이 수행된다. 결과 또한 그러한 것을 pbkdf2에 대한 함수 5개를 사용해본 뒤 Callback 함수에서 log를 찍어보면 알 수 있다.
pbfkdf2를 2개를 돌리면 (Thread Pool의 사이즈가 4개일 때 그보다 작은 개수로 돌린 경우), 거의 동시에 작업이 수행되면서 1초 정도 소요된다. 반대로 pdkdf2를 4개를 돌린 경우 똑같이 Thread Pool 사이즈보다 작거나 같게 돌았을 뿐인데 1초가 아닌 2배된 2초가 걸린다. 5개의 경우, 그 중 4개는 똑같이 2초가 걸리는데 반해 마지막 1개는 1초면 된다. 굉장히 흥미롭다. 어떤 경우에는 1초에 끝나는데 왜 어떤 경우에서는 2배만큼 걸리는걸까? 이는 사용하는 컴퓨터의 환경에 따라서 다르다. 만일 Thread Pool의 크기가 4고 작업이 4개인데, 코어가 4개라면 모두 1초에 끝난다. 하지만 코어가 2개라면, 4개의 Thread를 동시에 처리할 수 없고 한 번에 2개까지만 동시에 처리할 수 있기 때문에 2초의 시간이 걸린 것이다.
이벤트 루프는 싱글 쓰레드, libuv의 UV Thread Pool은 멀티 쓰레드로 동작한다.
이벤트 루프는 JavaScript 코드에 대한 처리를 담당하고, Thread Pool은 Standard Library와 같이 엔진을 두고 C++로 돌아가야 하는 무거운 작업들에 한하여 처리하게 된다. 따라서 엄밀히 엔진을 갈구지 않는 순수한 JavaScript를 사용한다면 싱글 쓰레드로 돌고, C++ 엔진을 사용해야하는 무거운 작업들 혹은 Standard Library Module들을 이용한다면 멀티 쓰레드로 동작을 하기 때문에 Node는 완전 싱글 쓰레드로 동작하는 것은 아니다.
쓰레드 풀을 이용하는 작업들은 C++로 작업하는 것들 중에서 딱 쓰레드 풀을 이용하도록 정해진 작업들만 수행된다. (모든 C++로 돌아가는 코드들이 쓰레드 풀을 이용하는 것은 아니다.) 또한 쓰레드 풀은 대체적으로 fs모듈이나 crypto의 일부 모듈에서 이용하게 된다. 이런 Thread Pool을 이용하는 것은 pending Timers, pending OS Tasks, pendgin Operations 중 마지막 pending Operations에 해당한다.
libuv OS Delegation
fs 혹은 crypto들이 pending Operations이고, 이것들이 Thread Pool을 이용하게 된다. 그렇다면 pending OS Tasks들은 무엇이고,Thread Pool을 이용하는지 확인해보자.
네트워크 Request를 작성하고 async한 요청을 보내도록 한다면, 이는 곧 pending OS Tasks가 된다. 만일 pending OS Tasks가 Thread Pool을 이용한다면, UV_THREADPOOL_SIZE 및 현 컴퓨터의 코어 수에 꽤 큰 영향을 받을 것이다. 하지만 그렇지 않다. Request를 코어, 쓰레드 수보다 훨씬 높게 보내도 몇 개의 Request를 보내더라도 거의 동시에 끝나는 것을 볼 수 있다.
fs, crypto 라이브러리와 달리 네트워크 요청은 왜 pending OS Tasks라고 분류되는지, 쓰레드 풀은 이용하는지 살펴보자. C++로 이뤄진 코드들은 libuv를 사용하여 처리 되지만 libuv를 이용한다고 해서 모두 다 쓰레드 풀을 거치는 것은 아니다. fs, crypto 라이브러리들은 libuv에 의해 쓰레드 풀을 거치게 되지만, 네트워크 요청은 그렇지 않다는 것이다. 그렇다면 네트워크 요청들은 쓰레드 풀을 이용하지 않는다면 어떻게 처리 되는 것일까? libuv에 의해 쓰레드 풀을 이용하지 않는 작업들은 운영체제에 의존한다. 즉, 네트워크 요청들은 libuv가 해당 작업을 받으면 운영체제에 위임해버린다. 운영체제는 쓰레드를 만들어서 처리할지 만들지 않고 처리할지 혹은 요청에 대한 전반적인 과정을 처리하도록 되어 있는데, 네트워크 요청에 대해서는 쓰레드를 생성하지 않고 독자적으로 처리하게 된다. (운영체제가 독자적으로 네트워크 요청에 대해서 처리하는 와중에 libuv는 운영체제에게 작업을 위임하고 나면, 완료되었다는 시그널을 받기 전까지는 대기하게 된다.) 즉, 위 pbkdf2와 같이 쓰레드 풀을 이용하면서 쓰레드를 읽어내는 코어를 기다리는 상황이 아니라, 일단 libuv가 운영체제에 작업을 위임하고 나면 Event Loop는 비어 있는 상황이기 (Non-Blocking 상태) 때문에 Event Loop는 계속해서 JavaScript를 처리할 수 있고 그와 동시에 운영체제는 이미 작업을 계속해서 수행하고 있는 상황이 된다. 따라서 10개 20개에 대한 네트워크 작업을 실행하게 되면 Event Loop가 작업을 읽는 대로 운영체제에 넘겨버리고 바로 Event Loop가 또 읽어버리고 운영체제는 또 해당 작업을 바로 처리하게 되고, 이런 상황이기 때문에 쓰레드 풀의 상황과 달리 몇개를 요청하든 거의 동시에 Response가 오는 것을 볼 수 있다. 이렇듯 네트워크 요청은 운영체제가 위임 받아 쓰레드, 코어와 관련 없이 처리하기 때문에 OS Tasks라고 부른다.
fs, crypto와 같이 쓰레드 풀을 이용하는 것들 말고, 운영체제가 위임받아 비동기로 수행하는 (OS Async Stuff) Standard Library의 함수들은 어떤 것들이 있을까? 모든 운영체제에 대해서 네트워크 관련 함수들은 다 해당이 된다.
OS가 위임 받아 수행하는 작업 (OS Async Stuff)는 이벤트 루프의 무엇에 해당하는가 ?위와 같은 작업들은 pending OS Tasks Array에 해당한다.
Crazy Node Behavior
Mind Boggling Behavior (대체적으로 많이 들어오는 Interview Question)
pending OS Tasks와 pending Operations가 한 스크립트 내에 같이 존재하고 이를 이벤트 루프로 처리하는 경우 어떤 일이 벌어질까? 예시 참고 & 예측해보자 (fs, crypto, https)
내 예측: https → fs → crypto 혹은 fs → https → crypto
결과: http → fs ≤ crypto (즉, fs와 crypto가 얼추 2초를 사용) 하지만 fs를 단독으로 사용하여 하드 드라이브에서 읽ㄹ어오면 0.02초 정도면 된다. 어찌 이런 예기치 못한 상황이 발생할까?
Unexpected Event Loop Events
Node 내부에서 이벤트 루프가 처리하는 것들을 상기해보면 FS와 같은 Module은 Thread Pool을 통해서 처리하고, Https와 같은 네트워크들은 운영체제의 힘을 빌려 처리했었다.
https가 다른 것들에 비해서 비교적 빠른 시간에 먼저 결과가 나온 이유는, https 요청은 쓰레드 풀을 이용하지 않고 직접적으로 운영체제에 의존하기 때문에 FS 혹은 Crypto와 같은 곳에서 쓰레드 풀을 생성하는 작업을 거치지 않아도 되기 때문이다. 바로 Resolve가 가능한 것이다. (즉, 프로그램 내부적으로 어떤 상황을 갖고 어떻게 처리되고 있는지 크게 중요하지 않다. 그냥 요청했을 때 요청에 대한 응답을 받으면 그걸로 끝이다.)
그렇다면 FS를 통한 파일 Read 작업은 단독으로 수행 시 빠르게 수행될 수 있는데도 왜 crypto와 같이 사용하면 crypto 시간에 맞춰서 느려지게 되는 걸까? fs를 통한 Read File과 Crypto를 타임라인에 맞춰서 살펴보자.
fs를 통해 파일을 읽으라고 하면, 바로 파일을 읽는 것이 아니라 파일이 얼마나 큰지와 같은 statistic 관련 메타 데이터를 먼저 확인하게 된다. 그리고 그 결과를 리턴한다. Node가 해당 파일에 대한 Statistics를 알게 되었다면, 이 때 해당 파일을 읽으라고 다시 지시한다. 다시 하드드라이브에 접근하여 파일을 읽고 해당 파일을 Node Application 쪽으로 리턴하게 된다. 리턴과 동시에 완료되었을 때의 Callback 함수가 호출되고 Node Application이 리턴 받은 파일을 console.log로 확인할 수 있게 된다.
우선 fs의 특징은 위와 같고 다시 Thread Pool로 돌아와서 살펴보면, fs와 crypto 들은 이전에 알던 것 처럼 Thread Pool의 Thread에 각각 할당된다. 이 때 이전에 했던 것처럼 처리되는 것은 비슷하지만, 위 fs 처리 특성을 잘 고려해야 한다. 일단 4개의 쓰레드에 fs 1개, crypto 3개가 할당 되고 작업을 시작한다. 다른 crypto들은 작업을 시작하면서 cpu에서 처리하기 위해 스케줄러의 작업 순서에 맞춰 cpu로 Instruction을 넘기는 반면, fs의 최종 목적지는 하드 드라이브가 된다. fs의 요청을 담은 Thread가 하드 드라이브에 도착하고 나면, Thread에 담긴 요청을 하드 드라이브에 넘김과 동시에 Thread에 담긴 Task가 특정 정보를 받기 전까지 계속 기다려야 함을 알고서는 기존 Task를 신경쓰지 않게 되고 새로운 Task를 받을 수 있는 상태로 바뀌어 버린다. 그러면서 그 뒤에 남은 Task가 해당 자리를 채우게 된다. 따라서 해당 Thread는 fs에 대해 잠시 잊은 채 다른 작업을 진행하게 된다. 이 와중에 나머지 Thread 중에 Crypto 하나가 끝나게 되면서 해당 빈 자리에 다른 Task를 받을 수 있는 상태로 바뀌게 된다. 이 빈자리에 기존 작업에서 완료 되어야 하는 fs Task가 오게 된다. (이 때 처리하는 단계는 statistics를 받는 단계) 이렇게 statistics를 처리했다면 실제 파일에 대한 접근과 read를 수행해야 하는데 여전히 Thread 1개가 비어 있으므로 해당 작업을 바로 수행할 수 있다. 이 때의 Thread는 뒤에 밀린 작업이 없으므로 그냥 fs 작업이 완료될 때까지 Task를 버리지 않고 계속 잡고 기다린다. 그렇게 fs에 대한 작업이 완료된다. 결론적으로 fs의 작업이 Thread에서 왔다 갔다 하는 동안 새로운 Thread를 기다리는 시간 때문에, 첫 Crypto가 먼저 끝나고 상대적으로 fs 작업은 시간이 정말 짧게 걸리기 때문에 fs가 그 다음으로 바로 추격하는 것처럼 끝나고 나머지 작업들이 마치게 되는 것이다.
저런 미친 행동들이 나오는 것은 fs의 동작 특성 때문에 일어나는 것이라고 보면 된다.
쓰레드 풀의 쓰레드 수가 증가하게 되면, 대기하고 있는 작업들이 없기 때문에 Thread가 fs Task를 버리지 않게 되고, fs는 새 Thread를 기다릴 필요가 없기 때문에 먼저 끝나게 된다.
쓰레드 풀의 쓰레드 수가 1개일 때도 마찬가지다. fs가 새로운 Thread가 생길 때까지 기다려야 하기 때문에 이상한 현상이 발생하게 된다.