[Windows System Programming] 비동기 I/O와 APC
<비동기 I/O와 APC>
[공부했던 것을 되짚어보며]
https://sevenshards.tistory.com/71
[Windows System Programming] 구조적 예외처리(SEH) 기법
[공부했던 것을 되짚어보며] 이전 내용에서는 컴퓨터 구조에 대한 마지막 내용을 다뤘었습니다. 가상 메모리와 관련된 내용이 주를 이뤘었는데, 이번 글에서는 다루지 않습니다. 이번 글에서는
sevenshards.tistory.com
https://sevenshards.tistory.com/73
[Windows System Programming] 파일 I/O와 디렉터리 컨트롤
[공부했던 것을 되짚어보며] 이전 글에서도 언급을 했었지만 이번 내용 역시 참고의 성격이 강합니다. 달리 말하면 부담갖지 말고 편하게 보면 될 내용들입니다. 책을 통해서 가볍게 보고 넘어
sevenshards.tistory.com
이전에 작성했던 두 주제는 참고의 성격이 강한 글입니다.
저도 복습하는 차원에서 정리해두긴 했습니다만...
사실 필요할 때 다시 찾아보는 라이브러리 사용 방식의 성향이 더 큽니다.
이번에 정리하게 되는 주제인 입출력(이하 I/O)에 대한 것이 사실 더 중요한 부분입니다.
앞에서는 가볍게 쉬어가는 느낌이었다고 하면 이번 내용은 집중해서 봐야할 내용입니다.
[I/O와 CPU 클럭의 관계]
비동기 I/O라는 주제를 들어가기에 앞서 I/O와 CPU 클럭의 관계에 대해서 정리를 좀 하려고 합니다.
이전에 컴퓨터 구조 부분에서도 다루긴 했지만 CPU의 클럭이 높을수록 많은 작업을 처리할 수 있다고 했습니다.
그래서 CPU의 클럭이 높으면 높은 성능을 보이게 되고, I/O 역시 일반적으로 빠르게 수행될 수 있습니다.
하지만 I/O는 CPU보다 BUS의 클럭에 더 의존적입니다.
간단한 예를 하나 들어보겠습니다.
위와 같이 초당 100번의 연산을 하는 시스템과 초당 200번의 연산을 하는 시스템이 있다고 가정해보겠습니다.
그리고 버퍼는 똑같이 10클럭마다 비워지는 버퍼를 가지고 있다고 해봅시다.
위의 두 시스템은 모두 목적지에 데이터에 대한 I/O 작업을 하고 있습니다.
여기서 I/O는 파일, 네트워크 등을 모두 포함하는 개념입니다.
그리고 우리가 단순한 파일 입출력을 할 때와 마찬가지로 버퍼링을 수행하게 됩니다.
버퍼에 통해 데이터를 모아서 한 번에 데이터를 보내는 것이죠.
버퍼링을 하는 이유는 아시겠지만 데이터를 모아서 보내면 훨씬 빠른 시간에 많은 데이터를 보낼 수 있기 때문입니다.
이제 위의 그림을 통해서 버퍼링이 되는 시간을 계산을 해보겠습니다.
100클럭의 시스템은 1초에 10번, 200클럭의 시스템은 20번 버퍼가 비워지게 됩니다.
만약 '데이터를 생성하는 속도가 같다'라고 하면 두 시스템의 버퍼에 쌓이는 데이터의 양은 다르게 됩니다.
그래서 100클럭의 시스템에서는 보다 많은 양의 데이터를 묶어서 보낼 수 있게 됩니다.
반면에 200클럭의 시스템에서는 데이터의 양을 많이 묶어서 보내지 않게 되고요.
이걸 네트워크 I/O라고 생각을 해보겠습니다.
그러면 시스템과 목적지간 통신 프로토콜에 의해 데이터를 보내고 검증받는 과정을 거치게 됩니다.
이 과정을 100클럭의 시스템에서는 덜 거치게 됩니다.
하지만 200클럭의 시스템에서는 버퍼가 더 자주 비워지므로 과정을 많이 거치게 되죠.
네트워크의 I/O는 CPU보다 훨씬 느립니다.
그래서 CPU 클럭이 아무리 높다한들 두 시스템이 주고 받는 속도를 더 빠르게 할 수는 없습니다.
(네트워크의 입출력 속도는 CPU가 아닌 네트워크 장비에 따라서 의존적인 요소들이기 때문에)
위의 예만 놓고 본다면 100클럭의 시스템에서 네트워크 I/O가 더 빠를 수 있습니다.
그래서 I/O 연산에서 CPU의 클럭이 차지하는 영향은 크지 않다는 것입니다.
위에서는 굉장히 극단적인 예를 들게 되었지만, 바꿔서 말하면 버퍼를 비우는 정책을 설정하는 것도 중요한 요소입니다.
실제로 200클럭의 시스템이라면 100클럭의 시스템보다 2배는 더 좋은거잖아요.
그런데 버퍼를 비우는 정책을 동일하게 설정했기 때문에 위와 같은 비효율적인 결과가 나온 것입니다.
[비동기(Asynchronous) I/O]
사실 I/O는 굉장히 중요한 개념입니다.
저도 그렇고 아마 대부분이 프로그래밍을 공부하면서 가장 처음으로 접하게 되는 I/O라면 뭐가 있을까요?
콘솔에서의 I/O, 그리고 조금 더 나가면 파일 I/O 정도가 되겠네요.
지금은 다루고 있지 않지만 네트워크에서도 I/O가 굉장히 중요한 요소입니다.
이처럼 I/O라는 것은 거의 모든 영역에서 사용되고 있습니다.
이번에 다루게 될 주제는 비동기 I/O라는 개념입니다.
[비동기 I/O의 이해]
1) 동기 I/O (Synchronous I/O)
우선 비동기 I/O를 설명하기에 앞서서 동기 I/O가 무엇인지에 대해서 알아보겠습니다.
위 그림을 보면 CPU가 사용되는 모습이 사람의 심장박동처럼 주기적으로 솟구쳤다가 내려갔다합니다.
이런 패턴을 지니는 프로그램은 우리 주변에서도 흔히 볼 수 있습니다.
실제로 우리가 이런 프로그램을 만들어왔을 수도 있고요.
이와 같은 패턴이 생기는 이유를 생각해보면 대부분이 I/O와 관련이 되어 있습니다.
여기에 대한 예시를 동영상 플레이어라고 생각해보겠습니다.
우리가 흔히 아는 인터넷 방송에서 볼 때 사용하는 플레이어를 생각하면 좋겠네요.
서버에서 데이터를 수신해서 재생하는 방식의 플레이어인데 다음과 같이 동작하도록 설계되어 있다고 가정해봅시다.
위에서는 데이터를 수신한 후에 재생을 한다는 아주 간단한 모델입니다.
그런데 이 플레이어는 굉장히 큰 문제를 가지고 있는 플레이어입니다.
우선 '데이터 수신'은 I/O가 일어나는 과정입니다.
그래서 CPU가 동작하지 않는(정확히 말하면 CPU의 연산이 거의 필요 없는) 과정이죠.
반대로 '플레이'는 I/O가 없는 과정입니다.
여기서는 CPU가 동작하게 됩니다.
단순하게 그림만 놓고 봤을 때는 이게 무슨 문제가 있냐 싶으실겁니다.
그런데 이게 지속적으로 수행이 된다고 하면 문제가 될 수 있습니다.
데이터를 수신하는 동안에는 CPU가 가만히 놀고 있는 시간이 생기는 것이죠.
CPU 사용 빈도의 그림처럼요.
보다 효율적으로 써도 모자른 마당에 CPU를 가만히 놀고 있게 두는 것은 결국 성능 저하로 이어지게 됩니다.
이 플레이어는 큰 문제가 있다고 했었죠?
위와 같이 설계된 플레이어면 무조건 버퍼링이 걸리게 됩니다.
영상을 보는 중에 끊어지면 짜증나는데 주기적으로 툭툭 끊기는 플레이어다?
아무도 쓸 사람이 없을 겁니다.
그래서 이런 유형으로 구현된 프로그램은 쓸 일이 없을 것이다고 생각하시는 분들이 많을겁니다.
앞에서 굉장히 극단적인 사례를 들었기 때문에 '동기 I/O는 안좋다!' 라고 생각하실거 같거든요.
근데 이런 구조를 사용하는 프로그램들이 분명히 있습니다.
예를 들자면 ANSI 표준 함수를 기반으로 한 간단한 텍스트 편집기 프로그램이 이런 식으로 동작합니다.
ANSI 표준 파일 I/O 함수를 사용하면 파일에 데이터를 쓰거나 읽는 경우가 있을겁니다.
이 때 한 번 호출되면 작업이 완료될 때까지 블로킹 상태에 빠지게 됩니다.
이런 함수들을 블로킹(Blocking) 함수라고 합니다.
그리고 블로킹 함수를 활용해서 I/O 연산을 하게 되면 동기(Synchronous) I/O라고 합니다.
생각해보면 파일 입출력을 직접 경험해보신 분들이면 아마 간단한 텍스트 편집기를 만들어본 경험이 있을겁니다.
그때 ANSI 표준 함수를 이용해서 만들었잖아요.
우리가 만들었던 프로그램은 동기 I/O를 수행하는 프로그램이었던거죠.
그렇다고 이게 잘못된 것이냐고 하면 그건 또 아닙니다.
아까 말한 동영상 플레이어처럼 성능을 중요시 해야 하는 경우가 아니라면 동기 I/O 방식도 고려할 수 있습니다.
왜냐면 굳이 속도나 성능에 목을 매달 것이 아니기 때문에 구현하기가 쉽거든요.
2) 비동기 I/O (Asynchronous I/O)
앞서 설명했던 플레이어는 분명히 문제가 있는 플레이어입니다.
우리가 그런 플레이어를 가지고 인터넷 방송을 본다고 하면 참 끔찍한 일이 되겠죠.
그래서 다음과 같은 구조로 프로그램이 동작하게 되면 무한 버퍼링의 문제를 해결할 수 있게 됩니다.
위의 그림처럼 데이터도 수신하면서 영상의 재생도 같이 되는, 그런 구조의 프로그램으로 만들면 되겠죠.
앞서 말했듯이 I/O 과정은 CPU의 할당을 크게 요구하는 작업도 아니니까요.
그러니까 데이터 수신과 플레이를 같이 수행해도 크게 문제될 부분이 없는 것입니다.
그러면 시간이 지연되는 문제도 사라지고, 데이터 수신 속도가 충분히 빠르다면 버퍼링도 없을 것이고요.
이와 같은 구조의 I/O를 비동기 I/O (Asynchronous I/O) 라고 합니다.
그리고 비동기 I/O 구조를 사용하게 되면 CPU가 사용되는 모습도 위 그림처럼 나타날 수 있습니다.
아까의 심작박동 뛰듯이 주기적으로 치솟았다가 내려가는 것이 아닌 고른 분포가 되어있는 것을 확인할 수 있습니다.
이처럼 비동기 I/O를 사용하면 CPU를 효율적으로 사용할 수 있다는 것을 알아두시면 좋을 것 같습니다.
+ 추가 사항
이후에 다룰 내용은 Windows에서 제공하는 비동기 I/O 기법에 대해서 다루려고 합니다.
그 전에 I/O는 파일 I/O만 있는 것이 아니라고 했었던 것을 기억하시나요?
실제로 I/O는 콘솔, 네트워크 외에도 다양한 디바이스와의 I/O를 모두 포함해서 I/O라고 합니다.
그 중에서 비동기 I/O는 네트워크 부분에서도 다뤄질만큼 꽤 중요한 내용입니다.
나중에 제가 따로 TCP/IP 소켓 프로그래밍에 대해 공부한 내용을 정리하면서 다룰 내용이기도 합니다.
지금 읽고 있는 책에서는 네트워크가 아닌 파일과 파이프 통신을 기반으로 한 예제만을 다룹니다.
왜냐하면 소켓 통신에 대한 내용을 지금까지 공부한 적이 없으니까요.
따로 소켓 프로그래밍을 공부하신 분들이라면 아마 이 내용이 어렵지 않게 이해가 되셨을거라 생각합니다.
[중첩(Overlapped I/O)]
이제 본격적인 비동기 I/O 기법에 대해서 한 번 알아보도록 하겠습니다.
Windows에서 제공하는 비동기 I/O 기법 중 가장 대표적인 기법을 꼽으라고 하면 바로 중첩 I/O입니다.
일단 동기 I/O 방식을 통한 I/O를 수행하면 생길 수 있는 문제점이 뭐였죠?
블로킹 방식의 함수를 사용하기 때문에 CPU가 노는 시간이 생기는 것이 문제였죠.
앞에서 들었던 플레이어를 예로 든다면 데이터의 I/O 따로, 재생 시 CPU의 동작이 따로.
이걸 해결하기 위해서 데이터의 수신과 재생을 동시에 하면 된다고 했습니다.
간단한 예로 파일 입출력에 사용되는 ANSI 표준 함수인 fread와 fwrite 함수를 예로 들어보겠습니다.
이 함수는 블로킹(Blocking) 함수입니다. 다시 말해서 동기 I/O 방식으로 동작하게 되죠.
그럼 비동기 I/O 방식으로 동작하려면? fread, fwrite가 넌블로킹(Non-Blocking) 함수면 됩니다.
넌블로킹 함수는 블로킹 함수의 반대되는 개념입니다.
블로킹 함수는 함수가 호출되고 나면 작업이 완료될 때까지 반환하지 않고 블로킹 상태에 있게 됩니다.
반면 넌블로킹 함수는 작업이 완료가 되건 말건 상관 없이 바로 반환하는 특성이 있습니다.
그래서 Windows에서는 WriteFile, ReadFile이라는 함수가 넌블로킹 방식으로 동작하는 함수입니다.
넌블로킹 함수는 작업이 완료가 되지 않더라도 바로 반환이 되기 때문에 다음과 같은 형태로 동작하는 것도 가능합니다.
위의 그림과 같이 넌블로킹 함수 호출을 통해서 여러 작업도 동시에 진행될 수 있다는 것이죠.
함수가 호출되자마자 반환을 하기 때문에 얼마든지 이어서 I/O 요청을 하는 것이 가능합니다.
이처럼 I/O 연산이 중첩되어 실행되는 것을 중첩(Overlapped) I/O라고 합니다.
+ 추가 사항
앞에서도 이야기를 드렸던 부분이지만 비동기 I/O는 네트워크 통신에서 꽤 중요한 부분입니다.
여기서는 파일과 파이프 통신만을 예로 들고 있습니다.
파이프 통신을 잘 이해하시면 소켓 통신을 이용한 비동기 I/O 적용에도 도움이 됩니다.
[중첩(Overlapped) I/O 예제]
이번에는 비동기 I/O를 파이프 통신에 적용한 예제를 보도록 하겠습니다.
정확히는 비동기 I/O 기법 중 하나인 중첩 I/O를 이름있는 파이프(Named Pipe)에 적용하는 예제입니다.
중첩 I/O 기법은 대상이 파일이 되었건, 파이프건, 네트워크 I/O가 되었건 기본적인 코드 구성 방식은 동일합니다.
그래서 적용하기 전에 대략적인 큰 그림부터 보고 가겠습니다.
저도 처음에는 이 그림을 잘 이해를 못했는데, 다시 보니까 조금이나마 이해가 됐습니다.
우선 이 그림을 기반으로 하나씩 설명을 풀어나가도록 하겠습니다.
1) 중첩 I/O가 가능한 파이프를 만들자
우선 첫 번째로는 파이프가 중첩 I/O가 가능하도록 만들어야 합니다.
https://sevenshards.tistory.com/48
[Windows System Programming] 프로세스간 통신(IPC) - (2)
[이번 글을 따라 진행하기에 앞서] 이번 글의 제목대로 지난 글에 이어서 IPC에 대한 이야기를 마저 하려고 합니다. 그런데 그 전에 또 이야기를 좀 해두고 갈까 합니다. 아마 아시는 분들은 아시
sevenshards.tistory.com
이 글에서 이름있는 파이프(Named Pipe)에 대해 다뤘었죠.
글에 있는 예제 코드를 가지고 중첩 I/O가 가능한 파이프를 만들려고 합니다.
여기서는 두 번째 인자에 FILE_FLAG_OVERLAPPED라는 인자를 전달해서 비동기 특성을 부여해주면 됩니다.
2) OVERLAPPED 구조체를 선언하고 초기화하자
위의 그림에서 보면 OVERLAPPED라는 것을 인자로 전달한다고 되어있습니다.
여기서 OVERLAPPED라는 놈은 구조체입니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/minwinbase/ns-minwinbase-overlapped
OVERLAPPED(minwinbase.h) - Win32 apps
비동기(또는 겹치는) 입력 및 출력(I/O)에 사용되는 정보를 포함합니다.
learn.microsoft.com
이렇게 생겨먹은 놈인데, 여기서 우리가 주목해야 될 부분은 hEvent라는 것 딱 하나만 보시면 됩니다.
구조체의 멤버들을 보시면 맨 처음의 Internal과 InternalHigh는 Windows 시스템이 사용하는 멤버입니다.
그래서 그냥 0으로 초기화하면 됩니다.
그리고 공용체로 되어있는 Offset과 OffsetHigh, Pointer라는 놈이 있습니다.
얘네들은 파일 포인터의 위치를 지정하기 위해 사용하는 멤버입니다.
파일 포인터 이동에 관련된 부분은 다뤄본 적이 있지만 파일 이외에는 실제로는 다룰 일이 거의 없습니다.
그래서 이것도 0으로 초기화한다 생각하시면 됩니다.
그림에서 보시면 OVERLAPPED 구조체가 EVENT를 가리키고 있습니다.
이건 이벤트 오브젝트의 핸들을 OVERLAPPED 구조체의 hEvent에 저장하겠다는 의미입니다.
그리고 이 이벤트 오브젝트는 I/O 연산이 완료가 되었음을 확인하기 위해 사용하게 됩니다.
그래서 I/O 연산이 완료되면 이벤트 오브젝트는 Signaled 상태가 됩니다.
이벤트 오브젝트에 대해서 잘 이해가 안된다면 해당 내용을 복습하시는 것을 추천드립니다.
https://sevenshards.tistory.com/64
[Windows System Programming] 쓰레드 동기화 기법 - (2)
[공부했던 것을 되짚어보며] https://sevenshards.tistory.com/62 [Windows System Programming] 쓰레드 동기화 기법 - (1) [공부했던 것을 되짚어보며] 이전에 공부했던 내용은 쓰레드의 생성과 소멸과 관련해서 공
sevenshards.tistory.com
3) WriteFile 또는 ReadFile 함수 호출을 통해 중첩 I/O를 수행하자
이제 중첩 I/O가 가능한 파이프도 만들었고, OVERLAPPED 구조체 초기화도 끝났으면 다음에는 이걸 활용하면 됩니다.
여기서 WriteFile(또는 ReadFile) 함수의 인자에 파이프와 OVERLAPPED 구조체 변수의 포인터를 전달하면 됩니다.
그러면 위의 두 함수는 중첩 I/O 방식으로 동작하게 됩니다.
중첩 I/O 기법을 사용하는 방법에 대해서는 이게 전부입니다.
이제 예제 코드를 통해서 적용한 예를 확인해보겠습니다.
[namedpipe_asynch_server.cpp]
/*
* Windows System Programming - 비동기 I/O와 APC
* 파일명: namedpipe_asynch_server.cpp
* 파일 버전: 0.11
* 작성자: Sevenshards
* 작성 일자: 2023-12-23
* 이전 버전 작성 일자: 2023-12-14
* 버전 내용: 잘못된 부분의 코드 수정
* 이전 버전 내용: 중첩 I/O 기반 이름 있는 파이프 - 서버
*/
#define BUF_SIZE 1024
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
// 클라이언트와 통신하기 위해 선언한 함수
int CommToClient(HANDLE);
int _tmain(int argc, TCHAR* argv[])
{
// 예제에서는 이렇게 썼으나 C++11 이후로는 불가능
// LPTSTR pipeName[] = _T("\\\\.\\pipe\\simple_pipe");
LPCTSTR pipeName = _T("\\\\.\\pipe\\simple_pipe");
HANDLE hPipe;
// 여러 클라이언트가 순차적으로 접속하기 위한 무한 루프
while (1)
{
// 파이프 생성
hPipe = CreateNamedPipe(
pipeName,
PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, // Read, Write 모드 지정
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, // 메시지 기반
PIPE_UNLIMITED_INSTANCES, // 최대 파이프 개수 (제한을 두지 않겠다)
BUF_SIZE / 2, // 출력 버퍼 사이즈 / 2
BUF_SIZE / 2, // 입력 버퍼 사이즈 / 2
20000, // 클라이언트 Time-out
NULL // 디폴트 보안 속성(상속)
);
// 파이프 생성 실패할 경우
if (hPipe == INVALID_HANDLE_VALUE)
{
_tprintf(_T("CreateNamedPipe Failed!\n"));
return -1;
}
BOOL isSuccess = 0;
// 파이프 생성 성공 여부를 3항 연산자로 판단
// 성공했다면 TRUE, 실패했을 경우에는 에러코드를 ERROT_PIPE_CONNECTED인가를 확인
isSuccess = ConnectNamedPipe(hPipe, NULL) ? TRUE : (GetLastError() == ERROR_PIPE_CONNECTED);
if (isSuccess) // 파이프 연결이 성공됐다면
CommToClient(hPipe); // 클라이언트와 통신 개시
else // 실패했다면
CloseHandle(hPipe); // 핸들을 반환, Usage Count를 1줄인다.
}
return 1;
}
// 클라이언트와 통신을 하는 부분
int CommToClient(HANDLE hPipe)
{
TCHAR fileName[MAX_PATH];
TCHAR dataBuf[BUF_SIZE];
BOOL isSuccess;
DWORD fileNameSize;
// 파이프 핸들을 통해 데이터를 수신받는다.
isSuccess = ReadFile(
hPipe, // 파이프 핸들
fileName, // read 버퍼 지정
MAX_PATH * sizeof(TCHAR), // read 버퍼 사이즈
&fileNameSize, // 수신한 데이터의 실제 크기
NULL);
// 데이터 수신을 실패했거나 데이터가 없는 경우
if (!isSuccess || fileNameSize == 0)
{
_tprintf(_T("Pipe read message error!\n"));
return -1;
}
// 파일을 개방, 읽기모드로.
FILE* filePtr = NULL;
_tfopen_s(&filePtr, fileName, _T("r"));
if (filePtr == NULL) // 파일 개방에 실패했을 경우
{
_tprintf(_T("File open fault!\n"));
return -1;
}
OVERLAPPED overlappedInst;
memset(&overlappedInst, 0, sizeof(overlappedInst));
overlappedInst.hEvent = CreateEvent(
NULL, // 보안(상속) 속성
TRUE, // 수동 리셋 모드 결정 여부
TRUE, // 초기 상태, TRUE 전달시 Signaled
NULL // 이름을 지정할 때 사용, 안쓴다면 NULL
);
DWORD bytesWritten = 0;
DWORD bytesRead = 0;
DWORD bytesWrite = 0;
DWORD bytesTransfer = 0;
// EOF를 만날 때까지 파일을 읽어들인다
while (!feof(filePtr))
{
// fread 함수를 통해 데이터를 읽어들인다.
// 1 * 1024 바이트만큼 읽음
bytesRead = fread(dataBuf, 1, BUF_SIZE, filePtr);
bytesWrite = bytesRead;
isSuccess = WriteFile(
hPipe, // 파이프 핸들
dataBuf, // 전송할 데이터 버퍼
bytesWrite, // 전송할 데이터 크기
&bytesWritten, // 실제 전송된 데이터 크기
&overlappedInst // OVERLAPPED 구조체 변수의 주소
);
if (!isSuccess && GetLastError() != ERROR_IO_PENDING)
{
_tprintf(TEXT("Pipe write message error!\n"));
break;
}
// 다른 작업을 할 수 있는 기회(구간)
WaitForSingleObject(overlappedInst.hEvent, INFINITE);
GetOverlappedResult(hPipe, &overlappedInst, &bytesTransfer, FALSE);
_tprintf(TEXT("Transferred data size: %d \n"), bytesTransfer);
}
FlushFileBuffers(hPipe);
DisconnectNamedPipe(hPipe);
CloseHandle(hPipe);
return 1;
}
이전의 이름있는 파이프 서버 코드를 중첩 I/O가 가능하도록 코드를 변경했습니다.
여기서 따로 추가 설명을 해야 할 부분들이 좀 있습니다.
1) 이벤트 오브젝트 생성
overlappedInst.hEvent = CreateEvent(
NULL, // 보안(상속) 속성
TRUE, // 수동 리셋 모드 결정 여부
TRUE, // 초기 상태, TRUE 전달시 Signaled
NULL // 이름을 지정할 때 사용, 안쓴다면 NULL
);
인자를 준 부분을 보시면 생성 시 Signaled 상태로 만들고, 사용자가 리셋을 하도록 만들었습니다.
I/O가 완료되었을 때 이를 확인하기 위해 만든 이벤트인데, 초기 상태가 Signaled라는 것은 뭔가 이상합니다.
원래는 Non-Signaled 상태로 만들어서 Signaled 상태가 되면 작업이 끝난 것으로 알고 있는데 말이죠.
보다시피 이 이벤트 오브젝트는 반복적으로 사용됩니다.
Signaled 상태로 만들어놨으니 Non-Signaled 상태로 만들어줘야 하니 ResetEvent함수를 호출하는 부분이 있어야 합니다.
그래야 I/O 작업이 완료되고 나면 다시 Signaled 상태가 되면서 작업이 완료된 것을 알게 되니까요.
그런데 코드 내의 어디를 봐도 이 함수를 사용하는 부분이 없습니다!
여기에 대한 해답은 WriteFile (또는 ReadFile)에 있습니다.
현재 이벤트는 OVERLAPPED 구조체 변수에 멤버로 등록된 이벤트 오브젝트입니다.
그리고 ReadFile, WriteFile 함수 호출 시 자동으로 Non-Signaled 상태가 되는 특성이 있습니다.
그래서 Signaled 상태로 이벤트를 초기화해도 되고, 굳이 명시적으로 Non-Signaled 상태로 변경할 필요도 없습니다.
2) WriteFile 함수 호출 시 인자
isSuccess =
WriteFile(
hPipe, // 파이프 핸들
dataBuf, // 전송할 데이터 버퍼
bytesWrite, // 전송할 데이터 크기
&bytesWritten, // 실제 전송된 데이터 크기
&overlappedInst // OVERLAPPED 구조체 변수의 주소
);
중첩 I/O를 사용하기 위한 조건이 만족되었기 때문에 위의 WriteFile 함수는 비동기 I/O 방식으로 동작하게 됩니다.
그래서 함수가 호출이 되자마자 바로 반환을 하게 되고요.
그렇다는 말은 데이터 전송이 완료되기 전에 이미 반환이 되었다는 말입니다.
그래서 실제 전송된 데이터 크기를 WriteFile의 결과로 받아오는 것은 아무런 의미가 없습니다.
3) 다른 작업을 할 수 있는 기회(구간)와 그 이후
// 다른 작업을 할 수 있는 기회(구간)
WaitForSingleObject(overlappedInst.hEvent, INFINITE);
GetOverlappedResult(hPipe, &overlappedInst, &bytesTransfer, FALSE);
_tprintf(TEXT("Transferred data size: %d \n"), bytesTransfer);
주석을 달아놓은 부분이 사실상 추가적인 작업을 수행할 수 있는 위치입니다.
예제 코드에서는 별도로 추가적인 작업을 넣진 않았습니다.
만약 넣는다고 하면 데이터를 전송하면서 또 다른 연산을 할 수 있겠죠.
이후에는 별 다른 작업이 없이 GetOverlappedResult 함수를 호출하고 있습니다.
여기서 I/O 연산이 잘 마무리 되었는지를 확인하게 됩니다.
앞에서 실제 전송된 데이터의 크기를 WriteFile의 결과로 받아오는 것은 의미가 없다고 했죠.
실제 전송된 데이터의 크기는 해당 함수의 세 번째 인자를 통해서 확인할 수 있습니다.
4) if문의 조건을 바꾼 이유
if (!isSuccess && GetLastError() != ERROR_IO_PENDING)
보다시피 이전에는 없던 GetLastError() != ERROR_IO_PENDING이라는 조건이 하나 더 추가되었습니다.
중첩 I/O에서는 WriteFile, ReadFile 함수가 NULL을 반환했다고 해서 무조건 오류로 취급하면 안됩니다.
블로킹 함수를 기반으로 동작할 경우에는 I/O에 의한 병목현상(I/O 연산이 몰려들어 작업이 지체됨)이 없습니다.
I/O 연산 하나에 대해서 동작이 끝날 때까지 블로킹 상태에 놓이기 때문입니다.
하지만 중첩 I/O에서는 병목현상이 생길 수 있습니다.
기존의 I/O 연산이 끝나기 전에 다른 I/O 연산 요청이 들어올 수 있기 때문입니다.
그래서 과도한 I/O 연산 요청이 들어오게 되면 어느 순간부터 병목현상이 발생할 수 있습니다.
이럴 때는 기존에 진행 중이던 I/O 연산이 완료된 것을 확인한 후에 다시 요청하면 됩니다.
프로세스를 종료시키거나 병목현상 해결을 위해서 노력을 할 필요가 없다는 것입니다.
그래서 결론을 내리면, WriteFile 함수가 호출되고 나서 결과가 NULL이라면 다른 한 가지를 더 확인해야 합니다.
GetLastError 함수를 통해 과도한 I/O 요청에 의한 에러(ERROR_IO_PENDING) 인지 아닌지를 파악해야 됩니다.
과도한 I/O 요청에 의한 에러가 발생한 것이라면 다시 I/O 연산 요청을 하면 됩니다.
하지만 그게 아닌 다른 에러라면 처리를 달리 해야되기에 위와 같은 조건을 준 것입니다.
5) 입/출력 버퍼의 크기를 반으로 줄인 이유
// 파이프 생성
hPipe = CreateNamedPipe(
pipeName,
PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, // Read, Write 모드 지정
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, // 메시지 기반
PIPE_UNLIMITED_INSTANCES, // 최대 파이프 개수 (제한을 두지 않겠다)
BUF_SIZE / 2, // 출력 버퍼 사이즈 / 2
BUF_SIZE / 2, // 입력 버퍼 사이즈 / 2
20000, // 클라이언트 Time-out
NULL // 디폴트 보안 속성(상속)
);
이제 중첩 I/O가 잘 적용되는지 확인하기 위해서는 블로킹이 되는지 아닌지에 대한 여부를 확인해야 합니다.
위의 예제는 이전에 작성했던 namedpipe_client.cpp 코드를 그대로 사용하여 테스트를 진행하면 됩니다.
여기서 포인트가 서버쪽의 입/출력의 버퍼 사이즈는 반으로 줄여놨다는 점입니다.
만약 WriteFile을 통해서 전송할 데이터의 크기가 100바이트고, 출력 버퍼의 크기도 100바이트라고 가정해봅시다.
그러면 동기 I/O, 다시 말해서 블로킹 함수를 호출하는 방식이라도 블로킹이 없이 바로 반환을 하게 됩니다.
동기 방식 I/O 연산에서 최종 목적지인 클라이언트에게 데이터 전송이 되어야 반환하는 구조가 아닙니다.
전송을 위해 할당된 내부 메모리 버퍼에 복사가 이뤄지고 나면 반환하는 구조이기 때문입니다.
그래서 출력 버퍼를 반으로 줄여놓으면?
WriteFile 함수가 블로킹될 수 있는 조건을 갖추게 됩니다.
WriteFile 함수 호출을 통해서 전송하고자 하는 데이터의 크기를 버퍼 크기의 두 배로 만들었으니까요.
이제 진짜로 남은 것은 논블로킹 함수로 동작하는지의 여부입니다.
기본적으로 Visual Studio에서 제공하는 디버거를 사용할 줄 안다는 전제하에 설명하겠습니다.
서버는 WriteFile 함수를 호출하는 위치에, 클라이언트는 ReadFile 함수를 호출하는 위치에 브레이크 포인트를 겁니다.
그리고 실행을 시켜서 서버쪽에서 Step-Over를 했을 때, 블로킹되지 않고 바로 반환이 되는 것을 확인할 수 있습니다.
[완료루틴(Completion Routine) 기반 확장 I/O]
중첩 I/O는 Windows에서 제공하는 비동기 I/O 기법 중 하나라고 했습니다.
그런데 중첩 I/O의 단점이라고 하면, I/O 연산이 완료가 되었음을 확인하는 번거로운 작업이 있다는 점입니다.
다시 말해서 이벤트를 통해 Signaled 상태가 되었는지를 확인해야 하는 과정이 있습니다.
그래서 Windows는 또 하나의 비동기 I/O 기법을 제공합니다.
바로 완료루틴(Completion Routine) 기반의 확장 I/O입니다.
이게 어떤 식으로 동작하는지 먼저 그림을 통해서 예를 들어보겠습니다.
현재 3개의 I/O가 중첩되어 있는 상태입니다.
그리고 각각의 화살표는 I/O 연산이 끝난 이후 수행해야 하는 루틴(Routine)을 가리킵니다.
여기서의 루틴은 간단하게 "I/O 연산 A가 끝났을 때 D라는 함수를 호출해야 한다" 정도로 해석하셔도 됩니다.
그래서 루틴은 "I/O 연산이 완료되었을 때 실행되는 루틴"을 말합니다.
그리고 이걸 줄여서 "완료루틴(Completion Routine)"이라고 합니다.
이걸 중첩 I/O 방식에서 구현한다고 생각해보면 굉장히 어려운 일이 됩니다.
I/O A에 대한 연산이 끝나면 루틴 D를, B가 끝나면 E를, C가 끝나면 F를...
어떤 I/O 연산이 종료되었는지 구분하는 것이 상당히 머리가 아프게 되는 부분입니다.
그래서 Windows에서는 완료루틴 기반 확장 I/O(이하 확장 I/O)라는 비동기 I/O 기법을 제공하고 있습니다.
왜 '확장 I/O'냐고요?
여기서도 I/O 연산을 중첩하거든요.
다시 말해서 확장 I/O에서 I/O 연산을 할 때에도 연산은 중첩이 됩니다.
간단하게 요약하면 확장 I/O는 이런 녀석이라고 보면 되겠습니다.
완료루틴 기반 확장 I/O가 제공하는 기능 = 중첩 I/O의 기능 + α
여기서 α는 앞에서 고민하고 있던 연산이 끝난 이후 수행하는 루틴을 자동으로 컨트롤해준다는 것입니다.
쉽게 말하면 어떤 I/O가 끝났는지 따로 확인할 필요가 없어졌다는 말입니다.
중첩 I/O를 설명할 때 보였던 그림처럼 여기서도 그림으로 설명을 하겠습니다.
이전의 예와 마찬가지로 파이프를 기반으로 했을 때를 예로 들었습니다.
보시면 늘어난 것도 있지만 없어진 것도 있고, 바뀐 부분도 있습니다.
우선 OVERLAPPED 구조체를 전달하는 것은 그대로 동일합니다.
그런데 EVENT가 없어졌습니다!
이게 의미하는 것은 앞서 말했던 "I/O 연산이 끝났는 지를 확인할 필요가 없다"라는 것입니다.
I/O 연산이 종료되면 알아서 완료루틴을 수행하기 때문에 이벤트는 더 이상 쓸 필요가 없어졌습니다.
그리고 추가로 전달되는 인자는 완료루틴이 추가가 됐습니다.
마지막으로는 WriteFile(또는 ReadFile)에서 WriteFileEx(또는 ReadFileEx)로 바뀌었습니다.
이 함수는 WriteFile(또는 ReadFile)과 달리 확장 I/O에서 사용하기 위해 설계된 함수입니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/fileapi/nf-fileapi-writefileex
WriteFileEx 함수(fileapi.h) - Win32 apps
지정된 파일 또는 I/O(입력/출력) 디바이스에 데이터를 씁니다. 쓰기가 완료되거나 취소되고 호출 스레드가 경고 대기 상태에 있을 때 지정된 완료 루틴을 호출하여 완료 상태를 비동기적으로 보
learn.microsoft.com
https://learn.microsoft.com/ko-kr/windows/win32/api/fileapi/nf-fileapi-readfileex
ReadFileEx 함수(fileapi.h) - Win32 apps
지정된 파일 또는 I/O(입력/출력) 디바이스에서 데이터를 읽습니다. 읽기가 완료 또는 취소되고 호출 스레드가 경고 대기 상태에 있을 때 완료 상태를 비동기적으로 보고하고, 지정된 완료 루틴
learn.microsoft.com
실제로 인자를 보시면 완료루틴 지정을 위한 매개변수가 추가가 되어있는 것을 보실 수 있습니다.
이 마지막 인자에서 LPOVERLAPPED_COMPLETION_ROUTINE이라고 되어 있는 부분이 있습니다.
LPOVERLAPPED_COMPLETION_ROUTINE(minwinbase.h) - Win32 apps
ReadFileEx 및 WriteFileEx 함수와 함께 사용되는 애플리케이션 정의 콜백 함수입니다. 비동기 입출력(I/O) 작업이 완료 또는 취소되고 호출 스레드가 경고 가능한 상태일 때 호출됩니다.
learn.microsoft.com
typedef VOID (WINAPI *LPOVERLAPPED_COMPLETION_ROUTINE)(
_In_ DWORD dwErrorCode,
_In_ DWORD dwNumberOfBytesTransfered,
_Inout_ LPOVERLAPPED lpOverlapped
);
실제 코드를 따라가보면 이렇게 무지막지하게 생긴 놈이 있습니다.
그런데 자세히 보면?
요놈은 함수 포인터입니다.
그래서 완료루틴을 인자로 받을 수가 있는 것이죠.
앞으로 완료루틴을 작성할 때는 다음과 같이 선언해야 합니다.
VOID WINAPI FileIOCompletionRoutine(DWORD, DWORD, LPOVERLAPPED);
그리고 중요한 설명을 하나 빼먹을 뻔했습니다.
예전에 WINAPI나 CALLBACK이라는 것을 함수 호출 규약 부분을 통해서 보신 적이 있으실거에요.
그리고 여기서 이걸 또 보게 되었는데, 이런 애들을 콜백(Callback) 함수라고 합니다.
얘네는 함수를 호출하는 대상이 Windows 시스템입니다.
다시 말하면 시스템에 의해서 자동으로 호출이 되는 함수라는 것입니다.
마지막 인자로 완료루틴을 받는 것 말고도 하나 바뀐 인자가 또 있습니다.
바로 입/출력 데이터의 크기 값을 받기 위해 변수의 주소값을 받는 부분입니다.
근데 이게 없어진 이유는 여러분들도 금방 아실겁니다.
비동기 I/O를 통해서 WriteFile이나 ReadFile이 호출되는 시점에서 얻는 파일의 크기는 의미가 없다는 것이죠.
WriteFileEx와 ReadFileEx는 확장 I/O를 위해 설계된 함수입니다.
그래서 WriteFile과 ReadFile처럼 동기 I/O에서의 사용은 고려하지 않았다는 점을 추가로 알아두시면 될 것 같습니다.
이제 확장 I/O를 적용한 예제 코드를 한 번 보겠습니다.
이번에는 파이프가 아닌 파일을 기반으로 작성된 코드입니다.
[completion_routine_file.cpp]
/*
* Windows System Programming - 비동기 I/O와 APC
* 파일명: completion_routine_file.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 파일 기반의 확장 I/O 예제
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
TCHAR strData[] =
TEXT("Nobody was farther off base than the pundits who said \n")
TEXT("Royal Liverpool was outdated and not worthy of hosting ~ \n")
TEXT("for the first time since 1967. The Hoylake track ~ \n")
TEXT("Here's the solution to modern golf technology -- firm, \n")
TEXT("fast firways, penal bunkers, firm greens and, with any ~\n");
VOID WINAPI FileIOCompletionRoutine(DWORD, DWORD, LPOVERLAPPED);
int _tmain(int argc, TCHAR* argv[])
{
TCHAR fileName[] = (TEXT("data.txt"));
HANDLE hFile = CreateFile(fileName, GENERIC_WRITE, FILE_SHARE_WRITE, NULL, CREATE_ALWAYS, FILE_FLAG_OVERLAPPED, NULL);
if (hFile == INVALID_HANDLE_VALUE)
{
_tprintf(TEXT("File creation fault!\n"));
return -1;
}
OVERLAPPED overlappedInst;
memset(&overlappedInst, 0, sizeof(overlappedInst));
overlappedInst.hEvent = (HANDLE)1234;
WriteFileEx(hFile, strData, sizeof(strData), & overlappedInst, FileIOCompletionRoutine);
//SleepEx(INFINITE, TRUE);
CloseHandle(hFile);
return 1;
}
VOID WINAPI FileIOCompletionRoutine(DWORD errorCode, DWORD numOfBytesTransferred, LPOVERLAPPED overlapped)
{
_tprintf(TEXT("**********File write result**********\n"));
_tprintf(TEXT("Error code: %u\n"), errorCode);
_tprintf(TEXT("Transferred bytes len: %u\n"), numOfBytesTransferred);
_tprintf(TEXT("The other info: %u\n"), (DWORD)overlapped->hEvent);
}
파이프때와 마찬가지로 파일에도 FILE_FLAG_OVERLAPPED라는 속성을 추가한 것을 보실 수 있습니다.
이건 중첩 I/O건 확장 I/O건 동일하게 속성을 지정해줘야 한다는 것을 잊지 마시길 바랍니다.
그리고 여기서 하나 희한한 부분이 보일겁니다.
1234, 되게 눈에 띄는 숫자죠?
확장 I/O에서는 이벤트를 사용하지 않는다고 했는데, 굳이 저렇게 핸들로 형 변환을 해가면서 1234라는 값을 넣었습니다.
사실 안쓴다 그러면 NULL을 줘도 될텐데 굳이 저렇게 한 이유는 뒤에서 설명을 이어가도록 하겠습니다.
이제 여기까지 왔으면 WriteFileEx 함수에 의해서 파일에 대한 I/O 연산이 진행될 겁니다.
그리고 진행된 이후에는 완료 루틴이 자동적으로 수행이 될 것이고요.
완료 루틴 함수를 보시면 이해하시는 데에는 크게 어려움이 없을 겁니다.
다만 앞에서 1234라고 썼던 부분을 세 번째 인자인 OVERLAPPED 구조체 변수의 포인터를 통해서 가져다 쓸 수 있습니다.
굳이 1234가 아니라 뭔가 다른 정보를 포함해야 할 필요가 있다고 하면 쓸 수 있는 방법이 됩니다.
이제 마지막으로는 SleepEx라는 놈이 있는 부분은 주석처리가 되어 있습니다.
문제가 있어서 주석처리를 해둔 것이 아닙니다.
일단 이 부분을 그대로 놔두고 먼저 실행을 해보시기 바랍니다.
실행했을 때의 결과가 어떤가요?
완료루틴이 실행되지 않았을 겁니다.
파일 I/O도 제대로 수행되지 않았을거라는 생각이 드실 수도 있을거에요.
그래서 파일을 열어봤더니? 내용이 잘 들어가 있습니다.
그런데 우리가 기대했던 완료루틴은 실행이 되질 않았습니다.
왜냐하면 지금 우리가 생성한 '쓰레드'가 'Alertable State'가 아니었기 때문입니다.
[알림 가능한 상태(Alertable State)]
알림 가능한 상태? 이건 또 뭔 개뼈다귀같은 소리인가 싶으실겁니다.
일단 앞에서 WriteFileEx를 통해서 확장 I/O를 통해 I/O 연산을 수행했습니다.
그리고 이게 완료가 되었으니 완료루틴이 실행될 것이라고 생각했을겁니다.
그런데 유감스럽게도 우리의 예측과는 빗나갔죠.
Windows가 우리한테 배려를 해준겁니다.
"I/O 연산 끝나는 것도 확인하고, 거기에 맞춰서 완료루틴 실행까지는 내가 다 해줄게.
근데 이 완료루틴을 언제 실행시킬지는 니가 정해!"
다시 말해서 프로그램의 흐름 제어권을 프로그래머가 직접 정할 수 있도록 한 것입니다.
이 때 사용되는 함수가 바로 세 가지가 있는데, 첫 번째로는 위의 코드에서 주석 처리를 해놓은 SleepEx라는 놈입니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/synchapi/nf-synchapi-sleepex
SleepEx 함수(synchapi.h) - Win32 apps
지정된 조건이 충족될 때까지 현재 스레드를 일시 중단합니다.
learn.microsoft.com
"얘는 그냥 프로세스나 쓰레드가 CPU에서 연산하는걸 멈추게할 때 쓰는 애 아니었어요?"
네, 맞습니다.
근데 얘는 인자가 두 개입니다.
첫 번째는 Sleep이랑 똑같이 시간을 인자로 받습니다.
그런데 두 번째가 포인트에요.
이걸 FALSE로 주면 그냥 Sleep 함수랑 똑같이 동작합니다.
대신에 TRUE를 주면 SleepEx를 호출한 쓰레드가 Alertable State로 바뀌게 됩니다.
제가 여기서도 쓰레드라고 굵게 표시를 했고 앞에서도 쓰레드에 굵은 글씨로 표시를 했습니다.
프로세스가 아니라 쓰레드입니다.
여기서 Alertable State가 되었다는 것은 "나 완료루틴 실행할 준비 다 됐습니다" 하고 알려주는 겁니다.
그래서 호출되어야 할 완료 루틴이 둘 이상이면?
한 번만 호출해도 순서대로 완료루틴이 전부 수행됩니다.
완료루틴의 갯수에 맞춰서 매번 호출할 필요가 없습니다.
만약 완료된 I/O 연산이 없어서 호출할 완료 루틴도 없고, 지정한 시간도 다 지났다면?
그 때는 블로킹 상태에 놓이게 됩니다.
만약 시간이 다 되었을 경우에는 0을 반환하게 됩니다.
그리고 완료루틴을 수행한 경우에는 WAIT_IO_COMPLETION을 반환하게 된다는 점만 알아두시면 됩니다.
사실 위에 있는 MS 문서에 다 적혀있는 내용입니다.
쓰레드를 Alertable State로 만드는데 사용되는 함수는 세 가지가 있다고 했었습니다.
하나는 앞에서 소개한 SleepEx입니다.
그럼 나머지 둘은 뭘까요?
바로 이 둘입니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/synchapi/nf-synchapi-waitforsingleobjectex
WaitForSingleObjectEx 함수(synchapi.h) - Win32 apps
지정된 개체가 신호 상태에 있거나, I/O 완료 루틴 또는 APC(비동기 프로시저 호출)가 스레드에 큐에 대기되거나 시간 제한 간격이 경과할 때까지 기다립니다.
learn.microsoft.com
https://learn.microsoft.com/ko-kr/windows/win32/api/synchapi/nf-synchapi-waitformultipleobjectsex
WaitForMultipleObjectsEx 함수(synchapi.h) - Win32 apps
지정된 개체 중 하나 또는 전부가 신호 상태에 있거나, I/O 완료 루틴 또는 APC(비동기 프로시저 호출)가 스레드에 큐에 대기하거나 시간 제한 간격이 경과할 때까지 기다립니다.
learn.microsoft.com
어디선가 봤던 낯설지 않은 친구들입니다.
네, 우리가 알고있는 그 친구들 맞습니다.
얘네도 마지막 인자를 이용해서 쓰레드를 Alertable State로 변경하는게 가능합니다.
그것 말고는 WaitForSingleObject나 WaitForMultipleObjects 함수와는 차이가 없습니다.
[OVERLAPPED 구조체의 파일 위치 정보]
중첩 I/O에서는 파이프를 대상으로 했었는데 지금은 파일을 읽고 쓰는 예제로 넘어왔습니다.
마침 파일을 대상으로 비동기 I/O를 하고 있으니 여기에 대한 예를 들어보려고 합니다.
아까 OVERLAPPED 구조체에서 Offset이라는 인자가 있었던거 기억나시나요?
여기서는 그걸 써먹게 됩니다.
우선 확장 I/O를 통해서 파일 I/O를 한 번 해봅시다.
[nonBlocking_fileIOErr.cpp]
/*
* Windows System Programming - 비동기 I/O와 APC
* 파일명: nonBlocking_fileIOErr.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 비동기 파일 I/O 오류가 있는 예제 - 파일 위치 정보 고려 안했을 때
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
TCHAR strData1[] = TEXT("Nobody was farther off base than the pundits who said \n");
TCHAR strData2[] = TEXT("Royal Liverpool was outdated and not worthy of hosting ~ \n");
TCHAR strData3[] = TEXT("for the first time since 1967. The Hoylake track ~ \n");
TCHAR strData4[] = TEXT("Here's the solution to modern golf technology -- firm, \n");
TCHAR strData5[] = TEXT("fast fairways, penal bunkers, firm greens and, with any ~\n");
VOID WINAPI FileIOCompletionRoutine(DWORD, DWORD, LPOVERLAPPED);
int _tmain(int argc, TCHAR* argv[])
{
TCHAR fileName[] = (TEXT("data.txt"));
HANDLE hFile = CreateFile(fileName, GENERIC_WRITE, FILE_SHARE_WRITE, NULL, CREATE_ALWAYS, FILE_FLAG_OVERLAPPED, NULL);
if (hFile == INVALID_HANDLE_VALUE)
{
_tprintf(TEXT("File creation fault!\n"));
return -1;
}
OVERLAPPED overlappedInstOne;
memset(&overlappedInstOne, 0, sizeof(overlappedInstOne));
overlappedInstOne.hEvent = (HANDLE)"First I/O";
WriteFileEx(hFile, strData1, sizeof(strData1), &overlappedInstOne, FileIOCompletionRoutine);
OVERLAPPED overlappedInstTwo;
memset(&overlappedInstTwo, 0, sizeof(overlappedInstTwo));
overlappedInstTwo.hEvent = (HANDLE)"Second I/O";
WriteFileEx(hFile, strData2, sizeof(strData2), &overlappedInstTwo, FileIOCompletionRoutine);
OVERLAPPED overlappedInstThree;
memset(&overlappedInstThree, 0, sizeof(overlappedInstThree));
overlappedInstThree.hEvent = (HANDLE)"Third I/O";
WriteFileEx(hFile, strData3, sizeof(strData3), &overlappedInstThree, FileIOCompletionRoutine);
OVERLAPPED overlappedInstFour;
memset(&overlappedInstFour, 0, sizeof(overlappedInstFour));
overlappedInstFour.hEvent = (HANDLE)"Fourth I/O";
WriteFileEx(hFile, strData4, sizeof(strData4), &overlappedInstFour, FileIOCompletionRoutine);
OVERLAPPED overlappedInstFive;
memset(&overlappedInstFive, 0, sizeof(overlappedInstFive));
overlappedInstFive.hEvent = (HANDLE)"Fifth I/O";
WriteFileEx(hFile, strData5, sizeof(strData5), &overlappedInstFive, FileIOCompletionRoutine);
SleepEx(INFINITE, TRUE);
CloseHandle(hFile);
return 1;
}
VOID WINAPI FileIOCompletionRoutine(DWORD errorCode, DWORD numOfBytesTransferred, LPOVERLAPPED overlapped)
{
_tprintf(TEXT("**********File write result**********\n"));
_tprintf(TEXT("Error code: %u\n"), errorCode);
_tprintf(TEXT("Transferred bytes len: %u\n"), numOfBytesTransferred);
_tprintf(TEXT("The other info: %s\n"), (TCHAR*)overlapped->hEvent); // 강제 형 변환을 해도 출력이 생각처럼 안나옴, 유니코드 기반 호환이 완전히 안된다고 판단
}
보다시피 다섯 개의 문자열을 중첩해서 파일 I/O를 수행하는 코드입니다.
크게 어려움을 느낄 부분도 없는, 평범한 코드죠.
그리고 실행까지 하면 큰 문제 없이 실행이 되는 것도 보실 수 있습니다.
그런데! 결과가 이상하게 나옵니다.
우리가 생각했을 때는 다섯 줄이 파일에 입력이 되어있어야 할 것인데!
도대체 어디다가 긴빠이쳤는지빼돌려먹었는지 마지막 문자열만 남아있습니다.
사실 우리는 동기 I/O에 익숙해져 있기 때문에 이런 실수를 하게 됩니다.
지금까지 fread, fwrite만 써봤지, 이런건 처음이잖아요.
우리가 지금껏 이런 대전제를 깔아놓고 코드를 작성해서 그렇습니다.
"파일 I/O 연산을 하면 파일 위치 정보는 알아서 갱신이 된다"
실제로 hFile이라는 핸들이 가리키는 파일의 커널 오브젝트 안에는 파일의 위치 정보를 담고 있는 멤버변수가 있습니다.
이 멤버 변수를 편의상 파일 포인터라고 하겠습니다.
이 놈은 동기화된 입/출력 함수에서는 잘 돌아갑니다.
함수가 호출이 완료될 때마다 완료된 크기만큼 알아서 갱신을 하거든요.
애석하게도 비동기 I/O에서는 그딴거 없습니다.
근데 잘 생각해보면 없는게 당연합니다.
다섯 개의 문자열 I/O 연산을 중첩했는데, 여기서 순서대로 완료가 된다는 보장이 어디에 있을까요?
없습니다.
그보다도, 맨 처음에 호출된 I/O 연산 다음에 I/O 연산을 호출할 때 이전에 호출한 연산이 완료된다는 보장은?
마찬가지로 없습니다.
다시 말해서 "비동기 I/O에서 커널 오브젝트에 존재하는 파일의 위치 정보는 의미가 없다" 라는 결론을 내릴 수 있습니다.
그러면 답이 없는거 아니냐고 하실 수도 있습니다.
아까 앞에서 Offset이라는 인자가 있는 것을 기억하냐고 물어봤었잖아요.
얘가 바로 이 문제를 해결하는 포인트입니다.
물론 데이터의 입/출력 위치를 일일이 계산해야 한다는 번거로움이 있습니다만, 어쩌겠습니까.
이렇게라도 쓸 수 있다면 써야죠.
[nonBlocking_fileIO.cpp]
/*
* Windows System Programming - 비동기 I/O와 APC
* 파일명: nonBlocking_fileIO.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 비동기 파일 I/O 예제 - 파일 위치 정보 고려
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
TCHAR strData1[] = TEXT("Nobody was farther off base than the pundits who said \n");
TCHAR strData2[] = TEXT("Royal Liverpool was outdated and not worthy of hosting ~ \n");
TCHAR strData3[] = TEXT("for the first time since 1967. The Hoylake track ~ \n");
TCHAR strData4[] = TEXT("Here's the solution to modern golf technology -- firm, \n");
TCHAR strData5[] = TEXT("fast fairways, penal bunkers, firm greens and, with any ~\n");
VOID WINAPI FileIOCompletionRoutine(DWORD, DWORD, LPOVERLAPPED);
OVERLAPPED overlappedFilePtr; // 전역 변수로 선언, 0으로 초기화
int _tmain(int argc, TCHAR* argv[])
{
TCHAR fileName[] = (TEXT("data.txt"));
HANDLE hFile = CreateFile(fileName, GENERIC_WRITE, FILE_SHARE_WRITE, NULL, CREATE_ALWAYS, FILE_FLAG_OVERLAPPED, NULL);
if (hFile == INVALID_HANDLE_VALUE)
{
_tprintf(TEXT("File creation fault!\n"));
return -1;
}
OVERLAPPED overlappedInstOne;
memset(&overlappedInstOne, 0, sizeof(overlappedInstOne));
overlappedInstOne.hEvent = (HANDLE)"First I/O";
WriteFileEx(hFile, strData1, sizeof(strData1), &overlappedInstOne, FileIOCompletionRoutine);
overlappedFilePtr.Offset += _tcslen(strData1) * sizeof(TCHAR); // 다음 위치를 계산하여 파일 포인터 이동
OVERLAPPED overlappedInstTwo;
memset(&overlappedInstTwo, 0, sizeof(overlappedInstTwo));
overlappedInstTwo.Offset = overlappedFilePtr.Offset; // 이전 위치 다음으로 파일 포인터 이동
overlappedInstTwo.hEvent = (HANDLE)"Second I/O";
WriteFileEx(hFile, strData2, sizeof(strData2), &overlappedInstTwo, FileIOCompletionRoutine);
overlappedFilePtr.Offset += _tcslen(strData2) * sizeof(TCHAR); // 다음 위치를 계산하여 파일 포인터 이동
OVERLAPPED overlappedInstThree;
memset(&overlappedInstThree, 0, sizeof(overlappedInstThree));
overlappedInstThree.Offset = overlappedFilePtr.Offset; // 이전 위치 다음으로 파일 포인터 이동
overlappedInstThree.hEvent = (HANDLE)"Third I/O";
WriteFileEx(hFile, strData3, sizeof(strData3), &overlappedInstThree, FileIOCompletionRoutine);
overlappedFilePtr.Offset += _tcslen(strData3) * sizeof(TCHAR); // 다음 위치를 계산하여 파일 포인터 이동
OVERLAPPED overlappedInstFour;
memset(&overlappedInstFour, 0, sizeof(overlappedInstFour));
overlappedInstFour.Offset = overlappedFilePtr.Offset; // 이전 위치 다음으로 파일 포인터 이동
overlappedInstFour.hEvent = (HANDLE)"Fourth I/O";
WriteFileEx(hFile, strData4, sizeof(strData4), &overlappedInstFour, FileIOCompletionRoutine);
overlappedFilePtr.Offset += _tcslen(strData4) * sizeof(TCHAR); // 다음 위치를 계산하여 파일 포인터 이동
OVERLAPPED overlappedInstFive;
memset(&overlappedInstFive, 0, sizeof(overlappedInstFive));
overlappedInstFive.Offset = overlappedFilePtr.Offset; // 이전 위치 다음으로 파일 포인터 이동
overlappedInstFive.hEvent = (HANDLE)"Fifth I/O";
WriteFileEx(hFile, strData5, sizeof(strData5), &overlappedInstFive, FileIOCompletionRoutine);
overlappedFilePtr.Offset += _tcslen(strData5) * sizeof(TCHAR); // 다음 위치를 계산하여 파일 포인터 이동
SleepEx(INFINITE, TRUE);
CloseHandle(hFile);
return 1;
}
VOID WINAPI FileIOCompletionRoutine(DWORD errorCode, DWORD numOfBytesTransferred, LPOVERLAPPED overlapped)
{
_tprintf(TEXT("**********File write result**********\n"));
_tprintf(TEXT("Error code: %u\n"), errorCode);
_tprintf(TEXT("Transferred bytes len: %u\n"), numOfBytesTransferred);
_tprintf(TEXT("The other info: %s\n"), (TCHAR*)overlapped->hEvent); // 강제 형 변환을 해도 출력이 생각처럼 안나옴, 유니코드 기반 호환이 완전히 안된다고 판단
}
여기서 WriteFileEx를 다섯 번 호출하고 있고, 각 호출마다 독립적으로 OVERLAPPED 구조체 변수를 선언하고 있습니다.
굳이 이렇게 해야되느냐고 물으시면, 반드시 그렇게 해야합니다.
주석으로 달아놓은 부분이긴 하지만, 정 이해가 안되신다면 첫 번째 OVERLAPPED 구조체만 가지고 해보시면 됩니다.
나머지는 다 주석처리를 하시고.
그러면 왜 저렇게 했는지 금방 이해가 되실겁니다.
[타이머에서의 완료 루틴]
https://sevenshards.tistory.com/64
[Windows System Programming] 쓰레드 동기화 기법 - (2)
[공부했던 것을 되짚어보며] https://sevenshards.tistory.com/62 [Windows System Programming] 쓰레드 동기화 기법 - (1) [공부했던 것을 되짚어보며] 이전에 공부했던 내용은 쓰레드의 생성과 소멸과 관련해서 공
sevenshards.tistory.com
아마 기억이 나실지는 모르겠지만, 여기서 '타이머'라는 놈을 다룬 적이 있습니다.
제가 이 때는 완전 루틴 타이머라는 개념을 한참 뒤에서 소개한다 해서 해당 글에서는 정리를 안했습니다.
그걸 이제야 정리할 때가 오게 됐습니다.
저도 이 글을 쓰면서 '타이머 뭐였더라' 하다가 찾아보고 오는 길입니다.
일단 타이머를 셋팅하는데 사용했던 SetWaitableTimer라는 놈을 한 번 보겠습니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/synchapi/nf-synchapi-setwaitabletimer
SetWaitableTimer 함수(synchapi.h) - Win32 apps
지정된 대기 가능 타이머를 활성화합니다. 기한이 되면 타이머가 신호를 받고 타이머를 설정하는 스레드는 선택적 완료 루틴을 호출합니다.
learn.microsoft.com
보시면 전달해야하는 매개변수 중에서 두 개가 눈에 띌겁니다.
CompletionRoutine이 들어가는 인자가 두 개가 보이실거에요.
여기서 첫 번째는 완료 루틴을 지정해주면 됩니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/synchapi/nc-synchapi-ptimerapcroutine
PTIMERAPCROUTINE(synchapi.h) - Win32 apps
애플리케이션 정의 타이머 완료 루틴입니다. SetWaitableTimer 함수를 호출할 때 이 주소를 지정합니다.
learn.microsoft.com
얘를 토대로 해서 다음과 같은 형태의 완료루틴을 만드시면 됩니다.
VOID CALLBACK TimerAPCProc(
LPVOID lpArgToCompletionRoutine,
DWORD dwTimerLowValue,
DWORD dwTimerHighValue
);
그리고 두 번째 인자는 타이머 완료루틴의 첫 번째 전달인자로 그대로 전달할 때 사용합니다.
다음은 예제 코드를 통해서 이걸 사용한 예를 한 번 보도록 하겠습니다.
[CompletionRoutinePeriodicTimer.cpp]
/*
* Windows System Programming - 비동기 I/O와 APC
* 파일명: CompletionRoutinePeriodicTimer.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 완료 루틴 타이머 예제
* 이전 버전 내용:
*/
#define _WIN32_WINNT0x0400
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
VOID CALLBACK TimerAPCProc(LPVOID, DWORD, DWORD);
int _tmain(int argc, TCHAR* argv[])
{
HANDLE hTimer = NULL;
LARGE_INTEGER liDueTime;
liDueTime.QuadPart = -100000000;
hTimer = CreateWaitableTimer(NULL, FALSE, TEXT("WaitableTimer"));
if (!hTimer)
{
_tprintf(TEXT("CreateWaitableTimer failed (%d)\n"), GetLastError());
return 1;
}
_tprintf(TEXT("Waiting for 10 seconds...."));
TCHAR timerArg[] = TEXT("Timer was Signaled\n");
SetWaitableTimer(hTimer, &liDueTime, 5000, TimerAPCProc, timerArg, FALSE);
while (1)
SleepEx(INFINITE, TRUE);
return 0;
}
VOID CALLBACK TimerAPCProc(LPVOID lpArg, DWORD timerLowVal, DWORD timerHighVal)
{
_tprintf(TEXT("%s"), (TCHAR*)lpArg);
MessageBeep(MB_ICONEXCLAMATION);
}
크게 특별한 부분은 없고, 완료루틴 타이머는 이런 식으로 쓴다는 것 정도를 알아두시면 되겠습니다.
[지금까지의 내용 정리]
비동기 I/O에 대해서 참 많은 내용을 정리했습니다.
저도 오늘까지 강의를 포함해서 책을 세 번째 보고 있고, 오전부터 정리를 했는데 아직도 마무리를 못했습니다.
사실 어려운 내용인건 맞습니다.
아무래도 동기 I/O에 대해서만 알고 있다가 비동기 I/O라는 개념을 이제야 처음으로 접한 것이니까요.
처음 접할 때는 당연히 어려운 것이 맞습니다.
저도 아직은 어려운 개념이고요.
그래서 책에서 정리한 키워드를 토대로 비동기 I/O에 대한 이해를 잘 했는지 확인하는 방법이 있습니다.
"비동기 I/O, 동기 I/O, 블로킹 함수, 넌블로킹 함수, 중첩(Overlapped), 완료루틴"
이 6개의 키워드로 하나의 문장을 완성할 수 있다면 비동기 I/O에 대한 이해를 잘 했다고 볼 수 있습니다.
물론 이걸 하나의 문장으로 바로 만들기는 쉽지가 않습니다.
책에서는 이 문장을 만드는 과정을 대화를 통해 풀어나가고 있습니다.
그러니 책을 보면서간략하게나마 비동기 I/O에 대한 큰 그림을 다시 그려보시는 것을 권장드립니다.
[APC(Asynchronous Procedure Call)]
APC는 위의 제목에 써져있는 것처럼 Asynchronous Procedure Call의 약자입니다.
우리 말로 번역하면 비동기 프로시저 호출, 비동기 함수 호출 메커니즘을 의미한다고 볼 수 있습니다.
우리가 사용했던 완료루틴 기반 확장 I/O도 내부적으로는 APC를 활용하여 구현이 되어 있습니다.
더 정확하게 표현하면 ReadFileEx(WriteFileEx), SetWaitableTimer는 APC 메커니즘을 기반으로 구현된 함수입니다.
[APC의 구조]
APC도 크게 두 가지 종류로 나뉘게 됩니다.
하나는 User-mode APC, 또 다른 하나는 Kernel-mode APC가 있습니다.
여기서 또 Kernel-mode APC는 Normal Kernel-mode APC, Special Kernel-mode APC로 나뉘게 됩니다.
근데 이 글에서는 Kernel-mode APC에 대해서는 다루지 않습니다.
책에서도 다루지 않는 내용이고, 지금 공부하고 있는 시스템 프로그래밍의 영역에서 많이 벗어나는 내용입니다.
그래서 User-mode APC에 대해서만 다루는 것으로 하겠습니다.
지금까지 설명했던 내용들이 모두 User-mode APC와 관계가 있기 때문에 이것만 아셔도 큰 문제는 없습니다.
더 자세히 알고 싶으신 분들은 이 문서 외에도 따로 찾아보시는 것을 권장드립니다.
https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/types-of-apcs
Types of APCs - Windows drivers
Types of APCs
learn.microsoft.com
다시 APC에 대한 이야기를 이어서 가보겠습니다.
모든 쓰레드에는 자신만의 APC Queue라는 것을 가지게 됩니다.
다시 말하면 APC Queue는 쓰레드별로 독립적이라는 것이기도 합니다.
제가 앞에서 Alertable State를 이야기하면서도 '쓰레드'를 강조했던 이유기도 합니다.
다음 그림은 APC Queue에 대한 그림입니다.
위의 그림처럼 완료루틴을 구성하는 과정에서 WriteFileEx 함수가 호출되고 있는 상황입니다.
이때 인자로 전달된 함수(완료루틴) 포인터와 매개변수의 정보가 쓰레드의 APC Queue라는 곳에 저장됩니다.
이는 I/O 연산이 완료될 시 Queue에 저장이 됩니다.
이처럼 APC Queue에는 비동기적으로 호출되어야 할 함수들과 매개변수가 저장이 됩니다.
그런데 저장되었다고 해서 함수가 바로 호출되는 것이 아니라는 것을 아실겁니다.
Alertable State가 될 때 Queue에 있는 완료 루틴들을 호출하게 된다는 것은 앞서 설명드렸었습니다.
아까 APC Queue는 쓰레드별로 독립적이라고 했습니다.
이와 마찬가지로 완료루틴도 쓰레드별로 독립적인 메커니즘을 지닌다고 볼 수 있습니다.
[APC Queue의 접근]
이제 APC Queue라는 것에 직접적으로 접근하는 예제를 한 번 만들어볼까 합니다.
실제로 APC Queue에 함수 정보를 전달할 수 있는 방법은 우리가 알고 있는 함수들 중 딱 세 가지가 있습니다.
바로 WriteFileEx, ReadFileEx 그리고 SetWaitableTimer입니다.
그런데 위의 함수들 말고도 APC Queue에 직접적으로 호출하고자 하는 함수 정보를 전달하는 것도 가능합니다.
QueueUserAPC라는 함수를 이용하면 말이죠.
이제 마지막으로 QueueUserAPC 함수를 사용한 예제를 보면서 이번 글을 마무리 짓도록 하겠습니다.
[APCQueue.cpp]
/*
* Windows System Programming - 비동기 I/O와 APC
* 파일명: APCQueue.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: APC Queue 기본 예제
* 이전 버전 내용:
*/
#define _WIN32_WINNT0x0400
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
VOID CALLBACK APCProc(ULONG_PTR);
int _tmain(int argc, TCHAR* argv[])
{
HANDLE hThread = GetCurrentThread();
QueueUserAPC(APCProc, hThread, (ULONG_PTR)1);
QueueUserAPC(APCProc, hThread, (ULONG_PTR)2);
QueueUserAPC(APCProc, hThread, (ULONG_PTR)3);
QueueUserAPC(APCProc, hThread, (ULONG_PTR)4);
QueueUserAPC(APCProc, hThread, (ULONG_PTR)5);
Sleep(5000);
SleepEx(INFINITE, TRUE);
return 0;
}
VOID CALLBACK APCProc(ULONG_PTR dwParam)
{
_tprintf(TEXT("Asynchronous procedure call num %u\n"), (DWORD)dwParam);
}
[글을 마치며]
사실 글을 쓰면서 마지막에 내용을 잘 안남기는 편인데 오늘은 따로 좀 남기게 되었습니다.
아무래도 정리하면서 시간을 많이 쏟은 것도 있지만, 정말 어려운 개념입니다.
처음 보면 이게 뭔가 싶어서 잘 이해가 안되는 부분들도 분명히 있습니다.
저도 그랬고요.
그런데 어렵다고 안보고 넘어갈 수는 없는게 또 비동기 I/O 입니다.
이 개념은 네트워크 프로그래밍을 하면서 또 접하게 될 내용입니다.
결국에는 안보고 싶다고 거를 수 있는 내용이 아니기에 이렇게 글을 적습니다.
포기하지 마시고, 이해하기 어렵다면 가볍게 넘기되 나중에 다시 또 보면서 천천히 이해하면 됩니다.
날이 많이 춥습니다.
다들 건강 유의하시길 바랍니다.