본문 바로가기
기타/CS

blocking I/O vs non-blocking I/O (feat. socket I/O)

by oneny 2023. 7. 17.

I/O

I/O는 input/output의 약자로, 데이터의 입출력을 의미한다. 이러한 I/O의 종류에는 일반적으로 우리가 알고 있는 file I/O, 프로세스 간의 통신을 할 때 사용되는 pipe I/O이 있다. 그리고 일반적인 디바이스(모니터, 키보드 등)에 대한 device I/O이 있고, network(Socket) I/O에 대해서는 뒤에서 자세히 살펴보자.

 

I/O 란?

컴퓨터는 크게 2가지 역할을 수행한다고 볼 수 있는데 하나는 연산(computing)이고, 하나는 I/O 즉, 입출력을 처리하는 것이다. 컴퓨터에서 운영체제는 이러한 입출력에 대한 수행과 물리적인 입출력 장치(하드웨어)를 관리하고 컨트롤해주고 있다. 이러한 I/O 요청이 처리되는 과정을 하나씩 알아보자.

 

출처 - I/O는 어떻게 처리될까?

  1. 사용자 프로세스는 열고 있던 파일의 파일 디스크립터(fd)를 통해 blocking 함수인 read()를 호출(시스템 콜)한다.
  2. 커널 내부의 시스템 콜 코드에서는 정확성을 위해 파라미터들을 체크한다. 입력값인 경우에는 해당 데이터가 이미 버퍼 캐시에 존재하여 접근이 가능한 상태라면 그 데이터는 사용자 프로세스로 곧장 반환되고 I/O 요청은 완료된다.
  3. 만약 버퍼 캐시에 데이터가 없는 상태라면 물리적인 I/O가 반드시 수행되어야 한다. 시스템 콜을 요청한 사용자 프로세스는 실행 큐(run queue)에서 삭제되고 대기 큐(wait queue)에 추가된다. 그리고나서 시스템 콜에 의한 I/O 작업 요청이 스케줄에 포함된다. 궁극적으로 I/O 서브 시스템이 해당 요청을 디바이스 드라이버로 전달한다. I/O 작업 요청이 디바이스 드라이버로 전달되는 방식은 운영체제의 종류에 따라 subroutine call이 될 수도 있고, in-kernel message가 될 수도 있다.
  4. 디바이스 드라이버는 커널 버퍼 공간을 할당받아 데이터를 수신할 준비를 하고 I/O 스케줄을 처리한다. 궁극적으로 디바이스 드라이버는 디바이스 컨트롤 레지스터를 통해서 디바이스 컨트롤러에게 실제 커맨드를 전달한다.
  5. 디바이스 컨트롤러는 데이터 전송을 수행하기 위해 디바이스 하드웨어를 동작시킨다. 여기까기가 사용자 프로세스에서의 I/O 요청이 실제 하드웨어까지 도달하는 과정이다.
  6. 지금 보고 있는 예시의 경우에 (디바이스로 I/O 요청 전송이 완료된 경우 인터럽트를 발생시키는) DMA 컨트롤러가 전송 과정을 관리한다고 가정해보자.
  7. 인터럽트 벡터 테이블을 참조하여 해당 인터럽트 신호가 적절한 인터럽트 핸들러에게 전달되고, 해당 인터럽트 핸들러는 필요한 모든 정보를 저아하고 디바이스 드라이버로 신호를 전달한다.
  8. 디바이스 드라이버가 완료된 I/O 요청 상태를 결정하고 커널 I/O 서브 시스템에게 전달된 요청 정보 처리가 완료됐다고 알려준다.
  9. 커널은 디바이스 드라이버로부터 전달받은 데이터를 전송하고 사용자 프로세스의 주소 공간에 코드를 반환한다. 그리고나서 사용자 프로세스는 대기 큐(wait queue)에서 준비 큐(ready queue)로 이동하게 된다.
  10. 사용자 프로세스를 준비 큐(ready queue)로 이동시키는 작업은 해당 사용자 프로세스의 blocking 상태를 풀겠다는 의미이다. 스케줄러는 사용자 프로세스를 다시 CPU에 할당되고, 시스템 콜이 완료되는 시점에 사용자 프로세스는 실행 상태로 재개된다.

 

Socket

네트워크 통신은 위 그림처럼 socket을 통해 데이터가 입출력된다. 컴퓨터에 실행되는 각 프로세스가 서로 데이터를 주고받을 때는 반드시 프로세스 안에서 소켓을 열고, 소켓을 통해서 데이터를 주고받는다. 즉, 우리가 개발하는 백엔드 서버는 네트워크 상의 요청자들과 각각 소켓을 열고 통신하여 각각의 요청들을 처리한다고 할 수 있다.

그러면 block I/O와 non-block I/O가 어떻게 동작하는지 network IO를 예로 OS 레벨에서 개념적으로 살펴보자.

 

blocking I/O

block I/O는 I/O 작업을 요청한 프로세스/스레드가 I/O 작업이 완료될 때까지 블락되는 즉, 자신의 작업을 대기하는 I/O를 말한다. 

위 그림처럼 하나의 스레드에서 우리가 작성한 코드가 실행된다고 했을때 read()를 호출해 커널에 read I/O를 요청하는데 만약에 blocking system call이라고 한다면 이 read system call을 하게 되는 순간 스레드는 block이 돼서 아무것도 진행을 하지 못한다. 이 system call을 통해서 커널 모드로 전환이 되고, 커널 모드에서 실행이 되면 커널에서는 관련있는 디바이스에 read I/O를 실행한다. 그러고 난 뒤에 시간이 지나서 그 디바이스에서 읽을 준비가 됐다고 응답을 주면, 커널이 그 응답을 받고, 그 응답을 바탕으로 준비가 된 데이터를 커널 영역(kernel space)으로부터 유저 영역(user space)으로 옮긴다. 그러면 그 때의 block이 됐던 스레드는 다시 깨어나서 준비된 데이터를 읽어서 이어서 코드를 실행하게 된다.

 

socket에게 blocking I/O란?

위에서 설명한 것을 소켓에서 살펴보자. Socket에서 blocking I/O는 무슨 의미일까?

socekt S에서 socket A로 데이터를 보내려고 하는 경우 socket A는 일단 기다려야 한다. 여기서 기다린다는 말은 socket마다 send_buffer와 recv_buffer 두 개의 buffer가 존재하는데 read system call을 socket A에서 호출을 하게 되면 이 recv_buffer에 데이터가 들어올 때까지 이 read system call을 호출한 스레드는 block이 된다.

그러면 이번에는 socket S의 입장에서 살펴보자. socket S의 입장에서는 write하게 되면 send_buffer에 전송하려는 데이터를 쓰게(write) 된다. 근데 write system call도 종종 send_buffer가 가득 찰 경우에 더 이상 밀어넣을 수 없기 때문에 send_buffer가 비어서 공간이 생길 때까지 block이 된다.

이러한 방식으로 socket에서는 blocking I/O가 read system call일 때와 write system call일 때 각각 recv_buffer와 send_buffer의 상태가 block이 된다.

 

non-blocking I/O

non-blocking I/O는 blocking I/O와 다르게 프로세스/스레드를 블락시키지 않고 요청에 대한 현재 상태를 즉시 리턴하는 것이 특징이다. 위 그림을 살펴보면 스레드에서 read system call하는데 이번에는 non-blocking 모드라면 system call을 호출하는 순간 커널 모드로 context switching이 되고, 그때 커널 영역에서는 read I/O 작업을 실행시킨다. 하지만 blocking I/O와 다르게 커널에서 아직 요청한 데이터가 준비되지 않았지만 리눅스 기준 EAGAIN이나 EWOULDBLOCK(데이터가 없다는 메시지)이라는 에러코드와 함께 -1이라는 값을 바로 리턴한다. 그러면 스레드는 block되지 않고 이어서 다른 코드를 실행시킬 수 있다.

그리고 실행을 하는 동안에 I/O 디바이스로부터 읽을 준비가 됐다는 응답이 커널 영역에 오게 되면 커널은 데이터를 준비해 놨다가 스레드에서 다시 한 번 non-blocking 모드의 read system call을 호출하면 커널로 context switching이 되고, 준비해둔 데이터를 커널이 다시 유저 영역에 있는 스레드 쪽으로 전송한다. 그러면 그 데이터를 유저 스레드에서 다시 읽게되고, 이어서 다시 스레드의 작업을 처리한다.

이런 방식으로 non-blocking I/O는 블락되지 않고 즉시 리턴하기 때문에 스레드가 다른 작업을 이어서 수행할 수 있는 장점이 있다.

 

socket에게 non-blocking I/O란?

아까 위에서 설명했듯 socket에는 send_buffer와 recv_buffer 두 개의 buffer가 있다고 설명했다. 이 때, non-blocking I/O로 동작을 하고 아까와 같은 상황처럼 socket S에서 socket A로 데이터를 보낸다고 가정해보자.

socket A에서 blocing I/O 같은 경우에는 이 상황에 데이터가 아직 없다면 read system call을 호출한 스레드가 block된다고 했는데 지금은 non-blocking I/O이기 때문에 recv_buffer에 데이터가 없다면 그냥 없다고 알려주고, read system call에 대한 호출을 바로 종료한다.

마찬가지로 socket S에서 write system call을 할 때도 만약에 send_buffer에 데이터가 다차있어 더 이상 write를 할 수 없다면 이 write system call을 호출한 스레드를 블락시키지 않고, 적절한 에러코드와 함께 write system call이 반환되도록 한다.

이런 방식으로 소켓에서 non-blocking I/O가 동작한다.

 

non-block I/O 결과 처리 방식

non-block I/O에는 한 가지 이슈가 있는데 I/O 작업 완료를 어떻게 확인할 것인가? 이러한 이슈를 해결하기 위한 non-block I/O 결과를 처리하는 방식에 대해서 살펴보자.

 

1. 완료됐는지 반복적으로 확인

위에서 non-block I/O에서 살펴본 그림처럼 read system call을 non-blocking 모드로 호출하면 아직 데이터가 준비되지 않았기 때문에 리눅스라면 -1이라는 에러코드를 반환한다. 그러면 스레드에서는 다른 코드들을 실행시키고, 어느 순간에 다시 한번 read non-blocking system call을 호출해서 데이터가 준비되었는지 확인한다. 이러한 방식으로 읽으려는 데이터가 준비될 때까지 확인하는 방식으로 non-block I/O 결과를 처리할 수 있다.

 

하지만 이 방식에는 문제가 있다. 완료된 시간과 완료를 확인한 시간 사이의 갭으로 인해 block I/O에 비해서 처리 속도가 느려질 수 있다. 

위 그림에서 보는 것처럼 디바이스로부터 읽을 준비가 됐다라는 응답을 받아 유저 영역으로 데이터를 옮길 준비가 되었지만 스레드가 실제로 system call을 통해 준비됐는지 다시 확인하는 타이밍은 그 이후이기 때문에 그만큼의 time gap이 발생한다.

 

그리고 완료됐는지 반복적으로 확인하는 것은 CPU 낭비가 발생한다.

위에서 살펴본 socket I/O처럼 네트워크 상에서 데이터를 주고 받기 위해서 소켓 2개가 연결되었다고 가정해보자. 데이터를 기다리는 쪽 입장에서는 데이터를 보내는 쪽에서 언제 데이터를 보낼 줄을 모르기 때문에 계속 기다려야 한다. 따라서 주기적으로 계속 확인하기 위해 read system call을 요청하면 CPU 사이클을 사용하여 낭비가 된다. 

 

2. I/O multiplexing(다중 입출력) 사용

I/O multiplexing은 관심있는 I/O 작업들을 동시에 모니터링하고 그 중에 완료된 I/O 작업들을 한 번에 알려주는 방식이다. I/O multiplexing에는 select, poll, epoll(linux), kqueue(mac OS), IOCP(I/O completion port)(window, solaris 계열)가 있다.

 

위 그림처럼 I/O multiplexing을 사용해서 system call을 커널에 요청한 방식을 나타낸 것이다. 두 개의 소켓에 대해서 non-blocking 모드로 읽어달라고 요청을 하면 커널에서는 두 개의 소켓에 대해서 read I/O 요청을 네트워크 디바이스에 보내게 되고, 이 때 스레드는 block이 될 수도 있고, 어떻게 호출하냐에 따라서 또다른 코드를 실행하게끔 즉, non-blocking이 될 수도 있다.

일단은 blocking 모드로 동작하고, 비슷한 타이밍에 소켓에 데이터가 들어왔다고 가정해보자. 그러면 네트워크 디바이스로부터 read response를 받게 되고, 그러면 커널에 읽을 데이터가 있다는 것을 알려준다. 그러면 스레드가 이 호출을 통해 깨어나면서 각각 socket에 대해 non-blocking system call을 통해 두 번 요청을 보내 데이터를 읽어온다.

 

이러한 I/O multiplexing은 네트워크 통신에 많이 사용되고, 여담으로 I/O multiplexing system call은 blocking이 될 수도 있고, non-blocking이 될 수도 있는 것에 의견이 분분하다고 한다.

 

3. callback/signal 사용

aio_read라는 이름의 system call을 non-blocking 모드로 호출하면 커널에서는 read I/O 작업을 시작하고, 스레드는 독립적으로 스레드를 시작한다. 그러다가 디바이스로부터 read response가 오게 되면 데이터가 커널 영역에서 유저 영역으로 처리가 되는데 이 때 callback이나 signal을 사용하여 처리된다.

 

callback/signal 종류

callback/signal의 종류에는 POSIX AIO, LINUX AIO 등이 있다. 근데 callback/signal 사용방식은 널리 사용되지 않는다고 한다.

 

blocking, non-blocking, synchronous, asynchronous 차이

blocking/non-blocking, synchronous/asynchronous는 개발을 하다보면 자주 접하게 되는 용어이다. blocking I/O, non-blocking I/O을 공부하다가도 해당 용어를 접했고 이에 대해 간단하게라도 정리해보고자 한다. blocking/non-blocking과 synchronous/asynchronous는 엄밀히 말하자면 독립적인 개념이다.

 

blocking / non-blocking

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

 

blocking I/O

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

 

non-blocking I/O

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

 

synchronous / asynchronous

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

 

synchronous(동기)

모든 요청, 응답이 일련의 순서를 따른다. I/O 관점에서 설명하자면 호출된 I/O 함수가 종료된 후 I/O 함수의 결과 처리를 호출한 함수가 하는 경우를 의미한다.

 

asynchronous(비동기)

요청을 보냈을 때 응답 상태와 상관없이 다음 동작을 수행한다. 따라서 작업의 순서가 보장되지 않는다. I/O 관점에서 설명하자면 호출된 I/O함수가 종료된 후 I/O 함수의 결과 처리를 콜백함수를 통해 처리해서 작업 완료 여부를 신경쓰지 않는 경우를 의미한다.

 

출처

block I/O vs non-block I/O 개념을 설명합니다! 소켓 I/O를 예제로 주로 설명해요!

I/O는 어떻게 처리될까?

[10분 테크톡] 멍토의 Blocking vs Non-Blocking, Sync vs Async

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