Windows I/O의 심층(제4회) ── 캐시 관리자: WriteFile은 언제 디스크에 도달하는가

· 업데이트: · · Windows, Win32, I/O, 캐시, 커널, 파일 시스템, .NET, CSharp

수정 이력(4건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
기사 맨 앞에 「이 글의 지식 맵」을 추가했습니다. 본문에서 다루는 개념 사이의 관계를 한 장의 그림으로 볼 수 있습니다. 관계 전체 목록(근거 URL·확신도·확인일 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 모아 두었고, 기계가 읽을 수 있는 데이터를 JSON-LD와 Turtle로 공개합니다.
`FILE_FLAG_NO_BUFFERING` 예에서 `CreateFileW`가 실패했을 때 `VirtualFree`를 먼저 호출한 뒤 `GetLastError()`를 읽던 부분을 고쳤습니다. `GetLastError`가 반환하는 것은 직전 Win32 호출의 결과이므로, 액세스 거부나 경로 없음 같은 실제 실패 이유가 뒷정리 결과에 덮일 수 있습니다. 실패한 직후에 코드를 저장한 다음 해제하도록 바꿨습니다.
도구를 고르는 법을 2단 분기로 보여주는 흐름도를 추가했습니다(이에 따라 이후 그림 번호를 한 칸씩 내렸습니다). `FILE_FLAG_NO_BUFFERING`이 `ERROR_INVALID_PARAMETER`로 멈추는 정렬 요건 3가지 표와 최소 C++ 예, .NET의 `FileOptions`에 대응하는 값이 없음을 보태고, Fast I/O 장에 두 줄 요약을 넣었습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22175322)

아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.

Go Komura (2026). 「Windows I/O의 심층(제4회) ── 캐시 관리자: WriteFile은 언제 디스크에 도달하는가」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/windows-cache-manager-writefile-disk/

DOI(등록된 아카이브)
10.5281/zenodo.22175322
DOI(마지막 등록 버전)
10.5281/zenodo.22175323

WriteFile이 성공을 반환했습니다. 이제 데이터는 어디에 있을까요.

답은, 거의 확실히 아직 디스크에 없습니다. 메모리상 캐시에 복사되었을 뿐입니다. 그래서 「저장했는데, 전원이 나갔다 돌아오니 사라져 있었다」가 일어나고, 그래서 파일 복사 벤치마크가 물리적으로 있을 수 없는 속도를 찍으며, 그래서 데이터베이스는 꼬박꼬박 fsync를 호출합니다.

연재 「Windows I/O의 심층」의 제4회는, 이 사이에 서 있는 캐시 관리자입니다. 제2회에서 「캐시에 있으면 비동기 I/O도 동기로 완료된다」고 썼고, 제1회에서는 「IRP를 만들지 않는 지름길(Fast I/O)」을 숙제로 남겼습니다. 이번에는 그 복선을 모두 회수합니다.

1. 먼저 결론

  • Windows의 파일 캐시는 write-back 방식입니다. 읽기는 먼저 시스템 파일 캐시에서, 쓰기도 먼저 캐시로. 디스크로의 기록은 OS가 나중에 수행합니다.1
  • 캐시의 실체는 파일 매핑입니다. 캐시 관리자는 파일의 256KB 단위 구간을 시스템 주소 공간에 매핑하고, 읽기·쓰기는 「그 view와의 메모리 복사」가 됩니다(2장).1
  • 쓰기는 매초 동작하는 지연 쓰기(lazy writer)가 뒤에서 따라가며 디스크에 씁니다. 앱 크래시로는 데이터가 사라지지 않지만, 전원 차단이나 OS 크래시에서는 dirty 캐시가 사라집니다(4장).1
  • 「확실히 쓰는」 도구는 세 가지입니다. FlushFileBuffers(=.NET의 Flush(true)), FILE_FLAG_WRITE_THROUGH, FILE_FLAG_NO_BUFFERING. 잦은 쓰기에서 매번 flush하는 것은 비효율적이고, 공식 문서는 NO_BUFFERING+WRITE_THROUGH를 함께 쓰라고 합니다(5장).21
  • NO_BUFFERING에는 정렬 요건이 있습니다. 크기·offset은 섹터 크기의 정수배, 버퍼 주소도 물리 섹터 경계에 정렬. 그리고 NO_BUFFERING이어도 메타데이터는 계속 캐시됩니다(5.3절).31
  • map view와 캐시는 같은 데이터를 공유합니다. 메모리 맵 파일과 캐시를 쓰는 일반 I/O는 일관되며, map을 디스크에 남기려면 FlushViewOfFile+FlushFileBuffers의 두 단계입니다(6장).45
  • 캐시에 있는 동기 읽기·쓰기는 IRP조차 만들지 않는 경우가 있습니다. Fast I/O라는 지름길이 캐시 관리자로 바로 갑니다──제1회 숙제의 답입니다(7장).6

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 32건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 캐시의 정체 ── 파일은 메모리에 매핑된다

2.1. 256KB 슬롯과 메모리 복사

Windows의 파일 캐시를 「디스크 블록을 담는 그릇」으로 생각하면, 여러 동작을 설명할 수 없게 됩니다. 올바른 그림은 이렇습니다──캐시 관리자는 파일의 256KB 단위 구간을 시스템 주소 공간의 「슬롯」에 매핑하고, 캐시를 쓰는 읽기·쓰기는 그 슬롯과 앱 버퍼 사이의 메모리 복사로 실행됩니다.1

시스템 주소 공간앱(사용자 모드)ReadFile/WriteFile =슬롯과의 메모리 복사처음 접근할 때의 읽기와나중에 디스크에 다시 쓰는 것은 페이지 단위시스템 파일 캐시파일의 256KB 구간을 매핑한 슬롯앱의 버퍼(ReadFile/WriteFile에 넘긴 영역)디스크의 파일

그림 1: 캐시를 쓰는 I/O의 실제. 앱이 보는 「파일 읽기·쓰기」는 대부분 그저 메모리 복사입니다

오해하기 쉬운 점이 하나 있습니다. 256KB는 view(매핑)의 단위이지, 디스크 I/O가 항상 256KB 단위로 일어난다는 뜻이 아닙니다. 슬롯 안의 페이지는 필요할 때 읽어 들이고, 실제로 디스크로 가는 I/O의 양은 요청 크기와 접근 패턴에 따라 달라집니다. 처음 읽는 구간이라면 채우기 위해 디스크 I/O가 발생합니다(여기서 제1회의 IRP가 하위 스토리지 스택으로 갑니다). 이미 캐시에 있으면 읽기는 복사만으로 끝납니다. 제2회 5장에서 본 「캐시 hit이면 비동기로 내도 동기로 완료된다」는, 이 「바로 답할 수 있는 요청은 그 자리에서 끝낸다」는 동작의 모습이었습니다. 반대로, 캐시를 쓰는 채로 페이지가 메모리에 없으면 페이지 폴트 처리에 비동기 메커니즘이 없어서 비동기 읽기가 동기로 처리되는 경우가 있다──는 함정도 제2회에서 본 그대로입니다.7

2.2. 「여유 메모리가 줄었다」의 정체

캐시를 쓸지, read-ahead 상태는 어떤지는 연 방식마다(파일 개체 단위로) 관리되지만1, 캐시된 데이터 본체는 파일(스트림) 단위로 공유됩니다. 같은 파일을 여러 번 열어도 별도 캐시가 생기는 것이 아니라, 어느 handle에서도 같은 캐시 내용이 보입니다(6장 일관성의 토대입니다). 캐시는 Windows가 동작하는 동안 줄곧 캐시 관리자의 지휘 아래 움직입니다.1 큰 파일을 복사하거나 읽기·쓰기를 대량으로 하면, 비어 있던 물리 메모리는 점점 캐시로 돌려 쓰입니다. 작업 관리자의 여유 메모리가 줄어 보여도, 그 상당수는 「앱이 요청하면 곧바로 내어 주는, 가치 있게 쓰이고 있는 대기 메모리」입니다. 메모리 부족을 진단할 때 이 구분을 틀리지 않으려면, 관측 실무는 「.NET에서 GC 대기와 메모리 누수를 가려내기」에서도 다루었습니다.

이 동작은 화면에서 확인할 수 있습니다. 작업 관리자 > 성능 > 메모리를 열면, 아래쪽 「메모리 구성」 막대가 사용 중 / 수정됨 / 대기 / 사용 가능으로 나뉩니다. 파일 캐시의 대부분은 이 대기에 들어가고, 오른쪽 목록에서는 「캐시됨」으로 합산됩니다. 더 자세히 보려면 리소스 모니터 > 메모리 탭에서 같은 구분이 용량과 함께 나열됩니다. 수 GB 파일을 하나 복사한 뒤 다시 보면, 대기는 늘고 사용 가능은 줄어드는데 「사용 중」은 거의 변하지 않습니다──즉 「메모리를 잠식당한」 것이 아니라 「비어 있던 메모리가 캐시에 쓰인」 것을 눈으로 확인할 수 있습니다.

3. read-ahead ── 읽기를 앞질러 하기

캐시 관리자는 지난 접근 패턴으로 다음에 읽힐 구간을 앞서 읽어 둡니다(read-ahead). 순서대로 읽는 파일이라면, 앱이 요청하기 전에 이어지는 데이터가 이미 캐시에 올라가 있습니다──이것이 sequential 읽기가 빠른 이유입니다. read-ahead의 양은 고정이 아니라, 검출한 패턴과 요청 크기에 따라 달라집니다.

앱 읽기 요청의 이력앞에서부터 순서대로 읽고 있다캐시 관리자가패턴을 검출read-ahead: 이어지는 구간을요청되기 전에 읽어 둔다(양은 패턴과 요청 크기에 따라 가변)힌트 FILE_FLAG_SEQUENTIAL_SCAN= read-ahead를 적극적으로힌트 FILE_FLAG_RANDOM_ACCESS= read-ahead는 낭비이므로 줄인다

그림 2: read-ahead. 접근 패턴 검출에 더해, CreateFile 플래그로 힌트를 줄 수 있습니다

제1회 대응표에 실은 FileOptions.SequentialScan / RandomAccess는 이 read-ahead 엔진에 대한 힌트입니다. 「처음부터 끝까지 훑는」 배치에는 전자, 인덱스를 따라가는 접근에는 후자──앱만 아는 미래를 OS에 알려 주는 플래그로 생각하면 쓸 자리가 분명해집니다.

4. 지연 쓰기 ── WriteFile 「성공」의 의미

4.1. lazy writer는 매초 찾아온다

쓰기 쪽은 write-back 캐시입니다. WriteFile은 데이터를 슬롯에 복사한 시점에 성공을 반환하고, 디스크로의 기록은 뒤로 미룹니다. 이 「늦춰서 쓰는」 방침이 지연 쓰기(lazy writing)입니다.1

기록을 맡는 것이 캐시 관리자가 매초 실행하는 lazy writer입니다. 최근에 flush되지 않은 페이지의 8분의 1을 큐에 올려 쓰고, 써야 할 데이터가 많으면 더 쌓습니다. 참고로 FILE_ATTRIBUTE_TEMPORARY 특성으로 만든 임시 파일은 lazy writer의 flush 대상에서 빠집니다──곧 지울 것을 쓰기만 하는 것은 낭비이기 때문입니다.1 다만 이는 특성에 따른 힌트이지, 메모리가 빠듯하면 디스크에 되돌려 쓰일 수는 있고, 「이름만 임시처럼 보이는」 파일에는 적용되지 않습니다.

디스크lazy writer(매초 실행)시스템 캐시디스크lazy writer(매초 실행)시스템 캐시슬롯에 복사하고페이지를 dirty(아직 쓰지 않음)로여기부터 디스크에 다시 쓸 때까지가 「위험한 구간」전원 차단·OS 크래시라면 이 데이터는 사라진다여기서 처음으로 디스크에 남는다WriteFile(데이터)곧바로 TRUE가 반환된다dirty 페이지의 1/8을 고른다모아 디스크에 쓴다

그림 3: 지연 쓰기. WriteFile의 성공은 「OS에 넘겼다」이지 「디스크에 남았다」가 아닙니다

4.2. 무엇이 일어나면, 어디까지 사라지는가

「위험한 구간」의 뜻을 정확히 해 둡니다. 장애 종류에 따라 운명이 갈립니다.

WriteFile 성공 직후의 데이터(캐시상의 dirty 페이지)무엇이 일어났는가앱 프로세스가크래시/강제 종료OS 자체가 정지(전원 차단·블루스크린)데이터는 남는다캐시는 OS의 것이므로lazy writer가 예정대로 디스크에 쓴다dirty 페이지는 사라진다디스크에 이미 쓰인 분만 남는다

그림 4: 장애 종류와 생존의 갈림. 캐시는 「프로세스의 것」이 아니라 「OS의 것」입니다

  • 앱이 죽어도 데이터는 사라지지 않습니다. 캐시로의 복사가 끝난 시점에 데이터의 소유자는 OS입니다. 「저장 직후 앱이 죽었는데 파일은 그대로였다」는 이 덕분입니다.
  • OS 자체가 죽으면 dirty 페이지만 사라집니다. flush 빈도는 성능과 신뢰성의 트레이드오프로 조정되어 있고, 「갑작스러운 전원 상실이 일어나면 캐시된 데이터는 사라진다」고 문서도 명시합니다.1

즉 업무 앱 설계에서의 질문은, 「이 데이터는 전원 차단 순간에 사라져도 되는가」입니다. 로그 몇 초분은 허용될 수 있습니다. 수주 데이터의 확정 레코드라면 허용되지 않을 것입니다. 허용되지 않는 것에만 다음 장의 도구를 씁니다.

5. 「확실히 썼다」를 만드는 도구 상자

5.1. FlushFileBuffers ── 지금 바로 밀어 쓴다

FlushFileBuffers는 지정한 파일의 버퍼된 데이터를 디바이스까지 밀어 씁니다. 파일 시스템의 메타데이터는 항상 캐시되므로, 메타데이터까지 확실히 닿게 하려면 flush(또는 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 cache는 별도 단입니다. 전원 차단 내성까지 요구한다면 WRITE_THROUGH를 함께 쓰거나 FlushFileBuffers가 계속 필요합니다. 대량 데이터의 일괄 전송이나, 버퍼를 직접 관리하는 데이터베이스 엔진을 위한 도구이지만, 제약이 엄격합니다.3

  • 읽기·쓰기의 크기와 파일 offset은 볼륨의 섹터 크기 정수배일 것(512바이트 섹터라면 512·1024·1536…).
  • 버퍼 주소도 물리 섹터 크기에 정렬되어 있을 것(4096바이트 물리 섹터의 「Advanced Format」 디스크에 대한 배려도 필요).
  • 그래도 메타데이터는 계속 캐시되므로, 완전히 디스크에 남기려면 WRITE_THROUGH를 함께 쓰거나 FlushFileBuffers가 필요합니다.12

이 「제약」은 플래그만 추가하면 된다고 생각한 사람이 처음 넘어지는 지점입니다. 정렬을 지키지 않은 채 읽기·쓰기를 하면 ERROR_INVALID_PARAMETER(87)로 실패합니다. 지켜야 할 세 가지를 정리합니다.3

맞출 것 조건 어떻게 충족하는가
읽기·쓰기의 크기 볼륨의 섹터 크기 정수배 GetDiskFreeSpacelpBytesPerSector를 구해 그 배수로 맞춘다
파일 offset 동일(OVERLAPPEDOffset으로 지정하는 경우도 같음) 섹터 크기 배수씩 진행한다
버퍼 주소 물리 섹터 크기에 정렬 VirtualAlloc으로 확보한다(페이지 경계=보통 4096바이트에 정렬된 영역이 반환된다)

세 번째가 특히 빠집니다. malloc이나 new, C# 배열이 반환하는 주소에는 섹터 경계 정렬 보장이 없습니다. 페이지 경계에 확보할 수 있는 VirtualAlloc을 쓰면, 물리 섹터 4096바이트의 「Advanced Format」 디스크 요건도 함께 충족합니다. 최소 형태는 다음과 같습니다.

// C++ / Win32. 오류 처리는 최소로 줄여 두었습니다
DWORD sectorsPerCluster = 0, bytesPerSector = 0, freeClusters = 0, totalClusters = 0;
if (!GetDiskFreeSpaceW(L"C:\\", &sectorsPerCluster, &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 바이트 단위로 나아가므로 크기와 offset 모두 정렬이 유지된다
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. 용도 구분 정리

기본 WriteFile: 여기까지에서 성공이 반환된다lazy writer(매초)/ WRITE_THROUGH(즉시)장치의 타이밍 /FlushFileBuffers는 밀어 쓰기를 요구NO_BUFFERING은 캐시를 건너뛰고 직행앱의 버퍼시스템 파일 캐시(dirty 페이지)디스크 장치 내부 캐시비휘발 기록 매체

그림 5: 데이터의 계층과, 각 도구가 어디까지 밀어 넣는가. 「디스크 장치 내부 캐시」라는 마지막 단에도 주의하십시오

방법 무엇이 일어나는가 어울리는 장면
기본(캐시 사용) 캐시 복사로 완료. 디스크 기록은 lazy writer 대부분의 파일 I/O
FlushFileBuffers / Flush(true) 그 시점의 데이터+메타데이터를 밀어 쓴다 매듭에서의 확정(트랜잭션 commit 등)
FILE_FLAG_WRITE_THROUGH 쓸 때마다 즉시 디스크로(읽기는 캐시) 잃으면 안 되는 쓰기가 이어지는 로그·저널
FILE_FLAG_NO_BUFFERING(+WRITE_THROUGH) 캐시를 거치지 않음. 정렬 요건 있음 자체 버퍼 관리·대량 일괄 I/O

고르는 순서는 「전원 차단에서 몇 건까지 잃어도 되는가」를 먼저 정하고, 다음에 「그 대가로 얼마나 느려져도 되는가」를 확인하는 두 단계입니다. 표를 위에서 훑지 말고, 이 분기를 따라가십시오.

허용된다(직전 몇 초의 로그 등)허용되지 않는다매듭(거래 확정 등)한 건마다아니요(일반 앱)예(DB 엔진 등)이 데이터를 쓰려 한다전원 차단·블루스크린 순간에사라져도 허용되는가기본 그대로(캐시 사용)가장 빠르다. 대부분의 I/O는 여기잃으면 안 되는 것은「매듭」인가 「한 건마다」인가매듭에서 FlushFileBuffers.NET이라면 Flush(true)비용: 매듭의 대기만버퍼를 직접 관리하고5.3의 정렬 요건을 충족할 수 있는가FILE_FLAG_WRITE_THROUGH쓸 때마다 즉시 디스크로읽기는 캐시인 채로 빠르다FILE_FLAG_NO_BUFFERING+ FILE_FLAG_WRITE_THROUGH공식 문서가 제시하는 「잦은 영속화」의 형태

그림 6: 도구를 고르는 법. 1단계 분기가 신뢰성 요구, 2단계가 감수할 비용입니다. 「한 건마다 FlushFileBuffers」라는 길이 없는 것은, 5.1에서 본 대로 공식 문서가 그것을 비효율이라고 하기 때문입니다

실무 패턴도 세 가지만 듭니다.

  1. 「임시 파일에 쓴다→flush→이름 변경」이, 깨지다 만 파일을 남기지 않는 정석입니다. 내용을 밀어 쓴 뒤 이름으로 확정한다──이 원자적 넘김은 「파일 연동의 배타 제어 기초」에서 자세히 다루었습니다.
  2. 데이터베이스에 맡기는 것도 타당한 설계입니다. SQLite가 WAL과 flush로 내구성을 만들어 넣는 이야기는 「C#에서 SQLite를 업무 앱에 쓰기」를 보십시오. 「flush 전략을 직접 쓰지 않는다」는 선택지는 언제나 있습니다.
  3. 벤치마크는 캐시를 의심하십시오. 「읽기가 너무 빠른」 측정은 대개 두 번째 이후의 캐시 hit를 재고 있습니다. 측정 절차는 「Windows에서 프로그램 버전별 속도를 올바르게 비교하는 방법」에 정리했습니다.

그리고 그림 5의 마지막 단계──디스크 장치 내부 캐시도 잊지 마십시오. FlushFileBuffers는 거기까지 포함한 밀어 쓰기를 요구하지만, USB 메모리나 외장 디스크에서는 장치 측 write cache 정책(「빠르게 제거」와 「고성능」)이 얽힙니다. 제거 가능 디바이스의 취급은 「Windows 앱에서 USB 기기를 다루는 방법」도 보십시오.

6. 메모리 맵 파일과의 일관성

제1회에서 「캐시의 실체는 파일 매핑」이라고 듣고, 이렇게 생각한 분이 있을 것입니다──그렇다면 직접 MapViewOfFile한 view와 ReadFile/WriteFile의 캐시는 서로 어긋나지 않는가?

어긋나지 않습니다. 같은 구조 위에 올라 있기 때문입니다. 파일 매핑 개체는 파일에 뒷받침되고, 페이지를 내보내는 작업은 파일에 다시 쓰는 과정으로 이루어집니다. 같은 로컬 파일에 대해 여러 프로세스가 view를 만들어도, 보이는 내용은 coherent(일관)입니다.4

시스템 주소 공간프로세스 A의 주소 공간캐시 관리자의 view(ReadFile/WriteFile이 쓰는 슬롯)MapViewOfFile의 view같은 물리 페이지 묶음(파일에 뒷받침된 메모리)디스크의 파일FILE_FLAG_NO_BUFFERING의 I/O는이 공유의 밖(직접 디스크로)

그림 7: map view도 캐시도, 같은 「파일에 뒷받침된 페이지」를 보고 있습니다. 밖에 있는 것은 NO_BUFFERING뿐입니다

주의점은 두 가지입니다.

  • FILE_FLAG_NO_BUFFERING의 I/O는 이 일관성 밖입니다. 캐시를 거치지 않는 읽기·쓰기와, map view/캐시를 거친 내용은 맞춰 보지 않습니다. 섞는다면 스스로 정합을 잡아야 합니다.
  • map view를 디스크에 남기는 일은 두 단계입니다. FlushViewOfFile은 범위 안 dirty 페이지의 쓰기를 시작하지만, 메타데이터는 쓰지 않고, 디스크 장치 캐시에서의 물리 쓰기도 기다리지 않습니다. 확실히 닿게 하려면 FlushViewOfFile 뒤에 FlushFileBuffers를 호출합니다.5

공유 메모리로서의 파일 매핑 실무(이름 있는 공유, 동기, 사고 패턴)는 「공유 메모리의 함정과 실무 베스트 프랙티스」에서 다루고 있습니다.

7. Fast I/O ── 제1회 숙제 회수

먼저 두 줄로. Fast I/O란, 캐시에 있는 파일에 대한 동기 읽기·쓰기를 위해 마련된 지름길이며, IRP(I/O 요청 패킷. 커널이 드라이버에 넘기는 요청의 그릇)를 조립하지 않고 캐시와 직접 데이터를 주고받습니다. Procmon의 Operation 열에 IRP_MJ_READFASTIO_READ가 섞여 나오는 것은, 같은 「읽기」가 보통 경로와 지름길 중 어느 쪽을 지났는지의 차이입니다.

제1회 5.2절에서 「모든 I/O가 IRP가 되는 것은 아니다」라고 썼습니다. 이제 답을 확인합니다.

캐시에 있는 파일의 읽기·쓰기는, IRP를 조립해 디바이스 스택을 흘릴 필요 없이 캐시와의 메모리 복사로 끝나는 경우가 알려져 있습니다. 그래서 Windows는 캐시된 파일에 대한 동기 I/O를 위해 Fast I/O라는 지름길을 마련했습니다──IRP를 만들지 않고, 파일 시스템의 「Fast I/O 엔트리 포인트」를 직접 호출해 캐시 관리자에서 바로 복사하는 경로입니다.6 Fast I/O로 처리할 수 없는 경우(캐시에 없음, lock이 얽힘, 필터가 끼어듦 등)는 보통의 IRP 경로로 돌아갑니다. 참고로 이는 동기 요청을 위한 고속 경로이지 「캐시 hit = 항상 Fast I/O」가 아닙니다. 비동기(FILE_FLAG_OVERLAPPED) handle의 조작은, 캐시에서 그 자리로 완료되는 경우(제2회 5장)에도 IRP 경로로 처리되는 수가 있습니다.

된다되지 않는다캐시를 쓰는 handle에 대한 동기 읽기·쓰기Fast I/O로 처리할 수 있는가(캐시에 있는 등)Fast I/OIRP를 만들지 않고 캐시와 직접 복사Procmon에서는 FASTIO_로 표시보통 경로IRP를 조립해 디바이스 스택으로(제1회 그림 6의 세계)

그림 8: Fast I/O의 분기. Procmon에서 FASTIO_READIRP_MJ_READ가 섞여 보이는 것은 이 때문입니다

제1회 7장의 Procmon 관찰에서 FASTIO_ 행이 섞여 있던 이유를, 이제 설명할 수 있습니다. 캐시 hit 읽기는 IRP조차 사치입니다. 이 경로의 존재는 제6회에서 다루는 필터 드라이버에도 영향을 줍니다(미니필터는 Fast I/O에도 끼어들 수 있게 되어 있습니다).

8. 정리

  • Windows의 파일 캐시는 write-back이고, 실체는 파일 256KB 구간의 매핑입니다. 캐시를 쓰는 읽기·쓰기는 슬롯과의 메모리 복사가 됩니다.1
  • 읽기는 read-ahead가 앞서 읽고, SequentialScan/RandomAccess는 그 힌트입니다.1
  • 쓰기는 매초의 lazy writer가 뒤에서 따라갑니다. 앱이 죽어도 데이터는 남고, OS 자체가 죽으면 dirty 페이지만 사라집니다. 설계의 질문은 「이 데이터는 전원 차단 순간에 사라져도 되는가」입니다.1
  • 확실히 쓰는 도구는 FlushFileBuffers(매듭의 확정)/WRITE_THROUGH(쓸 때마다)/NO_BUFFERING(캐시를 거치지 않음+정렬 요건). 매번 flush는 비효율이고, 잦은 영속화에는 NO_BUFFERING+WRITE_THROUGH 병용이 공식 권고입니다. 메타데이터가 항상 캐시된다는 점에도 주의하십시오.231
  • map view와 캐시는 같은 페이지를 공유하며 일관됩니다. 밖은 NO_BUFFERING뿐입니다. map을 디스크에 남기는 일은 FlushViewOfFile+FlushFileBuffers의 두 단계입니다.45
  • 캐시 hit의 동기 읽기·쓰기는 Fast I/O로 IRP조차 생략됩니다. 제1회 Procmon에서 본 FASTIO_의 정체입니다.6

이어지는 글은 제5회 「NTFS의 내부 구조 ── MFT에서 이해하는 파일 시스템」입니다. 지금까지는 파일을 「offset과 바이트열」로 다루었지만, 그 뒤에서 NTFS가 데이터를 어떻게 배치하는지──MFT, 복수 데이터 스트림, 저널, 하드 링크──디스크 위의 정적 구조로 내려갑니다.

관련 기사

관련 상담 영역

합동회사 코무라소프트에서는 「저장했다고 생각한 데이터가 사라졌다」, 「파일 쓰기가 느리다/너무 빨라서 수상하다」와 같은 Windows 업무 앱의 파일 I/O 설계·장애 조사를 다룹니다.

참고 링크

  1. Microsoft Learn, File Caching. Windows가 기본으로 파일 데이터를 캐시하고, 읽기는 시스템 파일 캐시에서 이루어지며 쓰기도 캐시로 이루어지는 write-back 캐시라는 점, 캐시가 파일 개체 단위로 관리되며 캐시 관리자의 지휘 아래 동작한다는 점, 디스크로의 쓰기를 늦춰 캐시에 두는 방침이 지연 쓰기(lazy writing)라고 불린다는 점, 파일 읽기 때 256KB 구간이 시스템 주소 공간의 256KB 슬롯에 읽히고 사용자 프로세스가 그 슬롯과 데이터를 복사한다는 점, 캐시 관리자가 매초 lazy writer를 실행해 최근에 flush되지 않은 페이지의 8분의 1을 디스크 쓰기 큐에 올리고 필요하면 더 쌓는다는 점, 임시 파일은 flush되지 않는다는 점, 전원 상실 같은 갑작스러운 시스템 장애가 일어나면 쓰이지 않은 캐시 데이터는 사라진다는 점, FILE_FLAG_NO_BUFFERING으로 캐시를 꺼도 파일 메타데이터는 캐시될 수 있다는 점, FILE_FLAG_WRITE_THROUGH에서는 데이터가 캐시에도 쓰이면서 lazy writer의 지연 없이 즉시 디스크에 쓰인다는 점, 파일 시스템 메타데이터는 항상 캐시되므로 메타데이터를 디스크에 남기려면 flush나 FILE_FLAG_WRITE_THROUGH가 필요하다는 점에 대해.  2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19

  2. Microsoft Learn, FlushFileBuffers function. WriteFile이 보통은 내부 버퍼에 쓰고 OS가 주기적으로 디스크에 쓴다는 점, FlushFileBuffers가 지정 파일의 버퍼된 정보를 모두 디바이스에 쓴다는 점, 많은 쓰기를 할 때마다 매번 호출하는 것은 비효율이며 잦은 쓰기에서 중요 데이터를 디스크에 남겨야 하는 앱은 FILE_FLAG_NO_BUFFERING과 FILE_FLAG_WRITE_THROUGH에 의한 비버퍼 I/O를 써야 한다는 점, 볼륨 handle에 대해 호출하면(관리자 권한으로) 볼륨상의 열린 파일을 모두 flush할 수 있다는 점에 대해.  2 3 4 5

  3. Microsoft Learn, File Buffering. FILE_FLAG_NO_BUFFERING으로 연 파일에 대한 접근 요건으로, 읽기·쓰기의 크기와 파일 offset(OVERLAPPED로 지정하는 경우 포함)이 볼륨의 섹터 크기 정수배여야 한다는 점, 읽기·쓰기 버퍼 주소가 물리 섹터 크기에 정렬되어야 한다는 점, 물리 섹터 4,096바이트의 Advanced Format 디바이스에 대한 고려가 필요하다는 점에 대해.  2 3 4

  4. Microsoft Learn, File Mapping. 파일 매핑 개체가 디스크상 파일에 뒷받침되고, 페이지의 swap-out이 변경 내용의 파일 쓰기로 이루어진다는 점, 여러 프로세스가 같은 파일 매핑 개체에서 로컬 파일의 view를 만든 경우 데이터가 coherent(디스크상 파일과 같은 내용)하다는 점에 대해.  2 3

  5. Microsoft Learn, FlushViewOfFile function. FlushViewOfFile이 map view 범위 안 dirty 페이지의 디스크 쓰기를 시작한다는 점, 이 함수가 파일 메타데이터를 flush하지 않고 하드웨어 디스크 캐시에서의 물리 쓰기 완료도 기다리지 않는다는 점, dirty 페이지와 메타데이터를 모두 물리적으로 밀어 쓰려면 FlushViewOfFile 뒤에 FlushFileBuffers를 호출해야 한다는 점에 대해.  2 3

  6. Microsoft Learn, IRPs Are Different From Fast I/O. Fast I/O가 IRP를 만들지 않고 파일 시스템이나 캐시 관리자의 엔트리 포인트를 직접 호출하는, 캐시된 파일용 동기 I/O의 고속 경로라는 점, 캐시에서 직접 사용자 버퍼로(또는 그 반대로) 데이터가 전송된다는 점, Fast I/O로 처리할 수 없으면 IRP 기반의 보통 경로가 쓰인다는 점에 대해.  2 3

  7. Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. 데이터가 캐시에 있으면 요청이 그 자리에서 완료되어 TRUE가 반환된다는 점, Windows 캐시가 파일 매핑으로 구현되어 있고 페이지가 없을 때의 비동기 페이지 폴트 메커니즘이 없어서 캐시를 쓰는 비동기 읽기가 동기로 처리되는 수가 있다는 점에 대해. 

  8. Microsoft Learn, FileStream.Flush method (.NET). Flush()가 스트림의 내부 버퍼를 OS에 쓴다는 점, Flush(true)를 지정하면 그에 더해 모든 중간 파일 버퍼(OS 버퍼)도 flush된다는 점에 대해. 

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

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

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

자주 묻는 질문

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

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으로, 캐시 자체를 거치지 않습니다. Microsoft 문서는 쓸 때마다 FlushFileBuffers를 호출하는 것은 비효율적이며, 잦은 쓰기에서 디스크에 확실히 남겨야 한다면 FILE_FLAG_NO_BUFFERING과 FILE_FLAG_WRITE_THROUGH를 함께 쓰라고 합니다. 어느 쪽이든 캐시의 이점을 포기하는 만큼 느려지므로, 모든 I/O에 붙이는 것이 아니라 잃으면 안 되는 데이터의 쓰기에만 쓰는 것이 실무의 요령입니다.
FILE_FLAG_WRITE_THROUGH와 FILE_FLAG_NO_BUFFERING은 어떻게 다릅니까?
WRITE_THROUGH는 「캐시에는 쓰되, 완료 전에 디스크에도 쓴다」입니다. 읽기는 계속 캐시의 이점을 받으며, lazy write의 지연만 없앱니다. NO_BUFFERING은 「읽기·쓰기가 시스템 캐시를 거치지 않는다」는 뜻으로, 읽기도 쓰기도 매번 디스크 장치로의 I/O가 됩니다(다만 건너뛰는 것은 Windows 캐시까지이며, 장치 내부 write cache까지 건너뛰는 것은 아닙니다). 그 대신 제약이 엄격합니다. 읽기·쓰기의 크기와 파일 offset은 볼륨의 섹터 크기 정수배여야 하고, 버퍼 주소도 물리 섹터 경계에 맞춰야 합니다. 또한 NO_BUFFERING이어도 파일 시스템 메타데이터는 계속 캐시되므로, 메타데이터까지 확실히 쓰려면 FlushFileBuffers나 WRITE_THROUGH를 함께 써야 합니다. 데이터베이스 엔진처럼 버퍼를 직접 관리하는 소프트웨어가 쓰는 경우가 전형적이고, 일반 앱에서는 먼저 WRITE_THROUGH나 FlushFileBuffers부터 검토하는 것이 순리입니다.
작업 관리자에서 여유 메모리가 적어 보이는 것은 파일 캐시 때문입니까?
대부분의 경우 그렇고, 게다가 정상적인 동작입니다. Windows는 비어 있는 물리 메모리를 적극적으로 파일 캐시로 쓰며, 큰 파일을 복사하거나 읽기·쓰기를 대량으로 하면 그만큼 캐시가 커져 메모리 사용량이 늘어난 것처럼 보입니다. 다만 캐시가 쓰는 페이지의 대부분은 앱이 메모리를 요청하면 비교적 빨리 다른 용도로 돌려주는 종류이며, 「메모리를 잠식해 부족한」 상태와는 구분이 필요합니다. 메모리 부족을 의심할 때는 겉보기의 여유 용량뿐 아니라, committed memory나 hard fault 빈도 같은 지표를 보는 것이 실무적입니다.
메모리 맵 파일과 ReadFile/WriteFile로 같은 파일을 다루면 내용이 어긋나지 않습니까?
캐시를 쓰는 일반 I/O와의 사이에서는 어긋나지 않습니다. Windows 캐시 자체가 파일 매핑으로 구현되어 있어, 같은 로컬 파일에 대한 map view와 캐시는 같은 데이터를 공유하므로 한쪽의 변경은 다른 쪽에서도 보입니다. 여러 프로세스가 같은 파일 매핑 개체에서 view를 만든 경우에도 데이터는 coherent(일관)합니다. 다만 FILE_FLAG_NO_BUFFERING으로 연 handle의 읽기·쓰기는 캐시를 거치지 않으므로 이 일관성 밖입니다. 또한 map view의 변경을 디스크에 확실히 쓰려면, FlushViewOfFile만으로는 메타데이터가 쓰이지 않고 하드웨어 캐시도 기다리지 않으므로, FlushViewOfFile 뒤에 FlushFileBuffers를 호출해야 합니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기