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

· · Windows, Win32, I/O, 비동기, OVERLAPPED, 커널, .NET, CSharp

지난 회(제1회)에서는 Windows의 I/O 요청이 IRP라는 패킷이 되어 디바이스 스택을 흐른다는 것, 그리고 발행과 완료는 커널의 밑바닥에서 처음부터 분리되어 있다는 것을 살펴보았습니다.

이번 회에서는 그 분리를 애플리케이션 쪽에서 사용하는 구조──비동기 I/O(오버랩 I/O)──를 파고듭니다. FILE_FLAG_OVERLAPPED를 붙였는데 동기로 반환된다. OVERLAPPED를 재사용했더니 데이터가 깨졌다. CancelIoEx를 호출했는데 멈추지 않는다. 취소했더니 액세스 위반으로 죽었다──이런 ‘비동기 I/O의 무서운 이야기’는 모두 구조를 한 장의 그림으로 갖고 있지 않은 것에서 비롯됩니다. 이 글에서 그 그림을 만들겠습니다.

연재 ‘Windows I/O의 심층’의 제2회입니다. 전체 구성은 제1회의 도입부에 실어 두었습니다.

1. 먼저 결론

  • 동기 I/O와 비동기 I/O를 가르는 것은 커널이 아니라 ‘기다리는지 여부’입니다. 동기 I/O의 보장은 ‘완료되기 전에는 돌아오지 않는다’는 것입니다. 요청이 펜딩 상태가 되었을 때만 I/O 매니저가 완료를 기다리며, 스레드는 CPU를 사용하지 않는 대기 상태로 잠듭니다(2장).1
  • FILE_FLAG_OVERLAPPED는 핸들(파일 오브젝트)의 모드입니다. CreateFile 시점에 결정되며 호출할 때마다 전환할 수 없습니다. 비동기 핸들에서는 시스템이 파일 포인터를 관리하지 않으므로, 디스크상의 파일에서는 매번 OVERLAPPEDOffset으로 위치를 지정합니다(3장).21
  • OVERLAPPED 구조체는 ‘조작 1건분의 전표’입니다. 발행 중인 조작의 수만큼 필요하며, 공유·재사용은 데이터 손상으로 이어진다고 공식 문서에 명시되어 있습니다. 완료될 때까지는 구조체도 버퍼도 건드려서는 안 됩니다(3장).34
  • 완료를 받는 방법은 사실상 4가지입니다. 핸들의 시그널(비권장), OVERLAPPED의 이벤트+GetOverlappedResult, APC(alertable wait), 그리고 I/O 완료 포트(다음 회)입니다(4장).156
  • 비동기로 발행해도 동기 완료되는 경우가 있습니다. 캐시에 올라와 있는 경우, NTFS 압축/암호화, 파일을 늘리는 쓰기──가 대표적인 예입니다. ‘비동기 = 절대 블록되지 않는다’가 아닙니다(5장).3
  • 취소는 ‘요청’입니다. CancelIoEx를 호출해도 조작은 ERROR_OPERATION_ABORTED로 완료되어 돌아옵니다. 그 완료를 확인할 때까지 뒷정리를 해서는 안 됩니다(6장).78
  • .NET의 FileOptions.Asynchronous는 이 모드로 직결되는 스위치입니다. 핸들의 모드와 호출하는 API가 어긋나면, 스레드 풀이 대신 처리하는 ‘가짜 비동기’나 불필요한 대기가 발생합니다(7장).910

2. 동기 I/O ── 스레드는 어디서 잠드는가

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

제1회에서 본 것처럼, 드라이버는 요청을 보류(펜딩) 상태로 두고 하드웨어의 응답을 기다립니다. 그렇다면 동기 I/O일 때는 누가 기다리고 있을까요. I/O 매니저가 완료를 기다린 뒤에 앱으로 제어를 돌려줍니다.

드라이버(스택)I/O 매니저앱 스레드드라이버(스택)I/O 매니저앱 스레드스레드는 커널 내부에서 대기 상태가 되어CPU를 소비하지 않고 잠든다ReadFile(동기 핸들)IRP 발행STATUS_PENDING(응답 대기)완료(IoCompleteRequest)결과를 반환하고 깨어남ReadFile이 TRUE/FALSE로 반환

그림 1: 동기 I/O(요청이 펜딩 상태가 된 경우). I/O 매니저가 완료를 기다린 뒤 앱으로 돌아간다

다만 이 그림은 요청이 펜딩 상태가 된 경우입니다. 드라이버가 그 자리에서 완료할 수 있는 요청(캐시 히트 등. 제1회·그림 5의 ‘즉시 완료’ 경로)이라면 대기는 어디에서도 발생하지 않고, 그대로 결과를 가지고 돌아옵니다. 동기 I/O의 보장은 ‘완료되기 전에는 돌아오지 않는다’는 것이지 ‘반드시 잠든다’는 것이 아닙니다.

짚어 두어야 할 점은 두 가지입니다.

  • ‘기다림’은 CPU를 소비하지 않습니다. 대기 상태의 스레드는 스케줄러의 실행 대상에서 제외됩니다. 폴링으로 스스로의 목을 조르느니 기다리게 하는 편이 낫다는 이야기는 ‘Windows에서 타이머 대기보다 이벤트 대기를 우선하는 이유‘에 썼습니다.
  • 동기 모드 핸들에서는 커널이 파일 포인터(현재 위치)를 관리합니다. 그래서 연속된 ReadFile이 ‘이어서’ 읽을 수 있습니다. 이 상태는 핸들이 아니라 파일 오브젝트에 있으므로, DuplicateHandle로 복제한 핸들과는 위치를 공유합니다(제1회 3.3절).

동기 I/O의 약점은 기다리는 동안 그 스레드가 다른 일을 할 수 없다는 것으로 귀결됩니다. UI 스레드에서 동기 I/O를 하면 화면이 멈추고, 서버에서 접속마다 스레드를 세우면 수백 접속에서 스레드투성이가 됩니다. 참고로 동기 I/O로 멈춰 있는 다른 스레드를 밖에서 구출하기 위한 CancelSynchronousIo라는 API도 존재합니다(6장).11

3. 비동기 I/O ── 핸들의 모드와 조작의 전표

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

CreateFileFILE_FLAG_OVERLAPPED를 전달하면 그 핸들 뒤에 있는 파일 오브젝트가 비동기 모드로 열립니다.1 여기서 중요한 것은 이것이 핸들 단위의 성질이라는 점입니다. ‘이 호출만 비동기로’는 할 수 없습니다. 같은 파일을 동기용·비동기용 두 핸들로 여는 것은 가능합니다(파일 오브젝트가 두 개 생길 뿐입니다).

비동기 모드 핸들에는 또 하나 중요한 차이가 있습니다. 시스템이 파일 포인터를 관리하지 않습니다.2 여러 조작이 동시에 날아다니는 상태에서는 ‘현재 위치’가 의미를 갖지 않기 때문입니다. 디스크상의 파일처럼 위치를 가지는 디바이스에서는 읽기/쓰기 위치를 매번 OVERLAPPED 구조체의 Offset/OffsetHigh로 명시합니다. 반면 시리얼 포트나 명명된 파이프처럼 시크 위치라는 개념이 없는 디바이스에서는 Offset이 위치 지정으로 사용되지 않습니다(0으로 둡니다). 이 경우에도 다음 절에서 보듯 OVERLAPPED 구조체 자체는 조작 하나마다 필요합니다.

3.2. OVERLAPPED는 ‘조작 1건분의 전표’

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

멤버 역할
Offset / OffsetHigh 이 조작이 읽고 쓰는 파일상의 위치(발행 시 지정. 위치를 가지지 않는 디바이스에서는 미사용)
hEvent 완료 시 시그널되는 이벤트(선택. 수동 리셋 권장)
Internal 조작의 상태. 완료 전에는 STATUS_PENDING에 해당하는 값이 들어감(시스템용)
InternalHigh 완료 시 전송된 바이트 수(시스템용)
OVERLAPPED 구조체 = 조작 1건분의 전표Offset - 어디를 읽을 것인가hEvent - 완료를 어떻게 알 것인가Internal/InternalHigh -상태와 결과, 시스템이 기록핸들 - 파일 오브젝트 = 모드동기 모드커널이 현재 위치를 관리완료까지 ReadFile이 반환되지 않음비동기 모드 - FILE_FLAG_OVERLAPPED현재 위치는 관리되지 않음발행과 완료가 분리됨CreateFile 시점에 한 번만 결정됨ReadFile/WriteFile을 발행할 때마다 1개씩 준비

그림 2: 모드는 핸들에, 상태는 조작(전표)에 있다. 이 역할 분담을 혼동하면 사고로 이어진다

여기서부터 공식 문서가 명시하고 있는 두 가지 금지 사항이 자연스럽게 도출됩니다.3

  1. 동시에 진행 중인 조작의 수만큼 OVERLAPPED가 필요합니다. 3개를 발행한다면 3개. 재사용하면 ‘예측할 수 없는 결과나 데이터 손상’으로 이어집니다.
  2. 완료될 때까지는 OVERLAPPED도 데이터 버퍼도 살려 두고 건드려서는 안 됩니다. 커널이 그 영역에 쓰기 위해 찾아오기 때문입니다. 로컬 변수인 OVERLAPPED로 발행해 놓고 함수를 빠져나가는 것──은 스택을 커널이 밟게 만드는 전형적인 사고입니다.

3.3. 발행 결과는 세 가지로 나뉜다

비동기 핸들에 대한 ReadFile은 세 가지 방식으로 반환됩니다.2

ReadFile - 비동기 핸들, OVERLAPPED 포함반환값은?TRUE그 자리에서 완료됨 - 동기 완료기본적으로 완료 통지도 별도로 도착FALSE + ERROR_IO_PENDING접수됨. 완료는 나중에 통지된다FALSE + 그 외의 오류발행 자체가 실패한 것완료 통지를 기다린다4장의 4가지 방식

그림 3: 비동기 발행의 세 갈래. TRUE(즉시 완료)와 ERROR_IO_PENDING 양쪽을 모두 올바르게 처리해야 비로소 비동기 I/O가 제대로 동작한다

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

ReadFile의 반환값 GetLastError() 의미 호출자가 할 일
TRUE (보지 않음) 그 자리에서 완료됨(동기 완료) 기본적으로 완료 통지도 별도로 도착한다. 결과 처리는 통지 쪽에 맡긴다
FALSE ERROR_IO_PENDING(997) 접수됨. 진행 중 아무것도 하지 않는다. OVERLAPPED도 버퍼도 건드리지 않고 완료 통지를 기다린다
FALSE 그 외 발행 자체가 실패한 것 완료 통지는 오지 않는다. 그 자리에서 오류 처리를 하고 OVERLAPPED와 버퍼를 뒷정리한다
// C++ / Win32
// hFile : FILE_FLAG_OVERLAPPED로 연 핸들
// ov    : 이 조작 전용으로 확보한 OVERLAPPED(Offset과 hEvent는 설정 완료)
// buf/len: 이 조작 전용 버퍼. 완료 통지를 받을 때까지 해제하지 않음
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 완료 포트에 연결된 핸들이라면 완료 패킷이 큐에 쌓이고, 이벤트 방식이라면 이벤트도 시그널됩니다. 따라서 ‘TRUE라면 그 자리에서 결과를 처리하고, 통지가 오면 또 처리한다’라고 작성하면 같은 조작을 이중으로 처리해 전표를 이중으로 해제하는 사고가 됩니다. 안전한 기본형은 ‘TRUE(동기 완료)’와 ‘ERROR_IO_PENDING’ 두 경로에서 결과 처리를 통지 쪽으로 일원화하는 것입니다. 다만 세 번째 경로──발행 자체가 실패한 경우(FALSE+그 외의 오류)에는 완료 통지가 오지 않습니다. 이를 통지 대기로 돌리면 영원히 기다리게 되므로, 발행한 쪽에서 그 자리에서 오류 처리와 전표 뒷정리를 수행합니다. ‘동기 완료 시에는 통지를 건너뛰고 그 자리에서 처리’로 전환하고 싶을 때만 SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)를 명시적으로 활성화합니다──다만 이것이 억제하는 것은 I/O 완료 포트로의 패킷뿐이며, OVERLAPPED.hEvent의 시그널은 억제되지 않습니다. 이벤트 방식에는 사용할 수 없는, IOCP 경로 전용 최적화입니다(5장).12

참고로 동기 모드 핸들에 OVERLAPPED를 전달한 경우, Offset의 위치부터 읽히기는 하지만 완료까지 블록되는 동작은 변하지 않습니다.2 ‘OVERLAPPED를 전달했으니 비동기’가 되는 것은 아닙니다. 모드는 어디까지나 핸들이 가지고 있습니다.

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

발행과 완료가 분리된 이상, ‘완료되었다’를 어떻게 받을지가 설계의 핵심이 됩니다. 경로는 사실상 4가지입니다.1

커널에서 I/O 완료IoCompleteRequest → APC로 결과 확정1 - 파일 핸들이 시그널 상태가 된다수신 - WaitForSingleObject, 핸들2 - OVERLAPPED의 hEvent가 시그널 상태가 된다수신 - WaitForSingleObject + GetOverlappedResult3 - 완료 루틴이 발행 스레드의 APC 큐에 쌓인다수신 - SleepEx 등의 alertable wait 중에 실행4 - I/O 완료 포트에 완료 패킷이 들어간다수신 - GetQueuedCompletionStatus, 제3회

그림 4: 완료 통지의 4가지 경로. 어느 것으로 받을지는 발행 방법(hEvent의 유무, ReadFileEx, 포트 연결)에 따라 결정된다

먼저 전체상을 표로 정리해 둡니다. 각 절은 이 표 내용에 대한 설명입니다.

방식 완료 처리가 실행되는 스레드 동시에 발행할 수 있는 I/O 수 적합한 상황
(1) 핸들의 시그널 대기한 임의의 스레드 사실상 1개. 여러 개를 발행하면 어느 것이 완료됐는지 구별할 수 없음 거의 없음(4.1)
(2) 이벤트 + GetOverlappedResult 대기한 임의의 스레드 조작마다 이벤트가 1개 필요. WaitForMultipleObjects로 한꺼번에 기다릴 경우 64개가 상한 몇 개 수준의 동시 I/O. 디바이스 통신(4.2)
(3) APC(ReadFileEx) 발행한 스레드. 그것도 alertable wait에 들어가 있는 동안만 개수 제약은 없지만 완료 처리는 모두 그 스레드 1개에서 직렬로 실행됨 단일 스레드로 완결시키고 싶은 통신 처리(4.3)
(4) I/O 완료 포트 포트에 연결된 워커 스레드 그룹 다수를 소수의 스레드로 받을 수 있음 서버, 스레드 풀(4.4)

4.1. 핸들의 시그널 ── 사용하지 않는다

hEvent를 넣지 않고 발행하면 완료 시 파일 핸들 자체가 시그널 상태가 됩니다. 언뜻 간편해 보이지만, 같은 핸들로 여러 조작이 날아다니고 있으면 어느 것이 완료됐는지 구별할 수 없습니다.1 ‘비동기 I/O는 한 번에 하나씩만 발행한다’는 특수한 경우를 제외하면 사용하지 않는 것이 안전합니다.

4.2. 이벤트 + GetOverlappedResult ── 기본형

OVERLAPPED.hEvent수동 리셋 이벤트를 넣어 발행하고, WaitForSingleObject(또는 WaitForMultipleObjects로 여러 개를 동시에)로 기다린 뒤, GetOverlappedResult로 결과(성공 여부와 전송 바이트 수)를 꺼냅니다.13 GetOverlappedResultbWait에 TRUE를 전달하면 ‘완료까지 기다렸다가 꺼내기’도 가능합니다. 이벤트를 자동 리셋으로 하면, 다른 대기가 시그널을 소비했을 때 GetOverlappedResult가 멈춰 버리는 함정이 있으므로 수동 리셋이 권장됩니다.134

몇 개 수준의 동시 I/O를 견실하게 다루는, 가장 이해하기 쉬운 방법입니다. 시리얼 포트처럼 ‘읽으면서 쓰기’가 필수인 디바이스에서는 이 형태가 지금도 현역입니다(‘시리얼 통신 앱의 함정’).

4.3. APC ── 발행한 스레드로 배달된다

ReadFileEx/WriteFileEx는 이벤트 대신 완료 루틴(콜백)을 받습니다. 완료되면 그 루틴이 발행한 스레드의 APC 큐에 쌓이고, 스레드가 SleepExWaitForSingleObjectEx 등의 alertable wait에 들어갔을 때 실행됩니다.14515

이 방식의 특징은 ‘완료 처리가 반드시 발행 스레드에서 실행된다’는 것입니다. 락이 필요 없어지는 대신, 발행 스레드가 alertable wait에 들어가지 않는 한 완료 루틴은 영원히 실행되지 않습니다. UI 스레드의 메시지 루프와 조합하려면 MsgWaitForMultipleObjectsEx가 필요한 등 대기 방식의 설계가 어려워, 범용으로는 이벤트나 IOCP를 선택하는 경우가 많을 것입니다.

그리고 ‘APC가 오지 않는다‘는 이 방식의 전형적인 버그입니다. 원인은 거의 하나, 기다리는 방식이 alertable이 아니기 때문입니다.

// C++ / Win32. hFile는 FILE_FLAG_OVERLAPPED로 연 핸들,
// 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);                   // 기기 분리·잘못된 핸들 등
    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의 반환값을 확인하지 않고 대기 루프에 들어가지 마십시오. 기기가 방금 뽑혔거나 핸들이 이미 무효인 등의 이유로 발행 자체가 실패하면 ReadFileEx는 0을 반환하며, 완료 루틴은 하나도 쌓이지 않습니다. 이 상태에서 while (!completed)에 들어가면 completed는 영원히 세워지지 않고, 존재하지 않는 I/O에 대해 SleepExCancelIoEx를 끝없이 반복하는 루프가 됩니다. 게다가 겉보기에는 ‘기기가 응답하지 않는 것’처럼 보이므로 원인에 도달하기까지 오래 걸립니다. 0이 반환되면 GetLastError()그 자리에서 받고(사이에 다른 API를 하나만 끼워도 덮어써집니다), 대기에 들어가지 않고 빠져나옵니다.

SleepEx를 한 번만 호출하는 것으로는 부족합니다. 타임아웃으로 돌아오면 그 시점에 스레드는 alertable wait를 빠져나갑니다. I/O는 아직 나가 있는 상태이므로, 그 뒤 스코프를 벗어나 ovbuf가 사라지면 커널이 살아 있다고 생각하는 버퍼를 밟게 됩니다(3.2절). 완료 여부를 완료 루틴 쪽에서 기록해 그것이 세워질 때까지 계속 기다리거나, CancelIoEx로 취소한 뒤 완료의 배달을 기다리거나, 둘 중 하나로 하십시오.

WAIT_IO_COMPLETION이 반환된 것은 ‘APC가 하나 이상 실행되었다’는 것을 의미할 뿐이며, 그것이 자신의 I/O에 대한 완료 루틴이라고는 할 수 없습니다. 같은 스레드에 다른 I/O나 QueueUserAPC의 APC가 쌓여 있다면 그쪽에서 돌아온 것일 수 있습니다. 그래서 반환값만으로 판단하지 말고, 스스로 세운 플래그를 확인합니다.

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

4.4. I/O 완료 포트 ── 확장성을 갖춘 본명(다음 회)

다수의 동시 I/O를 소수의 스레드로 받기 위한 구조가 I/O 완료 포트(IOCP)입니다. 핸들을 포트에 연결해 두면 완료가 포트의 큐에 들어가고, 워커 스레드가 GetQueuedCompletionStatus로 꺼냅니다.6 .NET의 async/await의 I/O가 최종적으로 도달하는 곳이기도 합니다. 다음 회에서 한 편을 통째로 사용해 파고들겠습니다.

5. ‘비동기일 텐데 동기 완료된다’ 문제

비동기 I/O를 설계할 때 처음 걸려 넘어지는 부분이 여기입니다. 비동기 모드로 올바르게 발행해도 I/O가 동기적으로 완료되는 일은 흔히 있습니다. 마이크로소프트는 트러블슈팅 문서에서 대표적인 이유를 명시하고 있습니다.3

어느 것도 아님비동기 핸들에 ReadFile/WriteFile을 발행동기 완료 조건에 해당하는가바로 충족 가능한 요청데이터가 캐시에 있는 경우 등NTFS로 압축된 파일압축 파일은 비동기가 되지 않음EFS로 NTFS 암호화된 파일파일 길이를 늘리는 쓰기TRUE로 즉시 반환됨= 호출 안에서 완료까지 실행된 것ERROR_IO_PENDING으로 반환됨= 진짜로 비동기로 진행 중

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

각각 이유가 있습니다.3

  • 캐시 히트. 많은 드라이버는 ‘바로 완료할 수 있는 요청은 그 자리에서 완료시킨다’는 특별 취급을 가지고 있습니다. 디스크라면 데이터가 메모리상의 캐시에 있을 때입니다. 빠르니 불만은 없어야 하지만, ‘반드시 ERROR_IO_PENDING이 반환된다’는 전제로 짠 코드는 여기서 깨집니다.
  • 반대로 캐시에 없을 때도 함정이 있습니다. Windows의 캐시는 파일 매핑으로 구현되어 있으며, 페이지가 없을 때의 페이지 폴트 처리에는 비동기 메커니즘이 없기 때문에, 캐시가 유효한 비동기 읽기가 동기적으로 처리되는 경우가 있습니다. 캐시 메커니즘 자체는 제4회에서 다룹니다.
  • NTFS 압축·EFS 암호화. 파일 시스템 드라이버가 압축·암호화 파일에 대한 접근을 동기로 변환합니다.
  • 파일을 늘리는 쓰기. 길이를 변경하는 쓰기는 동기가 됩니다.

실무에 대한 시사점은 단순합니다.

  1. ‘TRUE로 즉시 반환되는’ 경로를 반드시 작성한다. 그림 3의 세 갈래는 모두 정상 경로입니다. 다만 기본적으로 동기 완료여도 완료 통지는 별도로 도착하므로, 결과 처리 자체는 통지 경로로 일원화해 두는 것이 안전합니다(3.3절).
  2. 응답성 보장에는 사용할 수 없다. ‘비동기니까 UI가 멈추지 않는다’는 성립하지 않습니다. 멈추면 안 되는 스레드에서는 애초에 I/O를 발행하지 않는 설계(전용 스레드나 스레드 풀로의 분리)가 필요합니다. 이 부분의 실무는 ‘일반 Windows에서 소프트 리얼타임을 최대한 실현하기 위한 실전 가이드‘에서도 다뤘습니다.
  3. 고빈도 I/O에서는 동기 완료가 최적화의 기회이기도 합니다. 동기 완료 시 I/O 완료 포트로의 패킷을 생략하는 SetFileCompletionNotificationModes라는 API가 있으며, IOCP와 조합하면 효과가 있습니다(제3회).12

6. 취소와 뒷정리 ── ‘멈춰 달라’는 요청일 뿐이다

오래 걸리는 I/O(응답 없는 네트워크 목적지, 오지 않는 시리얼 데이터)를 멈추고 싶을 때의 정공법이 CancelIoEx입니다.7

드라이버I/O 매니저드라이버I/O 매니저해당하는 미완료 IRP에취소를 요청(마크)취소 가능한 상태라면 중단완료 직전이라면 정상 완료되기도 함이 통지를 확인한 뒤OVERLAPPED와 버퍼를 해제한다CancelIoEx(핸들, OVERLAPPED)취소 루틴 호출IoCompleteRequest(STATUS_CANCELLED)완료 통지가 도착GetOverlappedResult는 ERROR_OPERATION_ABORTED

그림 6: 취소의 실제. 취소된 조작도 ‘완료’로 반환된다

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

  • 취소는 비동기적인 ‘요청’입니다. CancelIoEx가 성공해도 그것은 ‘표시했다’는 것뿐입니다. 완료 직전이던 조작은 정상 완료되는 경우도 있습니다.8
  • 취소된 조작도 완료 통지로 ERROR_OPERATION_ABORTED로 반환됩니다. 그 통지를 받을 때까지 OVERLAPPED와 버퍼는 커널이 사용 중입니다. 먼저 해제하면 메모리 손상이 됩니다. ‘취소했더니 죽게 되었다’의 원인은 거의 이것입니다.78
  • 핸들을 닫기 전에 미완료 I/O를 정리합니다. 제1회에서 본 것처럼 마지막 핸들이 닫히면 cleanup 처리로 미완료 IRP의 취소가 실행되지만, ‘발행된 I/O가 남은 채로 핸들만 닫는’ 코드는 완료 통지와 버퍼 수명 관리가 무너지기 쉽습니다. 취소 → 완료 확인 → 닫기 순서가 원칙입니다.

보충으로 두 가지. 예전의 CancelIo호출한 스레드 자신이 발행한 조작만 취소할 수 있습니다(Vista에서 CancelIoEx가 도입되기 전까지의 제약으로, 지금 굳이 사용할 이유는 없습니다).16 또한 동기 I/O로 멈춰 있는 다른 스레드에는 CancelSynchronousIo를 사용합니다.11 ‘타임아웃은 OS가 알아서 처리해 주지 않는다. 취소는 스스로 설계한다’──이것이 비동기 I/O 실무의 핵심입니다.

7. .NET에서 본 모습 ── 모드 불일치가 ‘가짜 비동기’를 낳는다

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

아니요await fs.ReadAsync 호출핸들은 비동기 모드인가FileOptions.Asynchronous 여부진짜 비동기 I/OOVERLAPPED에 해당하는 것을 발행하고완료는 IOCP를 거쳐 스레드 풀로 - 제3회가짜 비동기스레드 풀의 스레드가동기 Read를 대신 처리하며 기다림

그림 7: 같은 ReadAsync라도 핸들의 모드에 따라 그 밑바닥은 완전히 다른 것이 된다

차이가 나는 것은 파일을 여는 단 한 줄뿐입니다. 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로 하면 핸들이 동기 모드로 열린다
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: false))
{
    // 호출한 쪽은 블록되지 않지만, 뒤에서 스레드 풀의 스레드 하나가 동기 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를 거쳐 스레드 풀로(제3회)
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (C) .NET 6 이후. 모드와 오프셋을 명시하는 정직한 작성법
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)처럼 짧은 오버로드는 모두 동기 모드로 열립니다. 참고로 SafeFileHandle에서 FileStream을 만드는 경우에는 isAsync 인수를 핸들의 실제 모드와 일치시켜야 합니다.

  • 동기 모드 핸들 + ReadAsync는 스레드 풀의 스레드에서 동기 읽기를 수행하는 ‘가짜 비동기’입니다. 호출한 쪽은 기다리지 않지만, 뒤에서 스레드 하나가 잠들어 있습니다. 소수라면 실질적인 피해가 작지만, 서버나 고빈도 처리에서는 스레드 풀 고갈의 원인이 됩니다.9
  • 비동기 모드 핸들 + 동기 Read도 반대 방향의 불일치로, 내부적으로 완료를 기다리는 만큼 낭비가 발생합니다. 모드와 호출하는 API를 맞추는 것이 원칙입니다.10
  • .NET 6 이후FileStream의 내부가 전면적으로 다시 작성되었고, 나아가 File.OpenHandle + RandomAccess라는, ‘SafeFileHandle과 오프셋을 명시해 읽고 쓰는’ API가 도입되었습니다.9 오프셋을 매번 전달하는 이 형태는 본 글에서 본 비동기 핸들 + OVERLAPPED.Offset이라는 Win32의 있는 그대로의 모습 그 자체입니다.
  • 비동기 모드 핸들이라면 CancellationToken에 의한 파일 I/O 취소는 내부적으로 CancelIoEx로 이어집니다. 토큰을 전달한 ReadAsyncOperationCanceledException으로 끝나는 이면에서는 6장의 그림이 그대로 동작하고 있습니다. 취소가 ‘요청’이며 즉시성이 보장되지 않는다는 점도 같습니다. 한편 동기 모드 핸들의 ‘가짜 비동기’에서는 취소 대상이 될 오버랩 조작이 존재하지 않으므로 이 경로는 사용할 수 없습니다. 최근의 .NET 런타임에는 이러한 동기 실행 중인 호출에 대해 CancelSynchronousIo로 취소를 시도하는 메커니즘도 도입되어 있지만, 효과가 있는지는 런타임 버전과 조작 종류에 따라 달라지며 확실한 중단은 보장되지 않습니다. 취소를 설계의 전제로 삼으려면 모드를 맞춰서 진짜 비동기 I/O로 만들어 두는 것이 정도입니다.

참고로 async/await를 어떻게 작성해야 하는가라는 상위 계층의 실무(ConfigureAwait, UI 스레드와의 관계)는 ‘C# async/await 실무 판단표‘와 ‘WPF/WinForms의 async와 UI 스레드를 한 장으로 정리‘를 참조하십시오. 본 글은 그 지하 1층, 다음 회(IOCP)가 지하 2층입니다.

8. 정리

  • 동기 I/O와 비동기 I/O는 서로 다른 배관이 아니라, I/O 매니저가 완료를 기다리는지, 기다리지 않고 돌아가는지의 차이입니다. 동기 I/O의 스레드는 대기 상태로 잠들며 CPU를 소비하지 않습니다.1
  • 모드는 핸들(파일 오브젝트)에, 상태는 조작(OVERLAPPED)에 있습니다. 비동기 핸들에서는 파일 포인터가 관리되지 않으므로, 위치를 가지는 파일에서는 매번 Offset으로 지정합니다.24
  • OVERLAPPED와 버퍼는 완료 통지가 올 때까지 살려 두고 건드리지 않는다. 동시 발행 수만큼 준비한다. 재사용은 데이터 손상으로 이어진다.3
  • 완료 통지는 핸들·이벤트·APC·IOCP의 4가지 경로입니다. 여러 개를 동시에 발행하는 I/O에서는 핸들 시그널을 사용하지 않으며, 이벤트는 수동 리셋, APC는 alertable wait가 전제입니다.1135
  • 비동기로 발행해도 캐시·NTFS 압축·암호화·확장 쓰기에서는 동기 완료됩니다. ‘TRUE로 즉시 반환되는’ 경로는 정상 경로로서 반드시 작성하고, 응답성 보장에는 사용하지 않아야 합니다.3
  • 취소는 요청입니다. CancelIoEx 이후에도 완료 통지(ERROR_OPERATION_ABORTED)를 확인한 뒤 뒷정리를 합니다. 취소 → 완료 확인 → 닫기의 순서입니다.78
  • .NET의 FileOptions.AsynchronousFILE_FLAG_OVERLAPPED로 직결되며, 모드와 API의 불일치가 ‘가짜 비동기’를 낳습니다. CancellationToken의 밑바닥에서는 CancelIoEx가 동작하고 있습니다.910

다음은 제3회 ‘I/O 완료 포트(IOCP)와 .NET 스레드 풀: async/await의 지하실‘입니다. 이번 회 4.4절에서 이름만 언급했던 IOCP가 왜 ‘완료 통지의 큐’와 ‘실행 스레드 수의 제어’를 하나로 합친 설계인지, 그리고 await의 이어지는 부분이 어느 스레드에서 실행되는지까지 내려가 보겠습니다.

관련 글

관련 상담 영역

합동회사 코무라소프트에서는 비동기 I/O를 사용하는 Windows 업무 앱·디바이스 통신 앱의 설계와, ‘멈춘다’, ‘취소하면 죽는다’, ‘스레드 풀이 고갈된다’ 같은 결함의 원인 조사를 다루고 있습니다.

참고 링크

  1. Microsoft Learn, Synchronous and asynchronous I/O. 동기 I/O에서는 함수가 I/O 완료까지 블록되고, 비동기 I/O에서는 요청을 발행한 함수가 즉시 반환되어 스레드가 다른 작업을 계속할 수 있다는 점, 비동기 I/O에는 FILE_FLAG_OVERLAPPED를 지정해 핸들을 열어야 한다는 점, 완료 통지 방법으로 파일 핸들의 시그널, OVERLAPPED 구조체에 지정한 이벤트의 시그널, alertable wait에서 실행되는 완료 루틴(APC), I/O 완료 포트가 있다는 점, 여러 조작이 동시에 발행되어 있는 경우 파일 핸들의 시그널로는 어느 조작의 완료인지 구별할 수 없다는 점에 대해.  2 3 4 5 6 7 8 9

  2. Microsoft Learn, ReadFile function. FILE_FLAG_OVERLAPPED와 함께 연 핸들에서는 lpOverlapped가 필수이며, 읽기 시작 위치를 OVERLAPPED 구조체의 Offset/OffsetHigh로 지정한다는 점, 비동기로 처리되는 경우 FALSE와 ERROR_IO_PENDING이 반환된다는 점, 시스템이 비동기 핸들의 파일 포인터를 유지하지 않는다는 점, FILE_FLAG_OVERLAPPED 없이 연 핸들에 OVERLAPPED를 전달한 경우 지정한 오프셋부터 읽지만 읽기가 완료될 때까지 ReadFile이 반환되지 않는다는 점에 대해.  2 3 4 5

  3. Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. 비동기용으로 코딩해도 I/O가 동기적으로 완료되는 이유로, NTFS로 압축된 파일(파일 시스템 드라이버가 압축 파일에 비동기로 접근하지 않아 모든 조작이 동기가 됨), NTFS 암호화된 파일, 파일 길이를 늘리는 쓰기, 요청을 즉시 충족할 수 있는 경우(데이터가 메모리상의 캐시에 있는 경우 등)에 드라이버가 조작을 그 자리에서 완료시키고 TRUE를 반환한다는 점, Windows의 캐시가 파일 매핑으로 구현되어 있어 페이지가 없을 때 비동기 페이지 폴트 메커니즘이 없다는 점, 그리고 I/O를 3개 발행한다면 OVERLAPPED 구조체가 3개 필요하며 재사용하면 예측할 수 없는 결과나 데이터 손상으로 이어진다는 점, 조작이 완료될 때까지 해당 데이터 버퍼를 읽고 써서는 안 된다는 점에 대해.  2 3 4 5 6 7

  4. Microsoft Learn, OVERLAPPED structure. OVERLAPPED 구조체가 비동기 입출력을 위한 정보를 보유한다는 점, Offset/OffsetHigh가 파일 위치를, hEvent가 완료 시 시그널되는 이벤트를, Internal/InternalHigh가 조작의 상태 코드와 전송 바이트 수를 보유한다는 점, 조작이 실행되는 동안에는 구조체를 변경해서는 안 되며 유효한 상태로 유지해야 한다는 점, 이벤트를 사용할 경우의 주의점에 대해.  2 3 4

  5. Microsoft Learn, Alertable I/O. alertable I/O에서는 완료 루틴에 대한 엔트리가 스레드의 APC 큐에 쌓인다는 점, 스레드가 SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx 등으로 alertable 상태에 들어갔을 때 APC가 실행된다는 점, APC가 반드시 발행한 스레드의 컨텍스트에서 실행된다는 점에 대해.  2 3 4

  6. Microsoft Learn, I/O Completion Ports. I/O 완료 포트가 다중 프로세서 시스템에서 다수의 비동기 I/O 요청을 처리하기 위한 효율적인 스레딩 모델을 제공한다는 점, 파일 핸들을 포트에 연결하면 완료 패킷이 큐에 들어가고 워커 스레드가 GetQueuedCompletionStatus로 꺼낸다는 점, 동시에 실행되는 스레드 수를 포트가 제어한다는 점에 대해.  2

  7. Microsoft Learn, CancelIoEx function. CancelIoEx가 어느 스레드가 발행했는지에 관계없이 지정한 핸들에 대한 미완료 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의 내부 구현이 전면적으로 다시 작성되었다는 점, 핸들이 비동기 모드로 열려 있는지 여부에 따라 전략이 나뉜다는 점, File.OpenHandle로 SafeFileHandle을 직접 가져와 RandomAccess로 오프셋을 명시한 읽기/쓰기(스레드 세이프)가 가능해졌다는 점, 비동기 모드가 아닌 핸들에 대한 비동기 호출이 스레드 풀로 오프로드된다는 점에 대해.  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가 지정한 스레드가 실행 중인 동기 I/O 조작에 취소 표시를 한다는 점, 취소된 조작이 ERROR_OPERATION_ABORTED로 실패로서 반환된다는 점에 대해.  2

  12. Microsoft Learn, SetFileCompletionNotificationModes function. FILE_SKIP_COMPLETION_PORT_ON_SUCCESS를 통해 I/O가 즉시 성공한 경우 I/O 완료 포트에 완료 패킷을 쌓지 않는 동작을 선택할 수 있다는 점, FILE_SKIP_SET_EVENT_ON_HANDLE을 통해 파일 핸들의 이벤트 설정을 생략할 수 있다는 점에 대해.  2

  13. Microsoft Learn, GetOverlappedResult function. GetOverlappedResult가 비동기 조작의 결과(성공 여부와 전송 바이트 수)를 가져온다는 점, bWait에 TRUE를 전달하면 조작이 완료될 때까지 기다린다는 점, OVERLAPPED의 hEvent에 자동 리셋 이벤트를 지정했고 다른 대기가 시그널을 소비한 경우 bWait=TRUE인 호출이 완료를 감지하지 못하고 계속 기다릴 수 있으므로 수동 리셋 이벤트를 사용해야 한다는 점에 대해.  2 3

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

  15. Microsoft Learn, Asynchronous Procedure Calls. APC가 특정 스레드의 컨텍스트에서 비동기적으로 실행되는 함수이며, 각 스레드가 자신의 APC 큐를 가진다는 점, 사용자 모드 APC는 스레드가 alertable 상태일 때만 실행된다는 점에 대해. 

  16. Microsoft Learn, CancelIo function. CancelIo가 취소할 수 있는 것은 호출한 스레드 자신이 발행한 I/O 조작뿐이라는 점, 다른 스레드가 발행한 조작까지 포함해 취소하려면 CancelIoEx를 사용해야 한다는 점에 대해. 

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

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

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

자주 묻는 질문

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

FILE_FLAG_OVERLAPPED를 붙이면 무엇이 달라지나요?
핸들 뒤에 있는 파일 오브젝트가 '비동기 모드'로 열립니다. 이것은 CreateFile 시점에 정해지는 핸들 단위의 성질로, 호출할 때마다 동기·비동기를 전환할 수는 없습니다. 비동기 모드 핸들에서는 ReadFile/WriteFile에 OVERLAPPED 구조체를 반드시 전달해야 합니다. 시스템은 이 핸들에 대해 파일 포인터(현재 위치)를 관리하지 않으므로, 디스크상의 파일처럼 위치를 가지는 디바이스에서는 읽기/쓰기 위치도 OVERLAPPED의 Offset으로 매번 지정합니다(시리얼 포트처럼 위치를 가지지 않는 디바이스에서는 Offset을 사용하지 않습니다). 발행한 조작은 완료를 기다리지 않고 제어가 돌아오는 경우가 있으며, 이 경우 ReadFile은 FALSE를 반환하고 GetLastError가 ERROR_IO_PENDING이 됩니다. 완료는 이벤트, APC, I/O 완료 포트 등의 통지로 받습니다.
비동기 I/O를 발행했는데 바로 완료되어 돌아오는 이유는 무엇인가요?
비동기 모드는 '완료를 기다리지 않아도 된다'는 것이지 '절대로 기다리지 않는다'는 것이 아니기 때문입니다. 마이크로소프트의 문서는 비동기로 발행해도 I/O가 동기적으로 완료되는 대표적인 이유로, 요청을 바로 충족할 수 있는 경우(데이터가 캐시에 있는 경우 등), NTFS로 압축된 파일, NTFS 암호화(EFS)된 파일, 파일 길이를 늘리는 쓰기를 들고 있습니다. 이 경우 ReadFile/WriteFile은 TRUE를 반환하며 결과는 그 자리에서 확정됩니다. 따라서 비동기 I/O를 사용하는 코드는 'ERROR_IO_PENDING으로 반환되는 경우'와 '그 자리에서 완료되는 경우' 양쪽을 모두 반드시 상정해야 하며, 응답성이 절대적으로 보장되는 것도 아닙니다. 또한 기본적으로 동기 완료된 조작에 대해서도 완료 통지(이벤트 시그널이나 I/O 완료 포트로의 패킷)는 별도로 도착하므로, 결과 처리는 통지 쪽으로 일원화해 두는 것이 안전합니다.
OVERLAPPED 구조체는 재사용해도 되나요?
동시에 여러 조작에서 공유해서는 안 됩니다. OVERLAPPED 구조체는 '발행 중인 조작 1건분의 상태'를 나타내는 것으로, 마이크로소프트의 문서에도 I/O를 3개 발행한다면 OVERLAPPED 구조체가 3개 필요하며, 재사용하면 예측할 수 없는 결과나 데이터 손상으로 이어진다고 명시되어 있습니다. 조작이 완료될 때까지는 구조체도 읽기/쓰기 버퍼도 유효한 상태로 유지해야 하며, 내용을 건드려서는 안 됩니다. 완료된 후 재사용할 경우에는 이전에 남은 데이터가 영향을 주지 않도록 매번 다시 초기화합니다. hEvent에 넣는 이벤트는 수동 리셋 이벤트를 사용하는 것이 안전합니다.
실행 중인 I/O를 도중에 취소하려면 어떻게 해야 하나요?
CancelIoEx를 사용하면 어느 스레드가 발행했는지에 관계없이 지정한 핸들의 미완료 I/O에 취소를 요청할 수 있습니다. 두 번째 인수에 OVERLAPPED를 전달하면 특정 조작 하나만, NULL이면 그 핸들의 모든 조작이 대상이 됩니다. 예전의 CancelIo는 '호출한 스레드 자신이 발행한 조작'만 취소할 수 있습니다. 중요한 점은 취소가 '요청'일 뿐 즉시의 '보장'은 아니라는 것입니다. 이미 완료 직전이던 조작은 정상 완료되는 경우가 있으며, 취소된 조작은 ERROR_OPERATION_ABORTED로 완료 통지됩니다. 어느 쪽이든 완료 통지를 받을 때까지는 OVERLAPPED 구조체와 버퍼를 해제해서는 안 됩니다. 다른 스레드에서 동기 I/O에 들어가 멈춰 있는 스레드에는 CancelSynchronousIo라는 전용 API가 있습니다.
.NET의 FileStream에서 FileOptions.Asynchronous(useAsync)를 지정하지 않으면 어떻게 되나요?
핸들이 동기 모드로 열리기 때문에 ReadAsync/WriteAsync를 호출해도 진짜 비동기 I/O가 되지 않고, 스레드 풀의 스레드가 동기 읽기/쓰기를 대신 처리하는 '가짜 비동기'가 됩니다. 호출한 쪽의 스레드는 블록되지 않지만, 그 뒤에서 다른 스레드가 대기하고 있기 때문에 스레드 풀 고갈이나 확장성 저하의 원인이 됩니다. 반대로 비동기 모드로 연 뒤 동기 Read/Write를 호출하면 내부적으로 완료 대기의 오버헤드가 발생합니다. '핸들의 모드'와 '호출하는 API'를 맞추는 것이 원칙이며, .NET 6 이후라면 File.OpenHandle과 RandomAccess로 모드와 오프셋을 명시한 정직한 코드를 작성할 수 있습니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기