글을 시작함에 앞서, 본 포스트는 libuv가 C++으로만 작성되었다는 등의 잘못된 내용 등 일부를 수정하기는 하였으나 다른 분들의 포스트를 참고하였고, 그대로 가져다 쓴 부분도 적지 않습니다. 출처는 포스트의 최하단부에 따로 첨부하였습니다.
Summary
* javascript 자체는 싱글 스레드이다. 그러나 javascript의 런타임인 브라우저와 Node(혹은 Deno)는 각각 Worker, worker_threads 등을 지원하여 멀티 쓰레딩이 가능하다.
* Node는 기본적으로 I/O 관련 작업을 멀티 쓰레드 구조로 이루어진 OS 커널 혹은 libuv의 thread pool에서 처리한다.
따라서 Node가 단순히 싱글 스레드라는 것은 기술적으론 틀린 말이다. 그렇다고 어디가서 Node는 싱글 스레드 아니라고 빡빡 소리치지 말자 Wls 같다. 여튼 이벤트 루프가 싱글 스레드이고, 블로킹 I/O 관련을 처리하는 백그라운드는 멀티 스레드로 이루어져 있습니다.(요새 싱글 코어 CPU 없으니까요)
* Node.js의 싱글스레드 논블로킹 I/O 모델이 가능한 것은,
1. I/O 관련 블로킹 작업은 OS 커널 혹은 libuv thread pool에서 처리하여 이벤트 루프(싱글 스레드)가 blocking되지 않게 한다.
Input과 Output이 관련된 작업(http, Database CRUD, filesystem) 등의 블로킹 작업들을 백그라운드(OS 커널 혹은 libuv의 thread pool)에서 수행하고, 이를 비동기 콜백함수로 이벤트 루프에 전달한다. 블로킹을 모두 다른 곳으로 넘겼으니 논블로킹이다. 이러한 넘김을 'offloading(오프로딩)' 이라는 용어로 부른다.
스레드 풀을 사용하는 메서드는 다음과 같다.
- I/O-intensive
- DNS: dns.lookup(), dns.lookupService().
- File System: All file system APIs except fs.FSWatcher() and those that are explicitly synchronous use libuv's threadpool.
- CPU-intensive
- Crypto: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
- Zlib: All zlib APIs except those that are explicitly synchronous use libuv's threadpool.
2. 그러나 싱글 스레드인 이벤트 루프에서 blocking적인 코드를 작성하면 완전 blocking 되므로 주의하라.
따라서 이런 장점을 살리려면 백그라운드에서 처리되는 작업이 외에 이벤트 루프에서 도는 싱글 스레드에서는 동기적인 코드를 자제해야 합니다. 이벤트 루프가 도는 곳은 여러분들과 제가 아무 생각 없이 코드를 작성해온 바로 그 곳입니다.
만약 CPU 작업량이 많은 코드를 싱글 스레드인 이벤트 루프에서 실행할 경우 다른 자바스크립트 코드를 Block하게 만들어 다른 작업이 중단된 것처럼 보일 수 있습니다. 쉽게 예를 들어 수백명이 사용하는 서비스의 백엔드가 node.js로 만들어졌는데 blocking 적인 코드가 이벤트 루프에서 돌아 block이 되었다? 1초씩만 block 되어도 유저 수가 늘어날수록 계속 block이 심해질 것입니다. 흡사 디도스 공격을 받은 것처럼 말이죠.
그래서 공식문서(don't-block-event-loop)에서도 싱글 스레드에서 도는 이벤트 루프는 block하지 말라고 신신당부를 한거죠.
그럼 뭘 쓰지 말아야 할까요? 공식문서(don't-block-event-loop)에 따르면 다음과 같습니다.
In a server, you should not use the following synchronous APIs from these modules:
- Encryption:
- crypto.randomBytes (synchronous version)
- crypto.randomFillSync
- crypto.pbkdf2Sync
- You should also be careful about providing large input to the encryption and decryption routines.
- Compression:
- zlib.inflateSync
- zlib.deflateSync
- File system:
- Do not use the synchronous file system APIs. For example, if the file you access is in a distributed file system like NFS, access times can vary widely.
- Child process:
- child_process.spawnSync
- child_process.execSync
- child_process.execFileSync
추가적으로, 아래 두 메서드는 사용하지 맙시다. 복잡도가 O(n)인데 n이 깡으로 커지면 무거워진다네요.
JSON.parse and JSON.stringify are other potentially expensive operations. While these are O(n) in the length of the input, for large n they can take surprisingly long.
대신 아래와 같은 패키지를 이용할 것을 추천하고 있습니다.
- JSONStream, which has stream APIs.
- Big-Friendly JSON, which has stream APIs as well as asynchronous versions of the standard JSON APIs using the partitioning-on-the-Event-Loop paradigm outlined below.
일반적인 코드에서도 아래와 같이 비동기적으로 작성하도록 합시다.
const fs = require("fs");
// 동기적
const data = fs.readFileSync("./data.txt", "utf-8");
console.log(data);
// 비동기적
const data2 = fs.readFile("./data.txt", "utf-8", (err, data) => {
if (err) {
console.log(err);
}
console.log(data);
});
javascript가 동작하는 방식 (싱글스레드, 콜스택)
Javascript가 싱글스레드 기반의 언어입니다. 그러니까 프로세스 1개에 싱글 스레드인 것이죠.
프로세스와 스레드에 대한 용어를 모른다면 darrengwon.tistory.com/763?category=907676을 참고합시다.
다시, Javascript가 싱글스레드라는 것은 라는 말은 Javascript가 하나의 메인스레드와 하나의 콜스택(호출 스택)을 가진다는 것입니다.
하나의 메인스레드에서 호출되는 함수들은 하나의 콜스택에 쌓일것이고, 이 함수들은 스택 자료구조의 특성 상 LIFO(Last In First Out)방식으로 실행됩니다.
호출 스택에서 실행되는 순서를 묻는 면접 질문이 종종 나오곤 하는데 호출 스택에 대해서는
darrengwon.tistory.com/102?category=858365 에서 다뤘습니다. 일부를 가져와 보자면,
function a() {
function b() {
function c() {
console.log("c");
}
c();
console.log("b")
}
b();
console.log("a");
}
a();
위와 같은 코드가 있을 때, 우선 call stack에 호출()된 순서로 a()가 먼저 쌓인 후 b()가 쌓인 후 c()가 쌓인다. 고로 실행 순서는 c(), b(), a()대로 실행된다. LIFO(Last Input First Out). 그 결과 콘솔에 찍히는 문자는 c, b, a순으로 찍힌다. 호출() 되면 쌓이고 실행} 되면 지워진다고 생각하자.
이 외에도 메모리 할당을 관리하는 Memory Heap이 존재합니다만, 우선은 넘어갑시다.
메모리 힙: 메모리 할당을 담당하는 곳.
콜스택: 코드가 호출되면서, 스택으로 쌓이는 곳.
여기서 오해 하나를 지적하고 넘어가겠습니다. javascript 런타임은 비동기 처리를 지원하지 않습니다. 비동기 처리는 Javascript 엔진을 구동하는 런타임(Runtime) 환경에서 담당합니다. 여기서의 런타임 환경이란, 브라우저나 Node.js를 말합니다.
javascript를 감싼 런타임 환경들 (브라우저, Node) => 멀티쓰레드 지원!
우리가 사용하는 async/await와 같은 비동기처리를 지원하는 것은 JS를 둘러싼 환경입니다. 콜스택과 메모리힙만 존재하던 js 엔진에 외부 환경들이 조합하면 아래와 같은 모습이 됩니다.
이벤트 루프: 이벤트 발생 시 호출되는 콜백 함수들을 관리하여 태스크 큐에 전달하고, 태스크 큐에 담겨있는 콜백 함수들을 콜스택에 넘겨줍니다.
* 이벤트 루프가 태스크 큐에서 콜스택으로 콜백 함수를 넘겨주는 작업은 콜스택에 쌓여있는 함수가 없을때만 수행됩니다.
태스크 큐: web api에서 비동기 작업들이 실행된 후 호출되는 콜백함수들이 기다리는 공간입니다. 이벤트 루프가 정해준 순서대로 줄을 서있으며, '큐'라는 자료구조의 특성상 FIFO(First In First Out) 방식을 따릅니다.
* 태스크 큐는 하나의 큐로 이루어있지 않습니다. Microtask Queue, Animation Frames 등 여러개의 큐로 이루어져 있습니다. 단, 이 글에서는 이해의 편의를 위해 Task Queue로 통합하여 명칭합니다.
Web api: Web api는 브라우저에서 자체 지원하는 api입니다. Web api는 Dom 이벤트, Ajax (XmlHttpRequest), setTimeout 등의 비동기 작업들을 수행할 수 있도록 api를 지원합니다.
그래서 비동기 함수는 어떤 경로로 실행되는가?
제가 작성한 한 포스트에서는 아래와 같이 적었습니다.
콜백함수는 호출 스택에 바로 쌓이는 것이 아니라 백그라운드에 존재했다가 지정된 업무, 시간이 되면 태스크 큐(Task Queue)를 거쳐 차례대로(FIFO) 호출 스택으로 올라간다.
여기서 백그라운드는 Node 환경에서는 libuv의 thread pool이나 OS 커널을 의미합니다.
메인 쓰레드가 이벤트 루프쪽이고, 백그라운드 쓰레드가 OS 커널, libuv의 thread pool인 셈입니다.
이 과정을 자세하게 풀어내면 비동기적 코드는 다음과 같이 작동합니다.
1. 코드가 호출 스택에 쌓인 후 실행하되 그것이 비동기 작업이라면 이벤트 루프는 비동기 작업을 위임합니다.
2. Node를 구성하는 libuv는 해당 비동기 작업이 OS커널에서 할 수 있는 것인지, 아닌지(thead pool에서 처리)를 판단하여 비동기 함수를 처리합니다.
3. 비동기 작업을 처리하고 콜백 함수를 이벤트 루프를 통해 태스크 큐에 넘겨주게 됩니다.
4. 이벤트 루프는 콜스택에 쌓여있는 함수가 없을 때에, 태스크 큐에서 대기하고 있던 콜백함수를 콜스택으로 넘겨줍니다
5. 콜스택에 쌓인 콜백함수가 실행되고, 콜스택에서 제거됩니다.
여기서 중요한 건, 시간이 아닙니다. 아래를 보시죠.
console.log("첫번째로 실행됩니다.");
setTimeout(() => console.log("최소 0초보다 늦게 실행됩니다."), 0);
console.log("언제 실행될까요?");
첫번째로 실행됩니다.
언제 실행될까요?
최소 0초보다 늦게 실행됩니다.
setTimeout 함수는 비동기 함수라는 점이 중요합니다.
setTimeout의 콜백함수는 바로 콜스택에 쌓이는 것이 아니라, web api에서 비동기 처리된 후 콜백함수가 태스크 큐에 전달됩니다. 즉, 시간을 0초로 해놨을지라도, 콜스택에 바로 쌓이는 다른 함수들보다 늦게 호출되는 것입니다.
게다가, 시간이 0초라도하더라도 콜스택에 스택이 없는 것을 먼저 확인할 후에 작동하기 때문에 0초라도 하더라도 콜스택에 얼마나 많은 함수들이 쌓여있는지 여부에 따라 실행되는 시간이 지연될 수 있습니다.
Node 구성 요소
javascript 을 감싸고 있는 환경 중 하나인 Node를 살펴보겠습니다.
Node는 내장 라이브러리와 v8엔진 그리고 libuv로 구성되어 있습니다. libUV('리버브'라고 읽습니다)는 Node.js에서 사용하는 비동기 I/O 라이브러리입니다. Node.js의 특성인 이벤트 기반, 논블로킹 I/O 모델들은 모두 libuv 라이브러리에서 구현됩니다.
Node 뿐만 아니라 luvit, julia와 같은 곳에서도 사용됩니다.
libuv is a multi-platform support library with a focus on asynchronous I/O. It was primarily developed for use by Node.js, but it's also used by Luvit, Julia, pyuv, and others.
이 라이브러리는 C로 작성되었고 윈도우나 리눅스 커널을 추상화해서 Wrapping하고 있는 구조이다. 즉, libuv는 OS 커널에서 어떤 비동기 작업들을 지원해주는 지 알고 있기 때문에 커널을 사용하여 처리할 수 있는 비동기 작업을 발견하면 바로 커널로 작업을 넘겨버리고, OS가 지원하지 않는 비동기가 있다면 자체 thread pool을 이용하여 비동기를 처리합니다.
Node.js에서 작성되는 거의 모든 코드들은 콜백함수로 이루어져 있습니다. 콜백함수들은(심지어 if문까지) libuv 내에 위치한 이벤트 루프에서 관리 및 처리된다
이벤트 루프는 여러 개의 페이즈(Phase)들을 갖고 있으며, 해당 페이즈들은 각자만의 큐(Queue)를 갖습니다. 이벤트 루프는 라운드 로빈(round-robin) 방식으로 노드 프로세스가 종료될때까지 일정 규칙에 따라 여러개의 페이즈들을 계속 순회합니다. 페이즈들은 각각의 큐들을 관리하고, 해당 큐들은 FIFO(First In First Out) 순서로 콜백함수들을 처리합니다.
* Node.js는 Javascript와 C++, C언어로 구성되어 있습니다. V8엔진도 70% 이상의 C++로 구성되어 있으며, libuv는 100%의 C++언어로 구성된 라이브러리 입니다. 하지만 우리는 C/C++언어를 몰라도 Node.js는 사용할 수 있습니다. 이는 V8 엔진에서 Javascript를 C++로 Translate 해주기 때문에 가능한 일입니다. 또한 Node.js의 코어 라이브러리는 process.binding()을 통해 Javascript 환경에서 사용될 수 있습니다. 예를 들어 Node.js의 내장 모듈인 crypto는 원래 C++ 언어로 작성되어 있습니다.
* Node.js에 동작하는 이벤트 루프는 libuv 내에서 구현됩니다. 이벤트 루프가 libuv 내에서 실행된다고 해서, Javascript의 스레드와 이벤트 루프의 스레드가 별도로 존재한다고 생각하실 수 있습니다. 하지만 Node.js는 싱글스레드이기 때문에 하나의 이벤트 루프를 갖으며, 하나의 스레드가 모든 것을 처리합니다.
* 라운드 로빈(round-robin) : 그룹 내에 있는 모든 요소들을 합리적인 순서에 입각하여 뽑는 방법으로서, 대개 리스트의 맨 위에서 아래로 가며 하나 씩 뽑고, 끝나면 다시 맨 위로 돌아가는 식으로 진행된다. 쉽게 말해 라운드 로빈은 “기회를 차례대로 받기”라고 이해해도 좋을 것이다. 컴퓨터 운영에서, 컴퓨터 자원을 사용할 수 있는 기회를 프로그램 프로세스들에게 공정하게 부여하기 위한 한 방법으로서, 각 프로세스에 일정시간을 할당하고, 할당된 시간이 지나면 그 프로세스는 잠시 보류한 뒤 다른 프로세스에게 기회를 주고, 또 그 다음 프로세스에게 하는 식으로, 돌아가며 기회를 부여하는 운영방식이 있는데, 이를 흔히 라운드 로빈 프로세스 스케줄링이라고 부른다.
출처 : medium.com/@vdongbin/node-js-%EB%8F%99%EC%9E%91%EC%9B%90%EB%A6%AC-single-thread-event-driven-non-blocking-i-o-event-loop-ce97e58a8e21
Node의 특성. 이벤트 기반, 논블로킹(libuv의 비동기 처리 덕분), 싱글 스레드
node.js는 이벤트 기반(event driven), 논-블로킹 I/O, 싱글 스레드란 특징을 가진 JS 런타임이라고 종종 정의되는데 이 각각이 도대체 무엇을 말하는 것인지에 대해서 알아보자.
이벤트 기반 event driven
node.js는 콜 스택, 이벤트 큐에 항상 명령을 대기시키지 않고 이벤트 리스너event listener를 통해 이벤트가 발생하면 콜백함수를 실행하는 방식을 사용한다. 예를 들어 서버에 접속하는 이벤트event를 듣는listen 이벤트 리스너event listener를 코딩하고 해당 이벤트가 발생하면 콜백함수를 실행한다.
이벤트에 따라 호출되는 콜백함수를 관리하는 것이 바로 이벤트 루프입니다. 이후에 진하게 다뤄보겠습니다.
논 블로킹 I/O non-blocking I/O(input, output)
기다려야 하는 함수(특히 서버 API로부터 정보를 받아올 때나 특정 파일을 불러올 때)를 기다리지 않고 곧장 함수를 실행한다는 의미이다. fetch나 axios를 이용할 때 async/await를 사용하는 이유가 node가 논 블로킹 I/O이기 때문이다.
아래 코드를 보면 블로킹 I/O이라면 3초를 기다린 후에 다음 코드가 출력되어야 할 것이다. 논 블로킹이기 때문에 START, END, after 3sec 순으로 출력된다.
console.log("START");
setTimeout(() => console.log("after 3sec"), 3000);
console.log("END");
node.js가 논 블로킹인 이유는(이어야만 하는 이유는) 싱글 스레드이기 때문이다.
다른 스레드로 일을 보내지 못하기 때문에(동시적 처리를 할 수 없기 때문에) 효율적으로 일처리를 하기 위해서는 논 블로킹 I/O란 속성을 채택한 것이다.
여기서 왜 I/O라는 input, output이 나오는가에 대해서는, Node.js에서의 논블로킹 I/O 모델은 Input과 Output이 관련된 작업(http, Database CRUD, filesystem) 등의 블로킹한 작업들을 백그라운드(여기서 백그라운드란 OS 커널 혹은 libuv의 thread pool)에서 수행하고, 이를 비동기 콜백함수로 이벤트 루프에 전달하기 때문이다.
구체적으로 스레드 풀을 사용하는 메서드는 다음과 같다.
- I/O-intensive
- DNS: dns.lookup(), dns.lookupService().
- File System: All file system APIs except fs.FSWatcher() and those that are explicitly synchronous use libuv's threadpool.
- CPU-intensive
- Crypto: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
- Zlib: All zlib APIs except those that are explicitly synchronous use libuv's threadpool.
🔑 Single thread + non-blocking
프로세스와 스레드에 대한 쉬운 설명 : darrengwon.tistory.com/763?category=907676
싱글스레드는 프로세스 내에서 하나의 스레드가 하나의 요청만을 수행합니다. 해당 요청이 수행될 때 다른 요청을 함께 수행할 수 없습니다. 이를 싱글스레드 블로킹 모델이라고 합니다.
멀티스레드는 스레드 풀에서 실행의 요청만큼 스레드를 매칭하여 작업을 수행합니다. 언뜻 보면 멀티스레드가 훨씬 좋아보이지만, 멀티스레드는 효율성 측면에서 큰 단점을 갖고 있습니다. 스레드 풀에 스레드가 늘어날수록 CPU 비용을 소모하고, 만약 요청이 적다면 놀고있는 스레드가 생기기 때문입니다.
Node.js는 싱글스레드 논블로킹 모델로 구성되어 있습니다. 하나의 스레드로 동작하지만, 비동기 I/O 작업을 통해 요청들을 서로 블로킹하지 않습니다. 즉, 동시에 많은 요청들을 비동기로 수행함으로써 싱글스레드일지라도 논블로킹이 가능합니다. 단, 이 속성을 통해 이득을 보기 위해서는 코드를 가급적 동기적으로 짜서는 안됩니다.
또한 Node.js는 클러스터링을 통해 프로세스를 포크(fork)하여 멀티스레드인것 처럼 사용될 수 있습니다. 트래픽에 따라서 프로세스를 포크할 수 있으므로 서버의 확장성이 용이하다는 장점을 갖습니다.
만약 클러스터링에 관심이 있다면 멍개님의 포스트를 참고해봅시다
호출 스택, 태스크 큐, 이벤트 루프, 스코프, 클로저 : darrengwon.tistory.com/102?category=858365
동기(sync)와 비동기(async) / Blocking와 NonBlocking : darrengwon.tistory.com/104
Node는 싱글 스레드다. 그러나... (libuv의 thread pool)
Node.js가 완전하게 싱글스레드를 기반으로 동작하지는 않습니다.
계속 반복해서 언급하고 있지만 I/O 작업은 OS 커널 혹은 libuv의 스레드 풀(Thread pool)에서 수행됩니다. 대표적으로 파일시스템과 관련된 동작들은 thread pool에서 처리됩니다. 이 thread pool은 멀티 스레드로 이루어져 있습니다. 그래서 Node.js를 완전히 싱글 스레드라고는 할 수 없습니다. 또한, OS 커널 또한 대부분 멀티 스레드이므로...
다시, I/O들은 OS 커널 혹은 libuv 내의 스레드 풀에서 담당합니다. libuv는 OS 커널에서 어떤 비동기 작업들을 지원해주는지 알고 있기 때문에, OS 커널에서 지원해주는 비동기 작업들은 OS 커널에게 맡기고 그렇지 않은 작업은 자신의 thread pool에서 처리합니다.
어쨌거나 위에서 언급한대로 이러한 비동기 작업들은 콜백 함수를 이벤트 루프를 통해 태스크 큐에 넘어가고, 콜 스택이 비어있으면 이벤트 루프가 해당 콜백 함수를 콜 스택으로 옮겨 실행하도록 만듭니다.
이벤트 루프란 무엇인가
공식 문서 : nodejs.org/ko/docs/guides/event-loop-timers-and-nexttick/
노드의 내부 동작 원리 : sjh836.tistory.com/149
로우 레벨로 살펴보는 이벤트 루프 : evan-moon.github.io/2019/08/01/nodejs-event-loop-workflow/
앞서, 콜백 함수를 태스크 큐에 옮기거나, 호출 스택에 옮기는 작업을 이벤트 루프가 한다고 언급하였는데 이벤트 루프가 정확히 무엇을 하는 지에 대해서 알아보려고 합니다.
이벤트 루프는 아래와 같이 생겼습니다.
이 그림에 표기된 각각의 박스는 특정 작업을 수행하기 위한 페이즈들을 의미한다. 각 페이즈는 각자 하나의 큐를 가지고 있으며, 자바스크립트의 실행은 이 페이즈들 중 Idle, prepare 페이즈를 제외한 어느 단계에서나 할 수 있다.
(이해를 돕기 위해 큐라고 설명했지만 사실 실제 자료구조는 큐가 아닐 수도 있다.)
그리고 이 그림에서 nextTickQueue와 microTaskQueue를 볼 수 있는데, 이 큐들은 이벤트 루프의 일부가 아니며, 이 큐들에 들어있는 작업 또한 어떤 페이즈에서든 실행될 수 있다. 또한 이 큐들에 들어있는 작업은 가장 높은 실행 우선 순위를 가지고 있다.
이제 우리는 이벤트 루프가 각자 다른 여러 개의 페이즈들과 큐들의 조합으로 이루어져 있다는 것을 알게 되었다. 이제 각각의 페이즈가 어떤 작업을 수행하는 지는 다른 분이 잘 정리한 글이 있으니 참고하자
evan-moon.github.io/2019/08/01/nodejs-event-loop-workflow/
전체적인 Node의 동작 과정
출처)
(1)기초적인 내용들
(2)프로세스와 스레드
https://darrengwon.tistory.com/763
(3)이벤트 루프 깊이 파보기
공식 문서 : https://nodejs.org/ko/docs/guides/event-loop-timers-and-nexttick/
https://evan-moon.github.io/2019/08/01/nodejs-event-loop-workflow/
https://tk-one.github.io/2019/02/07/nodejs-event-loop/
(4)node 내부 동작 원리
https://sjh836.tistory.com/149
(5)thread pool
don't block the event loop (official doc) : nodejs.org/en/docs/guides/dont-block-the-event-loop/
worker threads : nodejs.org/docs/latest-v14.x/api/worker_threads.html
그 밖의 한국어 자료들 : melius.tistory.com/58, psyhm.tistory.com/45
'Node, Nest, Deno > 🚀 Node.js (+ Express)' 카테고리의 다른 글
express 보안 우수 사례를 참고한 보안 강화 (0) | 2020.11.23 |
---|---|
동기 I/O 와 비동기 I/O 의 성능 차이 + Node.js를 쓸 이유가 없다? (0) | 2020.11.22 |
중앙 집중식 API 에러 핸들링 (탑레벨 fetching handler를 만들어라) (0) | 2020.09.17 |
pm2 이용 및 로그 기록 살피기 (0) | 2020.08.06 |
노드 내장 모듈 util의 util.pomisify 사용하기 (0) | 2020.07.30 |