Hiun Kim essays / articles / notes

Node.js Internals

July 02, 2016 · SEOUL, KOREA

2014년경 첫 프로그래밍언어인 PHP를 파보면서 PHP가 general-purpose language(범용적인 언어)가 아니라는 생각이 들었다. 오히려 MySQL 드라이버를 전역에 내장한 것을 보면 DSL적인 특징도 갖추고 있다 생각한다. 물론 언어의 사용환경이나 목적을 제한하고 많은 assumption(가정)들을 설치한다면 해당 domain(분야)에서 생산성은 올라간다, 하지만 기능의 elastic(확장성)면에서 문제가 생기기 쉽다. 때문에 이제 general-purpose language로 넘어가야될 때가 되었다고 생각했다.

Python과 JavaScript중 고민하다가, 웹 분야에 있기때문에 JavaScript로 선택을 하였다. 지금생각해보면 둘 다 괜찮은 선택이였던것 같다. Python은 데이터과학에도 많이 쓰이고 무엇보다 JavaScript보다 bold(탄탄)한 느낌이 있기 때문이다. 기술적인 이유는 아니지만, Google 검색 엔진이나, Dropbox같은 대규모 서비스에도 적용되어 있기 때문일 것이다.

Node.js의 내부를 이해하기 위해서는 JavaScript에 대한 간단한 배경과, 언어에 대한 이해를 한다면 더욱 도움이 될거라 생각해, 거기서부터 시작하기로 하였다.

짧은 역사

JavaScript는 ECMASCript라는 표준이 존재하고, 표준의 specification(명세)를 기반으로 한 Google의 V8, Firefox의 SpiderMonkey, Microsoft의 ChakraCore와 같이 implementation(구현체)이 존재한다. 반면 Python은 Python Software Foundation이라는 single-source로 부터 언어가 개발된다, 물론 공식적인 implementation이 하나뿐이니 표준은 필요없을것이다.

1995년, 당시 웹브라우져계의 선도자였던 NetScape가 JavaScript를 발표하였고, 96년 브라우져에 탑재하자, Microsoft는 JavaScript의 dialect(고유한 특징이 있는 특정 언어의 ‘호환 언어’)로 JScript를 개발하였다. 표준화를 위해 NetScape는 JavaScript를 Ecma International에 표준으로 등록하였고, Netscape와 Microsoft는 타협을 통해 ECMAScript라는 이름을 만들었다.

V8 JavaScript Compiler

2008년 Google이 V8이라는 새로운 JavaScript implementation을 출시하였고, V8은 다른 implementation, 즉 JavaScript engine과는 다르게 JavaScript code를 바로 machine code로 바꾸는 방법으로 좋은 성능을 확보하였다. 이러 한 성능을 기반으로 2009년 Ryan Dahl은 Node.js를 개발한다.

Node.js

Node.js를 Google Trend로 검색한 결과를 보면, 사람들의 관심이 꾸준히 상승하는것을 볼 수 있다. Node.js in Google Trend

최근 Node.js는 Command-line interface(커맨드라인)툴로도 많이 사용되지만, 역시 가장 높은 인기를 얻고 있는것은 network-based application이다. Node.js는 홈페이지에도 다음과 같이 소개되어있다.

As an asynchronous event driven JavaScript runtime, Node is designed to build scalable network applications. (Node.js는 비동기, 이벤트 기반의 JavaScript런타임으로 확장성있는 네트워크 응용프로그램을 개발하는데 적합하다.)

09년 등장시 부터 꾸준하게 관심을 받고 성장해온 배경에는 사람들이 Node.js을 사용하려는 이유, 즉 scalable한 network application을 만드는데 있어서의 강점 2가지가 있다고 생각한다.

1.Asynchronous I/O(비동기 I/O)를 이용 한 높은 throughput
2.Asynchronous Programming(비동기 프로그래밍)에 적합한 evaluation strategy(코드 실행 방식)

Asynchronous I/O

I/O는 input/output의 약자이다. I/O라는 말은 일반적이기 때문에 CPU, RAM, Disk, Network와 같은 computing resource중 어느것 에도 적용가능하다. 예를들어 addition(더하기)를 하려면 타입 체크와 실제 더하는 작업을 진행하기 위해 IA32와 같은 instruction을 CPU로 던지지만 이것은 I/O라고 하지는 않는다. ‘Hello World’라는 string이 저장한 메모리조각 역시 마찬가지이다. 왜냐하면 접근 속도가 매우 빠르기 때문이다. 또한 CPU와 RAM은 프로그램 그 자체를 실행하는데 필요한 자원이기때문에 접근이 매우 빈번하며 이 다음에 언급할 진짜 I/O들과 다르게 사용을 하지 않으면 프로그램 실행 자체가 불가능하다. 진짜 I/O로 취급받는것은 Disk, Network가 있다. 이러한 자원들은 앞의 자원들과 다르게 접근 속도가 느리기때문에 접근비용이 높은 자원에 속한다.

웹 애플리케이션으로 따지면, 사용자와의 HTTP통신이나, MySQL에 대한 작업등이 network I/O에 속하며, jpeg파일을 서빙하거나 접근 로그를 파일에 저장하는것은 disk I/O에 속한다.

Asynchronous I/O는 전혀 특별한것은 아니다. 프로그래밍언어와 런타임에서 비동기(asynchronous)와 동기(synchronous)의 차이점은 특정 API 콜에 대한 request(요청)와 response(응답)의 procedural pair(순자적인 짝짓기)여부이다.

유닉스에서 로컬디스크의 파일을 읽는 API인 read의 경우 동기적으로 동작한다, read 함수가 완료되기까지 다른 read함수는 실행되지 않는다는 뜻이다. 이 경우 동기 API는 아무런 비효율성을 가지지 않는다, 반대로 말해 비동기 API는 아무런 추가 효용이 없다, 그 이유는 API에 대한 처리를 같은 머신에서 하기 때문이다.

벽돌 10자루를 같은 간격을 가진 세 지점 A에서 B를 거처 C로 옮기는데 synchronous하게 본다면 한자루씩 A->C로 10번 옮기는 것이고, asynchronous하게 본다면 한자루씩 A->B로 옮기고 B->C로 10번 옮기는 것이므로 총 일의 양은 동일하다, 오히려 asynchronous하게 벽돌을 옮길경우 벽돌자루를 옮기기위해 되돌아가는 비용, 즉 process scheduling비용이 잇기때문에 더 비효율적이다.

하지만 B지점에 일정시간마다 배가 운행되는 강이 있다면 어떻게 될까? 즉 물건을 옮기는데 있어서, 처리를 하는데 있어서 다른 요소로부터 dependency(의존성)가 생긴 것이다. 응용소프트웨어는거 대부분 그런 샌드위치 신세이며, JavaScript가 바로 그렇다. 위로는 사용자의 UI에 대한 일이, 아래로는 AJAX요청에 대한 일이 잇다.

사람들은 파일을 네트웍으로 전송하는데 있어서는 기다릴수 잇지만 버튼을 클릭하는것은 당연하게 기다리지 않는다. 만일 AJAX요청이 진행중일때 버튼이 눌리지 않는다면 그건 말이 안되는 것이다. 때문에 JavaScript는 작업량이 많다(a.k.a CPU를 많이 쓴다)기 보단, 적은 작업이라도 동시적으로 할수있는 멈추지 않는(non-blocking) 런타임이 필요한것이고 실제로 그렇게 디자인되었다.

이러한 연유에 의해, 즉 작업량이 적기때문에 JavaScript엔진은 single-threaded(단일 스레드)이고, 멈추기 않기위해 asynchronous I/O를 가진 것이다. 그럼 우리의 의문은 2가지가 된다.

1.Asynchronous I/O는 어떻게 동작되는가?
2.Sync기반의 Operating System에서 어떻게 효율적인 Asynchronous I/O를 구현할수 있는가?

Asynchronous I/O Mechanism

synchronous방식의 반대라고 보면된다, 즉 request를 하고 response가 오기전에도 다른 요청을 받아들일수 있다는 것이다. 물론 response와 request간의 pair를 만들기 위해 따로 관리를 하는 부분이 추가적으로 소모되나 CPU utilisation 최대화 - idle 최소화가 훨씬 더 큰 리소스 절약을 가능하게 한다.

아래그림에서 overlapped된 부분만큼 시간을 절약한것이라고 보면된다, 즉 그만큼 CPU를 효율적으로 사용한것이라고 할수 있고 이것이 asynchonous I/O의 장점이다.

Asynchronous I/O

방금전, request와 response의 pair에 대해서 언급한 적이 있다. asynchronous환경에서 pair가 지켜지지 않는 상황은 파일 a, b에 대한 read request에 대해 2건의 response가 돌아오는것이다, 문제는 그 중 어떤것이 a이고 b인지 구분할수가 없다는 것이다. 파일 크기가 같다면 첫번째 response를 a로 특정할수는 있겠으나, a의 파일 크기가 b보다 크다면 b먼저 응답이 올수도 있고 무엇보다 요청이 수십 수백개라면 요청과 응답의 순서를 보장하는것은 불가능하다. 물론 synchronise환경에서는 blocking이 되기때문에 pair에 대한 우려는 없다.

아래와 같은 read콜이 발생하는 코드를 asynchronous환경에서 실행한다고 생각해보자,

var contentA = read('a.txt');
var contentB = read('b.txt');
console.log(contentA, contentB);

console.log라는 함수에 contantA, contentB라는 2개의 dependency가 있으므로, 함수는 dependency의 값2개가 전부 나온 다음에 실행되는것이 맞을것이다, 그러나 위에서 아래까지 코드를 procedural하게 실행할 경우 console.log함수를 실행할 타이밍에 dependency들이 구해지지는 않을것이다. 즉, 현재의 procedural한 evaluation strategy를 가진 현재의 코드에서는, while loop을 돌면서 pooling방식으로 처리하는등 우리가 수동적으로 확인하는것만 가능하다.

JavaScript는 event라는 개념을 도입하여 console.log함수를 실행하기 위한 타이밍, 즉 dependency 2개가 정해지는 시점을 알려주어서, 우리가 그 다음 작업을 진행할수 있도록 도와준다.

Event를 처리하기 위해서는 여러 방법이 있지만, JavaScript에서는 기본적으로 callback function을 사용한다.[1]

var contentA, contentB; 

read('a.txt', contentAHandler) //1.read a.txt and pass handler function

var contentAHandler = (result) => { //2.read a.txt finished
    contentA = result;
    read('b.txt', contentBHandler); //3.read b.txt and pass handler function
}

var contentBHandler = (result) => { //4.read b.txt finished
    contentB = result;
    console.log(contentA, contentB); //5.print result
}

read라는 작업을 위해서는 UNIX API가 발생시키는 event가 필요하며, JavaScript는 이 event의 청취자이다. 즉 event를 발생켜 - 결과적으로 event handler function을 실행시키는 타이밍은 UNIX API가 정한다. JavaScript는 그 타이밍에 event handler function을 실행해 달라고 read함수에 argument(인자)로 던지는 것이다.

JavaScript가 UNIX API를 지원하지는 않기 때문에, Node.js하부의 C++확장 프로그램이 해당 작업을 대신 실행하고, Node.js runtime이 JavaScript코드와 C++확장 프로그램간의 연결을 관리한다. 따라서 Node.js는 JavaScript프로젝트이기도 하지만, 전체로 보면 굉장히 큰 C++프로젝트이다.

방금 C++라고 하였는데, C++는 JavaScript와 다르게 기본적으로 asynchronose runtime은 아니다. 따라서 synchronose방식으로 동작하는 C++, 더 나아가 Operating System을 기반으로 동작하는 Node.js가 어떻게 효율적인 asynchronose I/O을 구현할수 있는가?

Node.js Internals

결론적으로 말하면 Node.js의 muilti-thread기반이다.

event loop은 single-thread이지만, Node.js의 I/O call을 처리하는 하부 구조는 multi-thread기반이다.

Node.js Internals

이 하부구조는 libuv라 불리며, thread pool을 사용해 thread를 재사용 하는 방법으로, 효율성을 극대화 한다. 이 재사용이 sync기반의 OS에서 효율적인 asynchronose I/O를 만드는 원동력인 셈이다.

때문에 Node.js를 사용하는 이유에는 성능적인 측면도 존재하지만, event loop은 Node.js만의 전유물이 아니며, libuv는 다양한 언어의 하부구조로써 구현될 수 있다. 때문에 Asynchronous I/O를 이용 한 높은 throughput은 Node.js만의 장점은 아니다.

하지만 Node.js가 그들과의 유니크 함을 꼽으라 한다면 바로, asynchronous Programming에 적합한 evaluation strategy이다. 다른 언어의 경우 synchronous기반의 언어에 라이브러리 형태로 asynchronous 런타임을 구현하였지만, Node.js는 기반 부터 asynchronous하기때문에 언어의 표현력에서 더욱 유연하다고 생각이 든다.

Evaluation strategy는 Programming language의 implementation detail에 따라서 결정되는데, callback function과 같은 부분이 일례이다. 이 부분에 대해서는 추후 추가할 예정이며, 이번 글에서 깊게 다루지 못한 thread pool도 benchmark과 더불어 추후에 다뤄볼 예정이다.


« Modularity Papers Review Easy, safe watermarking and resizing with pixelmator on OS X »