2025. 4. 28. 23:40ㆍ개발/Windows
이 글은 윤성우 님의 '뇌를 자극하는 윈도우즈 시스템 프로그래밍'을 참고하여 공부한 내용입니다.
이번에 WinAPI를 사용하여 미니게임 프로젝트를 2주간 진행하였습니다. 그런데 이 프로젝트를 진행하며 오랜만에 창작하는 재미를 느꼈습니다. 무언가를 협업해 가며 만들어간다는 기쁨, 동료의 열정에 감화되는 기분을 말이죠. 어쨌든 이 프로젝트에서 저는 스레드를 사용하였습니다. 사용된 부분은 레이 캐스팅의 거리 탐색과 렌더링 부분이었습니다. 렌더링 부분은 그래픽 라이브러리를 사용하면 되지만 WinAPI를 사용하여 만드는 것이 목적이었기 때문에 사용하지 않았습니다. 그래서 시작하기 앞서 제가 사용한 방법을 간략히 적고 가겠습니다.

게임은 어두운 공간에서 괴물을 피해 앞에 보이는 영혼을 모두 획득하여 탈출하는 게임입니다. 그런데 여기서 문제가 발생합니다.

게임의 프레임 수가 너무 낮아 사용자의 편의성이 대폭 감소하게 된다는 것입니다. 이 문제를 회피하고자 해상도를 낮추거나 가시거리를 제한하는 방법을 사용하였지만 근본적인 문제 해결은 되지 않았습니다. 그래서 사용한 방법이 멀티 스레드입니다.

렌더링 스레드의 이해를 돕기 위해 밥 로스 선생님의 이미지를 사용하였습니다. 레이 캐스팅은 하나의 레이를 사용하여 뷰포트의 한 열을 모두 채울 수 있습니다. 그래서 각각의 스레드들은 열을 기준으로 작업을 분류하여 격리하였습니다.

메인 스레드는 Queue를 사용하여 작업을 배포하고, 나머지 스레드는 계속하여 Queue를 확인합니다. Queue가 비어있다면 아무 작업도 하지 않지만 작업이 있다면 하나의 작업 Queue에서 추출하여 점유합니다. 그래서 위 Queue를 접근하기 위해 뮤텍스하나를 사용하였습니다.

스레드들은 작업이 모드 끝나면 정수형 변수, done에 값을 1을 더하며 다시 Queue를 확인합니다. 그리고 메인 스레드는 작업을 배포하고 done 변수를 배포한 작업의 수와 같을 때까지 확인합니다. 작업이 모두 완료되었다면 다시 done을 0으로 초기화하며 다른 작업을 진행합니다.

정상적인 방법은 아니었지만 Windows에서 스레드를 사용해 볼 수 있어 좋았습니다.
Thread - Process
스레드는 프로세스가 실행되는 흐름의 단위입니다. 즉, CPU 스케줄링의 단위이기도 합니다. 그래서 프로세스 안에는 코드를 실행하는 최소 한 개의 스레드가 존재합니다. 그렇다면 스레드를 제외한 프로세스는 무슨 역할을 할까요. Windows에서 프로세스는 단순하게 다른 프로세스와 독립된 메모리 공간으로 취급합니다. 프로세스 내에 있는 각각의 스레드들이 접근할 수 있는 공간으로 말이죠. 그래서 프로세스 내에 있는 스레드들은 다른 스레드의 스택 메모리를 제외한 다른 데이터 영역을 모두 접근할 수 있습니다.
스레드를 생성하면 스택 메모리가 예약되고 커밋됩니다. 저는 여기서 잘못 오해하고 있었던 점이 있었습니다. 프로세스의 메모리 영역은 연속적이라고 생각했었습니다. 왜냐하면 프로세스의 메모리 영역을 검색하면 대부분 이런 이미지가 나오기 때문입니다.

이 이미지는 메모리 영역에 대해 이해하는 데는 좋지만 메모리가 연속적으로 할당된 것처럼 보입니다. 하지만 대부분의 시스템에서 프로세스의 메모리는 페이징 기법을 사용하여 불규칙적으로 할당됩니다. 가상 논리 주소를 사용하여 연속된 것처럼 보일 뿐이죠. 그렇기 때문에 새로 생성된 스레드의 스택 메모리 또한 프로세스의 스택 메모리 영역에 할당되는 것이 아닌 별도의 메모리를 할당받아 생성됩니다.
간단하게 스레드를 생성하여 지역변수의 주소를 조회해 보며 메모리가 연속적이지 않은 것을 확인해 보겠습니다.
#include <windows.h>
#include <iostream>
DWORD WINAPI ThreadFunc(LPVOID lpParam)
{
int var = 123;
std::cout << "[Thread] var address:\t" << &var << std::endl;
return 0;
}
int main()
{
int var = 123;
std::cout << "[Main] var address:\t" << &var << std::endl;
HANDLE handle = CreateThread(NULL, 0, ThreadFunc, NULL, 0, NULL);
if (handle != NULL)
{
WaitForSingleObject(handle, INFINITE);
CloseHandle(handle);
}
return 0;
}
[Main] var address: 000000520D8FFA14
[Thread] var address: 000000520D9FF884
스택 메모리는 높은 주소에서 낮은 주소로 이동하지만 생성된 스레드의 지역 변수 주소가 더 높은 것을 확인할 수 있습니다. 즉 생성된 스레드는 Main 스레드와 독립적인 메모리를 가지며 연속적으로 할당되지 않는다는 것을 알 수 있습니다.
Guard Page
그렇다면 스레드들의 스택 오버플로우는 어떻게 발생하는 것일까요. 프로세스의 메모리 영역이 연속적이라 생각했을 때에는 스택 포인터가 힙 메모리 영역을 침범하게 되면 발생하는 줄 알고 있었습니다. 하지만 메모리는 연속적이지 않기 때문에 다른 메모리 영역을 침범할 수는 없습니다. 그래서 Gaurd Page를 사용하여 현재 사용 중인 스택의 크기를 확인합니다.
Gaurd Page는 스택의 커밋된 가장 낮은 페이지에 위치해 있으며, 스택 포인터가 접근하게 되면 예외를 발생시켜 현재 커밋된 스택 메모리의 마지막 부분을 알립니다. 스택 메모리의 예약된 마지막 페이지라면 스택 오버플로우를 발생시키지만, 아닌 경우에는 스택의 추가 커밋을 할 수 있게 해주는 트리거 역할을 하게 됩니다. Gaurd Page는 추가적인 페이지의 특성이기 때문에 예외를 단 한 번만 발생시킵니다. 즉 커밋된 이후 스택 포인터가 다시 접근하여도 예외를 발생시키지 않는다는 것입니다. 그래서 Gaurd Page는 예외를 발생시키면 현재 페이지에 있는 Gaurd Page 특성을 제거하고 추가로 커밋된 마지막 페이지에 Gaurd Page 특성을 설정합니다.
Kernel-Level Thread - User-Level Thread
스레드의 종류는 크게 2가지로 나뉩니다. 나누는 기준은 스레드의 관리 주체입니다. 커널 레벨 스레드의 경우 OS 커널이 스레드를 관리하게 됩니다. 그래서 생성, 스케줄링, 동기화 등의 스레드 작업들을 커널에서 관리하며 각 스레드를 개별 실행 단위로 취급합니다. 때문에 각각의 스레드마다 커널 오브젝트를 통해 콘텍스트와 스택을 관리할 수 있으며 스케줄링을 통해 코어를 배정할 수 있습니다. Linux pthread, Windows Thread가 커널 레벨 스레드에 해당됩니다.
반대로 유저 레벨 스레드는 라이브러리를 사용하여 스레드를 관리하게 됩니다. 즉 커널 레벨에서는 하나의 스레드이지만 그 스레드 위에 유저 레벨 스레드들을 관리하게 되는 것입니다. 가장 큰 특징은 커널 레벨이 아니기 때문에 콘텍스트 스위칭이 없다는 점입니다. 커널 레벨로 전환되는 경우가 없기 때문에 성능에 매우 이점이 있습니다. 하지만 만약 프로세스 내에 하나의 유저 스레드가 Block 상태가 된다면 프로세스 내에 모든 유저 스레드들도 같이 Block 상태가 됩니다. 이 것은 커널 레벨에서 스레드가 하나로 취급되기 때문입니다.
'개발 > Windows' 카테고리의 다른 글
| Windows 스레드 동기화 (0) | 2025.05.13 |
|---|---|
| Windows 스레드 생성과 소멸 (0) | 2025.04.29 |
| Windows 스택 프레임과 레지스터 (0) | 2025.04.09 |
| IPC (0) | 2025.03.26 |
| 커널 오브젝트와 오브젝트 핸들 (0) | 2025.03.17 |