블로킹 I/O vs 논블로킹 I/O

GonnabeAlright·2022년 1월 29일
1
post-thumbnail

I/O는 컴퓨터의 기본적인 동작들 중에서 가장 느립니다. RAM에 접근하는 데에는 나노초인 반면, 디스크와 네트워크에 접근하는 데에는 밀리초가 걸립니다. 대역폭도 마찬가지입니다. RAM의 전송률은 GB/s 단위로 일관되게 유지되는 반면, 디스크나 네트워크의 전송률은 MB/s에서 GB/s까지 다양합니다. CPU의 측면에서는 I/O가 많은 비용을 요구하지 않지만 보내지는 요청과 작업이 완료되는 순간 사이의 지연이 발생하게 됩니다. 게다가 인간이라는 요소를 고려해봐야 합니다. 실제로, 사람이 하는 마우스 클릭처럼 애플리케이션의 입력이 일어나는 많은 상황들에서 I/O의 속도와 빈도는 기술적인 측면에만 의존하지 않으며 디스크나 네트워크보다 느릴 수 있습니다.

전통적인 블로킹 I/O 프로그래밍에서는 I/O를 요청하는 함수의 호출은 작업이 완료될 때까지 스레드의 실행을 차단합니다. 차단 시간은 디스크 접근의 경우 몇 밀리초부터 사용자가 키를 누르는 것과 같은 사용자 액션에 의해서 데이터가 생성되는 경우 몇 분까지 소요되기도 합니다. 다음 의사코드는 소켓을 가지고 작업이 수행되는 일반적인 블로킹 스레드를 보여줍니다.

// data가 사용가능해질 때까지 스레드를 블로킹
data = socket.read();
// data 사용 가능
print(data);

블로킹 I/O를 사용하여 구현된 웹 서버가 같은 스레드 내에서 여러 연결을 처리하지 못하는 것은 자명한 일입니다. 소켓의 각각의 I/O 작업이 다른 연결의 처리를 차단하기 때문입니다. 이 문제를 해결하기 위한 전통적인 접근 방법은 각각의 동시 연결을 처리하기 위해서 개별의 스레드 또는 프로세스를 사용하는 것입니다.

따라서 다중 커넥션을 처리하기 위해 다중 스레드를 사용하는데 이 경우 데이터베이스나 파일시스템과 상호작용할 때와 같이 모든 유형의 I/O가 요청의 처리를 차단할 수 있다는 것을 생각해보면 I/O 작업의 결과를 위해서 스레드가 꽤 많이 블로킹된다는 것을 알 수 있습니다.

안타깝게도 스레드는 시스템 리소스 측면에서 비용이 저렴하지 않습니다. 메모리를 소모하고 컨텍스트 전환을 유발하여 대부분의 시간 동안 사용하지 않는 장시간 실행 스레드(유휴시간)를 가지게 됨으로써 귀중한 메모리와 CPU 사이클을 낭비하게 됩니다.

대부분의 최신 운영체제는 리소스에 접근하기 위해서 블로킹 I/O 외에도 논블로킹 I/O라고 불리는 다른 메커니즘을 지원합니다. 이 운영모드에서 시스템 호출은 데이터가 읽혀지거나 쓰여지기를 기다리지 않고 항상 즉시 반환됩니다. 호출 순간에 사용 가능한 결과가 없는 경우, 함수는 단순히 미리 정의된 상수를 반환하여 그 순간에 사용 가능한 데이터가 없다는 것을 알립니다.

예를 들어, Unix 운영체제에서 운영모드를 논 블로킹(O_NONBLOCK 플래그 사용)으로 변경하기 위해서 기존 파일 디스크립터를 조작하는 fcntl() 함수가 사용됩니다. 우선 리소스가 논 블로킹 모드에 있고 리소스가 읽힐 준비가 된 데이터를 가지고 있지 않다면 모든 읽기 작업은 실패함과 동시에 코드 EAGAIN을 반환합니다.

이러한 종류의 논 블로킹 I/O를 다루는 가장 기본적인 패턴은 실제 데이터가 반환될 때까지 루프 내에서 리소스를 적극적으로 폴링(poll)하는 것입니다. 이것을 바쁜 대기(busy-waiting)이라고 합니다.

아래 의사코드는 논 블로킹 I/O와 폴링 루프를 사용하여 여러 리소스로부터 읽어 들이는 것이 어떻게 가능한지 보여줍니다.

resources = [socketA, socketB, fileA]
while(!resources.isEmpty()) {
  for (resource of resources) {
    // 읽기를 시도
    data = resource.read()
    if (data === NO_DATA_AVAILABLE){
      // 이 순간에는 읽을 데이터가 없음
      continue;
    }
    if (data === RESOURCE_CLOSED) {
      // 리소스가 닫히고 리스트에서 삭제
      resouces.remove(i)
    } else {
      // 데이터를 받고 처리
      consumeData(data)      
    }
  }
}

보다시피 간단한 기법으로 서로 다른 리소스를 같은 스레드 내에서 처리할 수 있지만 여전히 효율적이지 않습니다. 실제로 앞의 예제에서 루프는 사용할 수 없는 리소스를 반복하는 데에 소중한 CPU를 사용합니다. 폴링 알고리즘은 엄청난 CPU 시간의 낭비를 초래합니다.

바쁜 대기(Busy-waiting)는 논 블로킹 리소스 처리를 위한 이상적인 기법이 아닙니다. 다행히도, 대부분의 운영체제는 논 블로킹 리소스를 효율적인 방법으로 처리하기 위한 기본적인 메커니즘을 제공합니다. 이 메커니즘을 동기 이벤트 디멀티플렉서 또는 이벤트 통지 인터페이스라고 합니다.

동기 이벤트 디멀티플렉서는 여러 리소스를 관찰하고 이 리소스들 중에 읽기 또는 쓰기 연산의 실행이 완료되었을 때 새로운 이벤트를 반환합니다. 여기서 찾을 수 있는 이점은 동기 이벤트 디멀티플렉서가 처리하기 위한 새로운 이벤트가 있을 때까지 블로킹된다는 것입니다.

watchedList.add(socketA, FOR_READ);
watchedList.add(fileB, FOR_READ);
while (events = demultiplexer.watch(watchedList)) {
  for (event of events) {
    // 블로킹하지 않으며 항상 데이터를 반환
    data = event.resource.read();
    if(data === RESOURCE_CLOSED) {
      // 리소스가 닫히고 관찰되는 리스트에서 삭제
      demultiplexer.unwatch(event.resource);
    } else {
      // 실제 데이터를 받으면 처리
      consumeData(data);
    }
  }
}

0개의 댓글