devlog.akasai

비동기와 논블로킹


기본적인 Node.js의 비동기 처리에 대하여 정리해보았다.

비동기 처리에 있어서 단골로 등장하는 키워드인 Non-Blocking을 정리해보려고 한다.


정의

동기(Sync) / 비동기(Async)

처리해야 할 작업들에 대한 처리과정.

두 가지 이상의 작업과 이를 처리하는 시간으로 구분한다.

  1. Synchronous

    호출된 작업의 리턴하는 시간과 결과를 반환하는 시간이 일치하는 경우를 말한다.

    작업 A의 종료시간과 작업 B의 시작시간이 같으면 동기적이라고 한다.

    sync

  2. Asynchronous

    호출된 작업의 리턴하는 시간과 결과를 반환하는 시간이 일치하지 않는 경우를 말한다.

    동기와 반대로 대상이 작업시간이 같지 않을 때 비동기적이라고 본다.

    async

위 두가지 경우를 극단적인 예를 들어 설명하자면 작업 A가 0.5초, 작업 B가 2초 걸리는 작업이라면 Sync일 경우 2.5초, Async일 경우 0.5+a초 만큼의 응답속도를 보인다.

블로킹(Blocking) / 논블로킹(Non-Blocking)

Non-Blocking(이하 논블로킹) 이란 이전 작업이 완료될 때 까지 기다리지 않고, 다음작업이 바로 진행될 수 있도록 동작하는 패러다임이다.

처리해야 할 작업의 과정을 막는가? 막지 않는가?

직접 제어할 수 없는 대상을 처리하는 방법으로 구분한다.

  1. Blocking

    직접 제어할 수 없는 작업이 끝날 때까지 기다려야 하는 경우를 말한다.

    호출된 함수에서 I/O작업등을 요청했을 경우 I/O작업이 처리되 전까지 아무일도 하지 못한다.

  2. Non-Blocking

    직접 제어할 수 없는 작업이 완료되기 전에 제어권을 넘겨주는 경우를 말한다.

    호출된 함수에서 I/O작업등을 요청했을 경우 I/O작업의 처리여부와 관계없이 바로 다음 작업을 할 수 있다.


동작 방식

위 정의에 따라 4가지의 경우의 수를 확인할 수 있다.

동기 - 블로킹

동기이므로 요청 A의 실행시간과 종료시간이 보장되며 내부의 I/O작업 또한 종료까지 대기했다가 작업이 반환(블로킹)된다.

sync blocking

동기 - 논블로킹

동기이므로 요청 A의 실행시간과 종료시간은 보장된다. 하지만 내부 I/O작업은 논블로킹으로 이루어지므로 I/O작업의 반환여부와 상관없이 Kernel의 작업 결과는 Application으로 반환되며 Application에서는 부가적인 작업이 이루어진다. 이와 동시에 I/O작업이 반환되며 Application의 모든 작업이 완료되면 요청 A는 종료되어 결과를 반환한다.

sync nonblocking

비동기 - 논블로킹

비동기이므로 각각의 작업이 순차적인 실행순서를 보장하지 않는다. 요청 A가 요청되고 I/O작업까지 호출되면 논블로킹되어 Kernel은 다른 작업을 받을 대기를 한다. 그 사이 요청 B가 요청되고 Kernel을 작업을 완료하고 결과를 반환한다. I/O작업이 완료되면 요청 A역시 결과를 반환하여 모든 프로세스가 완료된다.

async nonblocking

비동기 - 블로킹

이 케이스는 비효율적이라 사용되는 모델이 많지 않다.

하지만 Node.js + MySQL을 사용할 경우 비동기-논블로킹일지라도 블로킹되는 경우가 발생한다.

MySQL드라이버가 Blocking방식으로 동작하기 때문이다.

table


Node.js의 논블로킹

블로킹은 Node.js 프로세스에서 추가적인 JavaScript의 실행을 위해 JavaScript가 아닌 작업이 완료될 때까지 기다려야만 하는 상황입니다. 이는 이벤트 루프가 블로킹 작업을 하는 동안 JavaScript 실행을 계속할 수 없기 때문입니다.

위 설명처럼 Node.js 역시 Javascript코드가 실행되는 도중 I/O 또는 네트워크 통신을 하는 작업이 수행되면 OS에게 이를 맡기는 방식으로 작업을 수행한다.

Node.js 표준 라이브러리는 두 종류의 I/O 메서드를 제공하며 블로킹의 경우 Sync라는 suffix를 붙이고 논블로킹의 경우 callback함수를 받는다.

블로킹

console.log('start')
const fs = require('fs')
const data = fs.readFileSync('./file.txt')
console.log(data)
console.log('end')

위 코드는 readFileSync()라는 블로킹함수를 이용한 예이다.

따라서 순차적으로 코드가 실행되며 file.txt를 모두 읽기전에는 (data가 출력되기 전에는) 콘솔이 출력되지 않는다.

// Result
start
blahblah
end

논블로킹

console.log('start')
const fs = require('fs')
fs.readFile('./file.txt', (err, result) => {
  if (err) throw err;
  else console.log(result)
})
console.log('end');

위 코드는 readFile()라는 논블로킹함수를 이용하였다.

I/O 작업은 콜백을 통해 그 결과를 반환하며, 이 작업과는 관계없이 console.log()함수는 순차적으로 수행된다.

// Result
start
end
blahblah

블로킹과 논블로킹 코드를 섞을 때의 위험성

공식문서에 나온 내용이지만 중요한 부분이라 다시 정리해본다.

두 방식의 함수를 섞어서 사용할 경우 수행순서의 차이로 인해 반환시간에 큰 차이가 발생하며

심할 경우 에러를 야기할 수 있다.

const fs = require('fs')
fs.readFile('/file.txt', (err, data) => {
  if (err) throw err
  console.log(data)
})
fs.unlinkSync('/file.txt')

위 함수를 살펴보면 readFile() 이후 unlinkSync()함수가 호출된다. 순차적으로 실행되지만

I/O의 결과반환을 보장하지 않는 readFile()이 완료되기 전에 unlinkSync()함수로 인해 대상 파일이 제거될 수 있다.

따라서, 위와같은 코드보다는 완전히 논블로킹으로 작성하여 작업 수행순서를 보장해야한다.

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

심화

설명을 위해 몇가지 내용을 대충 정리했다. 대략적으로 훑고 넘어가면

  1. Node.js의 I/O 작업

    위 쪽에서는 I/O, 네트워크 작업등을 모두 OS에게 넘긴다고 정리하였다.

    기본적으로 Node.js는 libuv위에서 동작한다.

    node 인스턴스가 동작하면 libuv에서는 워커 쓰레드풀(default 4개)이 생성되며 블로킹 작업(네트워크, DB R/W)들이 요청되면 커널(Window: IOCP, Linux: AIO)에서 어떤 비동기 작업들이 지원되는지 이미 알고 있기 때문에 커널의 비동기함수를 호출한다.

    작업이 완료되면 OS는 libuv에 완료를 알리고 이를 EventLoop의 콜백에 등록한다.

    처음 생성된 워커 쓰레드는 커널에서 지원하지 않는 작업들(소켓 작업, crypto모듈(pbkdf2()) 등)을 수행한다.

    단, File I/O는 커널을 이용한다.

    libuv


Reference


  • akasai

    👨‍💻 Backend Developer

    • #Node.js
    • #Typescript
    • #GraphQL
    • #Serverless
    • #PostgreSQL
    • #Kubernetes