Windows I/O의 심층(제1회) ── 모든 읽기·쓰기는 IRP가 된다: I/O 시스템의 전체상

· · Windows, Win32, I/O, 커널, 디바이스 드라이버, .NET, CSharp, 불량 조사

File.ReadAllText 한 줄, 혹은 ReadFile 한 번의 호출. 이 함수가 반환되기까지 Windows 내부에서는 무슨 일이 일어나고 있을까요.

불량 조사에서 Process Monitor를 열면 IRP_MJ_READFASTIO_READ라는 낯선 단어가 줄지어 있습니다. 핸들 누수를 추적하다 보면, CloseHandle을 호출했을 파일이 여전히 살아 있습니다. “네트워크 드라이브에서는 동작이 다르다”, “백신 소프트웨어를 설치한 환경에서만 느리다”──업무 앱 현장에서 반복해서 마주치는 이러한 현상은 모두 Windows의 I/O 시스템이라는 같은 지반 위에서 일어나고 있습니다.

이 글부터 그 지반을 밑바닥부터 파헤치는 연재 「Windows I/O의 심층」을 시작합니다. 명저 『인사이드 Windows』(Windows Internals)가 커널의 설계까지 내려가 해설하듯이, 이 연재도 “API 사용법”이 아니라 “왜 그렇게 동작하는가”를 다룹니다.1 예정된 구성은 다음과 같습니다.

  1. I/O 시스템의 전체상 ── 모든 읽기·쓰기는 IRP가 된다(본 글)
  2. 동기 I/O와 비동기 I/O ── OVERLAPPED의 진짜 의미
  3. I/O 완료 포트(IOCP)와 .NET 스레드 풀 ── async/await의 지하실
  4. 캐시 관리자 ── WriteFile은 언제 디스크에 도달하는가
  5. NTFS의 내부 구조 ── MFT로 이해하는 파일 시스템
  6. 필터 드라이버와 미니필터 ── Procmon과 백신 검사가 I/O에 개입할 수 있는 이유

제1회인 이 글은 이후 모든 회차의 토대가 되는 “등장인물과 요청의 흐름”을 그림으로 정리합니다. 드라이버를 작성하기 위한 글이 아닙니다. 애플리케이션 개발자가 자신이 호출한 API의 행선지를 끝까지 지켜볼 수 있게 되는 것을 위한 글입니다.

전제 지식: C#에서 FileStream을 사용해 본 적이 있거나, C/C++에서 CreateFile/ReadFile을 호출해 본 적이 있으면 읽을 수 있습니다. 드라이버 개발 경험은 필요 없으며, 커널 쪽 코드는 개념도로만 등장합니다. 읽는 데 걸리는 시간의 기준은 그림을 보면서 통독하면 25분 정도, 1장의 결론만이면 2~3분입니다.

건너뛰기 가이드: 길기 때문에 목적별 입구를 마련해 두었습니다.

  • 전체상만 파악하고 싶다 → 1장(결론) → 2장(이름 해석) → 5장(ReadFile 한 번의 왕복)
  • 용어를 정리하고 싶다(드라이버/디바이스/파일 오브젝트, IRP) → 3장·4장
  • 실무의 “고장 방식”을 알고 싶다(핸들 누수, “파일이 사용 중”) → 6장
  • 직접 손을 움직이고 싶다 → 7장

1. 먼저 결론

  • Windows의 I/O는 패킷 구동입니다. 디바이스 드라이버로 보내지는 요청의 대부분은 IRP(I/O Request Packet)라는 패킷에 담기고, 드라이버가 쌓인 층(디바이스 스택)을 위에서 아래로 흘러갑니다.23
  • CreateFile이 여는 것이 반드시 “파일”이라고는 할 수 없습니다. 이름의 해석은 오브젝트 관리자의 이름공간에서 이루어지며, C:의 실체는 \Device\HarddiskVolume3 같은 NT 디바이스 이름으로의 심볼릭 링크입니다(2장).45
  • 등장인물은 3가지 오브젝트입니다. 처리 함수의 표를 가진 “드라이버 오브젝트”, 요청의 목적지가 되는 “디바이스 오브젝트”, 연 한 번의 상태를 가진 “파일 오브젝트”. 여러분이 가진 HANDLE은 파일 오브젝트에 대한 참조입니다(3장).6789
  • 각 드라이버의 선택지는 3가지뿐입니다. IRP를 “스스로 완료한다”, “아래 드라이버로 넘긴다”, “보류해 두었다가 나중에 완료한다”. 이 3가지 선택의 조합이 필터 드라이버도 캐시도 비동기 I/O도 전부 설명합니다(4장).310
  • 커널 밑바닥에 “동기 I/O”라는 별개의 구조는 없습니다. 동기 I/O의 정체는 “완료되기 전에는 돌아오지 않는다”는 보장이며, 요청이 펜딩(보류)이 되었을 때만 대기가 발생합니다. 여기가 제2회·제3회의 입구가 됩니다(5장).11
  • CloseHandle은 “닫는다”가 아니라 “핸들을 하나 반환한다”입니다. 마지막 핸들이 닫히면 cleanup, 커널 내부의 참조도 모두 사라지면 close라는 2단계로, “닫았는데도 사용 중”의 정체는 이 차이에 있습니다(6장).1213
  • 이 계층은 직접 눈으로 관찰할 수 있습니다. WinObj로 이름공간을, Process Monitor로 IRP의 흐름을 볼 수 있습니다. Procmon의 표시 어휘는 이 글의 용어 그 자체입니다(7장).14

2. “모든 것이 파일로 보인다”의 정체

2.1. CreateFile에 전달한 이름은 어디로 가는가

Win32의 파일 API는 모두 “이름”에서 시작합니다. C:\project\report.csv와 같은 경로, \\server\share\data.csv와 같은 UNC 경로, \\.\COM3와 같은 디바이스 지정──이것들은 모두 같은 CreateFile에 전달할 수 있습니다.15

이 일관성의 이면에는 커널의 오브젝트 관리자가 관리하는 단일 이름공간이 있습니다. 커널 안에서는 디바이스도 이벤트도 공유 메모리 섹션도 모두 “오브젝트”로서 이 트리 형태의 이름공간에 등록되어 있습니다. Sysinternals의 WinObj를 사용하면 이 이름공간을 그대로 들여다볼 수 있습니다.14

\ ── 이름공간의 루트\Device드라이버가 만드는 디바이스 오브젝트\GLOBAL??Win32에서 보이는 전역 이름의 저장소\BaseNamedObjects이름 있는 뮤텍스 등HarddiskVolume3Serial0Mup ── 네트워크 리다이렉터C: → \Device\HarddiskVolume3COM1 → \Device\Serial0PhysicalDrive0 → \Device\Harddisk0\DR0

그림1: 오브젝트 관리자의 이름공간(발췌). \GLOBAL?? 아래에 있는 것은 심볼릭 링크이며, 실체는 \Device 아래에 있다

포인트는 Win32 앱이 사용하는 이름(드라이브 문자나 COM 포트 이름)과 커널이 사용하는 이름(NT 디바이스 이름)이 서로 다른 계층에 있다는 것입니다. 양쪽을 연결하는 것이 심볼릭 링크입니다.

2.2. 드라이브 문자는 심볼릭 링크다

드라이버는 자신의 디바이스를 Win32 앱에서 보이게 하고 싶을 때, IoCreateSymbolicLink\DosDevices\COM1과 같은 MS-DOS 디바이스 이름에서 NT 디바이스 이름으로의 심볼릭 링크를 만듭니다.4 C:도 같은 구조로, 실체는 \Device\HarddiskVolume3 같은 볼륨 디바이스로의 링크입니다.

그러니 CreateFile("C:\project\report.csv")의 이름 해석은 다음과 같이 진행됩니다.

앱이 전달한 이름C:\project\report.csvWin32 계층이 NT 형식으로 변환\??\C:\project\report.csv오브젝트 관리자가 이름공간을 검색\??\C: 이 심볼릭 링크임을 알아낸다링크를 따라가 치환\Device\HarddiskVolume3\project\report.csv\Device\HarddiskVolume3 에서볼륨의 디바이스 오브젝트에 도달남은 \project\report.csv 의 해석은I/O 관리자가 IRP_MJ_CREATE를 발행해파일 시스템 드라이버, NTFS에 맡긴다

그림2: CreateFile의 이름 해석. 전반부는 오브젝트 관리자, 디바이스에 도달한 후반부는 파일 시스템의 일

이 그림에서 평소의 의문 몇 가지가 풀립니다.

  • \\.\ 접두사의 의미. \\.\PhysicalDrive0\\.\COM10\\.\는 Win32 디바이스 이름공간(≒ \?? 디렉터리)을 직접 가리키기 위한 표기입니다. 드라이브 문자를 거치지 않고, 링크가 놓인 곳을 직접 지명하고 있습니다.515
  • CON이나 NUL을 파일 이름으로 쓸 수 없는 이유. 이들은 MS-DOS 디바이스 이름으로 예약되어 있어, 경로의 어디에 쓰든 디바이스 쪽으로 해석될 수 있기 때문입니다.5 이와 관련된 실무적인 함정은 「MAX_PATH와 Windows 경로·파일 이름의 함정」에 정리했습니다.
  • UNC 경로도 특별 취급이 아니다. \\server\share는 네트워크 리다이렉터의 디바이스(\Device\Mup)로 해석되며, 그다음은 SMB 클라이언트가 네트워크 너머로 요청을 운반합니다. 로컬과 UNC에서 동작이 달라지는 문제의 근원은 해석된 디바이스가 다르다는 데 있습니다(「네트워크 드라이브와 UNC 경로의 함정」 참조).

덧붙여 정확하게 말하면 \??는 실재하는 디렉터리의 별칭이 아니라, “먼저 로그온 세션별 로컬 DOS 디바이스 맵을 보고, 없으면 \GLOBAL??로 떨어진다”는 검색 순서를 나타내는 가상의 입구입니다. net usesubst로 만든 드라이브 문자는 이 로컬 쪽에 들어가기 때문에, 같은 PC에서도 사용자(로그온 세션)마다 보이는 드라이브가 다르다거나, 서비스에서는 사용자의 네트워크 드라이브가 보이지 않는다는 현상이 일어납니다. 그림1에 그린 것은 전역 쪽(\GLOBAL??)뿐입니다.

즉 “Windows에서는 모든 것이 파일로 보인다”의 정확한 표현은 “모든 이름이 최종적으로 디바이스 오브젝트로 해석되고, 그다음의 요청이 같은 형식(IRP)으로 통일되어 있다”는 것입니다. 그렇다면 그 디바이스 오브젝트란 무엇일까요. 등장인물을 정리합니다.

3. 등장인물은 3가지 오브젝트

Windows I/O 시스템의 구조는 3종류의 커널 오브젝트의 관계로 그릴 수 있습니다.

여기부터 전문 용어가 계속됩니다. 길을 잃지 않도록, 먼저 “평소 작성하는 코드에서 보이는 것”과 “커널 쪽의 명칭”의 대조표를 두겠습니다. 3장과 4장은 오른쪽 열의 언어로 쓰여 있습니다.

Win32 / .NET 쪽에서 보이는 것 커널 쪽의 대응물 한마디로 말하면
HANDLE / SafeFileHandle 핸들 테이블의 엔트리(파일 오브젝트에 대한 참조) “연 한 번”을 가리키는 번호표(3.3)
FileStream이 쥐고 있는 열린 상태 파일 오브젝트 공유 모드·플래그·현재 위치의 그릇(3.3)
드라이브 문자 C:\\.\COM3 디바이스 오브젝트(로의 이름 해석 결과) 요청의 목적지(2장·3.2)
“NTFS 드라이버”, “디스크 드라이버” 드라이버 오브젝트 요청 종류별 처리 함수의 표(3.1)
ReadFile / stream.Read 호출 1회 통상은 IRP 1개 목적지로 운반되는 요청 패킷(4장). 캐시에 올라 있는 파일에 대한 동기 읽기·쓰기는 패스트 I/O라는 지름길로 IRP를 만들지 않고 끝냅니다(5.2). Procmon에서 FASTIO_로 시작하는 행이 그것입니다
FileOptions / CreateFile의 플래그 파일 오브젝트에 기록되는 속성 미리 읽기나 비동기의 동작을 결정한다(7장의 대조표)
GetLastError의 값 / .NET의 예외 NTSTATUS(STATUS_PENDING 등) 완료 상태. Win32의 오류 코드로 번역되어 올라온다

3.1. 드라이버 오브젝트 ── 처리 함수의 표

드라이버가 로드되면 I/O 관리자는 그 드라이버를 나타내는 드라이버 오브젝트(DRIVER_OBJECT)를 만듭니다.6 앱 개발자의 시점에서 가장 중요한 멤버는 MajorFunction 배열입니다. 이것은 “요청의 종류 → 처리 함수”의 대응표로, 요청의 종류는 IRP_MJ_CREATE(열기), IRP_MJ_READ(읽기), IRP_MJ_WRITE(쓰기), IRP_MJ_CLEANUP, IRP_MJ_CLOSE 같은 메이저 함수 코드로 표현됩니다.16

C#으로 억지로 써 보면 다음과 같은 이미지입니다.

// 개념도. 실제로는 커널 내부의 C 구조체
class DriverObject
{
    // IRP_MJ_XXX 가 인덱스. 전부 28종류
    public DispatchRoutine[] MajorFunction = new DispatchRoutine[28];
}
// ntfs.sys 라면 MajorFunction[IRP_MJ_READ] 에 "NTFS의 읽기 처리"가 들어 있다

3.2. 디바이스 오브젝트 ── 요청의 목적지

드라이버는 자신이 담당하는 디바이스마다 디바이스 오브젝트(DEVICE_OBJECT)를 만듭니다.7 이것이 I/O 요청의 목적지입니다. 물리 디바이스와 1대1이라고는 할 수 없으며, 볼륨(HarddiskVolume3)과 같은 논리적인 존재나 필터 드라이버가 만드는 “끼어들기만을 위한 디바이스”도 있습니다. 디바이스 오브젝트는 자신을 만든 드라이버 오브젝트를 가리키고 있으므로, 목적지가 정해지면 처리 함수의 표도 정해지는 셈입니다.

3.3. 파일 오브젝트 ── “연 한 번”의 상태

CreateFile이 성공할 때마다 커널은 파일 오브젝트를 하나 만듭니다. 이것은 “디스크상의 파일” 그 자체가 아니라, 그 파일(또는 디바이스)을 연 한 번분의 세션을 나타내는 오브젝트입니다.8 같은 파일을 두 번 열면 파일 오브젝트는 두 개 만들어집니다. 현재의 파일 포인터(동기 핸들의 경우), 열었을 때의 공유 모드나 플래그는 여기에 들어 있습니다.

그리고 앱이 받는 HANDLE프로세스별 핸들 테이블을 경유한, 파일 오브젝트에 대한 참조입니다.9

커널 공간프로세스, 사용자 모드파일 오브젝트 1report.csv 를 읽기로 연 것현재 오프셋 4096파일 오브젝트 2report.csv 를 추가 기록으로 연 것현재 오프셋 65536디바이스 오브젝트HarddiskVolume3 에 해당드라이버 오브젝트 NTFSMajorFunction = 처리 함수의 표HANDLE 0x1A4HANDLE 0x1B8

그림3: 3가지 오브젝트의 관계. 핸들은 핸들 테이블을 경유해 파일 오브젝트를 가리키고, 파일 오브젝트는 디바이스로, 디바이스는 드라이버로 이어진다

이 그림을 머릿속에 넣으면 몇 가지 실무 지식이 “암기”에서 “당연함”으로 바뀝니다.

  • 핸들 누수의 정체는 계속 참조되어 해제되지 않는 파일 오브젝트(와 그 너머의 리소스)의 산더미입니다. 세어야 할 것은 핸들 테이블의 엔트리이며, Process Explorer나 handle.exe가 보여주는 것이 바로 이것입니다(「Process Explorer / Handle / VMMap 실전」, 조사 기록으로는 「산업용 카메라 장기 가동 크래시 조사 - 핸들 누수 편」).
  • 같은 파일을 연 두 개의 핸들에서 파일 포인터가 독립되어 있는 것은 포인터가 파일 오브젝트 쪽에 있기 때문입니다. 반대로 DuplicateHandle로 복제한 핸들은 같은 파일 오브젝트를 가리키므로 포인터를 공유합니다.
  • 공유 위반(sharing violation)은 기존 파일 오브젝트 무리의 공유 모드와 새로운 CreateFile 요청을 커널이 대조하여 판정합니다. 배타 제어의 실무는 「파일 연계의 배타 제어 기초 지식」에서 다루었습니다.

4. IRP ── I/O 요청은 소포가 된다

4.1. 왜 패킷으로 만드는가

I/O 관리자는 앱으로부터의 요청(열기·읽기·쓰기…)을 받으면 IRP(I/O Request Packet)라는 패킷에 담아 드라이버에 전달합니다. 디바이스 드라이버로 보내지는 요청의 대부분은 IRP로서 도착합니다.3 디바이스는 OS와 속도가 맞지 않기(디스크는 CPU보다 자릿수 단위로 느리기) 때문에, 요청을 “호출”이 아니라 “짐”으로 만들어 발행과 완료를 분리할 수 있는 형태로 만든 것입니다.2

IRP에는 요청의 전체 정보를 가진 헤더에 더해, I/O 스택 로케이션이라는 영역이 경유할 예정인 드라이버 수만큼 나란히 있습니다. 각 드라이버는 자신용 스택 로케이션에서 “자신에 대한 지시”(메이저 함수 코드, 파라미터)를 읽어냅니다.17

4.2. 디바이스 스택을 내려가다

디바이스 오브젝트는 쌓여서 디바이스 스택을 만듭니다.18 예를 들어 로컬 디스크상의 파일에 대한 읽기는 대략 다음의 경로를 지나갑니다.

스토리지 쪽 스택파일 시스템 쪽 스택볼륨/파티션 관리volmgr 등디스크 클래스 드라이버disk.sys스토리지 포트/미니포트storport 등파일 시스템 필터백신, 암호화, Procmon 등NTFS파일 내 오프셋을 볼륨상의 위치로 변환I/O 관리자가 IRP를 조립한다IRP_MJ_READ + 스택 로케이션디스크 장치

그림4: 읽기 요청이 지나가는 길. 파일 시스템 쪽과 스토리지 쪽은 별개의 디바이스 스택이며, NTFS는 스토리지 쪽 앞으로 향하는 하위 IRP를 새로 발행해 작업을 의뢰한다

이 그림에서 기억해 둘 것은 두 가지입니다.

  1. 필터는 정규 거주자다. 백신 소프트웨어가 모든 파일 I/O를 검사할 수 있는 것은 해킹이 아니라, 이 “사이에 끼어드는” 구조가 OS의 공식적인 확장점이기 때문입니다.19 Process Monitor도 같은 장소에 서서 모든 I/O를 기록합니다. “그 환경에서만 파일 접근이 느리다”는 조사에서 가장 먼저 의심해야 할 곳이기도 합니다(자세한 내용은 제6회).
  2. 계층마다 요청의 의미가 번역된다. 앱은 “이 파일의 오프셋 4096부터 8KB”라고 말하고, NTFS는 그것을 “이 볼륨의 이 클러스터”로, 스토리지 스택은 “이 디스크의 이 섹터”로 번역합니다. 위 계층은 아래 계층의 사정을 알지 못합니다. 여기서 중요한 보충 설명이 하나 있습니다. 하나의 IRP가 앱에서 디스크까지 그대로 관통해서 도달하는 것이 아니라는 점입니다. 파일 시스템 쪽과 스토리지 쪽은 서로 다른 스택이며, NTFS는 파일 앞으로 온 IRP를 처리한 결과로서, 볼륨(스토리지 스택) 앞으로 향하는 하위 IRP를 새로 만들어 발행합니다. 조각난 파일이라면 하나의 읽기가 여러 개의 하위 IRP로 나뉘는 경우도 있으며, 요청의 단위도 수명도 계층마다 달라집니다.

4.3. 각 드라이버의 3가지 선택지

IRP를 받은 드라이버가 할 수 있는 일은 본질적으로 3가지뿐입니다.310

아래 계층이 그 자리에서 완료했다아래 계층이 보류로 처리했다보류는 호출자에게까지 전달된다드라이버가 IRP를 받는다이 요청을 어떻게 처리할까1. 직접 완료한다IoCompleteRequest 를 호출예 ─ 캐시에 있는 데이터로 즉답2. 아래 디바이스로 넘긴다IoCallDriver 를 호출예 ─ 필터가 검사하고 그대로 통과시킴3. 보류한다STATUS_PENDING 을 반환하고 IRP 를 큐에 넣음예 ─ 하드웨어의 응답 대기인터럽트 등을 계기로나중에 IoCompleteRequest완료 처리가 스택을 역순으로 올라간다각 계층의 완료 루틴이 호출된다

그림5: IRP를 받은 드라이버의 3가지 선택. 셋은 배타적이지 않으며 “아래로 넘긴 곳에서 보류가 된다”가 가장 흔한 길로, 어느 쪽이든 마지막은 IoCompleteRequest에 의한 완료로 끝난다

덧붙여 이 3가지는 배타적인 선택지가 아닙니다. 가장 흔한 길은 “2에서 아래로 넘긴 곳에서 어딘가의 계층이 3의 보류를 선택한다”이며, 이 경우 STATUS_PENDING은 중간의 드라이버에도 호출자에도 그대로 전달됩니다. 나중에 아래 계층이 완료시키면, 넘길 때 완료 루틴을 등록해 두었던 각 계층이 역순으로 다시 호출됩니다──즉 “넘긴다”와 “보류”는 같은 하나의 IRP 위에서 겹쳐서 일어납니다.

이 3가지 선택이 Windows I/O의 유연성의 원천입니다.

  • 캐시 히트로 즉시 완료하면 빠릅니다(제4회의 캐시 관리자).
  • 그대로 통과시키는 필터를 몇 장이든 끼워 넣을 수 있습니다(제6회의 미니필터).
  • 보류할 수 있으므로, 느린 디바이스를 기다리는 동안 스레드를 묶어 두지 않아도 됩니다(제2회·제3회의 비동기 I/O).

연재의 남은 전부가 실은 이 그림의 각주입니다.

5. ReadFile 한 번의 왕복을 따라가다

등장인물과 도구가 갖춰졌으니, ReadFile 한 번의 왕복을 처음부터 끝까지 따라가 봅니다. 캐시를 벗어나 디스크까지 가는 경우입니다(캐시에 올라가는 이야기는 제4회에서).

디스크 장치스토리지 스택필터 + NTFSI/O 관리자앱 스레드디스크 장치스토리지 스택필터 + NTFSI/O 관리자앱 스레드핸들에서 파일 오브젝트를 해석하고IRP(IRP_MJ_READ)를 조립한다동기 I/O: 여기서 완료를 기다리며 잠든다비동기 I/O: 제어가 돌아와 다른 작업을 할 수 있다인터럽트 처리(ISR)에서DPC로 완료 처리를 이어간다필요한 하위 IRP가모두 완료되면각 계층의 완료 루틴을 역순으로 실행하고요청 원본 스레드로의 APC로 결과를 확정한다ReadFile → NtReadFile(시스템 콜)IoCallDriver(스택의 맨 위로)위치를 볼륨상으로 변환하고스토리지 스택 앞으로 향하는 하위 IRP를 발행읽기 명령을 발행STATUS_PENDING(하위 IRP는 보류)원래의 IRP도 보류인 채로 돌아온다(가는 길은 여기서 끝)인터럽트 "데이터를 다 읽었다"하위 IRP를 완료(IoCompleteRequest)NTFS 쪽의 완료 루틴이 받는다원래의 IRP(IRP_MJ_READ)를 완료상태와 바이트 수가 확정(이벤트 통지 등)

그림6: 캐시를 벗어난 ReadFile의 한 번의 왕복. 하위 IRP의 완료와 원래 IRP의 완료는 별개의 단계이며, “가는 길”과 “오는 길”도 서로 다른 이벤트로 진행된다

5.1. 가는 길과 오는 길은 별개의 사건

이 그림의 급소는 STATUS_PENDING 행입니다. 스토리지 드라이버는 하드웨어에 명령을 발행한 시점에서 일단 손을 떼고, “가는 길”의 처리는 거기서 끝납니다. 데이터를 다 읽었다는 것은 나중에 인터럽트라는 별개의 이벤트로 통지되고, 거기서부터 “오는 길”의 완료 처리가 시작됩니다.10

즉 커널 안에서는 I/O의 발행과 완료가 분리될 수 있도록 만들어져 있습니다. “동기 I/O”라는 별개의 배관이 있는 것이 아니라, 동기 I/O의 정확한 의미는 “완료되기 전에는 호출이 돌아오지 않는다”는 보장입니다. 대기가 발생하는 것은 요청이 펜딩이 되었을 때뿐이며, 드라이버가 그 자리에서 완료할 수 있는 요청(그림5의 “완료한다”의 길)이라면 동기 I/O라도 스레드는 한 번도 잠들지 않고 결과를 들고 돌아옵니다. Win32가 동기·비동기를 핸들을 여는 방식(FILE_FLAG_OVERLAPPED)으로 전환하는 것도, 비동기가 “특별한 추가 기능”이 아니라 대기 방식의 차이일 뿐이기 때문입니다.11

이 관점을 가지면 제2회에서 다룰 의문──왜 비동기 여부는 호출마다가 아니라 핸들을 열 때 정해지는지(OVERLAPPED 구조체 자체는 진행 중인 조작 하나마다 필요합니다), “비동기여야 하는데 동기적으로 완료된다”는 것은 무엇인지──가 구조의 필연으로서 보이게 됩니다. .NET의 async/await에서 I/O 대기가 스레드를 소비하지 않는 이유(「C# async/await 실무 판단표」에서 실무 쪽의 관점으로 쓴 이야기)도 근거는 이 그림입니다.

5.2. 예외도 있다 ── IRP를 만들지 않는 지름길

솔직하게 덧붙이자면, 모든 I/O가 IRP가 되는 것은 아닙니다. 캐시에 올라 있는 파일에 대한 동기 읽기·쓰기를 위해, 파일 시스템은 패스트 I/O라는 “IRP를 조립하지 않고 캐시에서 직접 복사하는” 지름길을 제공합니다. Procmon의 Operation 열에서 FASTIO_로 시작하는 행이 그것입니다. 지름길을 쓸 수 없는 조건이나 캐시 관리자와의 관계는 제4회에서 다룹니다.

6. CloseHandle의 이면 ── cleanup과 close는 별개

마지막으로 연 것의 닫는 방법입니다. 여기는 핸들 누수 조사나 “파일이 사용 중” 문제와 직결됩니다.

CloseHandle이 하는 일은 프로세스의 핸들 테이블에서 엔트리를 하나 지우는 것입니다. 파일 오브젝트에는 핸들 카운트(핸들의 수)와 참조 카운트(커널 내부로부터의 참조 수)가 있으며, 이 둘은 각각 별도로 줄어듭니다.

파일 시스템I/O 관리자오브젝트 관리자파일 시스템I/O 관리자오브젝트 관리자핸들 테이블에서 엔트리를 삭제핸들 카운트를 줄인다그 파일 오브젝트의미완료 I/O 취소나 잠금 해제alt[그것이 마지막 핸들이었다]단, 미완료 I/O나 섹션(메모리 매핑) 등커널 내부의 참조가 남아 있으면파일 오브젝트는 아직 살아 있다파일 오브젝트의 뒷정리가 완료여기서 비로소 "닫힘이 끝났다"alt[참조 카운트도 0이 되었다]CloseHandle(h)IRP_MJ_CLEANUPIRP_MJ_CLOSE

그림7: cleanup(마지막 핸들이 닫혔을 때)과 close(참조도 모두 사라졌을 때)의 2단계

  • IRP_MJ_CLEANUP은 “마지막 핸들이 닫혔다”는 통지입니다. 다만 미완료 I/O가 남아 있으면 파일 오브젝트의 해제는 아직일 수 있다고 문서 자체가 명기하고 있습니다.12
  • IRP_MJ_CLOSE는 “참조 카운트가 0이 되었다”는 통지입니다. cleanup과 close 사이에는 틈이 있으며, 즉시 이어진다고는 할 수 없습니다.13

이 2단계를 알고 있으면 현장의 신기한 현상을 설명할 수 있습니다.

  • 메모리 매핑된 파일을 닫아도 파일이 해제되지 않는다. 매핑된 섹션이 파일 오브젝트에 대한 참조를 계속 가지고 있기 때문으로, 뷰의 언매핑과 섹션의 클로즈가 끝날 때까지 close는 오지 않습니다. 공유 메모리 관련 실무는 「공유 메모리의 함정과 실무 베스트 프랙티스」에서 다루었습니다.
  • “사용 중인 파일”의 범인 찾기는 핸들만으로는 끝나지 않는다. 핸들을 전부 닫았더라도, 커널 내부의 참조(매핑된 이미지 등)가 파일을 붙잡고 있는 경우가 있습니다. Process Explorer의 검색이 핸들과 DLL(매핑된 파일) 양쪽을 대상으로 하는 것은 바로 이 이유 때문입니다.
  • .NET의 SafeFileHandle이나 파이널라이저가 “언젠가 닫아 준다”를 기대해서는 안 되는 것도, 닫는 것을 잊은 핸들이 cleanup을 지연시켜 공유 위반이나 잠금 유지를 길어지게 만들기 때문입니다.

7. 직접 눈으로 확인하다

여기까지의 내용은 관리자 권한이 있는 Windows PC 한 대만 있으면 전부 관찰할 수 있습니다.

필요한 것은 3가지뿐입니다.

  • 관리자 권한. Process Monitor는 커널 드라이버를 로드하기 때문에 관리자로 실행해야 합니다. WinObj도 관리자가 아니면 보이지 않는 오브젝트가 있습니다.
  • Sysinternals의 도구. WinObj와 Process Monitor는 마이크로소프트가 무료로 배포하고 있습니다. 개별 다운로드 페이지(WinObj, Process Monitor)에서 받거나, 한꺼번에 설치하려면 Sysinternals Suite입니다. 설치 프로그램은 없으며 압축을 풀고 exe를 실행하기만 하면 됩니다.
  • 관찰에 사용할 적당한 조작. 메모장으로 파일을 하나 저장한다, 작은 파일을 하나 복사한다 정도면 충분합니다. 업무용 공유 폴더나 운영 서버가 아니라, 손에 있는 로컬 디스크에서 시험해 보세요.

WinObj로 이름공간을 본다. Sysinternals의 WinObj를 실행하고 GLOBAL?? 디렉터리를 열면, C:\Device\HarddiskVolumeN으로의 심볼릭 링크라는 것(그림1)이 그대로 표시됩니다. \Device 아래에서는 드라이버들이 만든 디바이스 오브젝트의 실제 이름을 볼 수 있습니다.14

Procmon으로 IRP의 언어를 읽는다. Process Monitor의 메뉴에서 Filter > Enable Advanced Output을 활성화하면, Operation 열이 ReadFile이 아니라 IRP_MJ_READFASTIO_READ라는 커널 쪽의 어휘로 바뀝니다. 파일 복사 하나만 추적해도 IRP_MJ_CREATEFASTIO_READ/IRP_MJ_READIRP_MJ_WRITEIRP_MJ_CLEANUPIRP_MJ_CLOSE라는 이 글의 흐름이 실제로 나란히 나타납니다. Procmon의 실무적인 사용법은 「Process Monitor(ProcMon) 실전 가이드」에 정리했습니다.

.NET에서 밑바닥을 의식한다. C#의 FileStream은 내부에서 CreateFileW를 호출하고, SafeFileHandle로서 핸들을 보유합니다. 생성자의 FileOptions는 거의 그대로 Win32 플래그로 직결됩니다.

.NET (FileOptions) Win32 (CreateFile 플래그) 의미(관련 회차)
Asynchronous FILE_FLAG_OVERLAPPED 비동기 I/O용으로 핸들을 연다(제2회·제3회)
WriteThrough FILE_FLAG_WRITE_THROUGH 쓰기를 캐시에서 멈추지 않는다(제4회)
SequentialScan FILE_FLAG_SEQUENTIAL_SCAN 미리 읽기 힌트(제4회)
RandomAccess FILE_FLAG_RANDOM_ACCESS 미리 읽기 억제 힌트(제4회)
DeleteOnClose FILE_FLAG_DELETE_ON_CLOSE 마지막 핸들이 닫히면 삭제(6장 구조의 응용)

.NET 6 이후라면 File.OpenHandleRandomAccess 클래스로, FileStream의 추상화를 거치지 않고 “핸들+오프셋 지정 I/O”라는 Win32의 원형에 가까운 방식으로도 작성할 수 있습니다. 이 표의 오른쪽 의미를 알고 있으면 왼쪽의 선택은 암기가 아니게 됩니다.

8. 정리

  • Windows의 I/O는 오브젝트 관리자의 이름공간에서 목적지(디바이스 오브젝트)를 정하고, 요청을 IRP에 담아 디바이스 스택으로 흘려보낸다는 하나의 설계로 일관되어 있습니다.2318
  • C:는 심볼릭 링크, \\.\는 이름이 놓인 곳의 직접 지정, UNC는 리다이렉터로의 해석. “모든 것이 파일로 보인다”의 정체는 이름 해석의 일관성입니다.45
  • 등장인물은 드라이버 오브젝트(처리 함수의 표), 디바이스 오브젝트(목적지), 파일 오브젝트(연 한 번의 상태)의 3가지. HANDLE은 파일 오브젝트에 대한 참조입니다.6789
  • 드라이버의 선택지는 완료·통과·보류의 3가지. 필터의 개입도, 캐시의 즉답도, 비동기 I/O도 모두 이 3가지 선택의 응용입니다.310
  • 발행과 완료는 분리할 수 있도록 만들어져 있으며, 동기 I/O는 “완료되기 전에는 돌아오지 않는다”는 보장입니다. 펜딩이 된 요청에서는 가는 길(발행)과 오는 길(인터럽트→완료)이 별개의 이벤트로 진행됩니다.1110
  • CloseHandle은 “핸들을 반환”할 뿐. cleanup(마지막 핸들)과 close(마지막 참조)의 2단계를 알고 있으면 “닫았는데도 사용 중”은 더 이상 수수께끼가 아닙니다.1213

다음 회는 제2회 「동기 I/O와 비동기 I/O ── OVERLAPPED의 진짜 의미」입니다. 이번 글에서 본 “발행과 완료의 분리”를 앱 쪽에서 사용하는 구조──FILE_FLAG_OVERLAPPED, 완료 통지의 4가지 방식, 취소, 그리고 “비동기여야 하는데 동기로 돌아온다”는 함정──을 파헤칩니다.

관련 글

관련 상담 영역

합동회사 코무라소프트에서는 Windows 업무 앱의 파일 I/O 관련 설계·불량 조사(핸들 누수, “파일이 사용 중”, 특정 환경에서의 I/O 지연 등)를 다루고 있습니다.

참고 링크

  1. Microsoft Learn, Windows Internals - Sysinternals. 서적 『Windows Internals』(국내 번역서 『인사이드 윈도우』)의 소개 페이지. Windows의 커널 아키텍처와 I/O 시스템을 포함한 내부 구조를 다루는 정석서이며, 이 연재가 다루는 내용을 더 깊이 배우기 위한 출발점으로서. 

  2. Microsoft Learn, I/O manager. Windows 커널 모드 I/O 관리자가 애플리케이션과 디바이스 드라이버가 제공하는 인터페이스 사이의 통신을 관리한다는 점, 디바이스가 OS와 일치하지 않는 속도로 동작하기 때문에 OS와 드라이버 사이의 통신이 주로 IRP(I/O 요청 패킷)를 통해 이루어진다는 점, IRP가 네트워크 패킷이나 Windows 메시지와 비슷한 존재로서 OS에서 드라이버로, 또 드라이버에서 드라이버로 전달된다는 점에 대해.  2 3

  3. Microsoft Learn, I/O request packets. 디바이스 드라이버로 보내지는 요청의 대부분이 IRP에 담긴다는 점, OS의 구성 요소나 드라이버가 IoCallDriver(디바이스 오브젝트에 대한 포인터와 IRP에 대한 포인터를 받음)로 드라이버에 IRP를 보낸다는 점, IRP가 보통 디바이스 스택으로 쌓인 여러 드라이버에 의해 처리되며 먼저 스택 최상위의 디바이스 오브젝트로 보내진다는 점, 각 드라이버가 IRP를 처리하여 완료시키거나 아래 드라이버로 전달하는 것 중 하나를 선택할 수 있다는 점에 대해.  2 3 4 5 6

  4. Microsoft Learn, Introduction to MS-DOS device names. MS-DOS 디바이스 이름이 NT 스타일 디바이스 이름으로의 심볼릭 링크라는 점, 사용자 모드의 Windows 앱은 MS-DOS 디바이스 이름(드라이브 문자나 COM 포트 이름)으로 디바이스에 접근하는 반면 드라이버나 커널은 NT 스타일 이름을 사용한다는 점, 드라이버가 IoCreateSymbolicLink로 \DosDevices\이름 에서 디바이스로의 심볼릭 링크를 만든다는 점에 대해.  2 3

  5. Microsoft Learn, Naming files, paths, and namespaces. CON, PRN, AUX, NUL, COM1~COM9, LPT1~LPT9이 파일 이름으로 예약되어 있다는 점, Win32의 이름공간에 “파일 이름공간”과 “디바이스 이름공간”이 있으며 “\\.\” 접두사가 Win32 디바이스 이름공간으로의 접근을 의미한다는 점(예: \\.\PhysicalDrive0), 이러한 이름 해석 규칙에 대해.  2 3 4

  6. Microsoft Learn, Introduction to driver objects. 드라이버 로드 시 I/O 관리자가 DRIVER_OBJECT 구조체를 만든다는 점, 드라이버 오브젝트가 드라이버의 표준 루틴군으로의 입구(디스패치 테이블인 MajorFunction 배열을 포함)를 보유한다는 점, I/O 관리자가 이 표를 사용해 요청에 대응하는 처리 함수를 호출한다는 점에 대해.  2 3

  7. Microsoft Learn, Introduction to device objects. DEVICE_OBJECT 구조체가 논리·가상·물리 디바이스를 나타내며 I/O 요청의 목적지(타깃)가 된다는 점, 드라이버가 IoCreateDevice로 디바이스 오브젝트를 만든다는 점, 디바이스 오브젝트가 자신을 생성한 드라이버(드라이버 오브젝트)와 결부되어 있다는 점에 대해.  2 3

  8. Microsoft Learn, Using files in a driver. 커널에서 파일 오브젝트가 “열린 파일(또는 디바이스)의 인스턴스”를 나타낸다는 점, 파일을 열 때마다 파일 오브젝트가 만들어지며 열었을 때의 컨텍스트(현재 바이트 오프셋 등)를 보유한다는 점에 대해.  2 3

  9. Microsoft Learn, File handles. CreateFile이 반환하는 파일 핸들이 프로세스에 고유하며 열린 파일 오브젝트와 결부되어 있다는 점, 같은 파일을 여러 번 열면 각각 별개의 핸들(과 열린 상태)이 된다는 점, 핸들이 필요 없어지면 CloseHandle로 닫아야 한다는 점에 대해.  2 3

  10. Microsoft Learn, Completing IRPs. I/O 조작을 완료시키는 것이 IoCompleteRequest의 호출이라는 점, 완료 시에는 스택 상위 드라이버가 등록한 IoCompletion 루틴이 차례로 호출된다는 점, 요청의 완료가 발행과는 다른 시점에 일어나며 최종적으로 요청 원본에 상태가 반환되기까지의 흐름에 대해.  2 3 4 5

  11. Microsoft Learn, Synchronous and asynchronous I/O. 동기 I/O에서는 함수가 I/O 완료까지 돌아오지 않아 스레드가 대기하게 되는 반면, 비동기 I/O(오버랩 I/O)에서는 요청을 발행한 함수가 바로 돌아오고 스레드가 다른 작업을 계속할 수 있다는 점, 비동기 I/O에는 FILE_FLAG_OVERLAPPED를 지정해 핸들을 열어야 한다는 점, 완료 통지를 받는 여러 방법이 있다는 점에 대해.  2 3

  12. Microsoft Learn, IRP_MJ_CLEANUP. 이 요청의 수신이 “대상 디바이스 오브젝트와 연관된 파일 오브젝트의 마지막 핸들이 닫혔다”는 것을 나타낸다는 점, 다만 미처리된 I/O 요청 때문에 파일 오브젝트의 해제가 아직 이루어지지 않을 수 있다는 점, 이 IRP가 핸들을 닫은 프로세스의 컨텍스트에서 전송된다는 점에 대해.  2 3

  13. Microsoft Learn, IRP_MJ_CLOSE. 이 요청의 수신이 “파일 오브젝트의 참조 카운트가 0이 되어 파일 오브젝트가 해제되려 한다”는 것을 나타낸다는 점, cleanup 요청 이후에 전송되지만 미처리 I/O의 완료를 기다리기 때문에 즉시 이어진다고는 할 수 없다는 점에 대해.  2 3

  14. Microsoft Learn, WinObj - Sysinternals. WinObj가 NT 오브젝트 관리자의 이름공간을 표시하는 도구이며, 디바이스 오브젝트나 심볼릭 링크를 포함한 이름공간 내의 오브젝트를 열람할 수 있다는 점에 대해.  2 3

  15. Microsoft Learn, CreateFileW function. CreateFile이 파일뿐 아니라 물리 디스크·볼륨·콘솔·통신 포트(COM 포트)·파이프 등의 디바이스를 열고 핸들을 반환할 수 있다는 점, 디바이스를 열 때 “\\.\” 형식의 이름을 사용한다는 점, FILE_FLAG_OVERLAPPED를 비롯한 각종 플래그의 의미에 대해.  2

  16. Microsoft Learn, IRP major function codes. IRP의 메이저 함수 코드(IRP_MJ_CREATE, IRP_MJ_READ, IRP_MJ_WRITE, IRP_MJ_CLEANUP, IRP_MJ_CLOSE, IRP_MJ_DEVICE_CONTROL, IRP_MJ_PNP 등)의 목록과, 각각의 요청이 무엇을 의미하며 어떤 드라이버가 대응해야 하는지에 대해. 

  17. Microsoft Learn, I/O stack locations. I/O 관리자가 레이어드 드라이버 체인에 있는 드라이버마다 IRP 안에 I/O 스택 로케이션을 준비한다는 점, 각 스택 로케이션에 메이저/마이너 함수 코드와 해당 요청의 파라미터가 들어간다는 점, 각 드라이버가 IoGetCurrentIrpStackLocation으로 자신용 스택 로케이션을 가져와 요청 내용을 파악한다는 점에 대해. 

  18. Microsoft Learn, Device nodes and device stacks. 디바이스 오브젝트가 쌓여서 디바이스 스택을 구성한다는 점, IRP가 먼저 스택 최상위의 디바이스 오브젝트로 보내지고 각 계층에서 처리 또는 하위로의 전달이 이루어진다는 점, 필터 드라이버의 디바이스 오브젝트가 스택 사이에 끼어드는 형태로 존재한다는 점에 대해.  2

  19. Microsoft Learn, Filter Manager Concepts. 필터 매니저가 Windows에 포함된 커널 모드 드라이버이며, 미니필터 드라이버가 파일 시스템으로의 I/O 요청에 대해 사전(pre)·사후(post) 콜백으로 개입할 수 있다는 점, 각 미니필터의 개입 위치(알티튜드)에 따라 I/O 스택 안의 순서가 정해진다는 점에 대해. 

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

IRP란 무엇인가요?
IRP(I/O Request Packet)는 Windows 커널의 I/O 관리자가 애플리케이션의 읽기·쓰기 요청 등을 채워 넣어 디바이스 드라이버에 전달하기 위한 패킷입니다. 마이크로소프트의 드라이버 개발 문서는 디바이스 드라이버로 보내지는 요청의 대부분이 IRP에 담긴다고 설명합니다. IRP는 요청의 종류(작성·읽기·쓰기·정리 등)를 나타내는 메이저 함수 코드와, 경유하는 드라이버마다의 스택 로케이션을 가지며, 디바이스 스택을 위에서 아래로 전달되면서 처리됩니다. 각 드라이버는 IRP를 스스로 완료할지, 아래의 드라이버로 넘길지, 보류(펜딩)해 두었다가 나중에 완료할지를 선택합니다. 앱 개발자가 IRP를 직접 다룰 일은 없지만, Process Monitor의 Operation 열에 나오는 IRP_MJ_READ 등의 표기는 바로 이 구조 그 자체입니다.
왜 Windows에서는 파일도 시리얼 포트도 프린터도 같은 CreateFile로 열 수 있나요?
CreateFile에 전달한 이름이 어느 것이든 결국 오브젝트 관리자의 이름공간에서 디바이스 오브젝트로 해석되고, I/O 관리자가 그 디바이스와 결부된 파일 오브젝트를 만들어 핸들을 반환한다는 같은 경로를 따르기 때문입니다. C:와 같은 드라이브 문자의 실체는 \Device\HarddiskVolume3 같은 NT 디바이스 이름으로의 심볼릭 링크이며, \\.\COM1과 같은 지정도 마찬가지로 시리얼 포트의 디바이스 오브젝트로 해석됩니다. 해석된 곳이 어떤 디바이스이든, 그다음의 요청은 모두 IRP라는 같은 형식으로 채워져 드라이버에 전달되기 때문에, 파일도 디바이스도 같은 API로 열고 읽고 쓸 수 있습니다. UNC 경로도 같은 구조로, 네트워크 리다이렉터의 디바이스로 해석될 뿐입니다. 이 '이름공간+패킷'이라는 설계가 Windows I/O의 일관성의 정체입니다.
CloseHandle을 호출했는데도 파일이 바로 해제되지 않는 경우가 있는 것은 왜인가요?
CloseHandle이 하는 일은 '핸들을 하나 반환하는 것'이지, '파일을 닫는 것'이 아니기 때문입니다. 커널 내부의 파일 오브젝트에는 핸들의 수(핸들 카운트)와 커널 구성 요소로부터의 참조 수(참조 카운트)라는 두 가지 카운트가 있습니다. 마지막 핸들이 닫히면 파일 시스템에는 IRP_MJ_CLEANUP이 전송되지만, 미완료 I/O나 메모리 매핑된 파일의 섹션 등 커널 내부의 참조가 남아 있는 동안에는 파일 오브젝트 자체가 계속 살아 있고, 참조 카운트가 0이 되어야 비로소 IRP_MJ_CLOSE가 전송됩니다. 메모리 매핑된 파일을 사용한 후 파일을 삭제할 수 없다, 앱을 닫았는데도 '파일이 사용 중'이라고 나온다는 현상의 상당수는 이 2단계 구조로 설명할 수 있습니다.
IRP와 디바이스 스택에 관한 지식은 앱 개발자에게 어떤 도움이 되나요?
직접 IRP를 작성하는 일은 없더라도, 조사와 설계 양쪽에서 효과를 발휘합니다. 먼저 Process Monitor의 Operation 열(상세 출력을 활성화한 경우)은 IRP_MJ_CREATE나 IRP_MJ_READ 같은 IRP의 용어 그대로 표시되므로, 이 계층의 언어를 알고 있으면 로그를 읽을 수 있게 됩니다. 다음으로 백신 소프트웨어 등의 필터 드라이버가 모든 파일 I/O의 경로에 끼어 있다는 것을 알고 있으면, '특정 환경에서만 파일 접근이 느리다'는 문제를 조사할 때 짐작이 갑니다. 나아가 Windows의 I/O가 발행과 완료를 분리할 수 있는 구조이며, 동기 I/O는 '완료되기 전에는 돌아오지 않는다'는 보장에 지나지 않는다는 점을 이해하고 있으면, 비동기 I/O나 I/O 완료 포트, .NET의 async/await의 동작을 구조에서부터 납득하고 사용할 수 있습니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기