Tag Archives: Networking

Overlapped I/O and Socket

1. 배경

대부분의 운영체제는 스레드를 이용하여 작업을 나누어서 동시적(Concurrent)으로 수행할 수 있는 기능을 제공한다. 이러한 스레드의 동시성은 프로세서 자원의 활용성을 극대화시킬 수 있는 도구로 사용되며, 특히 멀티코어 프로세서 환경에서 병렬작업이 가능하도록 활용할 수 있다. 그런데 작업의 수행을 위해서는 입력을 받고 출력을 도출하는 과정이 전제되어야 하며, 이러한 입출력은 대부분 다른 장치에 대한 부가적인 I/O를 수반하게 된다. 장치와의 I/O가 특히 중요하게 다루어져야 하는 이유는 다른 I/O들에 비하여 장치 I/O의 성능이 상대적으로 떨어지기 때문이며, 따라서 요청된 I/O 작업이 완료될 때 까지 해당 스레드의 동작을 멈추고 대기(Blocking)하게 되는 I/O 요청방식(동기식 I/O 방식)은 성능 개선의 여지를 내포한다. 이러한 동기식(Synchronous) I/O의 스레드 대기는 스레드를 사용함에 따라 얻어지는 동시성의 이익을 제한하여 성능의 하락을 유발하는 잠재요소이며, 스레드는 오랜시간 대기하여야 하는 장치 I/O의 종료를 기다리지 않고 I/O의 발행 이후에 다른 작업을 동시적으로 수행되는 편이 보다 효과적인 경우가 많을 것이다. 즉 소프트웨어 시스템의 성능을 극대화하기 위해서는 I/O로 유발될 수 있는 스레드의 불필요한 대기가 제거되는 것이 바람직하며, 이를 통하여 I/O의 대기 중에도 다른 동작들이 동시적으로 수행되도록 구현하는 것이 보다 올바른 선택일 수 있다.

2. 비동기식 I/O의 개념

동기식 I/O 요청은 해당 요청이 종료되어야만 제어권이 스레드로 리턴되는 호출구조를 갖는다. 하지만 앞서 언급하였듯이 동기식 I/O 요청 방식은 동시성의 측면에서 성능의 저하를 유발할 수 있으며 이에 대한 개선의 여지가 크다. 이를 해결하기 위하여 비동기식(Asynchronous) I/O를 사용할 수 있다. 비동기식 I/O는 I/O 요청의 종료를 기다리지 않고 동시성을 활용하여 바로 제어권을 스레드로 돌려주는 개념으로, 이를통해 동기식 I/O가 갖는 불필요한 오버헤드를 감소시켜 성능의 향상을 꾀할 수 있다(MSDN: Synchronous and Asynchronous I/O). 즉 스레드는 I/O 요청이 수행되는 중에도 요청의 종료를 대기하며 다른 동작을 함께 수행할 수 있다. 또한 이는 복수의 I/O 요청이 앞서 이루어진 요청의 종료 여부에 상관없이 중첩되어 이루어질 수도 있음을 의미하는데 이러한 맥락에서 비동기식 I/O는 Overlapped I/O라고 불리기도 한다.

하지만 비동기식 I/O는 동기식 I/O와의 동작 방식의 차이 때문에 다음과 같은 2가지의 구조적 차이를 가질 수 밖에 없으며, 비동기식 I/O를 올바르게 활용하기 위해서는 이 두 가지 이슈에 대한 추가적인 고려가 충분히 이루어져야 한다.

  • I/O 요청의 종료시점이 피동적으로 통보된다.
  • 중첩된 I/O 요청이 순차적(FIFO)으로 처리됨을 보장하지 않는다.

우선 첫 번째 이슈의 경우는 비동기식 I/O가 I/O 요청의 종료를 기다리지 않고 바로 동작을 이어서 수행하기 때문에 나타나는 특징이다. 때문에 I/O 요청의 종료를 기다려 처리해야 할 기능이 있다면 별도의 설정이 필요하다. 뿐만 아니라 이러한 특징은 I/O 요청에 따라 공유되는 메모리 공간이나 수행결과(오류발생과 같은)에 대해서 별도로 고려해주어야 할 필요성을 내포한다. 종료시점의 처리에 대한 내용은 다음 절에서 좀 더 자세히 정리하도록 한다.

두 번째 이슈의 경우는 비동기식 I/O가 앞서 요청한 I/O의 종료를 기다려 순차적으로 다음 I/O를 요청하는 구조가 아니기 때문에 나타나는 특징이다. 디바이스 드라이버의 경우 대기중인 중첩된 I/O 요청에 대하여 디바이스의 효율적 동작을 위해 I/O 요청의 수행 순서를 요청된 순서와 다르게 변경할 수 있다. 때문에 비동기식 I/O를 사용할 때에는 이러한 점을 유념하여 구조를 설계하여야 할 것이다.

3. 비동기식 I/O의 종료에 대한 처리

비동기식 I/O에 따라 발생한 I/O 요청에 대하여 종료시점을 파악하는 방법은 크게 4가지[footnote]이번 절의 내용은 “Windows via C/C++, Jeffrey Richter”의 내용을 참고 및 인용하였다. 뿐만 아니라 이 포스트의 많은 내용이 이 책을 통해 얻은 지식을 기반으로 작성되었다.[/footnote]가 있다. 4가지 방법 중에서 가장 뛰어난 방법은 I/O Completion Port로 보이며, 이 절에서는 아래의 4가지 방법에 대한 특징을 간략히 정리한다.

  • 디바이스 커널 오브젝트의 시그널링
  • 이벤트 커널 오브젝트의 시그널링
  • Alterable I/O
  • I/O Completion Port

우선 디바이스 커널 오브젝트의 시그널링은, 장치 I/O 요청시에 획득한 디바이스에 대한 커널 오브젝트의 시그널링 상태를 검사하여 I/O 요청의 종료를 파악하는 방법이다. 이 방법은 일반적인 커널 오브젝트의 시그널/논시그널 상태 정보에 대한 일반적 처리 방법을 사용하는 것을 의미하며, 디바이스 커널 오브젝트에 대하여 WaitForSingleObject()나 WaitForMultipleObjects() 함수를 통해 구현할 수 있다. 하지만 이러한 함수들의 호출은 커널 오브젝트의 종료까지 스레드를 대기상태로 유지하기 때문에 결과적으로 동기식 I/O와 유사한 동작구조를 갖게 되며, 이는 비동기식 I/O의 동시성적 장점을 상쇄하기 때문에 바람직하지 않다.

앞서 설명한 디바이스 커널 오브젝트는 복수의 I/O 요청에 대하여 개별적인 종료 시점을 명확하게 파악할 수 없다. 다시 말해 WaitForSingleObject()나 WaitForMultipleObjects() 함수는 대기 중인 커널 오브젝트가 모두 종료되었을 때 단순히 호출이 리턴될 뿐이며 개별 오브젝트의 종료 시점에 대한 정보는 제공하지 못한다. 또한 두 함수가 호출되기 전에 이미 I/O 요청이 종료되는 경우도 발생할 수 있다. 이러한 문제를 해결하여 커널 오브젝트를 사용한 종료 시점을 처리를 수행하기 위해서는 이벤트 커널 오브젝트의 시그널링을 사용하는 방법이 있다. 이벤트 커널 오브젝트의 시그널링은 장치 I/O 요청시 전달해줄 OVERLAPPED 구조체의 hEvent 멤버에 이벤트 커널 오브젝트에 대한 핸들을 할당함으로써 구현할 수 있다. 이를 통해 각 I/O 요청이 종료될 때 할당된 이벤트 신호가 발생하여 이에 따른 처리를 할 수 있으며, 각 I/O 요청의 종료를 파악하기 위해 이벤트 커널 오브젝트에 대해 WaitForSingleObject()나 WaitForMultipleObjects()함수를 호출할 수도 있다.

Alterable I/OI/O 요청을 발행한 스레드가 Alterable 상태에 진입했을 때에 한하여, 진입된 시점보다 앞서 종료된 I/O 요청들의 컴플리션 루틴(콜백함수)이 호출되는 구조이다. 이를 위해서 I/O 요청시에 컴플리션 루틴의 주소를 함께 전달해주게 되며, 각 I/O 요청이 종료되면 대응되는 컴플리션 루틴의 주소가 I/O 요청을 발생시킨 스레드의 APC(비동기 프로시저 콜, Asynchrnous Procedure Call) 큐에 적재된다(이 때, 최초 요청시 사용되었던 OVERLAPPED 구조체의 주소도 함께 포함된다). 비동기적 요청에 이어서 자신의 작업을 수행하던 스레드는 특정 시점에서 Alterable 상태로 진입하게 되고, 이에 따라 APC 큐에 있는 I/O 요청의 완료 정보에 따라 컴플리션 루틴이 호출되어 I/O 요청의 종료에 대한 루틴을 수행한다.

Alterable 상태로 진입하는 방법: 스레드가 아래의 6가지 함수 중 하나를 호출한다.
1. SleepEx
2. WaitForSingleObjectEx
3. WaitForMultipleObjectsEx
4. SignalObjectAndWait
5. GetQueuedCompletionStatusEx
6. MsgWaitForMultipleObjectsEx

이 때 주의해야하는 것은 Alterable 상태로 진입하는 시점의 APC 큐 상태에 따라 함수 호출에 따라 수행하는 동작이 다르다는 점이다. 우선 APC 큐가 완전히 비워져있는 경우는 상태 진입을 위해 호출된 함수가 대기상태로 전환되며 스레드가 정지한다. 정지된 스레드는 함수 호출에 사용된 커널 오브젝트가 시그널 상태가 되거나 APC 큐에 새로운 컴플리션 루틴이 적재되며 다시 작업을 진행하게 된다. APC 큐에 적재된 컴플리션 루틴이 하나 이상 존재할 경우는 APC 큐가 모두 비워질 때까지 적재된 컴플리션 루틴이 큐에서 반환되며 호출되고 이 호출이 종료되면 다음 컴플리션 루틴에 대하여 호출을 반복한다. 그리고 반복 작업 중 APC 큐가 완전히 비워지게 되면 호출된 함수가 반환된다. APC 큐로부터 컴플리션 루틴이 실행되는 과정에서 컴플리션 루틴을 수행하는 주체는 APC 큐에 대응되는 스레드, 즉 I/O를 요청한 스레드이다. 이는 위의 함수가 호출된 후 컴플리션 루틴이 수행되는 과정에서도 스레드는 대기상태에 빠지지 않고 계속 작업을 수행하게 됨을 의미한다. 하지만 이렇게 하나의 스레드에 집중된 컴플리션 루틴(콜백함수)의 수행은 부하 분산을 어렵게 하기 때문에 소프트웨어의 확장성을 저하시킬 수 있다.

마지막으로 살펴볼 종료처리 방식은 I/O Completion Port다. 이에 대한 이해를 위해서는 서비스 어플리케이션의 컨커런트 모델(Concurrent Model)에 대한 이해가 필요하다. 컨커런트 모델은 시리얼 모델과 대조되는 개념으로, 하나의 스레드가 사용자의 요청을 대기하며 요청이 발생한 경우 새로운 스레드를 생성하여 해당 요청을 수행하도록 하는 모델이다. 이를 통해 요청을 대기하는 스레드는 최소한의 작업만을 수행하며 시스템의 효율을 높일 수 있고, 요청의 처리가 하나의 스레드에 집중되기 때문에 시스템의 확장성이 높아지게 된다. 그런데 많은 수의 요청이 집중되어 발생하는 경우 시스템에는 많은 수의 스레드가 한꺼번에 생성되어 동작하게 되며 실제 작업의 수행보다도 컨텍스트 전환(Context Switching)에 따른 자원의 소모가 커지는 부작용이 발생하게 되었다. 또한 요청이 발생할 때 마다 새로운 스레드를 생성해야하기 때문에 이에 따른 비용 역시 추가적으로 감당해야만 했다. 이에 따라 스레드 개수의 상한을 설정(보통은 프로세서 코어의 숫자와 동일하게 사용함)하여 컨텍스트 전환에 따르는 자원의 소모를 제한하며, 스레드를 미리 생성하여두고 이를 재사용하며 요청을 수행토록 함으로써 스레드 생성/소멸에 따른 과도한 자원의 낭비를 방지하는 구조가 나타나게 되었다. I/O Completion Port는 바로 이러한 맥락에서 기능하는 방식이다.

미리 생성된 스레드는 대기 스레드 큐(Waiting Thread Queue, LIFO)에서 종료 신호를 기다리게되며, I/O 요청의 종료에 따른정보는 I/O 컴플리션 큐(I/O Completion Queue, FIFO)에 적재된다. 새로운 종료 신호가 컴플리션 큐에 도달하고 실행중인 스레드의 수가 미리 정한 상한을 초과하지 않았다면 I/O Completion Port는 컴플리션 큐에서 종료정보를 획득하여 대기 스레드를 깨우게 된다. 깨워진 대기 스레드는 릴리즈 스레드 리스트(Released Thread List)에 추가되어 관리되며, 스레드 수의 상한에 도달할 때 까지 종료정보에 따른 대기 스레드를 다시 실행시키는 동작이 반복된다.

5. Winsock & Overlapped I/O

소켓의 기본적인 수신 기능은 recv() 함수를 사용하여 동기적으로 정보를 수신하는 것이다. 하지만 이러한 방식은 데이터를 수신하는 동안 recv() 함수의 종료를 대기하며 스레드가 정지되는 문제를 발생시킨다. 이는 앞서 살펴본 동기식 I/O가 갖는 문제점이며, 역시 앞서 살펴본 비동기식 I/O를 통하여 구조와 성능을 개선할 수 있다. Winsock에서는 Overlapped I/O를 지원하기 위한 기능을 제공하고 있다.