Windows I/O의 심층(제2회) ── 동기 I/O와 비동기 I/O: OVERLAPPED의 진짜 의미

· 업데이트: · · Windows, Win32, I/O, 비동기, OVERLAPPED, 커널, .NET, CSharp

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

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

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
이 글의 지식 맵에 들어 있는 관계를 다시 살펴보았습니다. 본문보다 넓은 주장이었던 것(조건부에서만 성립하는 관계, 「막는다」가 아니라 「줄인다」에 해당하는 관계, 전제가 아니라 권장에 해당하는 관계)을 조건부로 고치거나 더 정확한 술어로 바꿨습니다. 본문의 주장은 바꾸지 않았습니다.
글 맨 앞에 「이 글의 지식 맵」을 추가했습니다. 본문에서 다루는 개념 사이의 관계를 한 장의 그림으로 볼 수 있습니다. 관계 전체 목록(근거 URL·확신도·확인일 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 모았고, 기계 가독 데이터를 JSON-LD와 Turtle로 공개합니다.
APC 예에서 `ReadFileEx`의 반환값을 보지 않고 alertable 대기 루프에 들어가던 부분을 고쳤습니다. 장치를 막 제거한 직후나 잘못된 handle에서는 발행 자체가 실패하고, 완료 루틴은 하나도 쌓이지 않습니다. 이 상태에서 루프에 들어가면 `completed`는 영원히 서지 않고, 존재하지 않는 I/O에 대해 `SleepEx`와 `CancelIoEx`를 끝없이 반복합니다. 반환값이 0이면 `GetLastError()`를 그 자리에서 읽고, 대기에 들어가지 않고 빠져나오도록 했습니다.
`SleepEx`를 한 번만 호출하는 예를 고쳤습니다. 타임아웃으로 돌아오면 그 시점에 alertable wait를 빠져나오므로, I/O가 아직 나간 채로 버퍼 수명이 끝납니다. 완료를 기록해 그 플래그가 설 때까지 기다리거나, `CancelIoEx`로 취소한 뒤 완료 배달을 기다리는 형태로 바꿨고, `WAIT_IO_COMPLETION`이 자기 I/O의 완료라고 단정할 수 없다는 점도 명시했습니다.
반환값과 `GetLastError()`의 조합을 3행 표로 정리하고, 그 표를 그대로 구현한 C++ 함수를 추가했습니다. 발행에 실패했을 때만 완료 통지가 오지 않는다는 점을 표와 코드 양쪽에서 보입니다. C# 예는 `useAsync` 유무와 `RandomAccess`의 세 패턴으로 대비하고, 비교표를 장 앞쪽으로 옮겼으며, APC의 나쁜 예와 좋은 예를 추가했습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22175272)

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

Go Komura (2026). 「Windows I/O의 심층(제2회) ── 동기 I/O와 비동기 I/O: OVERLAPPED의 진짜 의미」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/windows-io-sync-async-overlapped/

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

지난 회(제1회)에서는 Windows의 I/O 요청이 IRP라는 패킷이 되어 device stack을 흐른다는 것, 그리고 발행과 완료는 kernel 바닥에서 처음부터 분리되어 있다는 점을 봤습니다.

이번에는 그 분리를 애플리케이션 쪽에서 쓰는 구조──비동기 I/O(overlapped I/O)──를 파고듭니다. FILE_FLAG_OVERLAPPED를 붙였는데 동기로 돌아온다. OVERLAPPED를 재사용했더니 데이터가 깨졌다. CancelIoEx를 호출했는데 멈추지 않는다. 취소했더니 access violation으로 죽었다──이런 「비동기 I/O의 무서운 이야기」는 모두 구조를 한 장의 그림으로 갖고 있지 않은 것에서 옵니다. 이 글에서 그 그림을 만듭니다.

연재 「Windows I/O의 심층」의 제2회입니다. 전체 구성은 제1회 서두에 두었습니다.

1. 먼저 결론

  • 동기 I/O와 비동기 I/O의 갈림은 kernel이 아니라 「기다릴지 여부」입니다. 동기 I/O의 보장은 「완료되기 전에는 돌아오지 않는다」는 것입니다. 요청이 pending이 되었을 때만 I/O Manager가 완료를 기다리고, thread는 CPU를 쓰지 않는 대기 상태에서 잠듭니다(2장).1
  • FILE_FLAG_OVERLAPPED는 handle(file object)의 모드입니다. CreateFile 시점에 정해지며, 호출마다 바꿀 수 없습니다. 비동기 handle에서는 시스템이 file pointer를 관리하지 않으므로, 디스크상의 파일에서는 매번 OVERLAPPEDOffset으로 위치를 지정합니다(3장).21
  • OVERLAPPED 구조체는 「작업 1건분의 전표」입니다. 발행 중인 작업 수만큼 필요하며, 공유·재사용은 데이터 손상으로 이어진다고 공식 문서가 명시합니다. 완료될 때까지는 구조체도 buffer도 건드리지 말아야 합니다(3장).34
  • 완료를 받는 방법은 실질적으로 네 가지입니다. handle의 signal(비권장), OVERLAPPED의 event + GetOverlappedResult, APC(alertable wait), 그리고 I/O completion port(다음 회)입니다(4장).156
  • 비동기로 발행해도 동기 완료되는 경우가 있습니다. cache에 올라가 있는 경우, NTFS 압축/암호화, 파일을 늘리는 쓰기──가 대표 예입니다. 「비동기 = 절대 block되지 않는다」가 아닙니다(5장).3
  • 취소는 「요청」입니다. CancelIoEx를 호출해도 작업은 ERROR_OPERATION_ABORTED로 완료되어 돌아옵니다. 그 완료를 확인할 때까지 뒷정리를 해서는 안 됩니다(6장).78
  • .NET의 FileOptions.Asynchronous는 이 모드의 직통 스위치입니다. handle 모드와 호출 API가 어긋나면, thread pool이 대신 수행하는 「가짜 비동기」나 불필요한 대기가 생깁니다(7장).910

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

2. 동기 I/O ── thread는 어디에서 잠드는가

먼저 기본 모습부터 봅니다. FILE_FLAG_OVERLAPPED를 붙이지 않고 연 handle은 동기 모드입니다. ReadFile을 호출하면 I/O가 완료될 때까지 함수가 돌아오지 않습니다.1

제1회에서 본 대로, driver는 요청을 보류(pending)하고 hardware 응답을 기다립니다. 그렇다면 동기 I/O일 때 누가 기다리고 있는가. I/O Manager가 완료를 기다린 뒤에 앱으로 제어를 돌려줍니다.

driver(stack)I/O Manager앱의 threaddriver(stack)I/O Manager앱의 threadthread는 kernel 안에서 대기 상태가 되어CPU를 쓰지 않고 잠든다ReadFile(동기 handle)IRP를 발행STATUS_PENDING(응답 대기)완료(IoCompleteRequest)결과를 반환하고 깨움ReadFile이 TRUE/FALSE로 돌아온다

그림 1: 동기 I/O(요청이 pending이 된 경우). I/O Manager가 완료를 맞춘 뒤에 앱으로 돌아온다

이 그림은 요청이 pending이 된 경우입니다. driver가 그 자리에서 완료할 수 있는 요청(cache hit 등. 제1회·그림 5의 「즉시 완료」 경로)이라면 대기는 어디에도 생기지 않고, 그대로 결과를 들고 돌아옵니다. 동기 I/O의 보장은 「완료되기 전에는 돌아오지 않는다」는 것이지, 「반드시 잠든다」가 아닙니다.

짚어 둘 점은 두 가지입니다.

  • 「기다림」은 CPU를 쓰지 않습니다. 대기 상태의 thread는 scheduler의 실행 대상에서 빠집니다. polling으로 스스로 목을 조르는 것보다 기다리게 하는 편이 낫다는 이야기는 「Windows에서 Sleep(1)보다 event 대기를 우선해야 하는 이유」에 썼습니다.
  • 동기 모드 handle에서는 kernel이 file pointer(현재 위치)를 관리합니다. 그래서 연속된 ReadFile이 「이어서」 읽습니다. 이 상태는 handle이 아니라 file object에 있으므로, DuplicateHandle로 복제한 handle과는 위치를 공유합니다(제1회 3.3절).

동기 I/O의 약점은 기다리는 동안 그 thread가 다른 일을 하지 못한다는 데 있습니다. UI thread에서 동기 I/O를 하면 화면이 멈추고, 서버에서 연결마다 thread를 세우면 수백 연결에서 thread가 넘칩니다. 참고로 동기 I/O로 멈춰 있는 다른 thread를 밖에서 구하기 위한 CancelSynchronousIo라는 API도 있습니다(6장).11

3. 비동기 I/O ── handle의 모드와 작업 전표

3.1. 모드는 handle 단위로 정해진다

CreateFileFILE_FLAG_OVERLAPPED를 넘기면, 그 handle 뒤의 file object가 비동기 모드로 열립니다.1 여기서 중요한 점은, 이것이 handle 단위 속성이라는 것입니다. 「이번 호출만 비동기로」는 할 수 없습니다. 같은 파일을 동기용·비동기용 두 handle로 여는 것은 가능합니다(file object가 두 개 생길 뿐입니다).

비동기 모드 handle에는 또 하나 큰 차이가 있습니다. 시스템이 file pointer를 관리하지 않습니다.2 여러 작업이 동시에 나가 있는 상태에서 「현재 위치」에 의미가 없기 때문입니다. 디스크상의 파일처럼 위치가 있는 device에서는 읽기/쓰기 위치를 매번 OVERLAPPED 구조체의 Offset/OffsetHigh로 명시합니다. 반면 serial port나 named pipe처럼 seek 위치라는 개념이 없는 device에서는 Offset을 위치 지정으로 쓰지 않습니다(0으로 둡니다). 그 경우에도 다음 절에서 보듯 OVERLAPPED 구조체 자체는 작업마다 필요합니다.

3.2. OVERLAPPED는 「작업 1건분의 전표」

OVERLAPPED 구조체의 역할은 발행 중인 작업 하나를 식별하고, 그 상태를 나르는 것입니다.4

멤버 역할
Offset / OffsetHigh 이 작업이 읽고 쓸 파일상의 위치(발행 시 지정. 위치가 없는 device에서는 미사용)
hEvent 완료 시 signal되는 event(선택. manual-reset 권장)
Internal 작업 상태. 완료 전에는 STATUS_PENDING에 해당하는 값이 들어갑니다(시스템용)
InternalHigh 완료 시 전송 바이트 수(시스템용)
OVERLAPPED 구조체 = 작업 1건분의 전표Offset: 어디를 읽을지hEvent: 완료를 어떻게 알지Internal/InternalHigh:상태와 결과(시스템이 씀)handle(file object) = 모드동기 모드kernel이 현재 위치를 관리완료까지 ReadFile이 돌아오지 않음비동기 모드(FILE_FLAG_OVERLAPPED)현재 위치는 관리되지 않음발행과 완료가 분리됨CreateFile 때 한 번만 정해짐ReadFile/WriteFile 발행마다 하나 준비

그림 2: 모드는 handle에, 상태는 작업(전표)에. 이 분담을 섞으면 사고가 난다

여기서부터 공식 문서가 명시한 두 가지 금지 사항이 자연스럽게 나옵니다.3

  1. 동시에 나가 있는 작업 수만큼 OVERLAPPED가 필요합니다. 3개를 발행하려면 3개. 재사용하면 「예측할 수 없는 결과나 데이터 손상」으로 이어집니다.
  2. 완료될 때까지 OVERLAPPED와 data buffer를 살려 두고 건드리지 말아야 합니다. kernel이 그 영역에 쓰러 오기 때문입니다. 지역 변수의 OVERLAPPED로 발행하고 함수를 빠져나가는 것은, stack을 kernel에 밟히게 하는 전형적인 사고입니다.

3.3. 발행 결과는 세 갈래다

비동기 handle에 대한 ReadFile은 세 가지로 돌아옵니다.2

ReadFile(비동기 handle, OVERLAPPED 포함)반환값은?TRUE그 자리에서 완료됨(동기 완료)기본값에서는 완료 통지도 따로 도착FALSE + ERROR_IO_PENDING접수됨. 완료는 나중에 통지됨FALSE + 그 밖의 오류발행 자체가 실패함완료 통지를 기다림(4장의 네 방식)

그림 3: 비동기 발행의 세 갈래. TRUE(즉시 완료)와 ERROR_IO_PENDING을 둘 다 올바르게 다루어야 비동기 I/O가 동작한다

코드로 옮길 때의 판정은 반환값과 GetLastError의 조합입니다. 이 표가 그대로 분기가 됩니다.

ReadFile의 반환값 GetLastError() 의미 호출 쪽이 할 일
TRUE (보지 않음) 그 자리에서 완료됨(동기 완료) 기본값에서는 완료 통지도 따로 도착합니다. 결과 처리는 통지 쪽에 맡깁니다
FALSE ERROR_IO_PENDING(997) 접수됨. 진행 중 아무것도 하지 않습니다. OVERLAPPED와 buffer를 건드리지 않고 완료 통지를 기다립니다
FALSE 그 밖 발행 자체가 실패함 완료 통지는 오지 않습니다. 그 자리에서 오류 처리하고 OVERLAPPED와 buffer를 뒷정리합니다
// C++ / Win32
// hFile : FILE_FLAG_OVERLAPPED로 연 handle
// ov    : 이 작업 전용으로 확보한 OVERLAPPED(Offset과 hEvent는 설정됨)
// buf/len: 이 작업 전용 buffer. 완료 통지를 받을 때까지 해제하지 않음
DWORD IssueRead(HANDLE hFile, OVERLAPPED* ov, BYTE* buf, DWORD len)
{
    // 비동기 발행에서는 lpNumberOfBytesRead에 NULL을 넘기고,
    // 전송 바이트 수는 완료 후 GetOverlappedResult로 받는다
    if (ReadFile(hFile, buf, len, nullptr, ov))
    {
        // (1) 동기 완료. 기본값에서는 완료 통지도 오므로, 여기서는 결과를 처리하지 않는다
        return ERROR_SUCCESS;
    }

    DWORD err = GetLastError();
    if (err == ERROR_IO_PENDING)
    {
        // (2) 접수됨. ov와 buf는 건드리지 않고 완료 통지를 기다린다
        return ERROR_IO_PENDING;
    }

    // (3) 발행 자체의 실패. 완료 통지는 오지 않으므로, 호출 쪽이 여기서 뒷정리한다
    return err;
}

ERROR_IO_PENDING오류가 아니라 「접수」입니다. 여기를 평범한 오류로 처리해 버리거나, 반대로 TRUE(동기 완료) 경우를 가정하지 않고 코드를 쓰는 것이 두 가지 전형적인 버그입니다. 동기 완료가 왜 일어나는지는 5장에서 다룹니다.

그리고 여기에 중요한 주의가 하나 있습니다. 기본값에서는 동기 완료(TRUE)한 작업에 대해서도 완료 통지가 따로 도착합니다. I/O completion port에 연결한 handle이면 완료 패킷이 queue에 쌓이고, event 방식이면 event도 signal됩니다. 따라서 「TRUE면 그 자리에서 결과를 처리하고, 통지가 오면 또 처리한다」고 쓰면 같은 작업을 이중 처리하고 전표를 이중 해제하는 사고가 됩니다. 안전한 기본형은 「TRUE(동기 완료)」와 「ERROR_IO_PENDING」의 두 경로에서 결과 처리를 통지 쪽으로 한곳에 모으는 것입니다. 다만 세 번째 경로──발행 자체가 실패한 경우(FALSE+그 밖의 오류)에는 완료 통지가 오지 않습니다. 이것을 통지 대기로 돌리면 영원히 기다리게 되므로, 발행 쪽에서 그 자리에서 오류 처리와 전표 뒷정리를 합니다. 「동기 완료 때는 통지를 건너뛰고 그 자리에서 처리」로 바꾸고 싶을 때만 SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)를 명시적으로 켭니다──다만 이것이 막는 것은 I/O completion port로의 패킷뿐이고, OVERLAPPED.hEvent의 signal은 막지 않습니다. event 방식에는 쓸 수 없는, IOCP 경로 전용 최적화입니다(5장).12

참고로 동기 모드 handle에 OVERLAPPED를 넘긴 경우에는 Offset 위치부터 읽히지만, 완료까지 block하는 동작은 바뀌지 않습니다.2 「OVERLAPPED를 넘겼으니 비동기」가 아닙니다. 모드는 어디까지나 handle이 갖고 있습니다.

4. 완료를 어떻게 아는가 ── 네 가지 통지 경로

발행과 완료가 분리된 이상, 「완료했다」를 어떻게 받을지가 설계의 중심이 됩니다. 경로는 실질적으로 네 가지입니다.1

kernel에서 I/O가 완료(IoCompleteRequest→APC로 결과 확정)(1) file handle이 signal 상태가 됨수신: WaitForSingleObject(handle)(2) OVERLAPPED의 hEvent가 signal 상태가 됨수신: WaitForSingleObject + GetOverlappedResult(3) 완료 루틴이 발행 thread의 APC queue에 쌓임수신: SleepEx 등의 alertable wait 중에 실행(4) I/O completion port에 완료 패킷이 들어감수신: GetQueuedCompletionStatus(제3회)

그림 4: 완료 통지의 네 경로. 어느 쪽으로 받을지는 발행 방법(hEvent 유무, ReadFileEx, port 연결)으로 정해진다

먼저 전체 그림을 표로 둡니다. 각 절은 이 표의 내용 설명입니다.

방식 완료 처리가 도는 thread 동시에 내보낼 수 있는 I/O 수 맞는 장면
(1) handle의 signal 대기한 임의의 thread 실질 1건. 여러 개를 내보내면 어느 것이 완료됐는지 구별할 수 없음 거의 없음(4.1)
(2) event + GetOverlappedResult 대기한 임의의 thread 작업마다 event가 하나 필요. WaitForMultipleObjects로 묶어 기다리면 상한은 64개 몇 건까지의 동시 I/O. device 통신(4.2)
(3) APC(ReadFileEx) 발행한 thread. 게다가 alertable wait에 들어가 있는 동안만 건수 제약은 없지만, 완료 처리는 전부 그 한 thread에서 직렬로 돈다 단일 thread로 끝내려는 통신 처리(4.3)
(4) I/O completion port port에 묶인 worker thread 무리 다수를 소수 thread로 받을 수 있음 서버, thread pool(4.4)

4.1. handle의 signal ── 쓰지 않는다

hEvent를 넣지 않고 발행하면, 완료 때 file handle 자체가 signal 상태가 됩니다. 겉보기엔 손쉽지만, 같은 handle로 여러 작업이 나가 있으면 어느 것이 완료됐는지 구별할 수 없습니다.1 「비동기 I/O는 한 건씩만 발행한다」는 특수한 경우를 빼고는 쓰지 않는 편이 안전합니다.

4.2. event + GetOverlappedResult ── 기본형

OVERLAPPED.hEventmanual-reset event를 넣어 발행하고, WaitForSingleObject(또는 WaitForMultipleObjects로 여러 개를 동시에)로 기다린 뒤, GetOverlappedResult로 결과(성패와 전송 바이트 수)를 꺼냅니다.13 GetOverlappedResultbWait에 TRUE를 넘기면 「완료될 때까지 기다렸다가 꺼내기」도 됩니다. event를 auto-reset으로 하면, 다른 대기가 signal을 소비했을 때 GetOverlappedResult가 멈추는 함정이 있으므로 manual-reset이 권장입니다.134

몇 건의 동시 I/O를 단단히 다루는, 가장 흐름이 잘 보이는 방법입니다. serial port처럼 「읽으면서 쓰기」가 필수인 device에서는 이 형태가 지금도 현역입니다(「시리얼 통신 앱의 함정」).

4.3. APC ── 발행한 thread로 배달된다

ReadFileEx/WriteFileEx는 event 대신 완료 루틴(callback)을 받습니다. 완료되면 그 루틴이 발행한 thread의 APC queue에 쌓이고, thread가 SleepExWaitForSingleObjectEx 같은 alertable wait에 들어갔을 때 실행됩니다.14515

이 방식의 특징은 「완료 처리가 반드시 발행 thread에서 돈다」는 점입니다. lock이 필요 없어지는 반면, 발행 thread가 alertable wait에 들어가지 않는 한 완료 루틴은 영원히 돌지 않습니다. UI thread의 message loop와 맞추려면 MsgWaitForMultipleObjectsEx가 필요한 등 대기 설계가 어렵고, 범용으로는 event나 IOCP를 고르는 경우가 많습니다.

그리고 「APC가 오지 않는다」는 이 방식의 전형적인 버그입니다. 원인은 거의 하나, 대기 방식이 alertable이 아닌 것입니다.

// C++ / Win32. hFile은 FILE_FLAG_OVERLAPPED로 연 handle,
// ov와 buf는 완료될 때까지 살려 두는 전제(3.2절)

// 나쁜 예: 완료 루틴은 영원히 호출되지 않는다
ReadFileEx(hFile, buf, len, ov, OnReadCompleted);
Sleep(1000);            // alertable이 아닌 대기. APC는 배달되지 않는다

// 좋은 예: 이 I/O가 끝날 때까지 alertable 대기를 이어 간다
//
// 완료 루틴 쪽에서 이 플래그를 세운다(ov를 담는 구조체 등에 둔다)
volatile bool completed = false;

// 발행에 성공했는지 반드시 확인한다. 0이 반환되면 완료 루틴이 쌓이지 않은 것이다
if (!ReadFileEx(hFile, buf, len, ov, OnReadCompleted))
{
    const DWORD err = GetLastError();   // 직후에 읽는다. 이후 API가 덮어쓴다
    ReportError(err);                   // 제거·잘못된 handle 등
    return;                             // ★ 아래 대기 루프에 들어가면 안 된다
}

while (!completed)
{
    DWORD r = SleepEx(1000, TRUE);   // 두 번째 인자의 TRUE가 alertable
    if (r == WAIT_IO_COMPLETION)
    {
        // 어떤 APC가 실행됐다. 다만 자기 I/O라고 단정할 수 없으므로,
        // completed를 보고 판단하고, 아니면 다시 기다린다
        continue;
    }
    // 타임아웃으로 돌아왔다. 아직 I/O는 나간 상태이므로,
    // 끊으려면 CancelIoEx로 취소하고, 완료가 배달될 때까지 기다린다
    CancelIoEx(hFile, ov);
}

ReadFileEx의 반환값을 보지 않고 대기 루프에 들어가지 마십시오. 장치를 막 뽑은 직후, handle이 이미 무효인 이유로 발행 자체가 실패하면 ReadFileEx는 0을 반환하고 완료 루틴은 하나도 쌓이지 않습니다. 이 상태에서 while (!completed)에 들어가면 completed는 영원히 서지 않고, 존재하지 않는 I/O에 대해 SleepExCancelIoEx를 끝없이 반복하는 루프가 됩니다. 게다가 겉보기에는 「장치가 응답하지 않을 뿐」이라 원인에 닿기까지가 깁니다. 0이 반환되면 GetLastError()그 자리에서 읽고(사이에 다른 API를 하나라도 끼우면 덮어씌워집니다), 대기에 들어가지 않고 빠져나옵니다.

SleepEx를 한 번 호출하는 것만으로는 부족합니다. 타임아웃으로 돌아오면 그 시점에 thread는 alertable wait를 빠져나옵니다. I/O는 아직 나간 상태이므로, 그 뒤 scope를 빠져나와 ovbuf가 사라지면 kernel이 살아 있다고 믿는 buffer를 밟습니다(3.2절). 완료했는지 여부를 완료 루틴 쪽에서 기록하고, 그것이 설 때까지 기다리거나, CancelIoEx로 취소한 뒤 완료 배달을 기다리기, 둘 중 하나로 하십시오.

WAIT_IO_COMPLETION이 반환된 것은 「APC가 하나 이상 실행됐다」는 뜻일 뿐, 그것이 자기 I/O의 완료 루틴이라고 단정할 수 없습니다. 같은 thread에 다른 I/O나 QueueUserAPC의 APC가 쌓여 있으면 그쪽에서 돌아옵니다. 그래서 반환값만으로 판단하지 말고, 스스로 세운 플래그를 봅니다.

대기 함수 선택 자체는 단순합니다. SleepSleepEx(..., TRUE)로, WaitForSingleObjectWaitForSingleObjectEx(..., TRUE)로 바꿉니다. 완료 루틴을 썼는데 아무 일도 일어나지 않을 때는, 먼저 대기 함수 끝에 Ex가 붙어 있는지와 alertable 인자가 TRUE인지를 보십시오.5

4.4. I/O completion port ── 스케일의 본좌(다음 회)

다수의 동시 I/O를 소수 thread로 받기 위한 구조가 I/O completion port(IOCP)입니다. handle을 port에 연결해 두면 완료가 port의 queue에 들어가고, worker thread가 GetQueuedCompletionStatus로 꺼냅니다.6 .NET의 async/await I/O가 최종적으로 닿는 곳이기도 합니다. 다음 회에서 한 편 전체를 써서 파고듭니다.

5. 「비동기인데 동기 완료된다」는 문제

비동기 I/O 설계에서 처음 걸리는 지점이 여기입니다. 비동기 모드로 올바르게 발행해도, I/O가 동기적으로 완료되는 일은 흔합니다. Microsoft는 문제 해결 문서에서 대표적인 이유를 명시합니다.3

어느 것도 아님비동기 handle에 ReadFile/WriteFile을 발행동기 완료 조건에 해당하는가바로 충족할 수 있는 요청(데이터가 cache에 있는 경우 등)NTFS 압축된 파일(압축 파일은 비동기가 되지 않음)NTFS 암호화(EFS)된 파일파일 길이를 늘리는 쓰기TRUE로 바로 돌아옴= 호출 안에서 완료까지 실행됨ERROR_IO_PENDING으로 돌아옴= 정말로 비동기로 진행 중

그림 5: 동기 완료되는 주요 조건. cache·압축·암호화·확장 쓰기는 「비동기가 되지 않는다」

각각 이유가 있습니다.3

  • cache hit. 많은 driver는 「바로 완료할 수 있는 요청은 그 자리에서 완료시킨다」는 특별 취급을 갖고 있습니다. 디스크라면 데이터가 메모리상 cache에 있을 때입니다. 빠르니 불만은 없어야 하지만, 「반드시 ERROR_IO_PENDING이 돌아온다」는 전제의 코드는 여기서 깨집니다.
  • 반대로 cache에 없을 때도 함정이 있습니다. Windows cache는 file mapping으로 구현되어 있고, page가 없을 때의 page fault 처리에 비동기 구조가 없기 때문에, cache가 켜진 비동기 읽기는 동기적으로 처리되는 경우가 있습니다. cache 구조 자체는 제4회에서 다룹니다.
  • NTFS 압축·EFS 암호화. file system driver가 압축·암호화 파일 접근을 동기로 바꿉니다.
  • 파일을 늘리는 쓰기. 길이를 바꾸는 쓰기는 동기가 됩니다.

실무에 대한 함의는 단순합니다.

  1. 「TRUE로 바로 돌아오는」 경로를 반드시 씁니다. 그림 3의 세 갈래는 모두 정상 경로입니다. 다만 기본값에서는 동기 완료여도 완료 통지가 따로 도착하므로, 결과 처리 자체는 통지 경로로 한곳에 모아 두는 편이 안전합니다(3.3절).
  2. 응답성 보장에는 쓸 수 없습니다. 「비동기이니 UI가 멈추지 않는다」는 성립하지 않습니다. 멈추면 안 되는 thread에서는 애초에 I/O를 발행하지 않는 설계(전용 thread나 thread pool로의 분리)가 필요합니다. 이 실무는 「평범한 Windows에서 소프트 실시간을 가능한 한 실현하기 위한 실천 가이드」에서도 다루었습니다.
  3. 고빈도 I/O에서는 동기 완료가 최적화 기회이기도 합니다. 동기 완료 때 I/O completion port로의 패킷을 생략하는 SetFileCompletionNotificationModes라는 API가 있고, IOCP와 조합하면 효과가 있습니다(제3회).12

6. 취소와 뒷정리 ── 「멈춰 달라」는 요청이다

오래 걸리는 I/O(응답하지 않는 network 상대, 오지 않는 serial 데이터)를 멈추고 싶을 때의 바른 방법이 CancelIoEx입니다.7

driverI/O ManagerdriverI/O Manager해당하는 미완료 IRP에취소를 요청(마크한다)취소할 수 있는 상태면 중단완료 직전이면 정상 완료되기도 함이 통지를 확인한 뒤에OVERLAPPED와 buffer를 해제한다CancelIoEx(handle, OVERLAPPED)취소 루틴 호출IoCompleteRequest(STATUS_CANCELLED)완료 통지가 도착GetOverlappedResult는 ERROR_OPERATION_ABORTED

그림 6: 취소의 실제. 취소된 작업도 「완료」로 돌아온다

구조를 알면 당연한 귀결이 세 가지 있습니다.

  • 취소는 비동기의 「요청」입니다. CancelIoEx가 성공해도 「마크했다」는 것뿐입니다. 완료 직전이던 작업은 정상 완료되기도 합니다.8
  • 취소된 작업도 완료 통지로 ERROR_OPERATION_ABORTED로 돌아옵니다. 그 통지를 받을 때까지 OVERLAPPED와 buffer는 kernel이 사용 중입니다. 먼저 해제하면 메모리 파괴가 됩니다. 「취소했더니 죽기 시작했다」의 원인은 거의 이것입니다.78
  • handle을 닫기 전에 미완료 I/O를 정리합니다. 제1회에서 본 대로, 마지막 handle이 닫히면 cleanup 처리에서 미완료 IRP 취소가 돌아가지만, 「발행된 I/O가 남은 채로 handle만 닫는」 코드는 완료 통지와 buffer 수명 관리가 깨지기 쉽습니다. 취소→완료를 확인→닫기 순서가 원칙입니다.

보충이 두 가지 있습니다. 옛 CancelIo호출한 thread 자신이 발행한 작업만 취소할 수 있습니다(Vista에서 CancelIoEx가 들어오기까지의 제약으로, 지금 굳이 쓸 이유는 없습니다).16 또한 동기 I/O로 멈춰 있는 다른 thread에는 CancelSynchronousIo를 씁니다.11 「타임아웃은 OS가 알아서 해주지 않는다. 취소를 스스로 설계한다」──이것이 비동기 I/O 실무의 핵심입니다.

7. .NET에서 보면 ── 모드 불일치가 「가짜 비동기」를 만든다

여기까지의 이야기는 .NET 코드로 바로 이어집니다. FileStream 생성자의 useAsync(또는 FileOptions.Asynchronous)는 바로 FILE_FLAG_OVERLAPPED의 직통 스위치입니다(제1회 대응표 참조).

아니요await fs.ReadAsync(...)handle은 비동기 모드(FileOptions.Asynchronous)인가?진짜 비동기 I/OOVERLAPPED에 해당하는 작업을 발행하고완료는 IOCP를 거쳐 thread pool로(제3회)가짜 비동기thread pool의 thread가동기 Read를 대신 수행하며 기다림

그림 7: 같은 ReadAsync라도 handle 모드에 따라 지하 구조는 전혀 다른 것이 된다

차이가 나는 것은 파일을 여는 한 줄뿐입니다. ReadAsync 호출 쪽은 그대로이므로, 코드를 읽어도 알아채기 어렵습니다.

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;

string path = @"C:\temp\data.bin";
byte[] buffer = new byte[4096];

// (A) 가짜 비동기. useAsync를 생략/false로 하면 handle은 동기 모드로 열린다
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: false))
{
    // 호출한 쪽은 block되지 않지만, 뒤에서 thread pool의 thread 하나가 동기 Read를 대신 수행하며 기다린다
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (B) 진짜 비동기. useAsync: true가 FILE_FLAG_OVERLAPPED에 직결된다
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: true))
{
    // 완료는 IOCP를 거쳐 thread pool로(제3회)
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (C) .NET 6 이후. 모드와 offset을 명시하는 단순한 작성
using (SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read,
                                               options: FileOptions.Asynchronous))
{
    int read = await RandomAccess.ReadAsync(handle, buffer, fileOffset: 0);
}

(A)와 (B)의 차이는 useAsync 한 단어(FileOptions.Asynchronous로 써도 같습니다)뿐이며, 이것이 「가짜 비동기」의 재현 조건 그 자체입니다. 기존 코드를 점검할 때는 ReadAsync/WriteAsync 쪽이 아니라 FileStream을 만드는 곳을 찾으십시오. File.OpenReadnew FileStream(path, FileMode.Open) 같은 짧은 overload는 모두 동기 모드로 엽니다. 참고로 SafeFileHandle에서 FileStream을 만들 때는 isAsync 인자를 handle의 실제 모드와 일치시켜야 합니다.

  • 동기 모드 handle + ReadAsync는 thread pool thread에서 동기 읽기를 하는 「가짜 비동기」입니다. 호출한 쪽은 기다리지 않지만, 뒤에서 thread 하나가 잠들어 있습니다. 소수라면 피해는 작지만, 서버나 고빈도 처리에서는 thread pool 고갈의 원인이 됩니다.9
  • 비동기 모드 handle + 동기 Read도 반대 방향의 불일치로, 내부 완료 대기만큼 낭비가 납니다. 모드와 호출 API를 맞추는 것이 원칙입니다.10
  • .NET 6 이후FileStream 내부가 전면 다시 쓰였고, 여기에 File.OpenHandle + RandomAccess라는 「SafeFileHandle과 offset을 명시해 읽고 쓰는」 API가 들어왔습니다.9 offset을 매번 넘기는 이 형태는, 이 글에서 본 비동기 handle + OVERLAPPED.Offset이라는 Win32의 맨얼굴 그대로입니다.
  • 비동기 모드 handle이면 CancellationToken에 의한 파일 I/O 취소는 내부적으로 CancelIoEx에 닿습니다. 토큰을 넘긴 ReadAsyncOperationCanceledException으로 끝나는 뒤에는 6장의 그림이 그대로 돌아갑니다. 취소가 「요청」이고 즉시성이 보장되지 않는 것도 같습니다. 반면 동기 모드 handle의 「가짜 비동기」에서는 취소 대상이 되는 overlapped 작업이 없으므로 이 경로는 쓸 수 없습니다. 최근 .NET runtime에는 이런 동기 실행 중인 호출에 CancelSynchronousIo로 취소를 시도하는 구조도 들어가 있지만, 효과가 있는지는 runtime 버전과 작업 종류에 의존하며 확실한 중단은 보장되지 않습니다. 취소를 설계 전제로 삼는다면 모드를 맞춰 진짜 비동기 I/O로 두는 것이 본류입니다.

참고로 async/await를 어떻게 써야 하는가라는 윗단 실무(ConfigureAwait, UI thread와의 관계)는 「C# async/await 실무 판단표」와 「WPF/WinForms의 async와 UI thread를 한 장으로 정리」를 보십시오. 이 글은 그 지하 1층이고, 다음 회(IOCP)가 지하 2층입니다.

8. 정리

  • 동기 I/O와 비동기 I/O는 별도 배관이 아니라, I/O Manager가 완료를 기다릴지, 기다리지 않고 돌아올지의 차이입니다. 동기 I/O의 thread는 대기 상태에서 잠들고 CPU는 쓰지 않습니다.1
  • 모드는 handle(file object)에, 상태는 작업(OVERLAPPED)에 있습니다. 비동기 handle에서는 file pointer가 관리되지 않으므로, 위치가 있는 파일에서는 매번 Offset으로 지정합니다.24
  • OVERLAPPED와 buffer는 완료 통지까지 살려 두고 건드리지 않습니다. 동시 발행 수만큼 준비합니다. 재사용은 데이터 손상입니다.3
  • 완료 통지는 handle·event·APC·IOCP의 네 경로입니다. 여러 동시 I/O에서 handle signal은 쓰지 않고, event는 manual-reset, APC는 alertable wait가 전제입니다.1135
  • 비동기로 발행해도 cache·NTFS 압축·암호화·확장 쓰기에서는 동기 완료됩니다. 「TRUE로 바로 돌아오는」 경로는 정상 경로로 반드시 쓰고, 응답성 보장에는 쓰지 마십시오.3
  • 취소는 요청입니다. CancelIoEx 뒤에도 완료 통지(ERROR_OPERATION_ABORTED)를 확인한 뒤에 뒷정리합니다. 취소→완료 확인→close 순서입니다.78
  • .NET의 FileOptions.AsynchronousFILE_FLAG_OVERLAPPED의 직통이며, 모드와 API의 불일치가 「가짜 비동기」를 만듭니다. CancellationToken의 지하에서는 CancelIoEx가 돌아갑니다.910

이어지는 글은 제3회 「I/O completion port(IOCP)와 .NET thread pool ── async/await의 지하실」입니다. 이번 4.4절에서 이름만 꺼낸 IOCP가 왜 「완료 통지의 queue」와 「실행 thread 수 제어」를 한 설계로 묶었는지, 그리고 await의 다음이 어느 thread에서 도는지까지 내려갑니다.

관련 글

관련 상담 영역

合同会社小村ソフト에서는 비동기 I/O를 쓰는 Windows 업무 앱·device 통신 앱의 설계와, 「멈춘다」「취소하면 죽는다」「thread pool이 마른다」 같은 장애의 원인 조사를 다룹니다.

참고 링크

  1. Microsoft Learn, Synchronous and asynchronous I/O. 동기 I/O에서는 함수가 I/O 완료까지 block하고, 비동기 I/O에서는 요청을 발행한 함수가 바로 돌아와 thread가 다른 일을 이어갈 수 있다는 점, 비동기 I/O에는 FILE_FLAG_OVERLAPPED를 지정해 handle을 열어야 한다는 점, 완료 통지 방법으로 file handle의 signal, OVERLAPPED 구조체에 지정한 event의 signal, alertable wait에서 실행되는 완료 루틴(APC), I/O completion port가 있다는 점, 여러 작업이 동시에 발행되어 있을 때 file handle의 signal로는 어느 작업의 완료인지 구별할 수 없다는 점에 대해.  2 3 4 5 6 7 8 9

  2. Microsoft Learn, ReadFile function. FILE_FLAG_OVERLAPPED로 연 handle에서는 lpOverlapped가 필수이고, 읽기 시작 위치를 OVERLAPPED 구조체의 Offset/OffsetHigh로 지정한다는 점, 비동기로 처리되면 FALSE와 ERROR_IO_PENDING이 반환된다는 점, 시스템이 비동기 handle의 file pointer를 유지하지 않는다는 점, FILE_FLAG_OVERLAPPED 없이 연 handle에 OVERLAPPED를 넘기면 지정 offset부터 읽지만 읽기가 완료될 때까지 ReadFile이 돌아오지 않는다는 점에 대해.  2 3 4 5

  3. Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. 비동기용으로 코딩해도 I/O가 동기적으로 완료되는 이유로, NTFS 압축 파일(file system driver가 압축 파일에 비동기로 접근하지 않아 모든 작업이 동기가 됨), NTFS 암호화 파일, 파일 길이를 늘리는 쓰기, 요청을 즉시 충족할 수 있는 경우(데이터가 메모리상 cache에 있는 경우 등)에 driver가 작업을 그 자리에서 완료시키고 TRUE를 반환한다는 점, Windows cache가 file mapping으로 구현되어 page가 없을 때 비동기 page fault 기구가 없다는 점, 더불어 I/O를 3개 발행하려면 OVERLAPPED 구조체가 3개 필요하고 재사용하면 예측할 수 없는 결과나 데이터 손상으로 이어진다는 점, 작업이 완료될 때까지 대응하는 data buffer를 읽거나 쓰면 안 된다는 점에 대해.  2 3 4 5 6 7

  4. Microsoft Learn, OVERLAPPED structure. OVERLAPPED 구조체가 비동기 입출력을 위한 정보를 담는다는 점, Offset/OffsetHigh가 파일 위치를, hEvent가 완료 시 signal되는 event를, Internal/InternalHigh가 작업의 상태 코드와 전송 바이트 수를 담는다는 점, 작업 실행 중에는 구조체를 바꾸지 말고 유효한 채로 유지해야 한다는 점, event를 쓸 때의 주의점에 대해.  2 3 4

  5. Microsoft Learn, Alertable I/O. alertable I/O에서는 완료 루틴으로의 진입이 thread의 APC queue에 쌓인다는 점, thread가 SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx 등으로 alertable 상태에 들어갔을 때 APC가 실행된다는 점, APC가 반드시 발행한 thread의 context에서 실행된다는 점에 대해.  2 3 4

  6. Microsoft Learn, I/O Completion Ports. I/O completion port가 멀티프로세서 시스템에서 다수의 비동기 I/O 요청을 처리하기 위한 효율적인 threading 모델을 제공한다는 점, file handle을 port에 연결하면 완료 패킷이 queue에 들어가 worker thread가 GetQueuedCompletionStatus로 꺼낸다는 점, 동시에 실행하는 thread 수를 port가 제어한다는 점에 대해.  2

  7. Microsoft Learn, CancelIoEx function. CancelIoEx가 어느 thread가 발행했는지와 관계없이 지정 handle에 대한 미완료 I/O에 취소 마크를 붙인다는 점, lpOverlapped를 지정하면 그 작업만, NULL이면 모든 미완료 I/O가 대상이 된다는 점, 취소된 작업이 ERROR_OPERATION_ABORTED로 완료된다는 점, 모든 작업의 취소가 보장되는 것은 아니며 완료 처리가 끝날 때까지 기다려야 한다는 점에 대해.  2 3 4

  8. Microsoft Learn, Canceling pending I/O operations. 미완료 I/O 취소의 구조와, 취소를 요청해도 작업이 이미 완료로 향하는 경우가 있다는 점, 취소된 작업의 완료를 확인한 뒤에 리소스를 해제해야 한다는 점, 동기 작업에는 CancelSynchronousIo를, 비동기 작업에는 CancelIo/CancelIoEx를 쓰는 정리에 대해.  2 3 4

  9. Microsoft .NET Blog, File IO improvements in .NET 6. .NET 6에서 FileStream 내부 구현이 전면 다시 쓰였다는 점, handle이 비동기 모드로 열려 있는지에 따라 전략이 나뉜다는 점, File.OpenHandle로 SafeFileHandle을 직접 얻고 RandomAccess로 offset을 명시한 읽기/쓰기(thread-safe)가 가능해졌다는 점, 비동기 모드가 아닌 handle에 대한 비동기 호출이 thread pool로 offload된다는 점에 대해.  2 3 4

  10. Microsoft Learn, Asynchronous file I/O (.NET). .NET에서의 비동기 파일 I/O 생각과, FileStream에서 비동기 I/O를 쓸 때 생성자에서 useAsync(FileOptions.Asynchronous)를 지정해 OS 수준 비동기 I/O를 켠다는 점, 동기 메서드와 비동기 메서드의 쓰임 구분에 대해.  2 3

  11. Microsoft Learn, CancelSynchronousIo function. CancelSynchronousIo가 지정한 thread가 실행 중인 동기 I/O 작업에 취소 마크를 붙인다는 점, 취소된 작업이 ERROR_OPERATION_ABORTED로 실패하여 반환된다는 점에 대해.  2

  12. Microsoft Learn, SetFileCompletionNotificationModes function. FILE_SKIP_COMPLETION_PORT_ON_SUCCESS로 I/O가 즉시 성공한 경우 I/O completion port에 완료 패킷을 쌓지 않는 동작을 고를 수 있다는 점, FILE_SKIP_SET_EVENT_ON_HANDLE로 file handle event 설정을 생략할 수 있다는 점에 대해.  2

  13. Microsoft Learn, GetOverlappedResult function. GetOverlappedResult가 비동기 작업의 결과(성패와 전송 바이트 수)를 얻는다는 점, bWait에 TRUE를 넘기면 작업 완료까지 기다린다는 점, OVERLAPPED의 hEvent에 auto-reset event를 지정한 채 다른 대기가 signal을 소비하면 bWait=TRUE 호출이 완료를 감지하지 못하고 계속 기다릴 수 있으므로 manual-reset event를 써야 한다는 점에 대해.  2 3

  14. Microsoft Learn, ReadFileEx function. ReadFileEx가 읽기 완료 때 호출되는 완료 루틴(FileIOCompletionRoutine)을 받는다는 점, 완료 루틴은 호출한 thread가 alertable wait 상태일 때 실행된다는 점, FILE_FLAG_OVERLAPPED로 연 handle이 필요하다는 점에 대해. 

  15. Microsoft Learn, Asynchronous Procedure Calls. APC가 특정 thread의 context에서 비동기로 실행되는 함수이며 각 thread가 자신의 APC queue를 갖는다는 점, user-mode APC는 thread가 alertable 상태일 때만 실행된다는 점에 대해. 

  16. Microsoft Learn, CancelIo function. CancelIo가 취소할 수 있는 것은 호출한 thread 자신이 발행한 I/O 작업뿐이라는 점, 다른 thread가 발행한 작업까지 취소하려면 CancelIoEx를 쓴다는 점에 대해. 

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

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

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

자주 묻는 질문

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

FILE_FLAG_OVERLAPPED를 지정하면 무엇이 달라지나요?
handle 뒤에 있는 file object가 「비동기 모드」로 열립니다. 이것은 CreateFile 시점에 정해지는 handle 단위 속성이며, 호출마다 동기·비동기를 바꿀 수는 없습니다. 비동기 모드 handle에서는 ReadFile/WriteFile에 OVERLAPPED 구조체를 반드시 넘깁니다. 시스템은 이 handle에 대해 file pointer(현재 위치)를 관리하지 않으므로, 디스크상의 파일처럼 위치가 있는 device에서는 읽기/쓰기 위치도 OVERLAPPED의 Offset으로 매번 지정합니다(serial port처럼 위치가 없는 device에서는 Offset을 쓰지 않습니다). 발행한 작업은 완료를 기다리지 않고 제어가 돌아올 수 있으며, 그때 ReadFile은 FALSE를 반환하고 GetLastError가 ERROR_IO_PENDING이 됩니다. 완료는 event, APC, I/O completion port 등의 통지로 받습니다.
비동기 I/O를 발행했는데 바로 완료되어 돌아오는 이유는 무엇인가요?
비동기 모드는 「완료를 기다리지 않아도 된다」는 뜻이지 「반드시 대기하지 않는다」는 뜻이 아니기 때문입니다. Microsoft 문서는 비동기로 발행해도 I/O가 동기적으로 완료되는 대표적인 이유로, 요청을 바로 충족할 수 있는 경우(데이터가 cache에 있는 경우 등), NTFS 압축 파일, NTFS 암호화(EFS) 파일, 파일 길이를 늘리는 쓰기를 듭니다. 이때 ReadFile/WriteFile은 TRUE를 반환하고 결과는 그 자리에서 확정됩니다. 따라서 비동기 I/O를 쓰는 코드는 「ERROR_IO_PENDING으로 돌아오는 경우」와 「그 자리에서 완료되는 경우」를 둘 다 반드시 가정해야 하며, 응답성이 절대 보장되는 것도 아닙니다. 참고로 기본값에서는 동기 완료된 작업에 대해서도 완료 통지(event signal이나 I/O completion port 패킷)가 따로 도착하므로, 결과 처리는 통지 쪽으로 한곳에 모아 두는 편이 안전합니다.
OVERLAPPED 구조체는 재사용해도 되나요?
동시에 여러 작업에서 공유해서는 안 됩니다. OVERLAPPED 구조체는 「발행 중인 작업 1건분의 상태」를 나타내며, Microsoft 문서에도 I/O를 3개 발행하려면 OVERLAPPED 구조체가 3개 필요하고 재사용하면 예측할 수 없는 결과나 데이터 손상으로 이어진다고 명시되어 있습니다. 작업이 완료될 때까지는 구조체와 읽기/쓰기 buffer를 유효한 채로 유지하고 내용을 건드리지 말아야 합니다. 완료 뒤에 재사용할 때는 이전에 남은 데이터가 영향을 주지 않도록 매번 다시 초기화합니다. event를 넣는 hEvent에는 manual-reset event를 쓰는 편이 안전합니다.
실행 중인 I/O를 도중에 취소하려면 어떻게 하나요?
CancelIoEx를 쓰면 어느 thread가 발행했는지와 관계없이, 지정 handle의 미완료 I/O에 취소를 요청할 수 있습니다. 두 번째 인자에 OVERLAPPED를 넘기면 특정 작업 하나만, NULL이면 그 handle의 모든 작업이 대상입니다. 옛 CancelIo는 「호출한 thread 자신이 발행한 작업」만 취소할 수 있습니다. 핵심은 취소가 「요청」이지 즉시 「보장」이 아니라는 점입니다. 이미 완료 직전이던 작업은 정상 완료될 수 있고, 취소된 작업은 ERROR_OPERATION_ABORTED로 완료 통지됩니다. 어느 쪽이든 완료 통지를 받을 때까지는 OVERLAPPED 구조체와 buffer를 해제해서는 안 됩니다. 다른 thread에서 동기 I/O에 들어가 멈춰 있는 thread에는 CancelSynchronousIo라는 전용 API가 있습니다.
.NET의 FileStream에서 FileOptions.Asynchronous(useAsync)를 지정하지 않으면 어떻게 되나요?
handle이 동기 모드로 열리므로 ReadAsync/WriteAsync를 호출해도 진짜 비동기 I/O가 되지 않고, thread pool의 thread가 동기 읽기/쓰기를 대신 수행하는 「가짜 비동기」가 됩니다. 호출한 쪽 thread는 block되지 않지만, 뒤에서 다른 thread가 대기하고 있으므로 thread pool 고갈이나 확장성 저하의 원인이 됩니다. 반대로 비동기 모드로 연 뒤 동기 Read/Write를 호출하면 내부에서 완료 대기의 오버헤드가 붙습니다. 「handle의 모드」와 「호출하는 API」를 맞추는 것이 원칙이며, .NET 6 이후라면 File.OpenHandle과 RandomAccess로 모드와 offset을 명시한 단순한 작성이 가능합니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기