본문 바로가기
C/[서적] 뇌를 자극하는 윈도우즈 시프

Chapter 19. 비동기 I/O와 APC

by GameStudy 2022. 2. 14.

19.1 비동기 IO(Asynchronous IO)

Prologue) 비동기 IO지만, 크게보면 파일 IO도 포함하는 내용.

 

19.1-1 IO와 CPU 클럭의 관계

  Note) CPU 클럭이 높아지면 성능도 따라서 올라간다는 걸 전제로 함.

    IO도 CPU 클럭이 높아지면 성능이 높아지긴 하나, 긴밀한 관계를 갖고 있진않음.

    대신 버스 클럭에는 의존도가 높음.

 

  Note) 두 개의 시스템이 있다고 가정해 보자. A 시스템과 B 시스템.

    두 시스템은 생성한 데이터를 가공해서, 목적지로 보냄.

    A 시스템은 100 클럭[?], B 시스템은 200 클럭임. 그럼 B 시스템이 IO처리가 더 빠를까?

    아님. IO라는 것(File, network, console, ...)은 결국 Buffering이 필요함. 즉 Buffer가 있음.

    즉 일정량의 데이터를 모아서 전송하게 됨. 그래야 좀 더 효율적으로 빠른 처리가 가능.

    이때 버퍼를 비우는 정책은 프로그래머가 결정할 수 있음. 강제로 비울수도 있음.

    두 시스템 모두 만약 버퍼가 10clock에 한 번씩 버퍼를 비운다고 해보자.

    그럼 시스템 A는 1초에 10번, 시스템 B는 20번 비워짐.

    만약 목적지에 내용 a, b가 들어와야만 온전한 통신 프로토콜 내용이 완성된다고 해보자.

    시스템 A는 오히려 느리기때문에 버퍼에 a, b가 한 사이클만에 전송되지만,

    시스템 B는 빠르기 때문에 버퍼에 b가 미처 채워지기도 전에 전송되어버릴 수 있음.

    따라서 오히려 IO에서는 시스템 B가 더 느려짐.

    즉, CPU의 클럭과 IO는 관계가 없다고 보는게 맞음.

    CPU 클럭이 아무리 높아봤자, 외부의 네트워크 프로토콜을 여러번 주고받는건 아주 부담이됨.

    네트워크 통신은 상당히 느린 통신이기 때문.

    그리고 또 하나, 버퍼를 비우는 정책은 상당히 중요할 수 있다는 것까지 기억하자.

 

 

 

19.1-2 비동기 IO의 이해

  Note) 동기 IO가 뭘까?

    write() 함수가 호출 되는 순간을 전송 시작, 반환 되는 순간을 전송 끝이라면

    함수의 호출과 전송 시작이 동기화되어 있고, 반환과 전송 끝이 동기화 되어 있다고 함.

    이런 경우를 동기 IO라고 함. 그래서 IO가 끝나야만 플레이하고 IO를 해야지만 플레이 또 가능.

 

  Note) 아래 그림은 CPU를 원활히 사용 못하는 프로그램.

    부하가 없는 부분에선 CPU가 Blocked 된 상태. 가만히 놀고 있음.

    물론 코드가 깔끔하면서, 쉽게 구현 가능했음 좋겠다 하면 동기 IO로 구현.

    모든 프로그램에 비동기 IO를 쓸 필욘 없음.

출처. 윤성우, "뇌를 자극하는 윈도우즈 시스템 프로그래밍"

 

  Note) 비동기 IO가 뭘까?

    write() 함수가 호출 되는 순간이 전송 시작이긴 하나, 반환되는 순간이 전송의 끝은 아님.

    즉, 함수의 호출과 전송 시작이 동기화되어 있지만, 반환과 전송 끝은 동기화되어 있지않음.

    이런 경우를 비동기 IO라고 함. 즉, 함수의 호출과 동시에 반환되고 데이터의 전송이 진행됨.

    동기 IO에 비해 비동기 IO가 좋은 점은 뭘까? 동기 IO일때, write() 함수가 호출되었다는 것은

    데이터 전송이 끝날때까지 반환 안한다는 것. 그 사이에 할일이 있음에도 다른 일을 할 수 없음.

    반면에 비동기 IO는 호출과 동시에 반환하고, 내부적으론 데이터 전송이 계속됨. 또다른 일 할 수 있음.

    그럼 이런 의문점이 생김. 아무리 반환 했더라도, 데이터 전송중인데 다른 일을 할 수 있나?

    할 수 있음. IO 연산은 CPU의 도움을 그리 받지 않기 때문. 마치 FDE 과정에서 중첩진행이 가능한 것 처럼.

    그래서 우리는 비동기 IO를 선호하게 됨. 아래 그림은 CPU가 계속 일하는 중. 그동안 IO도 연산 중.

    아래 그림 우측 처럼, 데이터 IO를 버퍼링 해가면서 동기에 플레이도 가능함.

출처. 윤성우, "뇌를 자극하는 윈도우즈 시스템 프로그래밍"

 

  Note) Windows에서 비동기 IO 방식을 구현할 수 있는

    대표적이며, 일반적인 모델은 두 가지가 있음.

    1. 중첩 IO

    2. 완료 루틴 IO

 

19.1-3 비동기 IO의 종류1

  Note) 중첩 IO(Overlapped IO)

    IO 연산이 중첩되었다는 것. read() 함수가 호출되었을때 데이터 수신 시작.

    곧바로 반환하고 다시 read() 함수가 호출됨. 또 반환되고 호출하고...

    아래 그림은 총 4개의 IO가 중첩됨. 이게 도움이 되나?

    CPU 연산은 IO 연산보다 비교적 훨씬 빠름. 그래서 대부분 CPU 입장에서 대기 시간이 걸림.

    물론 하나의 IO에서 엄청난 양이 들어온다면 문제지만,

    대부분 그렇지 않기 때문에 여러 IO를 중첩하는게 기다릴바에야 나은 선택.

출처. 윤성우, "뇌를 자극하는 윈도우즈 시스템 프로그래밍"

 

  Note) 근데 문제가 있음. IO 연산이 끝나고 나면, 받은 데이터를 File에

    저장을 하건, 계산을 하건, 플레이를 하건 그에 따른 부가적인 작업을 해야 함.

    따라서 중첩 IO시에, A B C IO작업 들 중 어느 작업이 끝났는지 확인해야 함.

    언뜻 생각해봐도 이 확인과 분류작업이 부담스러움. 그래서 나온것이 완료 루틴.

 

 

19.1-4 비동기 IO의 종류2

  Note) 완료 루틴(Completion Routine) 기반 확장 IO

    A IO, B IO, C IO가 끝났을때 각각 정해진 Routine(== 함수 호출)이 실행되게끔 구성.

    즉 이러면 끝났는지 확인해서, 분류할 필요가 없어짐.

출처. 윤성우, "뇌를 자극하는 윈도우즈 시스템 프로그래밍"

 

 

19.1-5 중첩 IO 구현 모델

  Note) 관련예제 Ex19-1. 아래 그림이 핵심.

    - 중첩 IO 할땐 WriteFile() 호출하면 됨.

      다만 WriteFile() 할 때, OVERLAPPED 구조체를 인자로 전달 가능했음.

      중첩 IO을 하겠다는 알림 효과 + 관련 정보 전달의 의미

    - OVERLAPPED 구조체를 초기화 하기 전에 Event 오브젝트를 생성해서

      OVERLAPPED 구조체 초기화할 때 대입하면 됨.

    - 이렇게 초기화된 OVERLAPPED 구조체와 IO를 누구랑할지 핸들 정보가 필요함

      이를 위해서 PIPE 핸들을 인자로 전달함.

    - 그래서 IO가 끝나면 Event 오브젝트가 자동으로 Signaled 상태로 전환됨.

      이게 중첩 IO의 기본틀.

    - 완료 루틴 IO도 사실 중첩 IO의 확장임.

출처. 윤성우, "뇌를 자극하는 윈도우즈 시스템 프로그래밍"

 

  Note) OVERLAPPED 구조체

typedef struct _OVERLAPPED
{
    ULONG_PTR Internal;     //
    ULONG_PTR InternalHigh; //
    DWORD Offset;           // [union]
    DWORD OffsetHigh;       // [union]
    HANDLE hEvent;          // IO가 끝났음을 확인 할 수 있는 인자.
} OVERLAPPED;

 

  Ex)

<hide/>

/*
    namedpipe_asynch_server.cpp
    프로그램 설명: 이름 있는 파이프 서버 중첩 I/O 방식.
*/
#include <stdio.h>
#include <stdlib.h>
#include <windows.h> 

#define BUF_SIZE 1024

int CommToClient(HANDLE);

int _tmain(int argc, TCHAR* argv[]) 
{
    LPTSTR pipeName = _T("\\\\.\\pipe\\simple_pipe"); 

    HANDLE hPipe;
    
    while(1)
    {
        hPipe = CreateNamedPipe ( 
            pipeName,            // 파이프 이름
            PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,       // 읽기,쓰기 모드 지정
            PIPE_TYPE_MESSAGE |
            PIPE_READMODE_MESSAGE | PIPE_WAIT,
            PIPE_UNLIMITED_INSTANCES, // 최대 인스턴스 개수.
            BUF_SIZE / 2,           // 출력버퍼 사이즈.
            BUF_SIZE / 2,           // 입력버퍼 사이즈 
            20000, // 클라이언트 타임-아웃  
            NULL                    // 디폴트 보안 속성
            );
      
        if (hPipe == INVALID_HANDLE_VALUE) 
        {
            _tprintf( _T("CreatePipe failed")); 
            return -1;
        }
      
        BOOL isSuccess; 
        isSuccess = ConnectNamedPipe(hPipe, NULL) ? TRUE : (GetLastError() == ERROR_PIPE_CONNECTED); 
      
        if (isSuccess) 
            CommToClient(hPipe);
        else 
            CloseHandle(hPipe); 
    }
    return 1; 
} 

int CommToClient(HANDLE hPipe)
{ 
    TCHAR fileName[MAX_PATH];
    TCHAR dataBuf[BUF_SIZE];

    BOOL isSuccess;
    DWORD fileNameSize;

    isSuccess = ReadFile ( 
        hPipe,        
        fileName,    // read 버퍼 지정.
        MAX_PATH * sizeof(TCHAR), // read 버퍼 사이즈 
        &fileNameSize,  // 수신한 데이터 크기
        NULL);       

    if (!isSuccess || fileNameSize == 0) 
    {
        _tprintf( _T("Pipe read message error! \n") );
        return -1; 
    }

    FILE * filePtr = _tfopen(fileName, _T("r") );

    if(filePtr == NULL)
    {
        _tprintf( _T("File open fault! \n") );
        return -1; 
    }

    OVERLAPPED overlappedInst;
    memset(&overlappedInst, 0, sizeof(overlappedInst));
    overlappedInst.hEvent = CreateEvent( // IO 연산이 완료가 되면, 이 이벤트 오브젝트는 Signaled 상태가 될 예정.
        NULL,    
        TRUE,    
        TRUE,    
        NULL);   

    DWORD bytesWritten = 0;
    DWORD bytesRead = 0;
    DWORD bytesWrite = 0;
    DWORD bytesTransfer = 0;

    while( !feof(filePtr) )
    {
        bytesRead = fread(dataBuf, 1, BUF_SIZE, filePtr);

        bytesWrite = bytesRead;

        isSuccess = WriteFile ( 
            hPipe,			// 파이프 핸들
            dataBuf,		// 전송할 데이터 버퍼  
            bytesWrite,		// 전송할 데이터 크기 
            &bytesWritten,	// 전송된 데이터 크기. 근데 이 지점에서는 의미가 없음. 이제 막 IO 전송이 시작되기 때문. GetOverlappedResult()에서 얻을 수 있음.
            &overlappedInst);	

       if (!isSuccess && GetLastError() != ERROR_IO_PENDING)
        {
            _tprintf( _T("Pipe write message error! \n") );
            break; 
        }

        WaitForSingleObject(overlappedInst.hEvent, INFINITE); // IO가 끝나기를 기다림.

        GetOverlappedResult(hPipe, &overlappedInst, &bytesTransfer, FALSE); // 전송된 데이터의 양을 여기서 얻을 수 있음.
        _tprintf(_T("Transferred data size: %u \n"), bytesTransfer);

    }

    FlushFileBuffers(hPipe); 
    DisconnectNamedPipe(hPipe); 
    CloseHandle(hPipe); 
    return 1;
}

 

19.1-6 완료루틴 IO 구현 모델

  Note) 관련예제 Ex19-2

    - 중첩 IO와 다르게 완료 루틴이라는게 존재함.

      즉, IO A 연산이 완료되고나면 함수가 자동으로 호출되어야 함.

      이게 추가된 내용의 전부임.

    - 그래서 WriteFile() 함수와 다르게 WriteFileEx() 함수는 

      Completion Routine을 인자로 전달 받게됨.

    - 즉, Completion Routine이 호출되었다는 것은 IO 연산이 끝났음을 의미함.

      그래서 OVERLAPPED 구조체의 이벤트 오브젝트 핸들이 굳이 필요 없어짐.

      그럼 OVERLAPPED 구조체도 노필요냐? 아님. 다른 멤버들이 필요해지기 때문.

출처. 윤성우, "뇌를 자극하는 윈도우즈 시스템 프로그래밍"

 

  Note)

BOOL WriteFileEx(
    HANDLE hFile, // handle to output file.
    LPCVOID lpBuffer, // data buffer.
    DWORD nNumberOfBytesToWrite, // number of bytes to write.
    LPOVERLAPPED lpOverlapped, // overlapped buffer
    LPOVERLAPPED_COMPLETION_ROUTIME lpCompletionRoutine // completion routine
)

 

  Note)

// 이 함수는 Windows에 의해서 자동적으로 호출되는 함수.
// 즉, 함수의 호출 대상이 프로그래머가 아니라서 Callback 함수.
// 또한 아래 인자들은 프로그래머가 아니라 윈도우즈에서 자동으로 전달해줌.

VOID CALLBACK FileIOCompletionRoutine(
    DWORD dwErrorCode,               // completion code. 무슨 에러가 발생했는디.
    DWORD dwNumberOfBytesTransfered, // number of bytes transferred. 몇 바이트가 전송되었는지.
    LPOVERLAPPED lpOverlapped        // IO Information buffer. 우리가 전달한 OVERLAPPED 구조체를 다시 전달함.
);

 

Ex)

<hide/>

/*
    completion_routine_file.cpp
    프로그램 설명: 파일 기반의 확장 I/O 예제.
*/
#include <stdio.h>
#include <stdlib.h>
#include <windows.h> 

TCHAR strData[] = 
        _T("Nobody was farther off base than the pundits who said \n")
        _T("Royal Liverpool was outdated and not worthy of hosting the Open again \n")
         _T("for the first time since 1967. The Hoylake track held up beautifully. \n")
         _T("Here's the solution to modern golf technology -- firm, \n")
         _T("fast fairways, penal bunkers, firm greens and, with any luck, lots of wind. \n");	

VOID WINAPI 
FileIOCompletionRoutine ( DWORD, DWORD, LPOVERLAPPED);

int _tmain(int argc, TCHAR* argv[]) 
{
    TCHAR fileName[] = _T("data.txt");

    HANDLE hFile = CreateFile (
                        fileName, GENERIC_WRITE, FILE_SHARE_WRITE, 0, CREATE_ALWAYS, FILE_FLAG_OVERLAPPED, 0 // FILE_FLAG_OVERLAPPED: 비동기 중첩이 가능함. 
                   );
    if(hFile == INVALID_HANDLE_VALUE)
    {
        _tprintf( _T("File creation fault! \n") );
        return -1; 
    }

    OVERLAPPED overlappedInst;
    memset(&overlappedInst, 0, sizeof(overlappedInst));
    overlappedInst.hEvent= (HANDLE)1234;	// 추가로 데이터 전송 가능한 경로. FileIOCompletionRoutine()에 인자로 다시 OVERLAPPED 구조체가 자동 전달됨.
                                            // 즉, 완료루틴에 데이터를 전달하고 싶다면, 이 경로를 통해서 전달 가능.
    WriteFileEx(hFile, strData, sizeof(strData), &overlappedInst, FileIOCompletionRoutine);

    SleepEx(INFINITE, TRUE);
    // SleepEx() 함수의 두 번째 인자에 TRUE를 전달하면, 이 함수가 호출한 "쓰레드"는 Alertable State가 됨.
    CloseHandle(hFile); 

    return 1; 
} 

VOID WINAPI 
FileIOCompletionRoutine ( DWORD errorCode, DWORD numOfBytesTransfered, LPOVERLAPPED overlapped)
{
    _tprintf( _T("**********File write result ************\n") );
    _tprintf( _T("Error code: %u \n"), errorCode);
    _tprintf( _T("Transferred bytes len: %u \n"), numOfBytesTransfered);
    _tprintf( _T("The other info: %u \n"), (DWORD)overlapped->hEvent);
}

 

19.2-7 알림 가능한 상태(Alertable State)

  Note) A라는 IO 연산을 요청 해 둠.

    그리고 그에 맞는 컴플리션 루틴을 전달해 놨다고 가정 해보자.

    비동기 IO이기 때문에 다른 CPU 연산을 하는 중임.

    그러다가 임의의 순간에 IO 연산이 끝나면, 컴플리션 루틴으로 진입해야 함.

    그럼 작업이 강제적으로 컴플리션 루틴으로 뺏길 수 밖에 없는 상황이 연출됨.

    근데 나는 좀 더 작업을 해야지만, 다른 루틴을 실행 할 수 있다면

    컴플리션 루틴의 우선순위를 낮추거나 기존 작업의 우선순위를 높혀야지만 안정적임.

    이런 이유에서 Alertable State 개념이 나왔음.

    내가 지금부터는 컴플리션 루틴으로 진입해도 괜찮다, 컴플리션 루틴의 우선순위가

    높아져도 괜찮다는 상태가 바로 Alertable State.

 

  Note) 알림 가능한 상태 진입을 위한 세 가지 함수

    아래 함수 중 하나가 호출되지 않는 한, 컴플리션 루틴으로 진입이 안됨.

    내가 '이쯤에선 컴플리션 루틴을 실행하겠다.'할 때 아래 함수를 호출해야 함.

DWORD SleepEx(
    DWORD dwMilliseconds, // time-out interval
    BOOL bAlertable // true 전달 시 Alertable State
);

DWORD WaitForSingleObjectEx(...);

DWORD WaitForMultipleObjectsEx(...);

 

19.2 APC(Asynchronous Procedure Call)

Prologue)

 

19.2-1 APC(비동기 함수 호출 메커니즘)

  Note) 모든 쓰레드는 자신만의 APC Queue를 소유

    해당 쓰레드가 Alertable State가 되었을때, 호출할 콜백함수들을 모아놓은 큐.

    한번 쓰레드가 Alertable State에 진입하면 큐에 있는 모든 콜백함수들은 호출되게 됨.

    WriteFileEx() 같은 경우에도 APC Queue에 콜백함수(Completion Routine)를 인큐 시킴.

    즉, 이런 APC 메커니즘을 통해서 Completion Routine IO가 완성됨.

출처. 윤성우, "뇌를 자극하는 윈도우즈 시스템 프로그래밍"

 

 

댓글