WriteFile이 성공을 반환했습니다. 자, 데이터는 지금 어디에 있을까요?
답은, 거의 확실히 아직 디스크에 없습니다. 메모리상의 캐시에 복사되었을 뿐입니다. 그래서 ‘저장했는데, 전원이 나갔다 돌아오니 사라져 있었다’가 일어나고, 그래서 파일 복사 벤치마크가 물리적으로 있을 수 없는 속도를 찍으며, 그래서 데이터베이스는 꼬박꼬박 fsync를 호출합니다.
연재 「Windows I/O의 심층」의 제4회는, 이 사이에 서 있는 캐시 관리자입니다. 제2회에서 ‘캐시에 올라와 있으면 비동기 I/O도 동기 완료된다’고 썼고, 제1회에서는 ‘IRP를 만들지 않는 지름길(패스트 I/O)’을 숙제로 남겼습니다. 이번에는 그 복선을 모두 회수합니다.
1. 먼저 결론
- Windows의 파일 캐시는 라이트백 방식입니다. 읽기는 우선 시스템 파일 캐시에서, 쓰기도 우선 캐시로. 디스크로의 반영은 OS가 나중에 수행합니다.1
- 캐시의 실체는 파일 매핑입니다. 캐시 관리자는 파일의 256KB 단위 구간을 시스템 주소 공간에 매핑하고, 읽기 쓰기는 ‘그 뷰와의 사이의 메모리 복사’가 됩니다(2장).1
- 쓰기는 매초 동작하는 지연 쓰기(lazy writer)가 뒤따라 반영합니다. 앱 크래시로는 데이터가 사라지지 않지만, 전원 차단이나 OS 크래시에서는 더티한 캐시가 사라집니다(4장).1
- ‘확실히 쓰는’ 도구는 세 가지.
FlushFileBuffers(=.NET의Flush(true)),FILE_FLAG_WRITE_THROUGH,FILE_FLAG_NO_BUFFERING. 빈번한 쓰기에서 매번 플러시하는 것은 비효율적이며, 공식 문서는 NO_BUFFERING+WRITE_THROUGH의 병용을 제시하고 있습니다(5장).21 - NO_BUFFERING에는 정렬 요건이 있습니다. 크기・오프셋은 섹터 크기의 정수배, 버퍼 주소도 물리 섹터 경계에 정렬. 그리고 NO_BUFFERING이라도 메타데이터는 계속 캐시됩니다(5.3절).31
- 맵 뷰와 캐시는 같은 데이터를 공유합니다. 메모리 매핑 파일과 일반적인 캐시 I/O는 일관되어 있으며, 맵의 영속화는
FlushViewOfFile+FlushFileBuffers의 2단계입니다(6장).45 - 캐시에 올라온 동기 읽기 쓰기는 IRP조차 만들지 않는 경우가 있습니다. 패스트 I/O라는 지름길이 캐시 관리자로 직행합니다──제1회 숙제의 답입니다(7장).6
2. 캐시의 정체 ── 파일은 메모리에 매핑된다
2.1. 256KB 슬롯과 메모리 복사
Windows의 파일 캐시를 ‘디스크 블록의 그릇’이라고 생각하면 여러 동작을 설명할 수 없게 됩니다. 올바른 그림은 이렇습니다──캐시 관리자는 파일의 256KB 단위 구간을 시스템 주소 공간의 ‘슬롯’에 매핑하고, 캐시가 유효한 읽기 쓰기는 그 슬롯과 앱 버퍼 사이의 메모리 복사로 실행됩니다.1
flowchart TB
subgraph U["앱 - 사용자 모드"]
BUF["앱의 버퍼<br/>ReadFile/WriteFile에 전달한 영역"]
end
subgraph S["시스템 주소 공간"]
SLOT["시스템 파일 캐시<br/>파일의 256KB 구간을 매핑한 슬롯"]
end
DISK[("디스크상의 파일")]
BUF <-->|"ReadFile/WriteFile =<br/>슬롯과의 사이의 메모리 복사"| SLOT
SLOT <-->|"최초 접근 시 읽기와<br/>후속 반영은 페이지 단위"| DISK
그림 1: 캐시 유효 I/O의 실상. 앱에서 본 ‘파일의 읽기 쓰기’는 대부분의 경우 그저 메모리 복사
오해하기 쉬운 점을 하나 짚습니다. 256KB는 뷰(맵)의 단위이지, 디스크 I/O가 항상 256KB 단위로 이루어진다는 뜻은 아닙니다. 슬롯 내의 페이지는 필요에 따라 읽혀 들어오고, 실제로 디스크로 향하는 I/O의 양은 요청 크기나 접근 패턴에 따라 달라집니다. 처음 읽는 구간이라면 채우기 위한 디스크 I/O가 발생합니다(여기서 제1회의 IRP가 하위 스토리지 스택으로 날아갑니다). 이미 캐시에 있다면 읽기는 복사만으로 완료됩니다. 제2회 5장에서 본 ‘캐시 히트라면 비동기로 발행해도 동기 완료된다’는 것은, 이 ‘즉답할 수 있는 요청은 그 자리에서 완료시킨다’는 동작의 표출이었습니다. 반대로 캐시가 유효한 채로 페이지가 메모리에 없는 경우, 페이지 폴트 처리에 비동기 메커니즘이 없기 때문에 비동기 읽기가 동기적으로 처리되는 경우가 있다──는 함정도 제2회에서 본 대로입니다.7
2.2. ‘여유 메모리가 줄었다’의 정체
캐시를 사용할지 여부나 선행 읽기 상태는 여는 방식마다(파일 오브젝트 단위) 관리되지만1, 캐시된 데이터 본체는 파일(스트림) 단위로 공유됩니다. 같은 파일을 몇 번을 열어도 별도의 캐시가 생기는 것이 아니라, 어느 핸들에서나 같은 캐시 내용이 보입니다(6장의 일관성의 토대입니다). 캐시는 Windows가 동작하는 동안 계속 캐시 관리자의 지휘 아래 동작합니다.1 큰 파일을 복사하거나 대량으로 읽기 쓰기를 하면, 비어 있는 물리 메모리는 점점 캐시로 전용됩니다. 작업 관리자의 여유 메모리가 줄어든 것처럼 보여도, 그 대부분은 ‘앱이 요구하면 신속히 넘겨주는, 가치 있게 쓰이고 있는 대기 중인 메모리’입니다. 메모리 부족 진단에서 이 구별을 그르치지 않기 위해, 관측 실무는 「.NET에서 GC 대기와 메모리 누수 구별하기」에서도 다뤘습니다.
이 동작은 화면으로 확인할 수 있습니다. 작업 관리자 > 성능 > 메모리를 열면, 아래쪽의 ‘메모리 구성’ 막대가 사용 중 / 수정됨 / 대기 / 사용 가능으로 나뉘어 있습니다. 파일 캐시의 대부분은 이 대기에 들어가며, 오른쪽 목록에서는 ‘캐시됨’으로 합산되어 있습니다. 더 자세히 보고 싶다면 리소스 모니터 > 메모리 탭에서 같은 구분이 용량과 함께 나열됩니다. 수 GB의 파일 하나를 복사한 뒤 다시 보면, 대기는 늘고 사용 가능은 줄어드는데 ‘사용 중’은 거의 변하지 않는──즉 ‘메모리가 잠식된’ 것이 아니라 ‘비어 있던 메모리가 캐시에 사용된’ 것임을 눈으로 확인할 수 있습니다.
3. 선행 읽기 ── 읽기의 투기적 수행
캐시 관리자는 과거의 접근 패턴으로부터 다음에 읽힐 것 같은 구간을 미리 앞서 읽어 둡니다(선행 읽기, read-ahead). 순서대로 읽고 있는 파일이라면, 앱이 요청하기 전에 이어지는 데이터가 이미 캐시에 올라와 있습니다──이것이 순차 읽기가 빠른 비결입니다. 선행 읽기의 양은 고정이 아니라, 감지한 패턴이나 요청 크기에 따라 달라집니다.
flowchart LR
A["앱의 읽기 요청 이력<br/>처음부터 순서대로 읽고 있음"]
D{"캐시 관리자가<br/>패턴을 감지"}
R["선행 읽기 - 이어지는 구간을<br/>요청되기 전에 미리 읽어 둠<br/>양은 패턴과 요청 크기에 따라 가변"]
H1["힌트 FILE_FLAG_SEQUENTIAL_SCAN<br/>= 선행 읽기를 적극적으로"]
H2["힌트 FILE_FLAG_RANDOM_ACCESS<br/>= 선행 읽기는 낭비가 되므로 억제"]
A --> D
D --> R
H1 -.-> D
H2 -.-> D
그림 2: 선행 읽기. 접근 패턴의 감지에 더해, CreateFile의 플래그로 힌트를 줄 수 있다
제1회의 대응표에 실은 FileOptions.SequentialScan / RandomAccess는 이 선행 읽기 엔진에 주는 힌트입니다. ‘전부 훑는’ 배치 처리에는 전자를, 인덱스를 따라가는 듯한 접근에는 후자──앱만이 아는 미래를 OS에게 알려주기 위한 플래그라고 생각하면 사용할 곳이 명확해집니다.
4. 지연 쓰기 ── WriteFile의 ‘성공’이 뜻하는 것
4.1. lazy writer는 매초 찾아온다
쓰기 쪽은 라이트백 캐시입니다. WriteFile은 데이터를 슬롯에 복사한 시점에 성공을 반환하고, 디스크로의 반영은 뒤로 미뤄집니다. 이 ‘늦춰서 쓰는’ 방침이 지연 쓰기(lazy writing)입니다.1
반영을 담당하는 것이 캐시 관리자가 매초 기동하는 lazy writer입니다. 최근 플러시되지 않은 페이지의 8분의 1을 큐에 담아 써 내보내고, 써야 할 데이터가 많으면 더 추가로 담습니다. 참고로 FILE_ATTRIBUTE_TEMPORARY 속성을 붙여 만든 임시 파일은 lazy writer의 플러시 대상에서 제외됩니다──곧 삭제될 것을 쓰는 것은 낭비이기 때문입니다.1 다만 이것은 속성에 의한 힌트일 뿐이며, 메모리가 핍박하면 다시 쓰이는 경우도 있고, ‘이름만 임시처럼 보이는’ 파일에는 적용되지 않습니다.
sequenceDiagram
participant App as 앱
participant C as 시스템 캐시
participant LW as lazy writer(매초 기동)
participant D as 디스크
App->>C: WriteFile(데이터)
Note over C: 슬롯으로 복사하고<br/>페이지를 더티(미기록)로 표시
C-->>App: 즉시 TRUE가 반환됨
Note over App,C: 여기서부터 반영까지가 '위험한 창'<br/>전원 차단・OS 크래시라면 이 데이터는 사라짐
LW->>C: 더티 페이지의 1/8을 선택
LW->>D: 모아서 반영
Note over D: 여기서 비로소 영속화됨
그림 3: 지연 쓰기. WriteFile의 성공은 ‘OS에 인계했다’는 것이지 ‘영속화되었다’는 것이 아니다
4.2. 무슨 일이 일어나면, 어디까지 사라지는가
‘위험한 창’의 의미를 정확히 해 둡니다. 장애의 종류에 따라 운명이 갈립니다.
flowchart TB
W["WriteFile 성공 직후의 데이터<br/>캐시상의 더티 페이지"]
Q{"무슨 일이 일어났는가"}
A1["앱 프로세스가<br/>크래시/강제 종료"]
A2["OS 자체가 정지<br/>전원 차단・블루스크린"]
S["데이터는 남는다<br/>캐시는 OS의 것이므로<br/>lazy writer가 예정대로 반영한다"]
L["더티 페이지는 사라진다<br/>디스크에 도달한 만큼만 남는다"]
W --> Q
Q --> A1
Q --> A2
A1 --> S
A2 --> L
그림 4: 장애의 종류와 생존의 갈림길. 캐시는 ‘프로세스의 소유물’이 아니라 ‘OS의 소유물’
- 앱이 죽어도 데이터는 사라지지 않습니다. 캐시로의 복사가 끝난 시점에서 데이터의 소유자는 OS입니다. ‘저장 직후에 앱이 죽었는데 파일은 무사했다’는 것은 이 덕분입니다.
- OS 자체가 죽으면 더티한 부분은 사라집니다. 플러시 빈도는 성능과 신뢰성의 트레이드오프로 조정되고 있으며, ‘갑작스러운 전원 손실이 일어나면 캐시된 데이터는 사라진다’고 문서에도 명기되어 있습니다.1
즉 업무 앱 설계에서의 질문은 ‘이 데이터는 전원 차단의 순간에 사라져도 괜찮은가‘입니다. 로그의 몇 초 분량이라면 허용할 수 있을지도 모릅니다. 수주 데이터의 확정 레코드라면 허용할 수 없을 것입니다. 허용할 수 없는 것에만 다음 장의 도구를 사용합니다.
5. ‘확실히 썼다’를 만드는 도구 상자
5.1. FlushFileBuffers ── 지금 당장 다 써라
FlushFileBuffers는 지정한 파일의 버퍼링된 데이터를 디바이스에 남김없이 씁니다. 파일 시스템의 메타데이터는 항상 캐시되기 때문에, 메타데이터까지 확실히 전달하려면 플러시(또는 WRITE_THROUGH)가 필요하다는 점도 짚어 둘 대목입니다.12 .NET에서는 FileStream.Flush(true)가 이에 해당합니다(Flush()만으로는 .NET 내부의 버퍼를 OS로 넘길 뿐이며, OS의 캐시는 그대로입니다).8
다만 공식 문서는 명확히 못을 박고 있습니다──쓸 때마다 매번 호출하는 것은 비효율적입니다. 수많은 쓰기에서 매번의 영속화가 필요하다면, 후술할 NO_BUFFERING+WRITE_THROUGH를 사용해야 한다고 말입니다.2
5.2. FILE_FLAG_WRITE_THROUGH ── 지연만을 제거한다
FILE_FLAG_WRITE_THROUGH로 열면, 쓰기는 캐시에도 쓰이면서 lazy writer를 기다리지 않고 즉시 디스크에도 쓰입니다.1 읽기는 계속 캐시의 이점을 누릴 수 있다는 점이 포인트이며, ‘읽기는 빠른 채로, 쓰기의 지연만 없애고 싶다’에 대한 정직한 답입니다.
5.3. FILE_FLAG_NO_BUFFERING ── 캐시를 거치지 않는다
FILE_FLAG_NO_BUFFERING은 읽기 쓰기에서 시스템 캐시 자체를 배제합니다. 모든 읽기 쓰기가 캐시를 경유하지 않고 매번 디스크 장치로의 I/O가 됩니다.1 다만 우회할 수 있는 것은 Windows의 시스템 캐시까지이며, 그림 5와 같이 장치 내부의 쓰기 캐시는 별개의 단계입니다. 전원 차단 내성까지 요구한다면 WRITE_THROUGH의 병용이나 FlushFileBuffers가 여전히 필요합니다. 대량 데이터의 일괄 전송이나, 자체적으로 버퍼를 관리하는 데이터베이스 엔진을 위한 도구이지만, 엄격한 약속이 따릅니다.3
- 읽기 쓰기의 크기와 파일 오프셋은 볼륨의 섹터 크기의 정수배일 것(512바이트 섹터라면 512・1024・1536…).
- 버퍼의 주소도 물리 섹터 크기에 정렬되어 있을 것(4096바이트 물리 섹터의 ‘Advanced Format’ 디스크에 대한 배려도 필요).
- 그래도 메타데이터는 계속 캐시되므로, 완전한 영속화에는 WRITE_THROUGH의 병용이나
FlushFileBuffers가 필요합니다.12
이 ‘약속’은 플래그만 붙이면 될 것이라 생각한 사람이 처음으로 걸려 넘어지는 지점입니다. 정렬을 지키지 않은 채 읽기 쓰기를 하면 ERROR_INVALID_PARAMETER(87)로 실패합니다. 지켜야 할 세 가지 항목을 정리합니다.3
| 맞춰야 할 것 | 조건 | 어떻게 충족할 것인가 |
|---|---|---|
| 읽기 쓰기의 크기 | 볼륨의 섹터 크기의 정수배 | GetDiskFreeSpace의 lpBytesPerSector를 얻어 그 배수로 반올림한다 |
| 파일 오프셋 | 위와 같음(OVERLAPPED의 Offset으로 지정하는 경우도 동일) |
섹터 크기의 배수만큼씩 진행한다 |
| 버퍼의 주소 | 물리 섹터 크기에 정렬 | VirtualAlloc으로 확보한다(페이지 경계=보통 4096바이트에 정렬된 영역이 반환됨) |
세 번째가 특히 간과되기 쉽습니다. malloc이나 new, C#의 배열이 반환하는 주소에는 섹터 경계로의 정렬이 보장되지 않습니다. 페이지 경계에 확보할 수 있는 VirtualAlloc을 사용하면, 물리 섹터 4096바이트인 ‘Advanced Format’ 디스크의 요건도 동시에 충족할 수 있습니다. 최소한의 형태는 다음과 같습니다.
// C++ / Win32. 에러 처리는 최소한으로 되어 있습니다
DWORD sectorsPerCluster = 0, bytesPerSector = 0, freeClusters = 0, totalClusters = 0;
if (!GetDiskFreeSpaceW(L"C:\\", §orsPerCluster, &bytesPerSector,
&freeClusters, &totalClusters))
{
return GetLastError();
}
// 읽기 쓰기의 단위를 섹터 크기의 정수배로 한다(여기서는 1MiB 상당)
const DWORD chunk = (1024 * 1024 / bytesPerSector) * bytesPerSector;
// 버퍼는 페이지 경계에 정렬된 영역을 확보한다(malloc/new로는 보장되지 않음)
BYTE* buffer = static_cast<BYTE*>(
VirtualAlloc(nullptr, chunk, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
if (buffer == nullptr) { return GetLastError(); }
HANDLE h = CreateFileW(L"C:\\temp\\big.bin", GENERIC_READ, FILE_SHARE_READ, nullptr,
OPEN_EXISTING, FILE_FLAG_NO_BUFFERING, nullptr);
if (h == INVALID_HANDLE_VALUE)
{
// GetLastError는 '직전의 Win32 호출'의 결과를 반환한다. 먼저 VirtualFree를
// 호출하면, CreateFileW의 실패 이유(액세스 거부・경로 없음 등)가
// 뒷정리의 결과로 덮어써져, 원인을 알 수 없는 코드만 반환된다
const DWORD err = GetLastError();
VirtualFree(buffer, 0, MEM_RELEASE);
return err;
}
// 매번 chunk 바이트 단위로 진행하므로 크기도 오프셋도 정렬이 유지된다
DWORD read = 0;
while (ReadFile(h, buffer, chunk, &read, nullptr) && read > 0)
{
// buffer의 앞쪽 read 바이트를 처리한다
// (파일 끝에서는 read < chunk가 된다. 이것은 정상)
}
CloseHandle(h);
VirtualFree(buffer, 0, MEM_RELEASE);
참고로 .NET의 FileOptions에는 FILE_FLAG_NO_BUFFERING에 대응하는 값이 없습니다. 아무래도 필요하다면 CreateFile을 직접 호출하게 되는데, 그 경우에도 위의 정렬 요건은 스스로 지켜야 합니다. ‘빠르게 하고 싶으니까 NO_BUFFERING’이 아니라, ‘자체적으로 버퍼를 관리하니까 NO_BUFFERING’이라는 순서로 검토하십시오.
5.4. 구분 사용 정리
flowchart TB
A["앱의 버퍼"]
B["시스템 파일 캐시<br/>더티 페이지"]
C["디스크 장치 내부의 캐시"]
D[("비휘발성 기록 매체")]
A -->|"기본 WriteFile: 여기까지 오면 성공이 반환됨"| B
B -->|"lazy writer(매초) / WRITE_THROUGH(즉시)"| C
C -->|"장치의 타이밍 /<br/>FlushFileBuffers는 완전한 기록을 요구"| D
A -.->|"NO_BUFFERING은 캐시를 건너뛰고 직행"| C
그림 5: 데이터의 계층과 각 도구가 어디까지 밀어 넣는가. ‘디스크 장치 내부의 캐시’라는 마지막 한 단에도 주의
| 방법 | 무슨 일이 일어나는가 | 적합한 상황 |
|---|---|---|
| 기본(캐시 유효) | 캐시 복사로 완료. 반영은 lazy writer | 대부분의 파일 I/O |
FlushFileBuffers / Flush(true) |
그 시점의 데이터+메타데이터를 완전히 씀 | 마디에서의 확정(트랜잭션 커밋 등) |
FILE_FLAG_WRITE_THROUGH |
쓸 때마다 즉시 디스크로(읽기는 캐시) | 잃어서는 안 되는 쓰기가 이어지는 로그・저널 |
FILE_FLAG_NO_BUFFERING(+WRITE_THROUGH) |
캐시 비경유. 정렬 요건 있음 | 자체 버퍼 관리・대량 일괄 I/O |
선택하는 순서는, ‘전원 차단으로 몇 건까지 잃어도 되는가’를 먼저 정하고, 다음으로 ‘그것을 위해 얼마나 느려져도 되는가’를 확인하는, 두 단계입니다. 표를 위에서부터 훑어보는 것이 아니라 이 분기를 따라가 보십시오.
flowchart TB
S["이 데이터를 쓰려고 한다"]
Q1{"전원 차단・블루스크린의 순간에<br/>사라져도 허용되는가"}
A0["기본값 그대로 - 캐시 유효<br/>가장 빠름. 대부분의 I/O는 여기"]
Q2{"잃을 수 없는 것은<br/>'마디'인가 '건별'인가"}
A1["마디에서 FlushFileBuffers<br/>.NET이라면 Flush true 지정<br/>비용은 마디에서의 대기뿐"]
Q3{"자체적으로 버퍼를 관리하고<br/>5.3의 정렬 요건을 충족할 수 있는가"}
A2["FILE_FLAG_WRITE_THROUGH<br/>쓸 때마다 즉시 디스크로<br/>읽기는 캐시 그대로 빠름"]
A3["FILE_FLAG_NO_BUFFERING<br/>+ FILE_FLAG_WRITE_THROUGH<br/>공식이 제시하는 '빈번한 영속화'의 형태"]
S --> Q1
Q1 -->|"허용됨<br/>최근 몇 초의 로그 등"| A0
Q1 -->|"허용되지 않음"| Q2
Q2 -->|"마디<br/>거래의 확정 등"| A1
Q2 -->|"건별"| Q3
Q3 -->|"아니오 - 일반적인 앱"| A2
Q3 -->|"예 - DB 엔진 등"| A3
그림 6: 도구를 고르는 법. 1단계 분기가 신뢰성 요구, 2단계가 감당할 수 있는 비용. ‘건별로 FlushFileBuffers‘라는 길이 없는 것은, 5.1에서 본 대로 공식 문서가 이를 비효율적이라고 밝히고 있기 때문입니다
실무 패턴도 세 가지만 꼽아 두겠습니다.
- ‘임시 파일에 쓰기 → 플러시 → 이름 바꾸기’가 깨지다 만 파일을 남기지 않는 정석입니다. 내용을 완전히 쓴 뒤에 이름으로 확정한다──이 원자적인 전달은 「파일 연계의 배타 제어 기초 지식」에서 자세히 다뤘습니다.
- 데이터베이스에 맡기는 것도 훌륭한 설계입니다. SQLite가 WAL과 플러시로 내구성을 갖춘 이야기는 「C#에서 SQLite를 업무 앱에 쓰는 법」을 참조하십시오. ‘스스로 플러시 전략을 짜지 않는다’는 선택지는 항상 있습니다.
- 벤치마크는 캐시를 의심하라. ‘읽기가 너무 빠르다’는 측정은 대개 두 번째 이후의 캐시 히트를 재고 있는 것입니다. 측정의 작법은 「Windows에서 프로그램의 버전별 속도를 올바르게 비교하는 방법」에 정리해 두었습니다.
덧붙여 그림 5의 마지막 한 단──디스크 장치 내부의 캐시도 잊지 마십시오. FlushFileBuffers는 거기까지 포함한 완전한 기록을 요구하지만, USB 메모리나 외장 디스크에서는 장치 측의 쓰기 캐시 정책(‘빠른 제거’와 ‘고성능’)이 얽혀 있습니다. 이동식 디바이스의 취급은 「Windows 앱에서 USB 기기를 다루는 방법」도 참조하십시오.
6. 메모리 매핑 파일과의 일관성
제1회에서 ‘캐시의 실체는 파일 매핑’이라는 말을 듣고, 이렇게 생각한 분이 있을 것입니다──그렇다면 직접 MapViewOfFile한 뷰와 ReadFile/WriteFile의 캐시는 서로 충돌하지 않는가?
충돌하지 않습니다. 같은 구조 위에 올라가 있기 때문입니다. 파일 매핑 오브젝트는 파일에 뒷받침되어 있고, 페이지의 축출은 파일로의 재기록으로서 이루어집니다. 같은 로컬 파일에 대해 여러 프로세스가 뷰를 만들어도 보이는 내용은 코히런트(일관)합니다.4
flowchart TB
subgraph P1["프로세스 A의 주소 공간"]
V1["MapViewOfFile의 뷰"]
end
subgraph SYS["시스템 주소 공간"]
SC["캐시 관리자의 뷰<br/>ReadFile/WriteFile이 사용하는 슬롯"]
end
PAGES["같은 물리 페이지 그룹<br/>파일에 뒷받침된 메모리"]
DISK[("디스크상의 파일")]
V1 --> PAGES
SC --> PAGES
PAGES --> DISK
NB["FILE_FLAG_NO_BUFFERING의 I/O는<br/>이 공유의 범위 밖 - 직접 디스크로"]
NB -.-> DISK
그림 7: 맵 뷰도 캐시도 같은 ‘파일에 뒷받침된 페이지’를 보고 있다. 범위 밖에 있는 것은 NO_BUFFERING뿐
주의점은 두 가지입니다.
FILE_FLAG_NO_BUFFERING의 I/O는 이 일관성의 범위 밖입니다. 캐시를 경유하지 않는 읽기 쓰기와 맵 뷰/캐시 경유의 내용은 서로 맞춰지지 않습니다. 섞어서 사용한다면 직접 정합성을 맞출 필요가 있습니다.- 맵 뷰의 영속화는 2단계 구조입니다.
FlushViewOfFile은 범위 내 더티 페이지의 기록을 시작하지만, 메타데이터는 쓰지 않고, 디스크 장치의 캐시로부터의 물리적 기록도 기다리지 않습니다. 확실히 전달하려면FlushViewOfFile다음에FlushFileBuffers를 호출합니다.5
공유 메모리로서의 파일 매핑의 실무(이름 있는 공유, 동기화, 사고 패턴)는 「공유 메모리의 함정과 실무 베스트 프랙티스」에서 다루고 있습니다.
7. 패스트 I/O ── 제1회 숙제 회수
먼저 두 줄로. 패스트 I/O란 캐시에 올라와 있는 파일에 대한 동기 읽기 쓰기를 위해 마련된 지름길로, IRP(I/O 요청 패킷. 커널이 드라이버에 전달하는 요청을 담는 그릇)를 조립하지 않고 캐시와 직접 데이터를 주고받습니다. Procmon의 Operation 열에 IRP_MJ_READ와 FASTIO_READ가 섞여 나오는 것은, 같은 ‘읽기’가 일반 경로와 지름길 중 어느 쪽을 지났는지의 차이입니다.
제1회 5.2절에서 ‘모든 I/O가 IRP가 되는 것은 아니다’라고 썼습니다. 답을 맞춰 볼 차례입니다.
캐시에 올라와 있는 파일의 읽기 쓰기는 IRP를 조립해 디바이스 스택을 흘려보낼 것도 없이, 캐시와의 메모리 복사로 끝난다는 것을 알고 있습니다. 그래서 Windows는 캐시된 파일에 대한 동기 I/O를 위해 패스트 I/O라는 지름길을 마련해 두고 있습니다──IRP를 만들지 않고, 파일 시스템의 ‘패스트 I/O 진입점’을 직접 호출하여 캐시 관리자로부터 직접 복사하는 경로입니다.6 패스트 I/O로 처리할 수 없는 경우(캐시에 없다, 락이 얽혀 있다, 필터가 끼어든다 등)에는 일반적인 IRP 경로로 되돌아갑니다. 참고로 이것은 동기 요청을 위한 고속 경로이지 ‘캐시 히트=항상 패스트 I/O’는 아닙니다. 비동기(FILE_FLAG_OVERLAPPED) 핸들의 작업은 캐시에서 그 자리에서 완료되는 경우(제2회 5장)에도 IRP 경로로 처리되는 일이 있습니다.
flowchart TB
REQ["캐시 유효 핸들에 대한 동기적 읽기 쓰기"]
Q{"패스트 I/O로 처리할 수 있는가<br/>캐시에 올라와 있는 등"}
FAST["패스트 I/O<br/>IRP를 만들지 않고 캐시와 직접 복사<br/>Procmon에서는 FASTIO_로 표시"]
IRP["일반 경로<br/>IRP를 조립해 디바이스 스택으로<br/>제1회 그림 6의 세계"]
REQ --> Q
Q -->|되는 경우| FAST
Q -->|안 되는 경우| IRP
그림 8: 패스트 I/O의 분기. Procmon에서 FASTIO_READ와 IRP_MJ_READ가 섞여 보이는 것은 이 때문
제1회 7장의 Procmon 관찰에서 FASTIO_ 행이 섞여 있던 이유를, 이것으로 설명할 수 있습니다. 캐시 히트의 읽기는 IRP조차 사치품인 것입니다. 이 경로의 존재는 제6회에서 다룰 필터 드라이버에도 영향을 미칩니다(미니필터는 패스트 I/O에도 끼어들 수 있게 되어 있습니다).
8. 정리
- Windows의 파일 캐시는 라이트백이며, 실체는 파일의 256KB 구간의 매핑입니다. 캐시 유효 읽기 쓰기는 슬롯과의 메모리 복사가 됩니다.1
- 읽기는 선행 읽기가 예측하며,
SequentialScan/RandomAccess는 그 힌트입니다.1 - 쓰기는 매초의 lazy writer가 뒤따릅니다. 앱이 죽어도 데이터는 남고, OS 자체가 죽으면 더티한 부분만 사라집니다. 설계의 질문은 ‘이 데이터는 전원 차단의 순간에 사라져도 되는가’입니다.1
- 확실하게 쓰는 도구는
FlushFileBuffers(마디에서의 확정)/WRITE_THROUGH(쓸 때마다)/NO_BUFFERING(캐시 비경유+정렬 요건)입니다. 매번 플러시하는 것은 비효율적이며, 빈번한 영속화에는 NO_BUFFERING+WRITE_THROUGH 병용이 공식 권장입니다. 메타데이터가 항상 캐시된다는 점에도 주의하십시오.231 - 맵 뷰와 캐시는 같은 페이지를 공유하며 일관됩니다. 범위 밖은 NO_BUFFERING뿐입니다. 맵의 영속화는
FlushViewOfFile+FlushFileBuffers의 2단계입니다.45 - 캐시 히트의 동기 읽기 쓰기는 패스트 I/O로 IRP조차 생략됩니다. 제1회의 Procmon에서 본
FASTIO_의 정체입니다.6
이어지는 이야기는 제5회 「NTFS의 내부 구조 ── MFT로 이해하는 파일 시스템」입니다. 이번 회까지는 파일을 ‘오프셋과 바이트 열’로 다뤄 왔지만, 그 이면에서 NTFS가 데이터를 어떻게 배치하는지──MFT, 복수 데이터 스트림, 저널, 하드 링크──디스크 위의 정적인 구조로 내려갑니다.
관련 글
- Windows I/O의 심층(제1회) ── 모든 읽기·쓰기는 IRP가 된다: I/O 시스템의 전체상
- Windows I/O의 심층(제2회) ── 동기 I/O와 비동기 I/O: OVERLAPPED의 진짜 의미
- Windows I/O의 심층(제3회) ── I/O 완료 포트(IOCP)와 .NET 스레드 풀: async/await의 지하실
- 파일 연계의 배타 제어 기초 지식 - 파일 락과 원자적 claim의 베스트 프랙티스
- 공유 메모리를 사용할 때의 함정과 베스트 프랙티스 - 동기, 가시성, 수명, ABI, 보안을 먼저 정리
- C#에서 SQLite를 업무 앱에 쓰는 법 ── WAL 모드・배타 제어・손상 대책・EF Core와의 구분
- Windows에서 서로 다른 버전의 프로그램의 실행 속도를 어떻게 비교할까. 전원 모드 등 환경을 맞추는 방법부터 한계까지
- Windows 앱에서 USB 기기를 다루는 방법 ── 가상 COM・HID・WinUSB・전용 SDK를 고르는 법
관련 상담 영역
합동회사 코무라소프트에서는 ‘저장했을 텐데 데이터가 사라졌다’, ‘파일 쓰기가 느리다/너무 빨라서 의심스럽다’ 같은 Windows 업무 앱의 파일 I/O 설계・결함 조사를 다루고 있습니다.
참고 링크
-
Microsoft Learn, File Caching. Windows가 기본적으로 파일 데이터를 캐시하며, 읽기는 시스템 파일 캐시에서 이루어지고 쓰기도 캐시로 이루어지는 라이트백 캐시라는 점, 캐시가 파일 오브젝트 단위로 관리되며 캐시 관리자의 지휘 아래 동작한다는 점, 디스크로의 쓰기를 늦춰 캐시에 유지하는 방침이 지연 쓰기(lazy writing)라고 불린다는 점, 파일 읽기 시 256KB 구간이 시스템 주소 공간의 256KB 슬롯에 읽혀 들어오고 사용자 프로세스가 그 슬롯과의 사이에서 데이터를 복사한다는 점, 캐시 관리자가 매초 lazy writer를 기동하여 최근 플러시되지 않은 페이지의 8분의 1을 디스크 쓰기 큐에 담고 필요하면 더 추가로 담는다는 점, 임시 파일은 플러시되지 않는다는 점, 전원 손실 같은 갑작스러운 시스템 장애가 일어나면 쓰이지 않은 캐시 데이터는 사라진다는 점, FILE_FLAG_NO_BUFFERING으로 캐시를 무효화해도 파일 메타데이터는 캐시될 수 있다는 점, FILE_FLAG_WRITE_THROUGH에서는 데이터가 캐시에도 쓰이면서 lazy writer의 지연 없이 즉시 디스크에 쓰인다는 점, 파일 시스템 메타데이터는 항상 캐시되므로 메타데이터의 영속화에는 플러시나 FILE_FLAG_WRITE_THROUGH가 필요하다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19
-
Microsoft Learn, FlushFileBuffers function. WriteFile이 통상적으로 내부 버퍼에 쓰고 OS가 주기적으로 디스크에 써 내보낸다는 점, FlushFileBuffers가 지정한 파일의 버퍼링된 정보를 모두 디바이스로 써 내보낸다는 점, 수많은 쓰기마다 매번 호출하는 것은 비효율적이며 빈번한 쓰기에서 중요한 데이터의 영속화가 필요한 앱은 FILE_FLAG_NO_BUFFERING과 FILE_FLAG_WRITE_THROUGH에 의한 비버퍼 I/O를 사용해야 한다는 점, 볼륨 핸들에 대해 호출하면(관리자 권한으로) 볼륨상의 모든 열린 파일을 플러시할 수 있다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, File Buffering. FILE_FLAG_NO_BUFFERING으로 연 파일에 대한 접근 요건으로서, 읽기 쓰기의 크기와 파일 오프셋(OVERLAPPED로 지정하는 경우를 포함)이 볼륨의 섹터 크기의 정수배여야 한다는 점, 읽기 쓰기 버퍼의 주소가 물리 섹터 크기에 정렬되어 있어야 한다는 점, 물리 섹터 4,096바이트의 Advanced Format 디바이스에 대한 고려가 필요하다는 점에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, File Mapping. 파일 매핑 오브젝트가 디스크상의 파일에 뒷받침되어 있으며, 페이지의 스왑 아웃이 변경 내용의 파일로의 쓰기로서 이루어진다는 점, 여러 프로세스가 같은 파일 매핑 오브젝트로부터 로컬 파일의 뷰를 만든 경우에 데이터가 코히런트(디스크상의 파일과 동일한 내용)하다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, FlushViewOfFile function. FlushViewOfFile이 맵 뷰의 범위 내 더티 페이지를 디스크에 쓰기 시작한다는 점, 이 함수가 파일 메타데이터를 플러시하지 않으며 하드웨어 디스크 캐시로부터의 물리적 쓰기 완료도 기다리지 않는다는 점, 더티 페이지와 메타데이터를 모두 물리적으로 완전히 쓰려면 FlushViewOfFile 다음에 FlushFileBuffers를 호출해야 한다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, IRPs Are Different From Fast I/O. 패스트 I/O가 IRP를 생성하지 않고 파일 시스템이나 캐시 관리자의 진입점을 직접 호출하는, 캐시된 파일을 위한 동기 I/O의 고속 경로라는 점, 캐시에서 사용자 버퍼로(또는 그 반대로) 데이터가 직접 전송된다는 점, 패스트 I/O로 처리할 수 없는 경우에 IRP 기반의 일반 경로가 사용된다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. 데이터가 캐시에 있는 경우 요청이 그 자리에서 완료되어 TRUE가 반환된다는 점, Windows의 캐시가 파일 매핑으로 구현되어 있으며 페이지가 없는 경우의 비동기 페이지 폴트 메커니즘이 없기 때문에 캐시가 유효한 비동기 읽기가 동기적으로 처리되는 경우가 있다는 점에 대해. ↩
-
Microsoft Learn, FileStream.Flush method (.NET). Flush()가 스트림의 내부 버퍼를 OS로 써 내보낸다는 점, Flush(true)를 지정하면 그에 더해 모든 중간 파일 버퍼(OS의 버퍼)도 플러시된다는 점에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows I/O의 심층(제2회) ── 동기 I/O와 비동기 I/O: OVERLAPPED의 진짜 의미
Windows의 동기 I/O와 비동기 I/O(오버랩 I/O)를 그림으로 설명하는 연재의 제2회입니다. FILE_FLAG_OVERLAPPED의 의미, 완료 통지의 4가지 방식, 비동기인데 동기 완료되는 조건, 취소의 작법, .NET과의 대응까지 정...
Windows I/O의 심층(제1회) ── 모든 읽기·쓰기는 IRP가 된다: I/O 시스템의 전체상
Windows의 I/O 시스템을 밑바닥부터 해설하는 연재의 제1회입니다. 오브젝트 관리자의 이름공간, 드라이버·디바이스·파일이라는 3가지 오브젝트, IRP의 라이프사이클, CloseHandle의 이면까지를 그림으로 정리합니다.
Windows I/O의 심층(제5회) ── NTFS의 내부 구조: MFT로 이해하는 파일 시스템
NTFS의 내부 구조를 그림으로 설명하는 연재의 제5회입니다. MFT와 파일 레코드, 복수 데이터 스트림(Zone.Identifier), 하드 링크와 8.3 이름, 리파스 포인트, 두 가지 저널, 스파스와 압축까지 개발자 시점에서 정리합니다.
Windows I/O의 심층(제3회) ── I/O 완료 포트(IOCP)와 .NET 스레드 풀: async/await의 지하실
I/O 완료 포트(IOCP)를 그림으로 설명하는 연재의 제3회입니다. 완료 큐와 스레드 수 제어를 일체화한 설계, 동시성 값과 LIFO 해제, .NET 스레드 풀과 async/await 계속(continuation)의 실행 스레드까지 정리합니다.
Windows I/O의 심층(제6회・최종회) ── 필터 드라이버와 미니필터: Procmon과 백신 검사가 I/O에 개입할 수 있는 이유
Windows의 필터 드라이버와 미니필터를 그림으로 설명하는 연재의 최종회입니다. 필터 매니저와 알티튜드, pre/post 콜백, Procmon과 백신이 모든 I/O를 검사할 수 있는 구조, '그 환경에서만 느리다'의 조사 절차까지 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- WriteFile이 성공을 반환한 시점에, 데이터는 디스크에 쓰여 있습니까?
- 기본적으로는 쓰여 있지 않습니다. Windows의 파일 캐시는 라이트백(write-back) 방식으로, WriteFile은 데이터를 시스템 파일 캐시에 복사한 시점에 성공을 반환합니다. 디스크로의 쓰기는 캐시 관리자가 매초 기동하는 지연 쓰기(lazy writer)가 나중에 수행합니다. 중요한 것은 장애 종류에 따른 차이입니다. 앱 프로세스가 크래시해도, 캐시에 들어간 데이터는 OS가 살아 있는 한 나중에 쓰이므로 사라지지 않습니다. 반면 OS 자체가 멈추는 장애(전원 차단, 블루스크린)에서는 아직 쓰이지 않은 더티(dirty)한 캐시가 사라집니다. 'WriteFile이 성공했다 = 영속화되었다'가 아니라 '성공했다 = OS에 인계했다'로 이해하는 것이 정확합니다.
- 확실하게 디스크에 쓰려면 어떻게 하면 됩니까?
- 세 가지 도구가 있습니다. 첫째는 FlushFileBuffers로, 해당 파일의 버퍼링된 데이터와 메타데이터를 디바이스에 완전히 씁니다(.NET이라면 FileStream.Flush(true)가 이에 해당합니다). 둘째는 FILE_FLAG_WRITE_THROUGH로, 쓸 때마다 캐시에 쓰면서 즉시 디스크에도 씁니다. 셋째는 FILE_FLAG_NO_BUFFERING으로, 캐시 자체를 경유하지 않습니다. 마이크로소프트 문서는 쓸 때마다 FlushFileBuffers를 호출하는 것은 비효율적이며, 빈번한 쓰기에서 확실한 영속화가 필요하다면 FILE_FLAG_NO_BUFFERING과 FILE_FLAG_WRITE_THROUGH를 병용해야 한다고 밝히고 있습니다. 어느 쪽이든 캐시의 이점을 포기하는 만큼 느려지므로, '전부에 붙이는' 것이 아니라 잃어서는 안 되는 데이터의 쓰기에 한정해 사용하는 것이 실무의 요령입니다.
- FILE_FLAG_WRITE_THROUGH와 FILE_FLAG_NO_BUFFERING은 어떻게 다릅니까?
- WRITE_THROUGH는 '캐시에는 쓰지만, 완료 전에 디스크에도 쓴다'입니다. 읽기는 계속 캐시의 이점을 누릴 수 있으며, 지연 쓰기의 지연만을 제거합니다. NO_BUFFERING은 '읽기 쓰기가 시스템 캐시를 경유하지 않는다'는 것으로, 읽기도 쓰기도 매번 디스크 장치로의 I/O가 됩니다(다만 우회하는 것은 Windows의 캐시까지이며, 장치 내부의 쓰기 캐시까지 건너뛰는 것은 아닙니다). 그 대신 엄격한 제약이 따릅니다. 읽기 쓰기의 크기와 파일 오프셋은 볼륨의 섹터 크기의 정수배여야 하며, 버퍼의 주소도 물리 섹터 경계에 정렬시켜야 합니다. 또한 NO_BUFFERING이라도 파일 시스템의 메타데이터는 계속 캐시되므로, 메타데이터까지 확실히 쓰려면 FlushFileBuffers나 WRITE_THROUGH의 병용이 필요합니다. 데이터베이스 엔진처럼 자체적으로 버퍼를 관리하는 소프트웨어가 사용하는 것이 전형적이며, 일반적인 앱에서는 먼저 WRITE_THROUGH나 FlushFileBuffers부터 검토하는 것이 순리입니다.
- 작업 관리자에서 여유 메모리가 적어 보이는 것은 파일 캐시 때문입니까?
- 대부분의 경우 그렇고, 게다가 정상적인 동작입니다. Windows는 비어 있는 물리 메모리를 적극적으로 파일 캐시로 사용하며, 큰 파일 복사나 대량의 읽기 쓰기를 하면 그만큼 캐시가 부풀어 메모리 사용량이 늘어난 것처럼 보입니다. 다만 캐시가 사용하는 페이지의 대부분은 앱이 메모리를 요구하면 비교적 신속하게 전용되는 종류이며, '메모리가 잠식되어 부족한' 상태와는 구별이 필요합니다. 메모리 부족을 의심할 때는 겉보기의 여유 용량뿐 아니라, 커밋된 메모리나 하드 폴트의 빈도 같은 지표를 보는 것이 실무적입니다.
- 메모리 매핑 파일과 ReadFile/WriteFile로 같은 파일을 다루면 내용이 어긋나지 않습니까?
- 일반적인 캐시 유효 I/O와의 사이에서는 어긋나지 않습니다. Windows의 캐시 자체가 파일 매핑으로 구현되어 있어, 같은 로컬 파일에 대한 맵 뷰와 캐시는 같은 데이터를 공유하므로 한쪽의 변경은 다른 쪽에서도 보입니다. 여러 프로세스가 같은 파일 매핑 오브젝트에서 뷰를 만든 경우도 데이터는 코히런트(일관)합니다. 다만 FILE_FLAG_NO_BUFFERING으로 연 핸들의 읽기 쓰기는 캐시를 경유하지 않으므로 이 일관성의 범위 밖이 됩니다. 또한 맵 뷰의 변경을 확실히 디스크에 쓰려면, FlushViewOfFile만으로는 메타데이터가 쓰이지 않고 하드웨어 캐시도 기다리지 않으므로, FlushViewOfFile 이후에 FlushFileBuffers를 호출할 필요가 있습니다.