nodeJS와 context switching

박기범·2021년 11월 30일
0

‘싱글 스레드 환경의 nodeJS에서 DB의 read / write, 파일 IO가 일어날 경우 context switching은 일어나는가? 별도의 쓰레드가 존재하지 않는다면, JavaScript 로직 이외의 작업들도 JS의 이벤트 루프가 관리할 수 있는가?’

질문부터가 머리아프고, 질문을 적고있는 이 순간에도 내 스스로 명확한 질문인지조차 파악하지 못한 주제이다. 파일을 object storage에 업로드하는 과정이 필수적으로 필요한 Web21-이거외않됌 팀의 ShallWeSound 프로젝트를 수행하면서 내 머릿속을 계속 맴돌던 의문이기도 하다.

나는 이러한 의문에 대해 조금이나마 도움이 될 자료를 오늘에야 찾았고, 이해한 바를 잊기전에 정리해보려 한다. (조금만 더 일찍 알았더라면 면접때 어필할 수 있었을텐데.. 정말 안타깝기 그지없다..)

이 주제는 다음과 같은 목차로 나뉘어 서술될것이다.

1. blocking IO vs non-blocking IO
2. nodeJS에서 file IO를 관리하는 주체
3. 내가 이해한 결론

1. blocking IO vs non-blocking IO

앞으로 서술할 모든 이야기는 이곳에 상세히 존재한다.
https://nodejs.org/ko/docs/guides/blocking-vs-non-blocking/

우선적으로 정의할 용어는 IO 작업이다. IO 작업은 nodeJS가 libuv에 의존하여, libuv를 통해 처리하는 시스템 디스크 혹은 네트워크 상호작용을 의미한다.
사실 이 설명은 정말정말 큰 의미를 갖고있고, 이 주제에 대한 게시글 전체를 관통하는 설명이므로 잘 명심하도록 하자.

다음으로 정의할 용어는 blocking이다. nodeJS 공식 문서에 따르면, nodeJS의 단일 쓰레드 실행 환경에서 연산이 복잡한 작업을 동기적으로 수행하느라 다음 작업을 수행하지 못하는 것은 보통 blocking이라 칭하지 않는다고 한다.

그렇다면 무엇을 blocking이라 칭하는가? 앞서 말한 IO작업 등에 의해 다음 작업을 수행하지 못하는 경우를 blocking이라고 일컫는다.

다음의 코드를 보자. 대표적인 blocking IO의 예제이다.

const fs = require('fs');
const data = fs.readFileSync('/file.md');
//moreFunction();

위 코드에서 moreFunction은 반드시 readFileSync가 완료된 뒤에 수행된다.

반면, 다음 코드는 다르다.

const fs = require('fs');
fs.readFile('/file.md', (err, data) => {
  if (err) throw err;
});
//moreFunction();

위의 코드에서 moreFunction()readFile의 종료 여부에 관계없이 먼저 실행된다. 즉, file IO 작업을 수행하면서도 단일 쓰레드 환경의 JS 로직이 영향을 받지 않고 실행된다는 것이다. 이것이 바로 non-blocking IO이다.

nodeJS가 non-blocking IO 작업을 수행할때는, JavaScript 런타임인 V8엔진의 이벤트루프가 빛을 발한다.

non-blocking IO가 요청되었을 때, 이벤트루프는 우선 동기적으로 선언된 다른 JavaScript 로직을 수행한다. 그러다가 file IO 작업이 끝났다는 이벤트 알림을 받게되면 이벤트 루프가 비동기 함수에 등록된 JavaScript callback function을 task queue에 등록하게 된다. 그 이후는 다들 알고있는 동기/비동기 로직 처리 과정에 따른다.

...??? 잠깐. 뭔가 이상하다.

file IO 작업이 끝났다는 이벤트 알림을 받는다고?
비동기 함수에 등록된 콜백 함수를 task queue에 등록해준다고?

맞다. nodeJS의 단일 쓰레드 실행 환경은 사실 직접 file IO를 처리하지 않는다. 오로지 JavaScript 로직을 처리하는데에만 관심이 있다.

이 게시글의 도입부를 기억하는가?

우선적으로 정의할 용어는 IO 작업이다.
IO 작업은 nodeJS가 libuv에 의존하여, libuv를 통해 처리하는 시스템 디스크 혹은 네트워크 상호작용을 의미한다.

라고 서술되어 있을것이다.

2. nodeJS에서 file IO를 관리하는 주체

이렇게 자연스럽게 2번 항목으로 넘어왔다.
이어서 말해보자면, nodeJS는 파일 시스템 작업을 직접 처리하는것이 아닌, 자신이 의존하고있는 libuv 라이브러리에 위임한다.

그렇다면 libuv는 무엇인가? nodeJS 공식 문서의 설명에 따르면 다음과 같다.

또 하나의 중요한 의존성은 libuv입니다. libuv는 C 라이브러리로 논블로킹 I/O 작업을 지원하는 모든 플랫폼에서 일관된 인터페이스로 추상화하는 데 사용됩니다. libuv는 파일 시스템, DNS, 네트워크, 자식 프로세스, 파이프, 신호 처리, 폴링, 스트리밍을 다루는 메커니즘을 제공하고 운영체제 수준에서 비동기로 처리될 수 없는 작업을 위한 스레드 풀도 포함하고 있습니다.

요약하자면, libuv는 C 언어로 작성된 외부 라이브러리이다.
개발자가 JavaScript로(대부분의 경우 fs 모듈일것이다.) 파일 시스템에 접근해야 하는 IO작업을 요청한다면, nodeJS는 libuv가 제공하는 추상화된 인터페이스를 활용하여 IO 작업을 지시한다. 그 뒤 JavaScript 로직의 실행에 집중한다.

그러다가 libuv가 IO 작업을 마쳐서 이벤트 알림을 보내면, nodeJS의 이벤트 루프가 비동기 함수에 등록된 콜백함수를 task queue에 집어넣어 실행될 수 있도록 관리한다.

IO작업이 끝나면 신호를 보내고, 이 신호에 맞춰 다른 작업이 이어서 수행된다.

어디서 많이 본것이 아닌가?
그렇다. context switching이다.
이제 슬슬 결론으로 넘어가자.

3. 내가 이해한 결론

nodeJS는 단일 쓰레드 실행환경이다. 의심할 여지가 없다.
다만, 비동기 file IO 작업이 요청될 경우, 직접적인 file IO는 이 단일 쓰레드 실행 환경에서 관리하지 않는다. 이 작업은 libuv에 위임한다. 그리고 그 작업이 끝났다는 알림을 받았을 경우, 그 비동기 file IO 작업에 대해 등록해둔 JavaScript callback function을 실행할 수 있도록 해준다.

즉, 서로 별개의 프로세스 내지는 쓰레드가 동작하며, 직접적인 file IO와 nodeJS의 단일 쓰레드 JavaScript 런타임이 context switching을 일으킨다고 설명할 수 있다.
(반대로, 만약 context switching이 일어나지 않는다면, nodeJS의 단일 쓰레드 프로세스와 libuv가 진행하는 파일 시스템 IO 작업이 병렬적으로 실행될 수 있음을 설명할 방법이 없다. nodeJS의 이벤트 루프는 직접적인 파일 시스템 IO 작업에 관여하지 않기 때문이다.)

이에 관해 몇가지 자료를 더 찾아보았다.

https://blog.appleseed.dev/post/nodejs-non-blocking-io-and-multicore-processing/

https://bcho.tistory.com/865

https://xyom.github.io/2017/12/08/Node%20js%20%E1%84%83%E1%85%A9%E1%86%BC%E1%84%8C%E1%85%A1%E1%86%A8%20%E1%84%8B%E1%85%AF%E1%86%AB%E1%84%85%E1%85%B5/

결국 nodeJS가 단일 쓰레드 실행 환경이라고 해서, 현대 OS에서 일어나는 context switching을 완전히 피해간것은 아니라는 것이다. 위의 게시글들에서 언급하는 nodeJS 뒷단의 thread pool은 아마 libuv 같은 외부 라이브러리들을 병렬적으로 실행시키기 위한 thread를 관리하는 장치일것이다. (이 부분에 대해서는 추가적인 조사가 필요하다.)

정말 신비하고 오묘한 JS의 세계이다...
만일 이 게시글을 보고 '이건 틀렸어!' 라고 당당히 설명할 수 있는 분이 계시다면, 두팔벌려 환영하겠다. 명확한 피드백은 항상 감사히 받아들일 준비가 되어있다.

끝!

profile
원리를 좋아하는 개발자

3개의 댓글

comment-user-thumbnail
2024년 4월 15일

안녕하세요, 오래된 글이라 댓글을 다는 것이 조심스럽긴 하지만... 궁금한게 있어 댓글 남깁니다!
"nodejs에서 비동기i/o작업을 할당하고 실행하기 위해 컨텍스트 스위칭이 발생하는 것 같다"까지는 이해하였습니다.
그런데 "nodeJS가 단일 쓰레드 실행 환경이라고 해서, 현대 OS에서 일어나는 context switching을 완전히 피해간것은 아니라는 것" 이라는 부분이 좀 헷갈리는데요.

자바와 같은 멀티 스레드 환경에서 동기적으로 i/o 작업을 진행하였을 때, os수준에서 해당 스레드의 상태를 대기 상태로 전환시키는 것으로 알고 있습니다.
대기 상태로 전환된 스레드는 작업이 완료되어 준비 상태로 바뀌기 전까지는 해당 스레드를 체크하지 않을텐데요.

context switching은 결국 비동기 작업이 스레드에 할당될 때, 완료되고나서 총 2번 발생할 것 같습니다. (더 발생하겠지만...)
그렇다면 싱글스레드나 멀티스레드나 context switching의 호출 횟수는 크게 다를것이 없을것으로 생각됩니다.
오히려 해당 스레드가 blocking되냐 아니냐의 차이가 가장 유의미한 차이가 아닐까 하는데요.

혹시 제가 놓친 점이 있을까요? 싱글 스레드로 인해 context switching이 더 일어나는 구간이 있을까요?

1개의 답글