✏️ 비동기 IO(Asynchronous IO)

💻 I/O와 CPU 클럭의 관계

일반적으로 CPU 클럭 속도가 높으면 시스템 성능이 높아짐.
I/O도 CPU의 클럭 속도에 어느정도 의존하지만 Bus 클럭에 민감함.
보편적으로 CPU 클럭이 높으면 그만큼 Bus 클럭도 높기 때문에 I/O와 CPU의 클럭 속도는 어느정도 연관이 있음.
그러면 I/O와 CPU 클럭 속도는 직접적인 연관이 있다고 볼 수 있을까?

두 개의 시스템이 있다고 가정.
두 시스템은 모두 생성된 데이터(받아온 데이터, 사용자에게 입력받은 데이터..)를 가공해서 목적지로 보내는 목적을 가짐.
이 때 A 시스템은 100 클럭, B 시스템은 200 클럭.
보통 클럭이 높으면 I/O가 더 빠를 것이라고 생각되어짐.
보편적으로는 맞지만, 그렇지 않은 경우도 있음.

I/O는 대상이 어느 것일지라도(File, Net, Console..) Buffering 을 함.
Buffering를 한다는 건 Buffer가 있다는 뜻.
Buffer를 둔 이유는, 데이터를 모아서 한 번에 보내면 빠른 시간에 많은 데이터를 보낼 수 있기 때문.

(Buffer를 비우는 정책은 개발자가 명시적으로 정하거나, 컨트롤 할 수 있음)
다시, 두 시스템은 10 클럭에 한 번씩 Buffer를 비운다고 가정. 데이터를 만드는 속도는 두 시스템이 같다고 가정.
B 시스템은 초당 200번 클럭이 발생하기 때문에 연산 횟수가 A 시스템보다 두 배 큼.
A 시스템은 1초에 10번, B 시스템은 1초에 20번 Buffer가 비워짐.
A 시스템은 클럭 발생 속도가 느리기 때문에 생성된 데이터가 적어도 3개는 되야 하나로 묶어 보낼 수 있음.
B 시스템은 클럭 발생 속도가 빠르기 때문에 데이터 하나가 들어오면 바로바로 버퍼를 비워버림.

A 시스템은 데이터를 묶어서 보냈기 때문에 목적지와 시스템 간의 통신 프로토콜이 데이터 3개 당 한 사이클이 필요. 그러나 B 시스템은 사이클을 세 번 돌아야 함.

결론은, I/O에 CPU의 클럭 속도가 주는 영향은 매우 작다고 볼 수 있음.
CPU의 클럭 속도가 아무리 빠르더라도 I/O 작업의 목적지가 로컬 시스템이 아닌 외부 시스템일 경우 네트워크 프로토콜을 여러 번 주고받는 것은 시스템에 부담이 됨.
또한 I/O 연산에서 Buffer를 비우는 정책은 상당히 중요함.

💻 비동기 I/O의 이해

  • 동기 I/O

데이터를 보내는 역할의 Write() 함수가 있다고 가정. 해당 함수를 호출하는 순간 데이터의 전송 시작되고, 함수가 반환되는 순간이 전송이 끝나는 타이밍. 이 경우 함수의 호출-데이터 전송, 함수의 반환-데이터 전송의 끝이 동기화 되어 있음. 이러한 I/O를 동기 I/O 라고 한다.

아래 이미지의 경우 CPU를 원활히 사용하지 못 하는 프로그램.
I/O 연산이나 별도의 연산에 의해 CPU가 Blocked 상태가 된 상황.

  • 비동기 I/O

Wirte() 함수를 호출했을 때 데이터 전송을 시작하는 것은 동일하나, 함수의 반환 시점이 데이터 전송의 끝을 의미하지 않는다. 비동기 I/O는 함수가 호출되자마자 바로 반환하고 내부적으로 데이터를 계속 보냄.

  • 비동기 I/O의 장점

동기 I/O에서 Write() 함수를 호출하면 데이터 전송이 끝날 때까지 함수를 반환하지 않는다. 이는 함수 반환 전까지 다른 일을 할 수 없다는 것을 의미. 비동기 I/O는 함수 반환이 바로 되기 때문에 데이터 전송과 동시에 다른 일을 할 수 있다.
I/O 연산은 CPU의 도움을 크게 받지 않는 독립된 연산이기 때문에 이런 일이 가능한 것.
I/O 연산을 처리하는 모듈과, 실제 연산을 진행 중인 CPU의 역할이 나눠져 있어 별도의 연산이 가능.

아래 이미지는 I/O 연산과 CPU 연산을 동시에 진행하는 예.

일반적으로 비동기 I/O가 동기 I/O보다 좋지만, 동기 I/O가 구현하기 더 쉽다. 따라서 굳이 I/O 속도에 민감하지 않고 코드를 간결하게 짜는 것에 초점을 맞춘다면 동기 I/O로 구현하는 편이 좋다.

💻 비동기 I/O의 종류(1) 중첩(Overlapped) I/O

Windows에서 제공하는 대표적인 비동기 I/O의 두 가지 모델로 중첩(Overlapped) I/O완료 루틴(Completion Routine) 기반 확장 I/O가 있다.

Read() 함수가 호출된 시점에 데이터 수신이 시작됐다고 가정. 아래 이미지는 데이터 수신이 끝나지 않았는데 다시 Read() 함수를 호출한 상황. 다시 말해 Read() 함수를 호출하자마자 반환이 되었고 다시 Read() 함수를 호출한 것.

  • 중첩 I/O의 장점

    A, B, C가 통신을 한다고 할 때 A-B, A-C가 동시에 I/O를 진행해도 성능에 문제가 되지 않는다. CPU는 상대적으로 I/O 연산을 기다리기 때문에 여러 I/O 연산을 기다리는 것도 가능.

  • 중첩 I/O의 문제점

    I/O 연산이 완료된다면 파일 저장, 계산 등 부가적인 작업이 필요하기 때문에 I/O 연산이 끝났다면 CPU는 어느 I/O 연산이 끝났는지 확인하고 목적에 맞는 작업을 해줘야 함.
    이를 해결하기 위해 완료 루틴(Completion Routine)이 등장함.

💻 비동기 I/O의 종류(2) 완료 루틴(Completion Routine) 기반 확장 I/O

완료 루틴 I/O는 각 I/O가 끝났을 때 실행해야 할 루틴(함수 호출)과 I/O 연산을 묶어둘 수 있다. 다시 말해 I/O 연산이 끝나면 자동으로 묶어둔 루틴이 실행된다.

따라서 연산이 끝난 후 부가 작업을 위해 어떤 연산이 끝났는지 따로 확인할 부담이 없다는 장점이 있다.

  • 중첩 I/O 구현 모델

중첩 I/O 구현 시 일반적인 WriteFile() 함수를 호출. 이 때 OVERLAPPED 구조체를 인자로 받는데, 이는 중첩 I/O를 하겠다는 걸 알리고 이에 필요한 정보를 담는 역할을 한다.
(완료 루틴 I/O도 중첩 I/O를 확장한 개념이기 때문에 OVERLAPPED 구조체를 사용한다.)

1) 이벤트 오브젝트 생성 후 hEvent에 저장
hEvent는 이벤트 핸들 값을 가지고, 이벤트를 가리킴.
따라서 I/O 연산이 완료가 되면 이 이벤트 오브젝트는 Signaled 상태가 될 예정.
2) OVERLAPPED 구조체 초기화 및 hEvent 값 대입
3) I/O 연산 대상의 핸들 정보를 인자로 넘겨줌

typedef struct _OVERLAPPED
{
    ULONG_PTR Internal;     //
    ULONG_PTR InternalHigh; //
    DWORD Offset;           // [union]
    DWORD OffsetHigh;       // [union]
    HANDLE hEvent;          // IO가 끝났음을 확인 할 수 있는 인자.
} OVERLAPPED;
*
    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,       // 읽기,쓰기 모드 지정
  // FILE_FLAG_OVERLAPPED: 비동기 I/O를 하곘다는 선언
            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 구조체 초기화
    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,	// 전송된 데이터 크기. 근데 비동기 I/O에서는 이 지점에서는 의미가 없음. 이제 막 IO 전송이 시작되기 때문. GetOverlappedResult()에서 얻을 수 있음.
            &overlappedInst);	

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

        WaitForSingleObject(overlappedInst.hEvent, INFINITE); // IO가 끝나기를 기다림. (hEvent가 Signaled 상태가 됐을 때)

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

    }

    FlushFileBuffers(hPipe); 
    DisconnectNamedPipe(hPipe); 
    CloseHandle(hPipe); 
    return 1;
}
  • 완료 루틴 I/O 구현 모델

중첩 I/O와 달리 완료 루틴이 존재.
따라서 중첩 I/O의 내용에 해당 연관 관계를 구성해주는 부분이 추가 됨.

WriteFile() 대신 WriteFileEx() 함수를 호출해야 하는데, WriteFileEx()는 lpCompletionRoutine을 인자로 전달해서 I/O 연산과 완료 루틴을 연결하는 역할을 하기 때문.

이 때 Completion Routine이 자동적으로 호출되면서 I/O 연산이 끝났음을 알 수 있기 때문에 OVERLAPPED 구조체의 기존 hEvent의 역할이 불필요함. 그러나 인자로는 넘겨줘야 하는데, FileIOCompletionRoutine() 함수 호출 시 OVERLAPPED 구조체가 전달되기 때문에 hEvent을 이용해 완료 루틴에 추가로 데이터 전송할 수 있다.

// ReadFileEx() 도 동일.
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
)
// 이 함수는 Windows에 의해서 자동적으로 호출되는 함수.
// 즉, 함수의 호출 대상이 프로그래머가 아니라서 Callback 함수.
// 또한 아래 인자들은 프로그래머가 아니라 윈도우즈에서 자동으로 전달해줌.

VOID CALLBACK FileIOCompletionRoutine(
    DWORD dwErrorCode,               // completion code. 무슨 에러가 발생했는디.
    DWORD dwNumberOfBytesTransfered, // number of bytes transferred. 몇 바이트가 전송되었는지.
    LPOVERLAPPED lpOverlapped        // IO Information buffer. 우리가 전달한 OVERLAPPED 구조체를 다시 전달함.
);
/*
    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가 됨.
    /// 해당 함수를 호출하지 않으면 I/O 연산이 완료됐음에도 불구하고 완료 루틴이 호출되지 않을 수 있기 때문에, 완료 루틴을 호출하고 싶은 시점에 호출해줘야 함.
    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);
}

💻 알림 가능한 상태(Alertable State)

A I/O의 완료 루틴을 만들어 두고 A I/O의 연산을 요청했다고 가정.
호출이 완료되면 자동으로 완료 루틴이 실행될 것이기 때문에 CPU는 다른 연산을 진행 중. I/O 연산이 끝나면 CPU는 완료 루틴을 연산하러 가야 하는데 해당 시점을 알 수가 없기 때문에 예고 없이 작업의 우선 순위를 뺏길 수 있다.
이를 해결하기 위해 완료 루틴의 우선 순위를 낮추거나, 기존 작업의 우선 순위를 높혀야 안정적인 소프트웨어 개발이 가능하다.
Alertable State는 기존 작업의 연산에서 완료 루틴으로 넘어가도 되는 상태, 완료 루틴의 우선 순위를 높히겠다는 상태를 말한다.

  • 알람 가능한 상태 진입을 위한 함수
DWORD SleepEx(
    DWORD dwMilliseconds, // time-out interval
    BOOL bAlertable // true 전달 시 Alertable State
);

DWORD WaitForSingleObjectEx(...);

DWORD WaitForMultipleObjectsEx(...);

✏️ APC(Asynchronous Procedure Call, 비동기 함수 호출 메커니즘)

모든 쓰레드는 자신만의 독립적인 APC Queue를 소유한다. 이는 해당 쓰레드가 Alertable State가 되었을 때 호출할 콜백 함수를 모아둔 Queue.
쓰레드는 Alertable State에 들어갔을 때 모든 콜백 함수를 호출해서 Queue를 비운다.

WriteFileEX() 함수는 I/O 완료 시 APC Queue에 콜백 함수의 정보를 입력한다.
이러한 APC 메커니즘을 통해 Completion Routine I/O가 진행된다.

0개의 댓글