프로그래밍/소켓 프로그래밍 입문

콜백 기반 비동기 파일 I/O: 내부 동작 상세

데일리 백수 2025. 1. 24. 22:14
#include "stdafx.h"
#include <Windows.h>

/////////////////////////////////////////////////////////////////////////
//파일 쓰기가 완료되면 역호출되는 함수.
void CALLBACK FileIoComplete(
	DWORD dwError,		//에러코드
	DWORD dwTransfered,	//입/출력이 완료된 데이터 크기
	LPOVERLAPPED pOl)	//OVERLAPPED 구조체
{
	printf("FileIoComplete() Callback - [%d 바이트] 쓰기완료 - %s\n",
		dwTransfered, (char*)pOl->hEvent);

	//hEvent 멤버를 포인터로 전용했으므로 가리키는 대상 메모리 해제한다.
	//이 메모리는 IoThreadFunction() 함수에서 동적 할당한 것들이다!
	delete[] pOl->hEvent;
	delete pOl;
	puts("FileIoComplete() - return\n");
}

/////////////////////////////////////////////////////////////////////////
DWORD WINAPI IoThreadFunction(LPVOID pParam)
{
	//메모리를 동적 할당하고 값을 채운다.
	//※이 메모리는 완료 함수에서 해제한다.
	char *pszBuffer = new char[16];
	memset(pszBuffer, 0, sizeof(char) * 16);
	strcpy_s(pszBuffer, sizeof(char) * 16, "Hello IOCP");

	//※OVERLAPPED 구조체의 hEvent 멤버를 포인터 변수로 전용한다!
	LPOVERLAPPED pOverlapped = NULL;
	pOverlapped = new OVERLAPPED;
	memset(pOverlapped, NULL, sizeof(OVERLAPPED));

	pOverlapped->Offset = 1024 * 1024 * 512;	//512MB
	pOverlapped->hEvent = pszBuffer;

	//비동기 쓰기를 시도한다. 쓰기가 완료되면 완료 함수가 역호출된다.
	puts("IoThreadFunction() - 중첩된 쓰기 시도");
	::WriteFileEx((HANDLE)pParam,
		pszBuffer,
		sizeof(char) * 16,
		pOverlapped,
		FileIoComplete);

	//비동기 쓰기 시도에 대해 Alertable wait 상태로 대기한다.
	for ( ; ::SleepEx(1, TRUE) != WAIT_IO_COMPLETION; );

	puts("IoThreadFunction() - return");
	return 0;
}

/////////////////////////////////////////////////////////////////////////
int _tmain(int argc, _TCHAR* argv[])
{
	//중첩된 쓰기 속성을 부여하고 파일을 생성한다.
	HANDLE hFile = ::CreateFile(
		_T("TestFile.txt"),
		GENERIC_WRITE,		//쓰기 모드
		0,					//공유하지 않음.
		NULL,
		CREATE_ALWAYS,		//무조건 생성.
		FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,	//중첩된 쓰기
		NULL);
	if (hFile == INVALID_HANDLE_VALUE)
	{
		puts("ERROR: 대상 파일을 열 수 없습니다.");
		return 0;
	}

	//비동기 쓰기를 위한 스레드를 생성한다.
	HANDLE hThread = NULL;
	DWORD dwThreadID = 0;

	dwThreadID = 0;
	hThread = ::CreateThread(
			NULL,	//보안속성 상속.
			0,		//스택 메모리는 기본크기(1MB).
			IoThreadFunction,	//스래드로 실행할 함수이름.
			hFile,	//함수에 전달할 매개변수.
			0,		//생성 플래그는 기본값 사용.
			&dwThreadID);	//생성된 스레드ID 저장.

	//작업자 스레드가 종료될 때까지 기다린다.
	::WaitForSingleObject(hThread, INFINITE);
	
	//파일을 닫고 종료한다.
	puts("main() thread 종료");
	::CloseHandle(hFile);
	return 0;
}

Windows에서 비동기 I/O(Overlapped I/O) 요청은 크게 다음 단계를 거칩니다:

  1. 사용자 모드(유저모드)에서 WriteFileEx 같은 함수를 호출하여 비동기 작업을 요청.
  2. 운영체제(커널)가 이 요청을 내부 I/O 큐(혹은 APC 큐)에 등록.
  3. 디바이스 드라이버(디스크, 네트워크 어댑터 등)가 실제 하드웨어 작업을 수행.
  4. 작업이 완료되면 OS가 지정된 콜백(Completion Routine)을 호출하거나, 이벤트를 SET하는 등으로 결과를 알림.
  5. 사용자 모드는 콜백 내에서 메모리를 정리하거나 후속 로직을 실행.

이 중 "큐를 통해 어떻게 I/O가 스케줄링되고, 콜백이 어떻게 호출되며, 메모리를 어디서 어떻게 해제하는지"가 핵심입니다.

 

1. 운영체제(커널) 내부의 I/O 큐

1.1 Overlapped I/O 요청 구조

  • WriteFileEx( hFile, pBuffer, size, pOverlapped, FileIoComplete ) 호출 시:
    1. 유저모드에서 OVERLAPPED 구조체와 버퍼(pBuffer)를 준비.
    2. 커널은 이 요청을 I/O 요청 큐(디바이스 드라이버나 커널 내)로 등록.
    3. 함수는 즉시 반환(비동기). 실제 작업(디스크에 쓰기)은 OS가 백그라운드에서 스케줄링.

1.2 APC 큐(Asynchronous Procedure Call)

  • 콜백 방식을 사용하는 WriteFileEx는, I/O 완료 시점에 해당 스레드의 APC 큐에 "이 콜백을 호출하라"는 항목을 등록합니다.
  • 스레드가 Alertable Wait 상태(SleepEx, WaitForSingleObjectEx(..., TRUE) 등)에 들어가 있으면, OS가 APC 큐의 항목을 찾아 콜백을 호출.

1.3 순서 요약

  1. 사용자 함수(WriteFileEx) → 커널 I/O 큐
  2. 커널: 디바이스 드라이버와 하드웨어 협업으로 실제 파일 쓰기 진행
  3. 작업 완료
    • 커널: "모든 바이트를 디스크에 기록 완료!"
    • 스레드의 APC 큐에 "이 콜백 호출" 항목 추가
  4. 스레드 Alertable Wait콜백 호출(APC 실행)

2. 메모리 할당과 해제

2.1 메모리 할당(스레드에서 수행)

예제에서 스레드 함수가 new로 다음을 동적 할당:

  1. 문자열 버퍼(예: char* pszBuffer = new char[16])
  2. OVERLAPPED 구조체(예: LPOVERLAPPED pOverlapped = new OVERLAPPED;)

왜 스레드에서 할당?

  • Overlapped I/O를 요청하는 순간, OS는 이 구조체와 버퍼 주소를 그대로 사용할 것이므로, 요청이 끝나기 전까지 메모리가 유효해야 함.
  • 스택 변수를 사용하면 함수 탈출 시 소멸해버려서 문제 발생.

2.2 콜백에서 해제(함수 FileIoComplete)

void CALLBACK FileIoComplete(
    DWORD dwError,
    DWORD dwTransferred,
    LPOVERLAPPED pOl
) {
    // pOl->hEvent를 '포인터'로 썼으므로 문자열 버퍼 주소가 들어 있음
    delete[] pOl->hEvent; // 문자열 해제
    delete pOl;           // Overlapped 구조체 해제
}
  • Overlapped I/O가 완료되면, OS가 이 콜백을 호출하고, pOverlapped 포인터를 인자로 넘겨줌.
  • 콜백 내에서 pOl->hEvent(원래 이벤트 필드지만 예제에선 버퍼 포인터로 전용)와 pOl 자체를 해제(delete) 처리.
  • 장점: 메모리를 딱 I/O가 끝난 시점에 안전하게 해제 가능.
  • 주의: 콜백이 호출되기 전, 스레드가 메모리를 해제하면 참조 오류 발생.

3. Alertable Wait와 SleepEx

3.1 Alertable Wait의 의미

  • SleepEx(ms, TRUE):
    • 스레드를 ms 밀리초 동안 Alertable 상태로 재움.
    • TRUE = Alertable: APC(Asynchronous Procedure Call) 발생 시 즉시 깨어나 콜백 실행.
  • WaitForSingleObjectEx(hEvent, ms, TRUE) 등도 같은 기법.
  • 결론: "콜백 기반의 비동기 I/O"는 해당 스레드가 Alertable Wait 상태일 때만 콜백이 동작.

3.2 스레드 진행 흐름

  1. 스레드가 WriteFileEx(비동기 요청) 후 SleepEx(1, TRUE) 같은 작은 단위로 반복(wait).
  2. I/O가 완료되면 OS가 "이 스레드에 APC 콜백을 실행하라" 통보.
  3. 스레드는 WAIT_IO_COMPLETION이 반환될 때까지 반복.
  4. 콜백 실행 → 메모리 해제 → 콜백 리턴 → 스레드가 다시 SleepEx → 최종적으로 WAIT_IO_COMPLETION 받아 탈출.

4. 내부 큐를 이용한 스케줄링 과정

  1. I/O Request Queue(커널 레벨)
    • WriteFileEx가 호출되면, I/O 요청을 커널(파일시스템 드라이버/디스크 드라이버) 큐에 등록.
    • Driver/Kernel은 각 요청(Overlapped)마다 실제 디스크 연산을 수행.
  2. 요청 완료 시점
    • Driver/Kernel: "이제 해당 Overlapped I/O 완료"
    • OS는 "APC Queue"에 "이 Overlapped에 대한 콜백" 등록
  3. APC Queue
    • Thread별로 존재.
    • 스레드가 alertable wait 상태일 때, APC Queue의 콜백 함수가 실행.
  4. 메모리/자원 반환
    • 콜백 함수가 Overlapped 구조체와 버퍼를 해제.

5. Q&A: "왜 이 구조가 필요한가?"

  1. 단일 스레드 vs 다중 I/O
    • 하나의 스레드가 여러 개의 비동기 I/O를 동시에 요청 가능.
    • I/O 완료를 콜백으로 받고, 그 시점에만 처리 → CPU와 I/O 병렬 활용.
  2. 큐와 이벤트
    • "이벤트" 기반 모델처럼 WaitForMultipleObjects로 처리하는 방식도 있음(Overlapped + 이벤트).
    • 콜백(APC) 기반은 Alertable Wait 상태를 유지해야 하며, 완료 시 함수가 호출됨.
  3. 매칭:
    • Overlapped 구조체가 요청을 구분(Offset, pOl->hEvent 등).
    • 콜백 함수에서 어떤 요청이 끝났는지 식별 가능.

6. 결론 및 요약

  • 콜백 기반 비동기 파일 I/O에서, OS 내부 큐(I/O Request Queue + APC Queue)가 핵심 역할.
  • WriteFileEx: Overlapped 구조체를 OS에 전달 → OS가 I/O 완료 시 APC 큐에 콜백 호출 등록.
  • Alertable Wait: 스레드가 SleepEx(…, TRUE) 상태여야 콜백이 실제로 실행됨.
  • 메모리 정리: Overlapped 구조체와 버퍼를 콜백 내에서 안전하게 해제.

요컨대, "비동기로 I/O를 요청하고, OS가 I/O를 끝낸 순간에 콜백 함수를 호출해준다"라는 흐름이 콜백 기반 Overlapped I/O의 모든 것입니다. 안에 쌓이는 I/O 요청들은 OS가 처리 완료 시점을 결정하고, 그 결과를 스레드의 APC로 알려줍니다. 이런 구조 덕분에, 애플리케이션은 I/O 대기 없이 고성능, 고동시성 작업을 수행할 수 있습니다.