이번 글에서는 윈도우에서 제공하는 비동기 I/O 방법 중에 가장 뛰어난 방법인 I/O completion port에 대해서

 알아봅니다.

 그전에 I/O작업과 동기 I/O와 비동기 I/O작업에 대해서 먼저 설명을 드리고 시작하겠습니다. 

I/O 작업이란?

 I/O 작업이란 input/output 작업의 줄임말로 컴퓨터 및 주변장치에 대하여 데이터를 전송하는 작업을 말합니다.

 대표적인 예로 File 읽고/쓰기 및 Socket을 통한 네트워크 전송 등이 있습니다.

 예전에 커널 오브젝트를 설명 드릴때 윈도우 환경에서는 커널을 통해서 각종 디바이스에 대한 요청을 처리한다고

 설명 드렸습니다. 실제로 I/O 작업을 요청하면 커널을 통해서 디바이스의 드라이버로 해당 요청을 전달합니다. 

 CPU 연산에 비해서 I/O 작업은 대부분 많은 비용을 소모합니다

동기 장치 I/O 수행

 동기 장치 I/O는 사용하기 쉽다는 장점이 있지만 단점으로는 스레드 수행 중에 동기 장치 I/O를 수행하게 되면

 요청한 I/O 작업이 완료 될 때까지 스레드가 정지됩니다. (프로그램의 응답성에 좋지 않은 영향을 미칩니다.)

 I/O작업은 CPU의 연산작업에 비해서 매우 비싼 작업이며 예측할 수 없는 작업이기 때문에 

 성능좋은 어플리케이션을 작성하기 위해서는 비동기로 I/O작업을 처리 하는 것이 좋습니다. 

동기I/O 작업 수행 로직

 

비동기 장치 I/O 수행

 비동기 장치 I/O를 수행하면 스레드는 I/O작업 대기 없이 다른 작업을 수행 할 수 있습니다. 

 비동기 요청은 내부적으로는 I/O 작업을 수행 할 장치의 디바이스 드라이버로 전달되며 해당 디바이스의 응답을

 디바이스 드라이버가 응답 대기 해 주기 때문에 비동기 I/O 작업이 가능합니다. 

비동기I/O 작업 수행 로직

 

OVERLAPPED 구조체

 OVERLAPPED 구조체는 아래와 같은 형식으로 설정되어 있습니다. 

typedef struct _OVERLAPPED {
    ULONG_PTR Internal;
    ULONG_PTR InternalHigh;
    union {
        struct {
            DWORD Offset;
            DWORD OffsetHigh;
        } DUMMYSTRUCTNAME;
        PVOID Pointer;
    } DUMMYUNIONNAME;

    HANDLE  hEvent;
} OVERLAPPED, *LPOVERLAPPED;
변 수 설 명
Internal 처리된 I/O 작업의 에러 코드를 담는데 사용됩니다.
InternalHigh 비동기 I/O 작업이 완료되면 실제로 송수신된 바이트수를 저장합니다.
Offset 파일과 같은 Offset 개념을 지원하는 탐색 장치에서 사용됩니다. 이외장치에는 0으로 셋팅
OffsetHigh 파일과 같은 Offset 개념을 지원하는 탐색 장치에서 사용됩니다. 이외장치에는 0으로 셋팅
hEvent

디바이스 드라이버는 해당 값이 NULL이 아니면 해당 이벤트에 SetEvent를 호출 해 줍니다

이벤트를 통한 비동기 I/O 처리를 위해서 사용합니다.

BYTE byReadBuffer[16];
OVERLAPPED oRead = {0};
oRead.Offset = 0;
ReadFile(hFile, byReadBuffer, 16, NULL, oRead);

 윈도우에서 비동기 I/O 요청시에는 OVERLAPPED 구조체를 전달합니다. 

 파일에서 OVERLAPPED의 Offset 값을 사용하는 이유는 비동기 I/O작업은 순서가 보장되지 않기 때문에 

 파일의 파일 포인터을 사용하게 되면 읽는 위치를 보장할 수 없기 때문에 위치를 명시적으로 지정해줘야합니다.

 사용하는 해당 구조체를 상속받은 사용자 커스텀 구조체를 만들어 사용 할 수도 있습니다. 

비동기 장치 I/O 작업시 주의 할 점

 디바이스 드라이버는 비동기 I/O작업을 선입선출(FIFO)방식의 순서를 보장하지 않는다는 점을 명심해야 합니다.

OVERLAPPED oRead = {0};
OVERLAPPED oWrite = {0};
BYTE bBuffer[100];
// 동기 I/O라면 파일을 읽고 파일 쓸것이라고 예상할 수 있지만
// 하지만 비동기 I/O라서 순서보장이 안됨
ReadFile(hFile, bBuffer, 100, NULL, &oRead);
WriteFile(hFile, bBuffer, 100, NULL, &oWrite);

 위의 코드를 보시면 동기 I/O라면 "파일을 버퍼에 읽은 후 읽은 버퍼 데이터를 다시 파일 쓴다" 라고 예상 할 수 있지만

 비동기 I/O에서는 WriteFile을 먼저 수행되고 ReadFile이 수행 될 수 있습니다.

 

 또한 ERROR_IO_PENDING에 대해서 알아야 하는데 비동기 I/O 함수 리턴값이 TRUE가 아니면

 GetLastError()를 호출해서 에러 코드 확인해야합니다.

 ERROR_IO_PENDING이라면 I/O 요청이 디바이스 드라이버에 정상적으로 전달되었으며 추후에

 완료 될 것이라고 생각하면 됩니다. ERROR_IO_PENDING이외의 에러라면 에러를 처리해야 합니다.

 

 비동기 I/O 요청을 수행 할때 사용되는 데이터 버퍼와 OVERLAPPED 구조체는 I/O요청이 완료될 때까지 옮겨지거나

 삭제되지 않아야 합니다. 위의 예제에서는 설명을 편하게 하기 위해서 스택에 OVERLAPPED와 버퍼를 선언했는데

 실제로 사용시에는 스택에 선언해서 사용하면 안됩니다. 

 

I/O Completion Port(I/O 컴플리션 포트)

 

 I/O completion port가 개발된 배경은 기존 윈도우에서는 서비스 어플리케이션이 개발은 두가지 구조 설계되었습니다.

   
시리얼 모델

하나의 스레드로 사용자의 요청을 대기 및 수행을 처리합니다. 

동시 사용자의 요청이 들어오면 그중 하나의 요청만 처리되고 하나의 요청은 대기 합니다.

컨커런트 모델

하나의 스레드가 사용자의 요청을 대기하고 요청이 들어올 때마다 새로운 스레드를 생성 해서

요청을 처리합니다. 요청 작업이 완료되면 새로 생성된 스레드는 종료됩니다. 

다중 클라이언트의 요청을 처리하기 위해서는 시리얼 모델보다는 컨커런트 모델을 채택해서 어플리케이션을

구현하였습니다. 하지만 컨커런트 모델을 사용하면서 생각보다 성능이 잘나오지 않는 점을 발견 했는데

동시에 많은 수의 요청이 들어올 경우에 동시 많은 스레드들이 생성되어서 스레드들간의 컨텍스트 스위치 때문에

CPU시간이 낭비되었기 때문입니다. 이러한 점을 개선하고 수정해서 I/O completion port가 탄생하였습니다.

( IOCP 커널 오브젝트를 생성시에 동시에 활성화 될 수 있는 스레드 수를 입력받는 이유도 이러한 이유입니다.

 추가적으로 IOCP는 더 똑똑하게 동작합니다.)

I/O 컴플리션 포트 사용 방법

함수이름 사용 설명
CreateIoCompletionPort

이 함수는 두가지 작업을 합니다. 

IOCP 커널 객체를 생성하거나 IOCP와 디바이스를 연결하는 작업을 진행합니다. 

GetQueuedCompletionStatus IOCP의 I/O completion queue에서 데이터가 입력될 때까지 대기합니다. 
PostQueuedCompletionStatus 사용자가 I/O completion queue를 입력한 것 처럼 메시지를 전송합니다. 

CreateIoCompletionPort 사용방법

HANDLE WINAPI CreateIoCompletionPort(
  _In_     HANDLE    FileHandle,
  _In_opt_ HANDLE    ExistingCompletionPort,
  _In_     ULONG_PTR CompletionKey,
  _In_     DWORD     NumberOfConcurrentThreads
);
인자값 설명
FileHandle

INVALID_HANDLE_VALUE이나 IOCP와 연결할 디바이스 핸들을 입력합니다.

INVALID_HANDLE_VALUE을 입력한다면 해당 함수를 통해서 IOCP 핸들 생성합니다.

연결할 디바이스 핸들을 전달한다면 비동기 옵션을 주어야 합니다.

ExistingCompletionPort

FileHandle과 연결할 IOCP 핸들을 전달합니다.

FileHandle이 INVALID_HANDLE_VALUE라면 NULL값을 입력합니다.

CompletionKey

연결할 디바이스 장비에 고유한 키를 등록합니다. I/O completion queue에

비동기 응답이온다면 해당 키를 통해서 구분할 수 있습니다. 

NumberOfConcurrentThreads

IOCP 핸들을 생성 할때만 사용합니다. I/O completion queue를 처리하는 스레드가

동시 수행가능한 수를 입력합니다.

리턴값

ExistingCompletionPort인자가 NULL이라면 새로 생성된 IOCP 핸들 리턴

ExistingCompletionPort인자가 정상이고 FILEHandle도 정상이라면 연동된 IOCP 핸들 리턴

만약 리턴값이 NULL이라면 GetLastError를 호출해서 에러를 확인합니다. 

 해당 함수를 통해서 2가지 작업을 지원하기 때문에 함수 사용 방법이 비직관적입니다. 

 IOCP 커널 오브젝트를 생성하기 위해서 사용합니다.

DWORD dwConcurreentThreadCount = 4;
// IOCP 커널 오브젝트를 생성합니다. 동시에 수행 가능한 스레드를 4로 셋팅합니다. 
HANDLE hIOCP = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, dwConcurreentThreadCount);

 비동기 디바이스와 IOCP과 연동 하기 위해서 사용합니다.

HANDLE hFile;
dwCompletionKey = 59432;
// hFile을 IOCP에 59432라는 키로 연동합니다. 
HANDLE hIOCP = CreateIoCompletionPort(hFile, hIOCP, dwCompletionKey, 0);

 해당 함수를 편하게 쓰기 위해서는 기능 별로 커스텀하여 사용하면 편리합니다. 

// IOCP 객체를 생성합니다.
HANDLE CreateNewIOCP(DWORD dwNumberOfConcurrentThreads)
{
	return (CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, dwNumberOfConcurrentThreads));
}
// IOCP에 디바이스를 연동합니다. 
BOOL AssociateDeviceWithIOCP(HANDLE hIOCP, HANDLE hDevice, DWORD dwCompletionKey)
{
	HANDLE h = CreateIoCompletionPort(hDevice, hIOCP, dwCompletionKey, 0);
	return (h == hIOCP);
}

GetQueuedCompletionStatus 사용방법

BOOL GetQueuedCompletionStatus(
  HANDLE       CompletionPort,
  LPDWORD      lpNumberOfBytesTransferred,
  PULONG_PTR   lpCompletionKey,
  LPOVERLAPPED *lpOverlapped,
  DWORD        dwMilliseconds
);
인자값 설   명
CompletionPort 대기를 수행할 대상 IOCP 핸들을 입력
lpNumberOfBytesTransferred 송수신된 바이트 수 (output)
lpCompletionKey 비동기 I/O 요청이 발생한 디바이스의 completionKey (output)
lpOverlapped 비동기 호출시 전달한 Overlapped 구조체의 주소 (output)
dwMilliseconds 대기를 수행할 시간(밀리초)을 입력합니다. 
리턴값 TRUE면 성공, FALSE 실패

이 함수를 호출하면 호출한 스레드는 I/O compleion queue에서 데이터를 가져옵니다. 

만약 데이터가 없다면 데이터가 전달 될 때 까지 대기합니다.

    HANDLE phIOCP = *(HANDLE*)pArg;
    DWORD dwNumBytes;
    ULONG_PTR CompletionKey;
    LPOVERLAPPED pOverlapped;

    do 
    {
        // 루프를 통해서 반복적으로 비동기 완료 통지를 대기합니다.
        BOOL bOk = GetQueuedCompletionStatus(phIOCP, &dwNumBytes, &CompletionKey, &pOverlapped, INFINITE);
        DWORD dwError = GetLastError();
        if(bOk)
        {} // 스레드가 깨어나면 전달 받은 비동기 완료 통지를 처리합니다.
        else
        {} // 에러 처리
    } while (true);

보통은 위에서 호출하는 것처럼 루프를 통해서 반복적으로 처리되도록 구성합니다. 

PostQueuedCompletionStatus 사용방법

BOOL WINAPI PostQueuedCompletionStatus(
  _In_     HANDLE       CompletionPort,
  _In_     DWORD        dwNumberOfBytesTransferred,
  _In_     ULONG_PTR    dwCompletionKey,
  _In_opt_ LPOVERLAPPED lpOverlapped
);
인자값 설   명
CompletionPort 메시지를 전송할 대상 IOCP 핸들을 입력
dwNumberOfBytesTransferred GetQueuedCompletionStatus으로 전달 될 lpNumberOfBytesTransferred 값
dwCompletionKey GetQueuedCompletionStatus으로 전달 될 lpCompletionKey 
lpOverlapped GetQueuedCompletionStatus으로 전달 될 lpOverlapped 
리턴값  실패하면 0을 리턴합니다.

해당 함수를 사용하면 I/O completion 통지를 사용자가 전달할 수 있습니다.

I/O 컴플리션 포트 커널 오브젝트 

 I/O 커널 오브젝트가 생성되면 아래와 같은 5개의 서로 다른 데이터 구조가 생성됩니다.

I/O 컴플리션 포트 내부 동작 방식

 IOCP 커널 오브젝트 생성 작업 

 CreateIoCompletionPort를 사용해서 커널 오브젝트를 생성합니다. 커널 오브젝트 내부에는 그림과 같이

 장치 리스트, I/O Completion Queue, 대기스레드 큐, 릴리지 스레드 리스트, 일시 정지 스레드 리스트

 5가지가 생성됩니다.

 

IOCP와 디바이스 연결 작업

 CreateIoCompletionPort를 사용해서 IOCP와 디바이스를 연동합니다. 내부적으로는 디바이스 핸들과 completionKey로

 항목이 장치 리스트 데이터 구조에 삽입됩니다.

 이후에는 연동된 디바이스의 비동기 I/O의 완료통지를 IOCP가 전달 받을 수 있습니다. 

GetQueuedCompletionStatus 함수 호출시

 스레드에서 GetQueuedCompletionStatus 함수를 호출했는데 I/O CompletionQueue에 데이터가 없다면 해당 스레드는

 대기 상태로 전환되는데 이때에 IOCP 커널 오브젝의 대기 스레드 큐에 스레드 ID가 추가됩니다. 

 비동기 I/O 작업 완료시 처리

 디바이스에 대한 비동기 I/O 요청이 완료되면 시스템은 디바이스와 연계된 IOCP가 있는지 확인합니다.

 만일 연동된 IOCP가 있다면 완료 통지를 I/O completion queue에 넣어줍니다. 

 I/O Completion Queue 처리 방법 

 I/O Completion Queue에 데이터가 추가되면 대기 스레드 큐를 검사해서 LIFO 방식으로 깨워서 데이터를 처리하도록

 합니다. 깨어난 스레드는 릴리즈 스레드 리스트로 추가됩니다.

 대기 스레드 큐를 LIFO(후입선출, 스택) 방식으로 깨우는 것이 특이한데 이것은 성능을 향상하기 위한 방법입니다.

 만약에 완료 통지가 자주 발생 되지 않는다면 하나의 스레드(Top위치)를 통해서 모든 통지를 처리 할 수 있을 것입니다.

 이러한 경우 다른 스레드의 리소스 자원을 아낄 수 있습니다. (스레드의 메모리를 Swap out, 프로세서의 캐시 자원)

PostQueuedCompletionStatus 함수 호출시

일시 정지 스레드 리스트 관리

 릴리즈 스레드 리스트 등록된 스레드가 수행중에 대기 상태로 전환 되면 IOCP는 해당 상황을 감지할 수 있습니다.

 해당 스레드 ID를 일시 정지 스레드 리스트로 추가하고 릴리즈 스레드 리스트에서 제거합니다.

 IOCP는 릴리즈 스레드 리스트의 갯수를 동시 수행 가능한 스레드 수만큼 유지 하려고 하기 때문에

 대기스레드 큐에서 스레드를 깨워서 릴리즈 스레드 리스트로 가져와서 수행시킵니다. 

 일시 정지된 스레드가 대기 상태에서 다시 수행 상태로 변동되면 다시 릴리즈 스레드 리스트로 옮겨갑니다. 

+ Recent posts