<프로세스간 통신(IPC) - (2)>
[이번 글을 따라 진행하기에 앞서]
이번 글의 제목대로 지난 글에 이어서 IPC에 대한 이야기를 마저 하려고 합니다.
그런데 그 전에 또 이야기를 좀 해두고 갈까 합니다.
아마 아시는 분들은 아시겠지만, 윈도우즈 시스템 프로그래밍을 공부할 때 권장하는 책들이 있습니다.
문제는 윈도우즈 시스템 프로그래밍 서적들이 옛날 책입니다.
쓰여진 년도가 대략 2006~2008년도에요.
지금 제가 읽고 있는 윤성우 아저씨의 '뇌를 자극하는 윈도우즈 시스템 프로그래밍' 역시 2006년도 책입니다.
그리고 앞으로 읽게 될 제프리 리처 아저씨의 'Windows via C/C++ 5판'도 2008년에 나온 책이고요.
다시 말해서 여기 수록된 코드나 예제들이 옛날 기준이라는거에요.
그러다보니 지금 환경에서 안돌아가는 경우는 다반사고, 예상했던대로 안돌아가는 경우가 많습니다.
이번에 겪은 케이스가 안돌아가는 경우가 참 많았습니다.
저도 코드를 잘못 썼나 싶어서 소스코드를 받아서 돌려봐도 결과는 똑같았습니다.
그러니 결과가 생각했던 것처럼 안나오거나 책에서 제시한 결과대로 안나왔다고 좌절하실 필요 없습니다.
메일슬롯이나 파이프 말고도 IPC 기법은 더 있습니다.
파일을 통해서 할 수도 있고, 메시지 큐라는 기법도 있고, 공유 메모리, 세마포어, 메모리 맵 등등...
그 중에서도 한번 쯤은 들어봤을 '소켓'이라는 놈이 있습니다.
여기서는 다루지는 않겠지만, 아마 이걸 주로 다루는 일이 많을 것이라 생각합니다.
지금은 아니지만 나중에 TCP/IP쪽을 공부하면서 소켓 프로그래밍을 공부할 겁니다.
그때쯤이면 여기서 부족했던 부분을 다시 메꿀 수 있지 않을까 하는 생각을 합니다.
[핸들 테이블과 오브젝트 핸들의 상속]
우선은 바로 IPC 기법 이야기에 들어가기 앞서서 커널 오브젝트 이야기를 좀 다시 짚고 넘어가겠습니다.
지난 글에서는 커널 오브젝트의 상태에 대한 이야기를 했었죠.
이제는 커널 오브젝트 핸들과 관련된 핸들 테이블과 상속에 대한 이야기를 하려고 합니다.
[도입 배경]
'뇌를 자극하는 윈도우즈 시스템 프로그래밍'을 쓴 윤성우 아저씨도 제프리 리처 아저씨가 쓴 책을 본 것 같습니다.
아무래도 윈도우즈 시스템 프로그래밍을 하는 사람이면 그 책은 어지간해서는 다 본 것 같습니다. 나도 읽어야하지만
제프리 리처 아저씨가 이런 말을 했다고 합니다.
"유능한 Windows 프로그래머는 프로세스의 핸들 테이블이 어떻게 관리되는지를 이해하고 있어야 한다."
우리는 지금 핸들 테이블이 뭔지는 몰라도 일단 이걸 알아야 유능한 Windows 프로그래머에 가까워질 수 있을겁니다.
제프리 리처 아저씨도 그렇고, 윤성우 아저씨도 Windows 핸들 테이블이 어떻게 관리되는지를 소개합니다.
그런데 윤성우 아저씨가 책에서 말하기를, 제프리 리처가 소개하는 방법은 실질적인 관리 방법과 차이가 있다고 합니다.
그리고 책에서 소개하는 방법도 일반적인 방법으로 소개한다고 했습니다.
두 분 다 Windows가 실질적으로 테이블을 관리하는 방법과는 차이가 있다고 합니다.
거기에는 다음과 같은 이유가 있어서 그렇습니다.
1. MS가 Windows OS의 소스코드를 공개하질 않습니다.
2. Windows OS의 종류나 버전마다 핸들 테이블이 관리되는 방법이 다 다릅니다.
위와 같은 이유로 실질적인 방법을 소개하는 것은 현실적으로 불가능한 것입니다.
그래서 제프리 리처, 윤성우 아저씨는 가장 일반적인 형태로 프로세스 핸들 테이블이 관리되는 방식을 설명합니다.
기본적인 메커니즘은 알고 있어야 나중에 뭐가 바뀌면 '이게 이렇게 된건가'라고 유추라도 해볼 수 있으니까요.
여기서도 그 내용을 기반으로 설명을 하려고 합니다.
[프로세스의 커널 오브젝트 핸들 테이블]
[지금까지 배웠던 핸들과 커널 오브젝트 정리해보기]
시스템의 리소스가 생성되는 과정에서 커널 오브젝트가 생성됩니다.
그리고 생성된 커널 오브젝트를 가리키는 핸들도 같이 반환되고요.
이런 일련의 과정을 알고 있으니 이제 이걸 대충 그림으로 정리를 해봅시다.
메일슬롯을 만드는 과정을 예로 들면 다음과 같이 표현할 수 있습니다.
근데 뭔가 이상하다 싶은 부분이 있습니다.
리소스도 생성됐고, 커널 오브젝트도 생성됐고, 핸들도 반환이 됐습니다.
"도대체 어디가 이상하다는거냐? 다 됐는데!"
저도 이걸 알기 전까지는 저 그림이 다 된거라고 생각했습니다.
근데 잘 생각해보시면 256이라는 핸들이 0x2400번지의 커널 오브젝트를 가리키는 정보가 없습니다.
다시 말해서 "저 256이라는 핸들이 어떻게 0x2400번지의 커널 오브젝트를 가리키느냐" 이 말입니다.
뭐 그까이꺼 대충 Windows OS가 알아서 해주겠거니 넘어갈 수도 있습니다.
그렇지만 어떤 방식으로 알아서 해주는지는 알아야합니다.
[프로세스의 핸들 테이블 도입]
그래서 나온 것이 바로 '핸들 테이블'이라는 것입니다.
'핸들 테이블'은 핸들 정보를 저장하고 있는 테이블이며, 프로세스별로 독립적입니다.
'프로세스별로 독립적'이라는 말이 뭐냐면, 각각의 프로세스마다 저 테이블을 가지고 있다는 말입니다.
그래서 프로세스가 프로세스나 메일슬롯 생성같은 리소스 생성을 요청하면 핸들 정보를 얻게 됩니다.
리소스 생성에 의해서 반환된 핸들 정보는 리소스 생성을 요청한 프로세스의 핸들 테이블에 등록되는 것입니다.
이제 256이라는 핸들이 0x2400번지에 할당되어 있는 커널 오브젝트를 가리키는지 아시게 됐을거라고 봅니다.
그리고 이번 소제목이 '핸들 테이블'이 아니라 '프로세스의 커널 오브젝트 핸들 테이블'입니다.
위에서도 말했던 부분이지만, 핸들 테이블은 프로세스별로 독립적이라는 것입니다.
[핸들의 상속]
상속! 아마 C++이나 JAVA같은 객체지향 언어를 공부하신 분이라면 들어본 단어일겁니다.
놀랍게도 여기서도 상속이라는 개념이 있습니다.
여기서는 클래스의 private이나 protected, public같은 그런 속성은 신경 안쓰셔도 됩니다.
우리가 지금가지 프로세스를 생성하면서 사용했던 함수인 CreateProcess 함수에서 다섯번째 인자가 있습니다.
아마 기억하실 분이 있을지 모르겠지만, 이걸 TRUE로 설정하면 상속이 가능합니다.
상속을 설정하면 부모 프로세스 핸들 테이블에 등록된 핸들 정보가 자식 프로세스에 그대로 상속이 됩니다.
[핸들의 상속에 대한 이해]
위 그림을 보면 부모 프로세스에서 자식 프로세스로 상속이 되는 과정을 볼 수 있습니다.
그런데 그림을 보면 아시겠지만, 상속 여부에 따라서 상속이 되는 핸들만 상속이 됩니다.
private나 protected, public같은 속성은 없다고 했지만, 상속 여부에 따라서 어떤 핸들이 상속될 것인지 결정됩니다.
그리고 상속여부 역시 부모 프로세스의 핸들 테이블에 있던 속성이 그대로 상속됩니다.
그래서 자식 프로세스가 또 다른 자식 프로세스에게 핸들 테이블을 상속하게 되면 그대로 이어져 내려가게 됩니다.
[핸들의 상속과 커널 오브젝트의 Usage Count]
커널 오브젝트의 Usage Count는 참조(공유)하는 프로세스의 수만큼 증가한다고 했습니다.
그렇다면 커널 오브젝트를 참조하는 프로세스가 되기 위한 조건은 뭘까요?
이전에 "핸들을 얻은 프로세스가 커널 오브젝트를 참조하는 프로세스가 된다" 라고 했는데 좀 부족한 설명입니다.
이제 정확히 설명할 수 있는 시점이 되었으니, 핸들 테이블의 관점에서 생각하면 다음과 같습니다.
"프로세스가 핸들을 얻게 되었다는 것은 핸들 테이블에 해당 핸들에 대한 정보가 갱신(추가)되었음을 의미한다."
즉, 핸들 테이블에 핸들에 대한 정보가 추가되면 그때부터 커널 오브젝트를 참조하는 프로세스가 되는 것입니다.
그럼 예를 들어서 CreateMailSlot 함수를 호출해서 리소스인 메일슬롯을 생성한 상황을 보겠습니다.
1) 메일 슬롯 리소스 생성
2) 커널 오브젝트 생성
3) 핸들 정보가 프로세스의 핸들 테이블에 갱신
4) CreateMailSlot 함수를 빠져나오면서 핸들의 값을 반환
여기서 프로세스가 핸들을 얻었다고 하면 지금까지는 4번 과정에서 핸들을 얻었다고 생각하게 됩니다.
정확히는 3번 과정 이후부터 프로세스가 핸들을 얻었다고 말할 수 있는 것입니다.
그러니까 메일슬롯에 대한 리소스의 정보가 핸들 테이블에 등록되면 그때부터 핸들을 얻었다고 하는 것입니다.
보통 3~4의 과정이 이어서 진행되기 때문에 4번 과정에서 핸들을 얻었다고 생각할 수 있습니다.
하지만 이 시점 이후로는 핸들 테이블에 갱신이 되는 시점에서 핸들을 얻었다고 정확히 이해하는 것이 좋습니다.
그래서 핸들 테이블을 상속하게 되면 자식 프로세스는 부모 프로세스에서 핸들 정보를 받게 됩니다.
그에 따라 자식 프로세스도 핸들 테이블에 있는 커널 프로젝트를 참조하게 되어 Usage Count는 1 증가하게 됩니다.
[상속이 되기 위한 핸들의 조건]
핸들의 상속 여부(Y 또는 N)는 리소스가 생성이 되는 순간에 결정이 됩니다.
그래서 프로그래머가 상속 여부를 결정하게 됩니다.
메일슬롯을 생성하는 CreateMailSlot을 기준으로 보면 마지막에 '보안속성'이라는 것이 있습니다.
이게 바로 상속 여부를 결정하는 인자입니다.
SECURITY_ATTRIBUTES라는 구조체를 받게 되어있는데, 이걸 적절하게 초기화하고 인자로 넘겨주면 됩니다.
+추가사항 1
여기서 보면 '보안속성'이라고 되어있는데, 프로세스의 입장에서는 보안과 관련된 사항입니다.
그래서 상속에 대한 선언을 '보안'관련 인자를 통해서 전달하게 되는 것입니다.
객체지향에서는 '정보은닉'과 연관지어서 생각하면 좋지 않을까 생각합니다.
+추가사항 2
상속이 되기 위한 핸들의 조건을 공부하면서 좀 오락가락하실 수도 있을것 같아 정리를 해둡니다.
상속의 여부를 결정할 때는 두 가지를 결정해야 합니다.
1) 부모 프로세스가 자식 프로세스에게 상속의 여부를 결정
- 5번 인자, TRUE/FALSE
2) 부모 프로세스가 자식 프로세스를 생성할 때 자식 프로세스의 핸들이 상속이 될지 안될지를 결정
- 3번 인자, 메일슬롯 리소스 생성 예시와 동일한 구조체를 통해 Y/N 여부를 결정
[예제를 통해 확인하는 핸들 정보의 상속]
우리가 이전 글에서 만들었던 메일 슬롯의 구조는 위의 그림과 같습니다.
그리고 이걸 상속을 통해서 다음과 같은 구조로 확장을 하려고 합니다.
이전에 작성했던 코드 중 MailReceiver.cpp는 그대로 사용하면 되고, MailSender를 확장하면 됩니다.
[MailSender_Parent.cpp]
/*
* Windows System Programming - 프로세스 간 통신(Inter-Process Communication, IPC)
* 파일명: MailSender2_Parent.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-05
* 이전 버전 작성 일자:
* 버전 내용: 메일슬롯(MailSlot) 핸들의 상속 확인용 예제 - 부모 프로세스
* 이전 버전 내용:
*/
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
// 사용할 메일슬롯의 주소
// \\computerName\mailslot\[path]name
// 아래의 주소는 \\.\mailslot\mailbox라고 쓴 것과 같다.
// 여기서 '.'이 의미하는 것은 로컬 컴퓨터, 즉 내 컴퓨터를 뜻한다.
// 루프백인 127.0.0.1과 같다고 보면 된다.
#define SLOT_NAME _T("\\\\.\\mailslot\\mailbox")
int _tmain(int argc, TCHAR* argv[])
{
HANDLE hMailSlot; // mailslot 핸들
TCHAR message[50];
DWORD bytesWritten; // 몇 바이트나 읽었는가 확인
SECURITY_ATTRIBUTES sa; // 보안 속성(상속과 관련된 속성) 구조체 정의
sa.nLength = sizeof(sa); // 구조체의 크기
sa.lpSecurityDescriptor = NULL; // 상속 관점에서는 의미가 없음
sa.bInheritHandle = TRUE; // 상속 여부를 결정 짓는 요소. TRUE로 설정해야 상속이 된다.
// mailslot에 메시지를 전달하기 위한 데이터 스트림 생성
hMailSlot = CreateFile(
SLOT_NAME, // 메일 슬롯의 주소, 즉 해당 주소의 메일슬롯을 개방한다.
GENERIC_WRITE, // 쓰기 모드
FILE_SHARE_READ, // 파일의 공유 방식, 여기서는 동시 읽기 접근 가능
&sa, // 보안 속성 지정, 핸들 상속과 관련있는 부분. 여기서는 상속을 위해 정의한 sa 사용
OPEN_EXISTING, // 기존의 파일을 개방. 없다면 함수 호출 실패. Receiver에 의해 메일슬롯이 생성되어 있는 상황.
FILE_ATTRIBUTE_NORMAL, // 파일의 속성. 별다른 특성이 없는 보통 파일. 일반적으로 이걸 쓴다고 보면 됨.
NULL // 기존에 존재하는 파일과 동일한 특성을 가지는 새 파일을 만들때 사용되는 전달 인자. 일반적으로 NULL 사용.
); // 함수 호출이 성공하면 파일의 핸들이 반환됨.
if (hMailSlot == INVALID_HANDLE_VALUE)
{
_fputts(_T("Unable to create mailslot stream!\n"), stdout);
return 1;
}
_tprintf(_T("Inheritable Handle : %d\n"), hMailSlot); // 현재 상속할 핸들 출력
FILE* file = _tfopen(_T("InheritableHandle.txt"), _T("wt")); // 파일을 개방. 텍스트 모드로 작성
_ftprintf(file, _T("%d"), hMailSlot); // 핸들을 파일에 기록
fclose(file); // 스트림 해제
STARTUPINFO si = { 0, };
PROCESS_INFORMATION pi = { 0, };
si.cb = sizeof(si);
TCHAR command[] = _T("MailSender2_Child.exe");
// 핸들을 상속한 프로세스 생성
CreateProcess(NULL, command, NULL, NULL,
TRUE, // 핸들 테이블을 상속하겠다
CREATE_NEW_CONSOLE,
NULL, NULL,
&si, &pi);
// Message 송신
while (1)
{
// 문자열을 입력받는다.
_fputts(_T("MY CMD>"), stdout);
_fgetts(message, sizeof(message) / sizeof(TCHAR), stdin);
// WriteFile 함수를 통해 메일슬롯으로 데이터를 전송한다.
if (!WriteFile(hMailSlot, // 데이터를 저장할 파일을 지정한다. 여기서는 데이터를 메일슬롯에 저장한다(보낸다).
message, // 전송할 데이터가 저장되어 있는 버퍼를 지정한다.
_tcslen(message) * sizeof(TCHAR), // 전송할 데이터 크기를 지정
&bytesWritten, // 함수 호출 완료 후 전송된 실제 데이터의 크기를 바이트 단위로 얻기 위한 변수의 주소를 지정.
NULL)) // ReadFile과 마찬가지로 현재는 NULL을 넣는다고만 알아둔다.
{
_fputts(_T("Unable to write!\n"), stdout);
CloseHandle(hMailSlot);
return 1;
}
if (!_tcsncmp(message, _T("exit"), 4))
{
_fputts(_T("Good Bye!\n"), stdout);
break;
}
}
CloseHandle(hMailSlot);
return 0;
}
[MailSender_Child.cpp]
/*
* Windows System Programming - 프로세스 간 통신(Inter-Process Communication, IPC)
* 파일명: MailSender2_Child.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-05
* 이전 버전 작성 일자:
* 버전 내용: 메일슬롯(MailSlot) 핸들의 상속 확인용 예제 - 자식 프로세스
* 이전 버전 내용:
*/
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain(int argc, TCHAR* argv[])
{
HANDLE hMailSlot; // mailslot 핸들
TCHAR message[50];
DWORD bytesWritten; // 몇 바이트나 읽었는가 확인
// 핸들을 얻는 부분
FILE* file = _tfopen(_T("InheritableHandle.txt"), _T("rt"));
_ftscanf(file, _T("%d"), &hMailSlot);
fclose(file);
_tprintf(_T("Inheritable Handle: %d\n"), hMailSlot);
// Message 송신
while (1)
{
// 문자열을 입력받는다.
_fputts(_T("MY CMD>"), stdout);
_fgetts(message, sizeof(message) / sizeof(TCHAR), stdin);
// WriteFile 함수를 통해 메일슬롯으로 데이터를 전송한다.
if (!WriteFile(hMailSlot, // 데이터를 저장할 파일을 지정한다. 여기서는 데이터를 메일슬롯에 저장한다(보낸다).
message, // 전송할 데이터가 저장되어 있는 버퍼를 지정한다.
_tcslen(message) * sizeof(TCHAR), // 전송할 데이터 크기를 지정
&bytesWritten, // 함수 호출 완료 후 전송된 실제 데이터의 크기를 바이트 단위로 얻기 위한 변수의 주소를 지정.
NULL)) // ReadFile과 마찬가지로 현재는 NULL을 넣는다고만 알아둔다.
{
_fputts(_T("Unable to write!\n"), stdout);
_gettchar();
CloseHandle(hMailSlot);
return 1;
}
if (!_tcsncmp(message, _T("exit"), 4))
{
_fputts(_T("Good Bye!\n"), stdout);
break;
}
}
CloseHandle(hMailSlot);
return 0;
}
위에 있는 예제 코드에서는 부모 프로세스에서 메일슬롯의 핸들을 파일(.txt) 형태로 생성합니다.
그러면 자식 프로세스에서는 이 파일을 읽어들어서 핸들을 얻고 동일한 메일슬롯에 데이터를 보낼 수 있게 됩니다.
여기서 의문점이 들 수 있는 부분입니다.
"왜 핸들 정보를 굳이 파일로? 상속했으면 끝 아닌가?"
분명히 메일슬롯과 관련된 핸들을 자식 프로세스한테 상속이 된 것은 맞습니다.
그래서 자식 프로세스의 핸들 테이블에는 이와 관련된 정보도 들어가있고요.
그런데 등록된 핸들의 정보를 자식 프로세스는 확인을 할 방법이 없습니다.
왜냐고요? 핸들 테이블은 OS가 관리하고 있기 때문에 우리가 프로그램 상에서 이걸 확인할 방법이 없습니다.
(사실 아예 방법이 없는 것은 아닌데 일반적으로는 일단은 없다고 칩니다)
그리고 리소스를 생성할 때 반환되는 핸들값은 고정적이지도 않습니다.
다시 말하면 핸들 값은 실행될 때마다 달라질 수 있다는 말입니다.
그래서 프로그램이 실행되는 과정에서 얻어진 핸들 정보를 자식 프로세스에게 전달해줘야 합니다.
그래야 상속된 핸들 정보를 이용할 수 있기 때문입니다.
+ 추가 사항 1
위 코드는 소스코드를 빌드하고 실행까지 하면 잘 돌아갑니다.
문제는 자식 프로세스에서 문제가 생깁니다.
아마 유니코드 기반으로 만들어져 있어서 뭔가 문제가 있는 것 같습니다.
파일로 전달되는 핸들값까지도 잘 읽어옵니다.
근데 문제는 자식 프로세스에서 메시지를 전달하면 "Unable to Write"라는 에러가 나옵니다.
즉, 메일슬롯에 데이터를 보내는데 문제가 있었다는 말입니다.
실제로 저자분이 작성하신 코드를 기준으로도 돌려봤는데 결과는 똑같았습니다.
아마 당시 XP나 Vista에서는 문제가 없었던 것 같은데 윈도우10에서는 동작을 하질 않습니다.
아무래도 C++의 버전이 바뀌면서 일부 변경된 부분이나 OS의 버전마다 다르게 동작한다고 생각하고 있습니다.
+ 추가 사항 2
파일을 가지고 핸들을 주고 받는 방법을 저자는 "촌스러운(?)" 방법이라고 했습니다.
그런데 실제로 파일로 핸들을 주고 받는 방법은 그렇게 권장되는 방법이 아닙니다.
자식 프로세스가 파일에 접근하기 전에 핸들이 담긴 파일이 삭제가 될 수도 있습니다.
그게 아니면 악성코드에 의해서 변조된 값에 접근할 수도 있기 때문입니다.
그래서 프로세스를 생성할 때에는 매개변수 형태로 전달하는 것을 보다 권장하고 있습니다.
[Pseudo 핸들과 핸들의 복제(Duplicate)]
앞서 보였던 방법은 핸들을 주고받는 하나의 예입니다.
또 하나의 방법은 핸들을 복제하는 방법입니다.
이전에 GetCurrentProcess 함수를 가지고 프로세스 자신의 핸들을 얻을 수 있다고 했습니다.
그런데 이 함수 호출을 통해서 얻은 핸들을 가짜 핸들(Pseudo Handle)이라고 합니다.
사실 이 핸들은 핸들 테이블에 등록이 되어 있지 않은 핸들입니다.
현재 실행 중인 프로세스를 참조하기 위한 용도로 정의해놓은, 약속된 상수가 반환되기 때문입니다.
그래서 GetCurrentProcess 함수를 사용해서 자식 프로세스에 상속해도 상속이 되지 않습니다.
더군다나 CloseHandle 함수의 인자로 전달할 필요도 없고요.
혹시라도 넣는다 해도 아무 일도 일어나지 않습니다.
그런데 현재 실행 중인 프로세스가 정말로 '진짜' 자기 자신의 핸들이 필요한 경우가 있을까요?
세상을 살다보면 별의 별 일이 다 있다고 하는데, 여기라고 없겠습니까.
당연히 있습니다!
일단은 그런 상황을 예제로 맛보기에 앞서서 이 핸들을 복제하는데 사용하는 함수를 보고 가겠습니다.
https://learn.microsoft.com/en-us/windows/win32/api/handleapi/nf-handleapi-duplicatehandle
함수에 대한 설명은 자세히 하지 않겠습니다.
그래도 함수의 네 번째 인자까지는 사용방법을 알고 있으셔야 합니다.
여기서 1번 인자는 복제할 핸들을 가지고 있는 프로세스를 가리킵니다.
그리고 2번 인자는 복제할 핸들을 지정합니다.
3번 인자에서는 복제된 핸들을 소유할 프로세스를 지정합니다.
마지막으로 4번 인자는 복제된 핸들값을 저장한 변수의 주소를 지정합니다.
위의 그림이 이걸 설명하고 있습니다.
그럼 여기서 1번 인자와 3번 인자를 똑같이 두면 어떻게 될까요?
놀랍게도 자신의 프로세스가 가지고 있는 핸들을 자신의 핸들 테이블에 복제하는 것이 됩니다.
이 방식을 이용하면 자기 자신의 핸들값도 얻어오는 것이 가능해집니다.
그리고 주의해야할 점이 있습니다.
복사해서 얻어온 핸들의 값에 대해서도 CloseHandle 함수를 통해 Usage Count를 줄여줘야 합니다.
이 사실을 잊으면 안됩니다.
[함수에 대한 설명을 안하는 이유]
여기는 사실 공부와 관련된 것보다는 제 개인적인 의견을 쓴 부분입니다.
잔소리니까 그냥 쭉 넘기셔도 됩니다.
사실 이전의 글들을 보시면 제가 MS의 링크만 올려놓고 함수에 대한 설명은 잘 안해놨습니다.
실제로 저 이외의 다른 곳에서 작성한 글을 보면 함수에 대한 설명을 잘 해놓은 곳들도 있습니다.
그런 글들이 편한 분들이 더 많으실거에요.
저도 함수에 대한 설명을 자세히 하라고 하면 할 수 있습니다.
근데 안합니다.
안하는 이유는 그때그때 찾아 보는 것이 가장 좋다고 생각하기 때문입니다.
이건 좀 제 개인적인 부분이긴 합니다만...
함수의 원형에 인자가 뭐고 어느 헤더파일에 선언되어 있는걸 외우는 것은 비효율적이라고 생각합니다.
요즘에 어지간하면 다 찾아보고 참고해가면서 만들게 됩니다.
위의 링크처럼 MS에서 함수의 원형에 대해 설명을 잘 해놓고 있습니다.
그리고 더 잘되어있는 경우는 예시로 사용한 코드까지 수록해놓기도 합니다.
자주 쓰는 함수면 굳이 외우라고 안해도 나중에 내가 빨리 코딩하려면 자연스럽게 외우게 됩니다.
그런데 이건 뭐고 저건 뭐고 외우려고 하면 밑도 끝도 없습니다.
[부모 프로세스의 핸들을 자식 프로세스에게 전달하기]
[DuplicateHandle.cpp]
/*
* Windows System Programming - 프로세스 간 통신(Inter-Process Communication, IPC)
* 파일명: DuplicateHandle.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-05
* 이전 버전 작성 일자:
* 버전 내용: DuplicateHandle 함수 사용 예제 - 부모 프로세스
* 이전 버전 내용:
*/
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain(int argc, TCHAR* argv[])
{
HANDLE hProcess;
TCHAR cmdString[1024];
// 자신의 가짜(Pseudo) 핸들을 복제하여 진짜 핸들을 얻어옴
DuplicateHandle(
GetCurrentProcess(), GetCurrentProcess(),
GetCurrentProcess(), &hProcess, 0,
TRUE, DUPLICATE_SAME_ACCESS
);
// 부모 프로세스의 핸들을 "ChildProcess.exe hProcess" 형식으로 전달
_stprintf(cmdString, _T("%s %u"), _T("DuplicateHandleChildProcess.exe"), (unsigned)hProcess);
STARTUPINFO si = { 0, };
PROCESS_INFORMATION pi = { 0, };
si.cb = sizeof(si);
BOOL isSuccess = CreateProcess(
NULL, cmdString, NULL, NULL, TRUE,
CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi);
if (isSuccess == FALSE)
{
_tprintf(_T("Create Process Failed\n"));
return -1;
}
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
_tprintf(_T("[Parent Process]\n"));
_tprintf(_T("OOOOOOOOOOOOOOOOOOOOOOPPPS!!!\n"));
return 0;
}
여기서는 자신의 가짜 핸들을 복제하여 진짜 핸들을 핸들 테이블에 등록하는 과정이 들어가 있습니다.
그리고 자식 프로세스의 첫 번째 인자로 핸들의 값을 전달하게 됩니다.
[DuplicateHandleChild.cpp]
/*
* Windows System Programming - 프로세스 간 통신(Inter-Process Communication, IPC)
* 파일명: DuplicateHandleChildProcess.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-05
* 이전 버전 작성 일자:
* 버전 내용: DuplicateHandle 함수 사용 예제 - 자식 프로세스
* 이전 버전 내용:
*/
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain(int argc, TCHAR* argv[])
{
HANDLE hParent = (HANDLE)_ttoi(argv[1]);
DWORD isSuccess = WaitForSingleObject(hParent, INFINITE);
_tprintf(_T("[Child Process]\n"));
if (isSuccess == WAIT_FAILED)
{
_tprintf(_T("WAIT_FAILED returned!\n"));
Sleep(10000);
return -1;
}
else
{
_tprintf(_T("General Lee said, \"Don't inform "));
_tprintf(_T("the enemy my death\""));
}
Sleep(10000);
return 0;
}
자식 프로세스에서는 부모 프로세스의 핸들 값을 잘 받았는지 확인하기 위한 코드입니다.
WaitForSingleObject 함수를 이용하여 부모 프로세스가 종료되는 것을 기다리고 있습니다.
그래서 부모 프로세스가 종료되면 else의 문장이 수행되는 것을 확인할 수 있습니다.
[파이프 방식의 IPC]
이제 다시 진짜 IPC로 돌아왔습니다.
전에는 메일슬롯에 대해서 다뤘었는데, 이번에는 파이프에 대한 소개를 할까 합니다.
[메일슬롯에 대한 회고와 파이프에 대한 이해]
Windows에서 사용하는 파이프 메커니즘은 크게 두 가지가 있습니다.
첫 번째로는 '이름 없는 파이프(Anonymous Pipe, 또는 무명 파이프)'가 있습니다.
그리고 다른 하나는 '이름 있는 파이프(Named Pipe, 또는 명명 파이프)'입니다.
어째 네이밍 센스가 이상합니다만, 둘은 구분하기가 참 쉽습니다.
이름이 없다는 말은 가리킬 주소(이름)가 없다는 말입니다.
그래서 메일슬롯에서 \\.\\mailslot\\mailbox라고 했던 것처럼 주소를 쓸 필요가 없습니다.
반대로 이름이 있다는 말은 가리킬 주소(이름)이 있다는 말이고, 메일슬롯에서 썼던 것처럼 주소를 사용합니다.
이제 메일슬롯과 비교를 해봅시다.
메일슬롯은 서로 관련이 없는 프로세스들 사이에서 통신할 때 유용한 기법입니다.
(관련이 없다 == 네트워크 내에서 연결되어 통신하는 프로세스나, 부모 자식의 관계가 없는 것)
그래서 메일슬롯은 메일슬롯의 주소만 공유했지 아무런 관계가 없는 프로세스 사이에서 통신을 한 것이죠.
'이름 없는 파이프'는 메일슬롯과 같은 점이라면 단방향 통신을 합니다.
그리고 다른 점이라면 첫 번째로는 주소를 사용하지 않습니다.
또한 부모 자식 관계에 있는, 관계가 있는 프로세스 사이에서 통신을 하는 경우에 유용하게 쓰이게 됩니다.
마지막으로 '이름 있는 파이프'는 메일슬롯과 비교하면 주소를 사용하는 점은 동일합니다.
그래서 관계가 없는 프로세스들 사이에서도 데이터를 주고 받을 수 있습니다.
그런데 다른 점은 '이름 있는 파이프'는 양방향 통신이 가능하다는 점입니다.
"보니까 이름있는 파이프가 제일 좋은거 같은데 저거만 쓰면 되지 않나?"
그건 절대 아닙니다.
메일슬롯도 분명히 장점이 있습니다.
바로 '브로드캐스팅(Broadcasting)'이 가능하다는 점입니다.
다시 말해서 1:N의 통신이 가능하다는 말입니다.
반면에 파이프는 브로드캐스팅이 불가능합니다.
정리를 하면 다음과 같습니다.
1) 메일슬롯
- 단방향, 브로드캐스팅 가능
- 통신 범위: 제한 없음(관계가 없는 프로세스와 통신 가능)
2) 이름 없는 파이프
- 단방향
- 통신 범위: 부모-자식간 관계가 있는 프로세스
3) 이름 있는 파이프
- 양방향
- 통신 범위: 제한 없음(관계가 없는 프로세스와 통신 가능)
[이름없는 파이프(Anonymous Pipe)]
이름 없는 파이프는 총 2개의 핸들을 사용합니다.
실제로 파이프는 들어오는 곳이 있으면 나가는 곳이 있습니다.
마찬가지로 여기서도 2개의 핸들을 사용하는 이유는 데이터를 읽는 핸들과 데이터를 쓰는 핸들로 나뉘기 때문입니다.
그래서 데이터를 쓰는 핸들을 '입구'라고 생각하고, 데이터를 읽는 핸들을 '출구'라고 생각하는게 편합니다.
[anonymous_pipe.cpp]
/*
* Windows System Programming - 프로세스 간 통신(Inter-Process Communication, IPC)
* 파일명: anonymous_pipe.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-05
* 이전 버전 작성 일자:
* 버전 내용: 이름 없는 파이프의 기본 원리 파악 예제
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain(int argc, TCHAR* argv[])
{
HANDLE hReadPipe, hWritePipe; // pipe handle, 입력과 출력 총 2개의 핸들이 필요하다
TCHAR sendString[] = _T("anonymous pipe");
TCHAR recvString[100];
DWORD bytesWritten;
DWORD bytesRead;
// 파이프 생성
CreatePipe(&hReadPipe, &hWritePipe, NULL, 0);
// 파이프의 한쪽 끝을 이용한 데이터 송신(Write)
WriteFile(
hWritePipe, // 파이프의 이름
sendString, // Write할 데이터의 버퍼
_tcslen(sendString) * sizeof(TCHAR), // Write할 데이터의 크기
&bytesWritten, // 실제로 Write한 데이터의 크기를 저장할 변수의 주소
NULL);
_tprintf(_T("string send: %s\n"), sendString);
// 파이프의 다른 한쪽 끝을 이용한 데이터 수신(Read)
ReadFile(
hReadPipe, // 파이프의 이름
recvString, // Read할 데이터의 버퍼
bytesWritten, // Read할 데이터의 크기
&bytesRead, // 실제로 Read한 데이터의 크기를 저장할 변수의 주소
NULL);
recvString[bytesRead / sizeof(TCHAR)] = '\0'; // 마지막에 널 문자 추가
_tprintf(_T("string recv: %s\n"), recvString);
CloseHandle(hWritePipe);
CloseHandle(hReadPipe);
return 0;
}
위 예제에서는 프로세스를 생성하지 않고 단일 프로세스 내에서 이름없는 파이프의 특성만을 보여주고 있습니다.
아까 위에서 정리했던 특징처럼 부모가 자식 프로세스를 생성하면서 입력이나 출력용 핸들을 상속해준다면?
부모와 자식 프로세스간의 통신이 가능하게 되는 것입니다.
[이름있는 파이프(Named Pipe)]
이름있는 파이프의 핵심은 '양방향 통신'에 있습니다.
이름있는 파이프는 서버와 클라이언트의 입장을 놓고 생각하시면 이해하기가 편합니다.
순서를 정리하면 이렇게 됩니다.
1) 서버에서는 파이프를 생성
2) 클라이언트에 의한 연결 요청이 올때까지 서버는 파이프를 열어놓고 대기
3) 클라이언트는 파이프의 주소를 가지고 파이프로 연결 요청
4) 연결이 되면 통신을 시작
[namedpipe_server.cpp]
/*
* Windows System Programming - 프로세스 간 통신(Inter-Process Communication, IPC)
* 파일명: namedpipe_server.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-05
* 이전 버전 작성 일자:
* 버전 내용: 이름 있는 파이프의 기본 원리 파악 예제 - 서버
* 이전 버전 내용:
*/
#define BUF_SIZE 1024
#define _CRT_SECURE_NO_WARNINGS
#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, // Read, Write 모드 지정
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, // 메시지 기반
PIPE_UNLIMITED_INSTANCES, // 최대 파이프 개수 (제한을 두지 않겠다)
BUF_SIZE, // 출력 버퍼 사이즈
BUF_SIZE, // 입력 버퍼 사이즈
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 = _tfopen(fileName, _T("r"));
if (filePtr == NULL) // 파일 개방에 실패했을 경우
{
_tprintf(_T("File open fault!\n"));
return -1;
}
DWORD bytesWritten = 0;
DWORD bytesRead = 0;
// EOF를 만날 때까지 파일을 읽어들인다
while (!feof(filePtr))
{
// fread 함수를 통해 데이터를 읽어들인다.
// 1 * 1024 바이트만큼 읽음
bytesRead = fread(dataBuf, 1, BUF_SIZE, filePtr);
WriteFile(
hPipe, // 파이프 핸들
dataBuf, // 전송할 데이터 버퍼
bytesRead, // 전송할 데이터 크기
&bytesWritten, // 실제 전송된 데이터 크기
NULL);
// 전송할 데이터 크기와 실제 전송된 데이터 크기가 다를 경우
if (bytesRead != bytesWritten)
{
_tprintf(_T("Pipe write message error!\n"));
break;
}
}
FlushFileBuffers(hPipe);
DisconnectNamedPipe(hPipe);
CloseHandle(hPipe);
return 1;
}
[namedpipe_client.cpp]
/*
* Windows System Programming - 프로세스 간 통신(Inter-Process Communication, IPC)
* 파일명: namedpipe_client.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-05
* 이전 버전 작성 일자:
* 버전 내용: 이름 있는 파이프의 기본 원리 파악 예제 - 클라이언트
* 이전 버전 내용:
*/
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#define BUF_SIZE 1024
int _tmain(int argc, TCHAR* argv[])
{
HANDLE hPipe;
TCHAR readDataBuf[BUF_SIZE + 1];
LPCTSTR pipeName = _T("\\\\.\\pipe\\simple_pipe");
while (1)
{
// 파이프와 연결할 스트림 생성
hPipe = CreateFile(
pipeName, // 파이프 이름
GENERIC_READ | GENERIC_WRITE, // Read, Write 모드 지정
0,
NULL,
OPEN_EXISTING,
0,
NULL
);
// 연결이 되었을 경우에는 break
if (hPipe != INVALID_HANDLE_VALUE)
break;
// ERROR_PIPE_BUSY는 연결 요청이 Pending상태에 놓여있음을 의미함.
// Pending은 여유분의 파이프가 존재하지 않아서 요청이 수락되려면 잠시 기다려야 되는 상황
// ERROR_PIPE_BUSY가 발생할 경우에는 다시 시도를 해봐야 함.
// 그렇다고 계속 연결을 시도할 수는 없으므로 밑에서 Timeout이 되는 경우를 별도로 놔뒀음
if (GetLastError() != ERROR_PIPE_BUSY)
{
_tprintf(_T("Could not open pipe\n"));
return 0;
}
// Timeout이 되는 경우
if (!WaitNamedPipe(pipeName, 20000))
{
_tprintf(_T("Could not open pipe\n"));
return 0;
}
}
DWORD pipeMode = PIPE_READMODE_MESSAGE | PIPE_WAIT; // 메세지 기반으로 모드 변경
BOOL isSuccess = SetNamedPipeHandleState(
hPipe, // 서버 파이프와 연결된 핸들
&pipeMode, // 변경할 모드 정보
NULL,
NULL);
if (!isSuccess)
{
_tprintf(_T("SetNamedPipeHandleState failed\n"));
return 0;
}
LPCTSTR fileName = _T("D:\\news.txt");
DWORD bytesWritten = 0;
// 데이터 송신
isSuccess = WriteFile(
hPipe, // 서버 파이프와 연결된 핸들
fileName, // 전송할 파일명
(_tcslen(fileName) + 1) * sizeof(TCHAR), // 메시지의 길이
&bytesWritten, // 실제 전송한 바이트 수
NULL);
if (!isSuccess)
{
_tprintf(_T("WriteFile failed\n"));
return 0;
}
DWORD bytesRead = 0;
while (1)
{
isSuccess = ReadFile(
hPipe,
readDataBuf,
BUF_SIZE * sizeof(TCHAR),
&bytesRead,
NULL);
if (!isSuccess && GetLastError() != ERROR_MORE_DATA)
break;
readDataBuf[bytesRead] = 0;
_tprintf(_T("%s\n"), readDataBuf);
}
CloseHandle(hPipe);
return 0;
}
전체적인 동작 과정은 클라이언트 쪽에서 파일 이름을 보냅니다.
그러면 서버쪽에서는 받은 파일 이름을 가지고 파일을 찾아서 읽습니다.
그리고 읽은 파일의 내용을 다시 클라이언트쪽으로 보내는 프로그램입니다.
+ 추가사항
아마 이번 글이 추가사항이 가장 많은 글이 될 것 같습니다.
파일을 읽어들이는 것까지 문제 없이 잘 읽어들입니다.
다만 유니코드와 관련해서 출력 부분에 문제가 있는지 결과를 정말 단순하게 집어넣어도 ???????로 나오게 됩니다.
마찬가지로 한빛미디어에 올라와있는 소스코드를 가지고 결과를 봤지만 똑같은 결과가 나왔습니다.
아마 클라이언트를 켜자마자 프로그램이 꺼지는 분들도 있으실겁니다.
디버깅 과정을 통해서 결과를 보더라도 ??????와 같이 나옵니다.
의미 있는 결과를 보려면 유니코드 기반이 아닌 MBCS 기반으로 작성해보면 제대로 나올 것이라고 생각합니다.
[프로세스 환경변수]
이제 마지막으로 프로세스 환경변수에 대해서 다뤄보겠습니다.
사실 프로세스 환경변수는 '환경변수'라는 말 그대로 해당 프로세스의 고유 정보를 담기 위한 것입니다.
그런데 자식 프로세스를 생성할 때 상속을 해주면 부모-자식 간의 통신이 되는 효과를 볼 수도 있습니다.
환경 변수는 자료구조인 테이블의 형태인 Key = Value 형태로 쌍을 이루는 구조입니다.
[EnvParent.cpp]
/*
* Windows System Programming - 프로세스 간 통신(Inter-Process Communication, IPC)
* 파일명: EnvParent.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-05
* 이전 버전 작성 일자:
* 버전 내용: 프로세스 환경변수를 이용하는 예제 - 부모 프로세스
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain(int argc, TCHAR* argv[])
{
SetEnvironmentVariable(_T("Good"), _T("Morning"));
SetEnvironmentVariable(_T("Hey"), _T("Ho!"));
SetEnvironmentVariable(_T("Big"), _T("Boy"));
STARTUPINFO si = { 0, };
PROCESS_INFORMATION pi = { 0, };
si.cb = sizeof(si);
TCHAR command[] = _T("EnvChild.exe");
CreateProcess(
NULL, command, NULL, NULL, FALSE,
CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT,
NULL, // 부모 프로세스의 환경변수 등록
NULL, &si, &pi);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return 0;
}
[EnvChild.cpp]
/*
* Windows System Programming - 프로세스 간 통신(Inter-Process Communication, IPC)
* 파일명: EnvParent.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-05
* 이전 버전 작성 일자:
* 버전 내용: 프로세스 환경변수를 이용하는 예제 - 자식 프로세스
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#define BUF_SIZE 1024
int _tmain(int argc, TCHAR* argv[])
{
TCHAR value[BUF_SIZE];
if (GetEnvironmentVariable(_T("Good"), value, BUF_SIZE) > 0)
_tprintf(_T("%s = %s\n"), _T("Good"), value);
if (GetEnvironmentVariable(_T("Hey"), value, BUF_SIZE) > 0)
_tprintf(_T("%s = %s\n"), _T("Hey"), value);
if (GetEnvironmentVariable(_T("Big"), value, BUF_SIZE) > 0)
_tprintf(_T("%s = %s\n"), _T("Big"), value);
Sleep(10000);
return 0;
}
특별히 설명할 부분은 많지 않은 코드입니다.
SetEnvironmentVariable이라는 함수를 쓴 것과, GetEnvironmentVariable 함수를 사용한 것.
그리고 CreateProcess에서 7번째 인자를 NULL로 주는 부분 정도만 보시면 되겠습니다.