쿼카러버의 기술 블로그

blocking I/O, non-blocking I/O에 대하여 (sync, async와의 차이) 본문

CS지식

blocking I/O, non-blocking I/O에 대하여 (sync, async와의 차이)

quokkalover 2022. 5. 15. 20:03

Blocking I/O / Non Blocking I/O, synchronous / asynchronous 는 개발을 하다보면 자주 접하게 되는 용어다. 특히 파이썬, NodeJS와 같이 싱글스레드로 동작하는 언어에서는 성능을 위해 필수적으로 알아두어야 하는 개념 중 하나다. 하지만 이 term들의 개념을 설명하라고 해보면 많은 사람들이 sync/async의 개념과 blocking/nonblocking의 개념을 같은 것으로 설명하는 경우가 많다. 하지만 엄밀히 말하면 Blocking/Non Blocking과 sync/async는 독립적인 개념이다.

 

따라서 본 글에서는 다음을 다룬다.

1) I/O가 성능에 미치는 영향

2) blocking, non blocking I/O 개념 이해

3) IBM이 제안하는 2:2 매트릭스를 기반으로 Blocking, Non, Blocking, Synchronous, Asynchronous의 개념과 차이점 이해

 

참고로 소켓 I/O관점에서 I/O multiplexing등 더 깊은 개념들도 다루어야 하지만, 본 글에서는 blocking, non-blocking I/O에 초점들 두어 가장 기초적인 개념을 이해하는 것을 목표로 하고 있다. 추후에 python을 활용한 socket programming관련 포스팅을 할 때 더 깊이있는 내용을 다뤄보도록 하겠다.

 

1) I/O가 성능에 미치는 영향

I/O란 데이터의 입력(Input)과 출력(Output)을 함께 일컫는 말이다. 일반적으로 I/O라고 하면 파일 I/O만 생각하는 경우가 있는데, 어떤 디바이스를 통해 입력과 출력이 이뤄지는 작업을 모두 I/O라고 한다. 네트워크를 통해서 다른 서버로 데이터를 전송하거나, 다른 서버로부터 데이터를 전송받는 것도 I/O에 포함된다. 심지어 콘솔에 출력하는 것도 스트림을 통해서 출력하는 것이기 때문에 I/O다.

 

I/O는 어플리케이션의 성능에 가장 영향을 많이 미친다. 아래 표를 보면 와닿을 것이다.

위 표에서 보이듯이 CPU-bound processing의 latency가 1초라고 가정했을 때, I/O가 들어가는 순간부터는 처리속도가 짧게는 7시간, 길게는 5년이 될만큼 CPU의 처리속도보다 훨씬 오래걸린다. 이렇게 I/O에서 발생하는 시간은 CPU를 사용하는 시간과 대기 시간 중에 대기 시간에 속하기 때문에 I/O가 많아진다는 것은 애플리케이션이 연산을 할 때까지 CPU가 아무것도 하지 못하고 대기하는 시간이 길어진다는 의미고, 애플리케이션의 처리 속도가 같이 느려진다. 따라서 높은 성능을 보장해야 하는 어플리케이션 입장에서 I/O는 큰 장애물이 될 수 있다.

 

사실 어떤 함수를 실행하든 일정 시간동안은 CPU를 잡아두고 사용하기 때문에 다음 작업 실행을 block한다. 따라서 이렇게 CPU를 사용하는 작업으로 인한 blocking은 개발자가 할 수 있는게 별로 없는데, I/O로 인한 blocking은 CPU를 긴 시간동한 idle하게 두기 때문에 다른 작업을 할 수 있음에도 오랫동안 다른 작업을 실행할 수 없어 매우 비효율적이다.

 

따라서 이제 I/O가 무엇인지, 그리고 얼마나 느린지 알았기 때문에 두 가지 방식의 I/O인 blocking I/O와 non blocking I/O에 대해 알아보자.


2-1) blocking I/O란?

blocking I/O란 I/O작업이 진행되는 동안 유저 프로세스가 자신의 작업을 중단한채, I/O가 끝날때까지 대기하는 방식을 의미한다. 아래 그림을 보자.

위 그림에서 보이듯 어플리케이션에서 Read()를 호출해 커널에 read I/O를 요청하면, read가 끝날 때까지 application은 block이 되어 다른 작업을 하지 못한다. 이는 read I/O가 수행될 때까지는 어플리케이션이 다른 작업을 수행하지 못한다는 것을 의미한다.

2-2) non blocking I/O란?

non blocking I/O란 A함수가 I/O작업을 호출했을 때 I/O작업이 완료될까지 A함수의 작업을 중단하지 않고 I/O 호출에 대해 즉시 리턴하고, A함수가 이어서 다른 일을 수행할 수 있도록 하는 방식을 의미한다. 아래 그림을 보자.

  • EWOULDBLOCK → 데이터가 없다는 메시지

read I/O를 하기 위해 system call을 수행하면, 커널의 I/O작업 완료 여부와는 무관하게 즉시 응답한다. 이는 커널이 시스템 콜을 받자마자 CPU 제어권을 다시 어플리케이션에게 넘겨주고, 따라서 어플리케이션은 I/O 작업이 완료되기 전에 다른 작업을 수행할 수 있다. 그리고 어플리케이션은 다른 작업들을 수행하다가 중간중간 시스템 콜을 보내서 I/O가 완료됐는지 커널에게 물어보고, 완료되면 I/O작업을 완료한다.


3) blocking, non-blocking, sync, asnyc의 차이

자 이제 I/O의 두가지 방식인 blocking / non blocking에 대해 알아보았으니, IBM의 아티클에서 정리한 blocking, non-blocking, sync, async의 차이를 이해해보자.

참고로 위 매트릭스는 큰 틀에서 개념을 이해하기 위한 분류법 정도로 참고만 하자. I/O Multiplexing이  Blocking방식인지, Non-Blocking 방식인지에 대한 의견이 분분하기 때문이다.  따라서 sync, async, blocking, non-blocking의 개념을 이해하기 위한 분류법 정도로 생각하고 보자.

개념 요약

blocking / non-blokcing : 호출되는 I/O함수가 바로 리턴하느냐 아니면 제어권을 가져가서 block하느냐의 차이

 

blocking I/O

I/O가 호출되면 제어권을 가져가서 어플리케이션이 멈춤.

 

non-blocking I/O

I/O가 호출되면 결과를 즉시 리턴하고, I/O가 완료될 때까지 대기하지 않는다. 제어권을 어플리케이션이 가지고 어플리케이션은 계속 동작함. 필요한 경우에는 polling과 같은 상태확인은 할 수 있다.


sync /async : 호출되는 함수의 작업 완료 여부에 따라 이어지는 작업을 누가 처리하느냐의 차이

synchronous : 동기를 의미한다.

모든 요청, 응답이 일련의 순서를 따른다.

I/O관점에서 설명하자면 호출된 I/O함수가 종료된 후 I/O함수의 결과 처리를 호출한 함수가 하는 경우를 의미한다. 따라서 I/O완료 여부를 커널에 계속 물어봐야 한다.(콜백함수를 안넘김) 다르게 표현하면 요청한 순서대로 작업을 완료시켜야 하기 때문에 여러개의 파일을 동시에 처리하기 위해서는 multi-thread로 동작해야 한다.

 

asynchronous : 비동기를 의미한다.

요청을 보냈을 때 응답 상태와 상관없이 다음 동작을 수행한다. 따라서 작업의 순서가 보장되지 않는다.

I/O관점에서 설명하자면 호출된 I/O함수가 종료된 후 I/O함수의 결과 처리를 콜백함수를 통해 처리해서 작업 완료 여부를 신경쓰지 않는 경우를 의미한다. I/O완료시 커널이 유저 프로세스에게 알려준다. (콜백함수를 넘김)


위 글 만으로는 이해가 안될테니, 위 4가지 항목들을 하나하나 자세히 살펴보자.

3-1) sync + blocking

sync blocking은 위 그림에서 볼 수 있듯이, I/O가 실행되는 동안 어플리케이션이 다른일을 하지 못하고 Read()만 수행하고 있다가, I/O가 끝나고 나서야 이어서 작업을 처리하는 경우를 의미한다.

 

blocking : I/O 호출이 발생했을때 커널의 I/O작업이 완료될 때까지 제어권을 커널에서 가지고 있기 때문에, 유저 프로세스는 I/O가 완료되기 전에 다른 작업을 할 수 없다.

sync : 작업이 완료되면 해당 작업 결과를 가지고 어플리케이션에서 직접 처리한다.

 

system call마다 thread를 생성하기 때문에 I/O요청이 많은 서비스에서는 작업당 한 번의 context switching이 발생하기 때문에 점점 성능이 떨어진다. 또한 block될 동안 kernel응답만 기다리기 때문에 CPU를 사용하지 못한다는 점에서 resource사용 관점에서 비효율적이다.

 

코드 예제

file.read()
file.write()
const fs = require('fs');
const data = fs.readFileSync('/file.md'); // blocks here until file is read
console.log(data) // will run after file is read
moreWork(); // will run after console.log

 

3-2) async + blocking

async + blocking의 경우에는 IBM에서는 I/O multiplexing(I/O다중화)이라고 분류됐지만, 의미상 명확하게 I/O multiplexing으로 분류할 수 있을지 없을지 애매하고 의견이 분분한 모델이다. 구현 방식에 따라 차이가 있고, 관점 주체에 따라 Blocking/Non-Blocking이 갈리기도 하며, 실제 I/O동작은 Synchronous방식으로 동작하기 때문이다. 따라서 I/O Multiplexing을 단순히 async+blocking방식으로 정의하기에는 무리가 있다.

 

개념은 아래처럼 분리해서 이해해보자.

blocking : I/O 호출이 발생했을때 커널의 I/O작업이 완료될 때까지 제어권을 커널에서 가지고 있기 때문에, 유저 프로세스는 I/O가 완료되기 전에 다른 작업을 할 수 없다.

async : 작업이 완료되면 해당 작업 결과를 가지고 어플리케이션에서 호출한 함수가 직접 처리하는게 아니라 콜백을 넘기면서 콜백함수 호출을 통해 작업 결과를 처리한다.

 

즉 위 내용을 정리해보면 I/O작업을 호출할 때 callback을 같이 넘겨주면서, I/O작업이 종료됐을 때 어플리케이션에 해당 콜백함수가 호출되는 방식이지만 실질적으로 I/O로직이 처리될 때 까지는 어플리케이션이 block되는 경우를 의미한다. 명확히 구분하자면 I/O작업 자체에 의해 block되는 것이 아니라 select, poll과 같은 multiplexing관련 system call에 대한 kernel의 응답이 block된다고 할 수 있다. 첫 read()요청에 대해서는 즉각 미완료 상태를 반환하는 non-blocking의 동작을 보여주기 때문이다.

 

사실 의도적으로 이 모델을 쓰는 경우는 거의 없다고 할 수 있고, Non-blokcing Async방식을 쓰는데, 그 과정 중 하나가 Blocking방식으로 동작하는 경우 Blocking-Async로 동작할 수 있다. (예: node JS에서 async와 non-blocking로직을 고수하다가 필요에 의해 blocking방식인 mysql 드라이버를 호출하는 경우)

3-3) sync + non-blocking I/O

sync+non-blocking의 경우에는 아래와 같이 분류해서 이해해보자

 

non-blokcing : I/O 호출이 발생했을때 커널의 I/O작업 완료 여부와는 무관하게 즉시 응답한다. 커널이 시스템 콜을 받자마자 제어권을 다시 어플리케이션에 넘겨주기 때문에, 유저 프로세스는 I/O가 완료되기 전에 다른 작업을 할 수 있다.

sync : 하지만 sync이기 때문에, 다른 작업을 수행하다가 중간중간에 시스템 콜을 보내서 I/O작업이 완료됐는지 커널에게 지속적으로 물어본다. 그리고 I/O작업이 처리됐을때의 결과를 호출한 함수에서 처리한다. 직접 결과를 처리해야 하기 때문에 지속적으로 I/O종료를 물어보는 것도 이 때문이다.

 

코드 예제

device = IO.open()
ready = False
while not ready:
    print("There is no data to read!")

    # 다른 작업을 처리할 수 있음

    # while 문 내부의 다른 작업을 다 처리하면 데이터가 도착했는지 확인한다.
    ready = IO.poll(device, IO.INPUT, 5) 
data = device.read()
print(data)

커널로부터 제어권을 받기 때문에 Blocking I/O보다 효율적인 것처럼 느껴질 수 있지만 커널로부터 결과를 반환받기 까지 계속 상태를 체크하는 busy-wait 상태가된다. 즉, 작업 order를 맞추기 위해 I/O작업의 완료를 기다리기 때문에 어떻게 보면 context switching만 빈번하게 일어나는 구조가 될 수 있다. 또한 loop내 polling주기도 적절히 설정하지 않으면 커널에게 의미없는 요청이 빈번하게 갈 수 있기 때문에 오히려 I/O작업의 지연을 초래할 수 있다.

3-4) async + non-blocking I/O (AIO)

async+non-blocking의 경우는 어플리케이션은 system call이후 I/O처리에 신경쓰지 않고 있다가 작업이 완료되면 kernel로부터 signal, thread 기반 callback등으로 결과를 마치 event처럼 전달받는다. 그렇기에 응답이 오기 전까지 user process는 I/O와 독립적인 다른 processing이 가능한 구조다.

 

개념은 아래와 같이 분류해서 이해하자

non-blocking : I/O호출이 발생했을 때 시스템 콜이 들어오면 커널의 I/O 작업 완료 여부와는 무관하게 즉시 응답한다. 따라서 유저 프로세스는 I/O가 완료되기 전에 다른 작업을 할 수 있다.

async : I/O 처리는 백그라운드에서 실행되다가 완료되면 커널이 유저 프로세스에게 작업 완료 시그널을 보내거나 콜백을 보낸다. 즉 sync에서는 어플리케이션에서 I/O작업 완료 여부를 커널에게 계속 물어봤지만 async이기 때문에 I/O가 완료되면 그 때 커널이 유저프로세스에게 알려주는 방식이다. (작업이 완료되면 결과를 가지고 콜백 함수를 호출한다로 생각해볼 수 있겠다)

 

코드 예제 1)

const fs = require('fs');
fs.readFile('/file.md', (err, data) => {
  if (err) throw err;
  console.log(data); // will run after file is read
});
moreWork(); // will run before console.log

코드 예제 2)

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

위처럼 파일을 읽고나서 무언가를 처리한 뒤에 파일을 종료해야 하는 경우에는 위처럼 하면안된다. readFile하기 전에 file.md를 지워버릴 수 있기 때문에 callback함수를 통해 async한 처리는 하지만 아래처럼 correct order로 처리해야 한다.

const fs = require('fs');
fs.readFile('/file.md', (readFileErr, data) => {
  if (readFileErr) throw readFileErr;
  console.log(data);
  fs.unlink('/file.md', (unlinkErr) => {
    if (unlinkErr) throw unlinkErr;
  });
});

 


마지막으로 잠깐 짚고 넘어가자면 I/O multiplexing은 관심있는 I/O작업들을 동시에 모니터링하고 그 중에 완료된 I/O작업들을 한번에 알려주는 기법이다. 대표적인 예로 select, poll epoll(linux), kqueue(mac OS), IOCP(window, solaris계열)

쉬운 코드 유튜브 캡처

예를 들어 두 개의 소켓에 대해서 non-blocking 모드로 읽어달라고 요청을 하면 커널에서는 두 개의 소켓에 대해서 read I/O요청을 네트워크 디바이스에 보내게 되는데, 이 때 스레드는 block이 될 수도 있고, 어떻게 호출하냐에 따라서 또다른 코드를 실행하게끔 만들 수도 있음. 위 분류에선 blocking으로 표현하지만 non-blocking도 될 수 있다는 말이다. 무튼 read I/O작업이 완료되면 kernel은 thread에 요청이 완료됐음을 알리고, thread가 이 호출을 통해 깨어나면서 각각 socket에 대해 non-blocking system콜을 통해 두 번 요청을 보내 데이터를 읽어온다.

 

무튼 포인트는 I/O multiplexing 시스템 콜이 blocking이 될 수도 있고, non-blocking이 될 수 있기 때문에 의견이 분분하다.

자세한 내용은 https://youtu.be/mb-QHxVfmcs 이 유튜브 채널의 내용을 참고하길 바란다.


 

 

자 이렇게 blocking I/O, non blocking I/O, synchronous, asynchronous의 개념을 익혀보았다. 이제 개념을 익혔으니 소켓 프로그래밍, 특히 소켓 I/O에 대해 공부해보아야 한다. 내용이 너무 길어지기 때문에 추후 포스팅에서 파이썬 언어로 소켓프로그래밍을 소개하고, I/O multiplexing과 같은 내용을 좀 더 깊이 다루어 보도록 하겠다. 

 

 

참고자료:

https://luminousmen.com/post/asynchronous-programming-blocking-and-non-blocking

https://homoefficio.github.io/2017/02/19/Blocking-NonBlocking-Synchronous-Asynchronous/

https://jh-7.tistory.com/25

https://grip.news/archives/1304

https://limdongjin.github.io/concepts/blocking-non-blocking-io.html#ibm-아티클

https://incredible-larva.tistory.com/entry/IO-Multiplexing-톺아보기-1부

https://youtu.be/mb-QHxVfmcs

Comments