<메모리 관리(Virtual Memory, Heap, MMF)>
이제 여기까지 왔다면 사실상 책의 최종장에 들어섰다 해도 과언이 아닙니다.
이번 글에서는 가상 메모리를 관리하는 방법에 대해서 다루게 됩니다.
[공부했던 것을 되짚어보며]
이전 글에서는 꽤 어려운 개념인 비동기 I/O에 대해서 공부를 했었죠.
아마 한 번에 이해가 되기에는 어려운 개념인지라 여러 번 보면서 천천히 내용을 정리하시는 것이 좋습니다.
앞에서도 말했지만 이번에는 가상 메모리를 관리하는 방법에 대해 다루게 됩니다.
https://sevenshards.tistory.com/68
[Windows System Programming] 컴퓨터 구조 - (4)
이제 책의 마지막 챕터까지 오게 되었습니다. 이번 글에서는 컴퓨터 구조에 대한 마지막 이야기를 다루게 됩니다. 그리고 앞으로 작성하게 되는 글은 메모리 관리와 I/O(입출력) 등에 대해 다룰
sevenshards.tistory.com
여기서 가상 메모리와 관련된 개념을 정리를 했습니다.
그래서 이 글에서는 가상 메모리에 대한 이야기를 따로 하지 않습니다.
가상 메모리에 대한 개념이 없는 상태에서 이 글을 읽겠다고 하시면 당장 위에 있는 내용부터 정리하는 것이 좋습니다.
[가상 메모리(Virtual Memory) 컨트롤]
이번 글의 주제는 Windows에서 가상 메모리를 제어할 수 있는 기능을 다룹니다.
그리고 이 기능을 사용하면 메모리의 효율적인 사용에 도움이 되기도 하고요.
너무 어렵게 생각할 것은 없습니다.
쉽게 생각하면 C에서 메모리 동적 할당할 때 malloc과 free, C++의 new와 delete를 생각하시면 편합니다.
[Reserve, Commit 그리고 Free]
Windows에서는 가상 메모리, 즉 페이지를 관리할 때 세 가지의 상태를 부여하게 됩니다.
여기서 Reserve는 예약, Commit은 할당, Free는 할당되지 않았음을 뜻합니다.
그리고 페이지의 총 개수는 다음과 같이 계산할 수 있습니다.
가상 메모리의 크기 / 페이지 하나의 크기 = 페이지의 총 개수
사실 공식이라고 할 것까지는 없지만, 가상 메모리가 크면 클수록 페이지의 총 개수도 늘어나게 됩니다.
그리고 모든 페이지는 위의 세 가지 상태 중 하나를 반드시 가지게 됩니다.
우선 Commit과 Free 상태에 대한 예를 그림으로 나타낸 것입니다.
보시는 바와 같이 할당이 된 물리 메모리에 할당이 된 페이지는 COMMIT으로 나타냅니다.
여기서 말하는 물리 메모리는 하드디스크를 포함한다는 점 참고해두시고요.
할당이 되지 않은 페이지는 FREE 상태로 놓여있습니다.
그래서 우리가 malloc이나 new를 통해 메모리를 동적 할당 하게 되면 해당 페이지는 COMMIT 상태가 됩니다.
위의 그림만 놓고 봐도 굳이 Reserved라는 상태가 필요할까라는 의문이 들 수도 있습니다.
실제로 메모리 동적 할당에서도 malloc과 free, new와 delete만 있는데.
Windows에서는 Reserved 상태를 둔 이유는 메모리를 효율적으로 사용하기 위해서입니다.
그럼 이게 왜 필요한 지 한 번 확인해보겠습니다.
현재 페이지는 모두 FREE인 상태에서 5개의 페이지를 COMMIT 상태로 변경한다고 해보겠습니다.
보다시피 FREE 상태에서 COMMIT 상태로 바꾸는 것은 크게 문제가 되지 않습니다.
malloc이나 new를 통해서 다섯 페이지만큼 할당을 하면 되는거니까요.
그런데 물리 메모리의 효율성 관점에서 생각해보면 문제가 될 수도 있습니다.
다음과 같은 상황이 생긴다고 가정해봅시다.
"현재 필요한 페이지의 분량은 하나면 된다.
그런데 향후 사용량이 늘어날 것을 고려해서 현재 100페이지 정도를 할당하려고 한다.
장기적인 관점을 고려했을 때 이와 같은 결정을 했다."
위와 같은 상황이 펼쳐진다면 필요할 때마다 조금씩 추가로 할당하면 되지 않을까 하는 생각을 할 수 있습니다.
물론 가능합니다.
그런데 순차적으로 연결되어 있는 메모리, 배열과 같은 경우라면 이야기가 많이 달라지게 됩니다.
여러분도 아시다시피 배열은 메모리를 순차적으로 사용하게 되는데 이걸 조금씩 추가로 할당하는 것은 불가능하죠.
배열의 크기를 10000을 준다고 하면 이걸 필요할 때마다 할당하는게 가능한가요?
배열의 특성상 불가능합니다.
*(배열명 + α)
필요할 때마다 작은 크기의 배열을 추가하는 방식으로 할당하면 위처럼 배열 요소에 접근할 수가 없습니다.
그럼 시스템이 물리 메모리 사용의 효율성을 높이기 위해 큰 배열을 선언하면 필요한만큼 메모리 할당을 조금씩 해줄까요?
애석하지만 그럴 수가 없습니다.
시스템은 우리가 생각한 것보다 단순합니다.
페이지를 COMMIT 상태로 만들면 그게 RAM이 되었건 HDD가 되었건 물리 메모리에 할당되어 버립니다.
그래서 도입한 개념이 RESERVE 상태를 둔 것입니다.
RESERVE 상태는 FREE와 COMMIT의 중간 상태라고 보시면 됩니다.
그래서 일부 페이지를 RESERVE 상태로 두어 다른 메모리 할당 함수에 의해서 해당 번지가 할당되지 못하도록 합니다.
물론 예약만 한 것이라 실제로 물리 메모리에 할당은 되지 않은 상태고요.
쉽게 생각하면 우리가 기차표를 예매할 때 좌석을 예약해놓는 것입니다.
좌석을 예약해뒀지만 결제는 하지 않았기 때문에 돈이 나가지 않는 것처럼요.
또한 RESERVE 상태의 메모리 중의 일부는 실제로 사용을 하게 되면 COMMIT 상태로 변경하는 것도 가능합니다.
그래서 메모리 사용량이 늘어나는 것에 따라 점진적으로 COMMIT 상태의 페이지 수를 늘릴 수가 있습니다.
다시 말하면, 필요한 만큼의 페이지만 물리 메모리에 할당하는 것이 가능해지는 겁니다.
[메모리 할당의 시작점과 단위 확인하기]
페이지 단위로 메모리를 할당하기에 앞서 알아야 할 정보가 있습니다.
메모리를 할당하기전에 기본적으로 생각해야 할 요소는 두 가지입니다.
1) 메모리 할당의 시작 주소 - 시작점
2) 할당할 메모리의 크기 - 단위
아시다시피 가상 메모리 시스템은 페이지 단위로 관리됩니다.
그래서 페이지의 중간 위치에서부터 할당을 시작하는 것은 불가능하고, 페이지 크기의 배수 단위로 할당을 해야합니다.
우선 메모리 할당의 시작 주소부터 알아보겠습니다.
페이지 크기가 4K 바이트라면 4K의 배수 값이 할당의 시작 주소가 됩니다.
그런데 실제 Windows에서는 메모리가 지나치게 조각나는 것(단편화)을 방지하려고 합니다.
그리고 관리의 효율성을 위해서 조금 더 넓은 범위의 값을 할당의 경계로 정의합니다.
Windows에서 메모리 할당의 시작 주소가 될 수 있는 기본 단위를 Allocation Granularity Boundary라 합니다.
그리고 메모리 할당의 기본 단위는 페이지 하나의 크기가 할당의 기본 단위가 되고, 배수 단위로 할당할 수 있습니다.
결론을 정리하면 다음과 같습니다.
1) 메모리 할당의 시작 주소 - Allocation Granularity Boundary를 통해
2) 할당할 메모리의 크기 - 페이지의 크기
이 정보는 GetSystemInfo라는 함수를 통해서 얻을 수 있습니다.
[SYSTEM_INFO.cpp]
/*
* Windows System Programming - 메모리 관리(Virtual Memory, Heap, MMF)
* 파일명: SYSTEM_INFO.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 시스템 정보 확인 예제
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain(int argc, TCHAR* argv[])
{
SYSTEM_INFO si;
DWORD allocGranularity;
DWORD pageSize;
GetSystemInfo(&si);
pageSize = si.dwPageSize;
allocGranularity = si.dwAllocationGranularity;
_tprintf(TEXT("Page Size: %u KByte\n"), pageSize);
_tprintf(TEXT("Allocation Granularity: %u KByte\n"), allocGranularity);
return 0;
}
실행 결과는 아마 페이지는 4K, Allocation Granularity는 64K가 나올 겁니다.
(물론 다를 수도 있습니다.)
Allocation Granularity Boundary의 값은 페이지 크기의 배수라는 점을 확인해두시면 될 것 같습니다.
+ 추가 사항 - 단편화(Fragmentation)
위에서 단편화에 대한 이야기를 하게 되었는데, 실제로 OS를 공부하다 보면 더 자세히 알 수 있습니다.
페이징 기법, 세그멘테이션 기법, 내부 단편화, 외부 단편화 등등...
여기서 다 다루기는 어렵고, 나중에 저도 따로 정리를 할 내용입니다.
그래도 기왕 이야기를 꺼냈으니 간략하게 정리하고 가겠습니다.
단편화라는 것은 기억 장치의 빈 공간이나 자료가 여러 개의 조각으로 나뉘어지는 현상을 말합니다.
그래서 단편화 현상은 기억장치가 실제로 사용할 수 있는 공간을 줄어들게 만듭니다.
또한 읽기와 쓰기의 수행 속도를 낮추는 원인이 됩니다.
[VirtualAlloc & VirtualFree 함수]
이제 프로그램 코드를 통해서 가상 메모리를 할당하고 해제하는 시스템 함수에 대해서 알아보도록 하겠습니다.
https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualalloc
VirtualAlloc function (memoryapi.h) - Win32 apps
Reserves, commits, or changes the state of a region of pages in the virtual address space of the calling process. (VirtualAlloc)
learn.microsoft.com
VirtualAlloc 함수는 페이지의 상태를 RESERVE 또는 COMMIT 상태로 만들 때 사용하는 함수입니다.
https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualfree
VirtualFree function (memoryapi.h) - Win32 apps
Releases, decommits, or releases and decommits a region of pages within the virtual address space of the calling process.
learn.microsoft.com
VirtualFree 함수는 VirtualAlloc과 반대되는 함수로, COMMIT 상태의 페이지를 RESERVE나 FREE로 만들 때 사용합니다.
앞서 말했던 것과 같이 malloc과 free, new와 delete를 생각하면 쉽게 이해하실 수 있을겁니다.
[Dynamic Array Design]
가상 메모리 관리에 대한 설명도 됐고, 사용해야 할 함수도 알게 되었습니다.
이제 이걸 이용해서 동적으로 할당이 되는 배열을 한 번 만들어보려고 합니다.
예제 코드는 다음과 같습니다.
[DynamicArray.cpp]
/*
* Windows System Programming - 메모리 관리(Virtual Memory, Heap, MMF)
* 파일명: DynamicArray.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: SEH 기반의 가상 메모리 컨트롤 - 동적 배열 예제
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#define MAX_PAGE 10
int* nextPageAddr;
DWORD pageCnt = 0;
DWORD pageSize;
int PageFaultExceptionFilter(DWORD);
int _tmain(int argc, TCHAR* argv[])
{
LPVOID baseAddr;
int* lpPtr;
SYSTEM_INFO sysInfo;
GetSystemInfo(&sysInfo);
pageSize = sysInfo.dwPageSize; // 페이지 사이즈
// MAX_PAGE의 수 만큼 페이지를 RESERVE 상태로
baseAddr = VirtualAlloc(
NULL, // 임의의 주소로 할당
MAX_PAGE * pageSize, // 예약할 메모리의 크기
MEM_RESERVE, // RESERVE 상태로
PAGE_NOACCESS // NO ACCESS
);
if (baseAddr == NULL) // 메모리 할당이 안됐다면
_tprintf(TEXT("VitualAlloc reserve failed\n"));
lpPtr = (int*)baseAddr; // 배열의 시작 주소를 가리키는 것과 동일
nextPageAddr = (int*)baseAddr;
// page fault 발생 시 예외처리
for (int i = 0; i < (MAX_PAGE * pageSize) / sizeof(int); i++)
{
__try
{
lpPtr[i] = i; // 배열처럼 접근, 범위를 넘어가면 예외 발생
}
__except (PageFaultExceptionFilter(GetExceptionCode()))
{
ExitProcess(GetLastError()); // 문제 발생 시 종료
}
}
// 저장한 배열의 데이터 출력
for (int i = 0; i < (MAX_PAGE * pageSize) / sizeof(int); i++)
_tprintf(TEXT("%d "), lpPtr[i]);
// 할당한 가상 메모리 해제
BOOL isSuccess = VirtualFree(
baseAddr, // 헤제할 메모리의 시작 주소
0, // 해제할 메모리 크기의 단위, MEM_RELEASE의 경우에는 반드시 0
MEM_RELEASE // MEM_DECOMMIT은 RESERVE 상태로, MEM_RELEASE는 FREE 상태로
);
if (isSuccess) // 메모리 해제가 됐다면
_tprintf(TEXT("Release Done!\n"));
else // 안됐다면
_tprintf(TEXT("Release Failed\n"));
return 0;
}
// 사실 여기가 핵심.
// 예외가 발생할 때마다 추가로 COMMIT 상태로 바꾸는 과정을 구현했다.
int PageFaultExceptionFilter(DWORD exptCode)
{
// 예외의 원인이 'page fault'가 아니라면
if (exptCode != EXCEPTION_ACCESS_VIOLATION)
{
_tprintf(TEXT("Exception code = %d\n"), exptCode);
return EXCEPTION_EXECUTE_HANDLER;
}
_tprintf(TEXT("Exception is a page fault\n"));
if (pageCnt >= MAX_PAGE) // 최대 페이지의 수를 넘은 경우
{
_tprintf(TEXT("Exception: out of pages\n"));
return EXCEPTION_EXECUTE_HANDLER;
}
// RESERVED된 가상 메모리 공간을 COMMIT 상태로
LPVOID lpvResult = VirtualAlloc(
(LPVOID)nextPageAddr,
pageSize,
MEM_COMMIT,
PAGE_READWRITE
);
if (lpvResult == NULL) // COMMIT이 안됐다면
{
_tprintf(TEXT("VirtualAlloc Failed\n"));
return EXCEPTION_EXECUTE_HANDLER;
}
else
_tprintf(TEXT("Allocating Another Page\n"));
pageCnt++;
nextPageAddr += pageSize / sizeof(int); // 실수할 수 있으므로 주의
return EXCEPTION_CONTINUE_EXECUTION; // page fault가 발생한 지점부터 이어서 실행
}
코드 자체는 어려운 부분이 없기 때문에 별도의 설명을 하지 않겠습니다.
[힙(Heap) 컨트롤]
사실 힙(Heap)은 우리가 알고 있는 메모리 영역입니다.
프로그래머가 동적으로 할당할 수 있는 영역이죠.
Windows에서는 프로그래머가 직접 힙을 생성하고 소멸시킬 수 있는 방법을 제공합니다.
다시 말해서 malloc, free와 같이 이미 만들어진 힙 영역에 동적할당을 하는 것이 아닙니다.
우리가 직접 힙 영역을 만드는겁니다.
그리고 힙 영역을 직접 만드는 것은 가상 메모리를 컨트롤하는 것보다는 훨씬 유용하다고 생각할 수도 있습니다.
가상 메모리 컨트롤은 '예약'을 통한 메모리의 효율성 측면에서 개념이 중요한 부분입니다.
그런데 힙 컨트롤은 데이터의 분리(관리) 측면을 중요시한 개념입니다.
그래서 구현의 용이성 측면에서도, 관리의 효율성 측면에서도 도움이 되는 부분입니다.
[힙(Heap) 컨트롤에 대해서]
아마 자료구조를 공부하셨다면 연결 리스트라는 것을 다들 아실겁니다.
그래서 위와 같은 구조를 연결 리스트를 통해 구현을 하는 것이 가능하죠.
연결 리스트는 여러분들도 아시다시피 동적 할당을 통해서 노드를 연결하는 구조입니다.
위의 예제는 비디오 가게에서 대여한 비디오의 정보를 다음과 같이 저장했다고 가정해보겠습니다.
근데 최대수라는 사람이 비디오 가게를 더 이상 이용하지 않아 탈퇴한다고 하면?
저기 있는 데이터를 다 지워야하죠.
이걸 한방에 데이터를 날릴 방법이 있을까요?
애석하게도 없습니다.
리스트의 처음부터 끝까지 쭉 조회를 하면서 지워야합니다.
여기서 발생할 수 있는 문제점을 한 번 나열해보겠습니다.
1) 메모리 누수의 문제
사실 연결 리스트의 모든 노드를 삭제하는 것은 그렇게 어려운 일은 아닙니다.
다만, 사람이 신은 아니기에 실수라는 것을 하지 않습니까.
위의 그림에서는 노드가 몇 개 안돼서 그렇지, 저게 몇 만개씩 된다고 생각해보세요.
실수로 인해 몇 개의 노드나 리스트가 완전히 지워지지 않았다면 이는 메모리 누수로 이어지게 됩니다.
이 메모리 누수가 점차 누적이 되다보면 결국 Out-of-Memory로 인해 프로그램이 종료될 수도 있고요.
2) 성능의 문제
앞에서 예로 들었던 노드가 몇 만개씩 된다고 가정을 해보겠습니다.
그냥 딱 1만개의 노드가 있다고 칩시다.
거기서 5천번째의 노드를 지우고 싶다면?
4999번째 노드까지 링크를 타고 쭉 들어가야 합니다.
데이터의 양만큼 조회와 삭제의 시간이 걸리는 것이죠.
데이터가 많으면 많을수록 시스템에는 부담을 줄 수밖에 없습니다.
그래서 결국 할당된 메모리를 한방에 시원하게 날려서 위에 있는 두 가지 문제를 해결할 방법이 있으면 좋겠습니다.
이 이야기를 왜 했겠습니까?
Windows에서는 이 문제를 해결할 수 있기 때문입니다.
[디폴트 힙(Default Heap) & Windows 시스템에서의 힙]
위의 그림을 다시 가져왔습니다.
일단 홍길동과 최대수라는 노드만 다른 메모리 공간에 있다고 가정하겠습니다.
뒤에 대여 목록은 동적 할당을 통해 만들어진 리스트입니다.
보시면 'Windows 디폴트 힙'이라고 되어있는데 프로세스를 생성할 때 같이 생성되는 기본 힙입니다.
정확히 말하면 1MB 크기의 디폴트 힙 영역에 메모리를 할당한 것이죠.
프로세스를 생성할 때 같이 생성되는 힙이라고 해서 '프로세스 힙'이라고도 부릅니다.
그리고 시스템 함수를 통해서 다음과 같은 구조로 메모리 구성을 만드는 것도 가능합니다.
그림을 보시면 디폴트 힙이 아니라 '힙 A', '힙 B'로 나뉘어져 있습니다.
앞서 말했던 것처럼 추가적인 힙의 생성이 가능한 것이죠.
아까 최대수라는 회원이 탈퇴할 때 정보를 한 번에 삭제할 방법을 고민했었습니다.
힙 B를 한 번에 삭제할 방법은? 당연히 있습니다!
그래서 고민하던 문제점 두 개가 한 번에 해결이 됩니다.
조회와 삭제에 시간이 오래 걸리지도 않으면서, 메모리 누수로 인한 걱정도 없게 되는 것이죠.
마지막으로는 디폴트 힙에 대한 문제를 하나 가볍게 내볼까 합니다.
디폴트 힙을 구성하는 페이지의 상태는 과연 무엇일까요?
FREE, COMMIT, RESERVE 셋 중 하나입니다.
확률은 1/3!
정답은.... RESERVE입니다.
사실 더 정확하게 말하면 일부 페이지는 COMMIT 상태로 놓여있고 나머지는 RESERVE 상태라고 보는게 맞습니다.
이걸 맞추셨다면 가상 메모리에 대한 개념은 잘 잡혔다고 보시면 됩니다. 사실 저는 처음에 틀렸습니다
그래서 RESERVE 상태에 있는 힙의 페이지들을 malloc과 free, new와 delete를 사용하게 되면?
페이지 크기의 정수배로 COMMIT과 RESERVE를 왔다갔다 하게 되는 것입니다.
[디폴트 힙 컨트롤]
앞에서 디폴트 힙의 크기는 1M바이트라고 했습니다.
근데 이건 링커 옵션을 통해서 변경하는 것이 가능합니다.
RESERVE 상태에 놓이게 되는 힙의 전체 크기와 초기에 COMMIT 상태로 놔둘 메모리의 크기를 지정할 수 있습니다.
사용하시는 IDE의 설정을 통해서 설정하는 것도 가능하고요.
그런데 디폴트 힙의 크기를 넘어서게 되는 경우에는 어떻게 될까요?
추가적인 할당이 불가능하다거나 1M바이트를 처음부터 넘긴다면?
별 다른 문제가 없이 잘 돌아가게 됩니다.
디폴트 힙의 '초기' 크기가 1M바이트라는 것입니다.
이후 필요에 따라서 크기가 자동으로 늘어나게 됩니다.
엄밀히 따지면 Windows 시스템이 상황에 맞춰서 늘려준다고 보면 되겠네요.
그래서 굳이 디폴트 힙의 크기를 정해줄 필요는 없기도 합니다.
하지만 디폴트 힙의 크기를 정해줌으로 갖게 되는 장점도 생각을 해볼 수는 있습니다.
프로세스가 실행 중일 때 새로운 메모리 영역을 할당하는 것(RESERVE 상태로 예약하는 것)은 시간이 걸리는 작업입니다.
필요한 크기만큼 여유있게 디폴트 힙을 지정해둔다면 그만큼의 시간을 아낄 수 있다는 것도 생각해볼 수 있습니다.
[힙(Dynamic Heap) 생성이 가져다 주는 또 다른 이점]
앞서 '힙 A', '힙 B' 처럼 디폴트 힙을 사용하지 않고 Windows 시스템 함수를 통해 힙을 생성할 수도 있습니다.
이렇게 생성된 힙을 '동적 힙(Dynamic Heap)'이라고 합니다.
그리고 연결 리스트를 대상으로 동적 힙을 사용하면 어떤 이점이 있는지도 알 수 있었죠.
여기에는 또 다른 이점이 있습니다.
1) 메모리 단편화의 최소화에 따른 성능 향상
A, B, C라는 각각의 기능을 이용하기 위해 힙 A, B, C를 생성한다고 하면 왼쪽과 같이 메모리가 할당 됩니다.
그런데 이걸 디폴트 힙에서 할당을 하게 되면 오른쪽처럼 메모리가 할당됩니다.
보시면 단편화가 발생할 소지가 높습니다.
힙을 따로 선언하고 사용하게 되면 RESERVE 상태에 놓여서 메모리 단편화가 생기지 않습니다.
그에 비해 디폴트 힙에서는 프로그램 실행 과정에서 무작위로 메모리를 할당하게 됩니다.
그리고 점점 힙의 크기가 커지면서 메모리 단편화가 심하게 일어나게 됩니다.
메모리의 단편화가 심해진다는 것은?
프로그램의 Locality 특성이 낮아진다는 것을 의미하게 되고, 결과적으로는 성능에 영향을 미치게 됩니다.
그래서 필요에 맞게 추가적인 힙을 생성하여 활용하면 성능 향상을 기대해볼 수도 있습니다.
2. 메모리 공간 할당의 동기화 문제에서 자유로워짐으로 인한 성능 향상
멀티 쓰레드 프로그래밍을 하면서 쓰레드별로 사용할 힙을 별도로 할당해주는 것 역시 의미가 있습니다.
우리가 쓰레드에 대해 배우면서 쓰레드는 스택을 제외한 모든 영역을 공유한다고 했습니다.
그래서 동시 접근 문제가 발생할 수 있기 때문에 Windows 내부적으로 동기화 처리를 하고 있습니다.
여기서 말하는 동기화는 힙에 선언된 변수를 뮤텍스와 세마포어를 이용하여 해결하는 문제가 아닙니다.
'메모리 공간 할당과 해제'의 동기화 문제를 이야기하려고 합니다.
만약 같은 주소 번지에 둘 이상의 쓰레드가 동시에 메모리를 할당하고 해제하는 상황이 발생한다 생각해봅시다.
당연히 메모리 오류(Corrupt)가 발생하게 되겠죠.
그래서 디폴트 프로세스 힙은 쓰레드가 메모리를 할당하려고 하는 경우 내부적으로 동기화 처리를 합니다.
그런데 각각의 쓰레드마다 독립된 하나의 힙을 할당해준다면?
동기화 처리에 대해 고민할 필요가 없어지게 됩니다.
쓰레드별로 동기화 처리를 하지 않기 때문에 성능 향상을 기대할 수 있게 됩니다.
[힙의 생성과 소멸 그리고 할당]
이제 Windows 시스템에서의 힙에 대한 설명은 다한 것 같습니다.
가상 메모리 때는 가상 메모리를 제어하는 함수들이 있었죠?
이번에도 마찬가지로 동적 힙을 생성하고 소멸하는 함수가 있습니다.
그리고 동적 힙 내에서 메모리를 할당하고 해제하는 함수들도 있고요.
그 함수들은 다음과 같습니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/heapapi/nf-heapapi-heapcreate
HeapCreate 함수(heapapi.h) - Win32 apps
호출 프로세스에서 사용할 수 있는 프라이빗 힙 개체를 만듭니다. 함수는 프로세스의 가상 주소 공간에 공간을 예약하고 이 블록의 지정된 초기 부분에 대한 물리적 스토리지를 할당합니다.
learn.microsoft.com
https://learn.microsoft.com/ko-kr/windows/win32/api/heapapi/nf-heapapi-heapdestroy
HeapDestroy 함수(heapapi.h) - Win32 apps
지정된 힙 개체를 제거합니다. 프라이빗 힙 개체의 모든 페이지를 커밋 해제하고 해제하고 힙에 대한 핸들을 무효화합니다.
learn.microsoft.com
https://learn.microsoft.com/ko-kr/windows/win32/api/heapapi/nf-heapapi-heapalloc
HeapAlloc 함수(heapapi.h) - Win32 apps
힙에서 메모리 블록을 할당합니다. 할당된 메모리는 움직일 수 없습니다.
learn.microsoft.com
https://learn.microsoft.com/ko-kr/windows/win32/api/heapapi/nf-heapapi-heapfree
HeapFree 함수(heapapi.h) - Win32 apps
HeapAlloc 또는 HeapReAlloc 함수에 의해 힙에서 할당된 메모리 블록을 해제합니다.
learn.microsoft.com
[Heap & Linked List 예제]
이제 위의 함수들을 가지고 동적 힙을 사용한 예제를 하나 보이겠습니다.
[DynamicHeap.cpp]
/*
* Windows System Programming - 메모리 관리(Virtual Memory, Heap, MMF)
* 파일명: DynamicHeap.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 힙(동적 힙, 디폴트 힙) 사용 예제
* 이전 버전 내용:
*/
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain(int argc, TCHAR* argv[])
{
SYSTEM_INFO sysInfo;
GetSystemInfo(&sysInfo);
UINT pageSize = sysInfo.dwPageSize;
// 힙 생성
HANDLE hHeap = HeapCreate(
HEAP_NO_SERIALIZE, // 생성된 힙에 메모리 할당 및 해제 시 동기화 처리를 하지 않음
pageSize * 10, // 초기에 할당할 물리 메모리의 크기, COMMIT 상태
pageSize * 100 // 힙의 최대 크기, 지정하는 크기만큼 RESERVED 상태
//0을 전달할 경우 증가 가능한 메모리가 되며 메모리가 허락하는 한도 내에서 힙의 크기 증가
);
// 메모리 할당
int* p = (int*)HeapAlloc(
hHeap, // 할당할 힙의 핸들
0, // HEAP_GENERATE_EXCEPTIONS를 인자로 전달하면 오류 발생시 NULL이 아닌 예외 발생.
// 힙 생성 시 HEAP_NO_SERIALIZE를 인자로 전달한 경우 이 때는 지정하지 않아도 됨
// HEAP_ZERO_MEMORY가 전달되면 메모리는 0으로 초기화
sizeof(int) * 10 // 할당하고자 하는 메모리의 크기
);
// 메모리 활용
for (int i = 0; i < 10; i++)
p[i] = i;
// 메모리 해제
HeapFree(
hHeap, // 할당되어 있는 힙의 핸들
0, // HEAP_NO_SERIALIZE를 인자로 전달 가능, 이미 생성 시 전달했다면 생략 가능
p // 해제할 메모리의 시작 주소
);
// 힙 소멸
HeapDestroy(hHeap);
// 디폴트 힙 사용 예시
HANDLE hDefaultHeap = GetProcessHeap();
TCHAR* pDefault = (TCHAR*)HeapAlloc(hDefaultHeap, HEAP_NO_SERIALIZE, sizeof(TCHAR) * 30);
_tcscpy(pDefault, TEXT("Default Heap!"));
_tprintf(TEXT("%s\n"), pDefault);
HeapFree(hDefaultHeap, HEAP_NO_SERIALIZE, pDefault);
return 0;
}
여기서 마지막 부분에는 추가적으로 GetProcessHeap()이라는 함수를 사용한 부분이 있습니다.
디폴트 힙을 사용하는 부분이며, 그 이외에는 이해하시는 데에 크게 어려운 부분은 없습니다.
다음은 동적 힙을 응용한 연결 리스트를 구현한 코드입니다.
자료구조를 공부하신 분이라면 코드를 보면서 크게 어려움을 느끼지 않으실 것이라고 생각됩니다.
[LINKED_HEAP.cpp]
/*
* Windows System Programming - 메모리 관리(Virtual Memory, Heap, MMF)
* 파일명: LINKED_HEAP.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: 힙을 사용한 연결 리스트 예제(MBCS 기반)
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
typedef int ListElementDataType; // 리스트의 데이터 타입
typedef struct _node
{
ListElementDataType data;
struct _node* next;
} Node;
Node* head;
Node* tail;
HANDLE hHeap = 0;
// 힙 초기화
void InitListHeap()
{
SYSTEM_INFO sysInfo;
GetSystemInfo(&sysInfo);
UINT pageSize = sysInfo.dwPageSize;
hHeap = HeapCreate(HEAP_NO_SERIALIZE, pageSize * 10, pageSize * 100);
}
// 리스트 초기화 (head, tail 모두 더미노드 기반)
void InitList()
{
InitListHeap(); // 동적 힙 기반에서만 호출.
// C 기준 head = (Node*)malloc(sizeof(Node));
head = (Node*)HeapAlloc(hHeap, HEAP_NO_SERIALIZE, sizeof(Node));
// C 기준 tail = (Node*)malloc(sizeof(Node));
tail = (Node*)HeapAlloc(hHeap, HEAP_NO_SERIALIZE, sizeof(Node));
head->next = tail;
tail->next = tail;
}
// 노드 제거
int DeleteNode(int data)
{
Node* cur = head; // 무조건 시작은 head부터 (LFirst)
Node* curNext = cur->next; // 현재 노드의 다음 위치
// 제거할 데이터를 찾거나 tail이 아닐때까지
while (curNext->data != data && curNext != tail)
{
cur = cur->next;
curNext = cur->next;
}
if (curNext != tail)
{
// 현재 노드가 가리키는 next를 현재 위치 다음 노드로
cur->next = curNext->next;
// C 기준 free(curNext);
HeapFree(hHeap, HEAP_NO_SERIALIZE, curNext);
return 1; // 제거 성공 시 1
}
else
return 0; // 제거하지 못했을 경우 0
}
// 노드 삽입 (단, 정렬해서 삽입)
void OrderedInsert(int data)
{
Node* cur = head;
Node* curNext = cur->next;
Node* newNode;
// 오름차순 정렬
while (curNext->data <= data && curNext != tail)
{
cur = cur->next;
curNext = cur->next;
}
// C기준 newNode = (Node*)malloc(sizeof(Node));
newNode = (Node*)HeapAlloc(hHeap, HEAP_NO_SERIALIZE, sizeof(Node));
newNode->data = data;
cur->next = newNode;
newNode->next = curNext;
}
// 리스트에 저장된 모든 데이터 출력
void PrintAllList()
{
Node* startNode = head->next;
while (startNode != tail)
{
printf("%-4d", startNode->data);
startNode = startNode->next;
}
_tprintf(TEXT("\n\n"));
}
// 리스트에 저장된 모든 데이터 삭제
void DeleteAll()
{
/*
Node* cur = head->next;
Node* delNode;
while (cur != tail)
{
delNode = cur;
cur = cur->next;
free(delNode);
}
*/
// 위와 같이 구현해도 되지만 힙은 한 방에 날리는 것이 가능하다
HeapDestroy(hHeap); // 싹 다 날리고
InitList(); // 다시 초기화하면 된다 (성능 향상, 에러 발생율 낮음)
}
int _tmain(int argc, TCHAR* argv[])
{
InitList();
printf("Input 1, 2, 3\n");
OrderedInsert(1);
OrderedInsert(2);
OrderedInsert(3);
PrintAllList();
printf("Input 4, 5, 6\n");
OrderedInsert(4);
OrderedInsert(5);
OrderedInsert(6);
PrintAllList();
printf("Delete 2, 5\n");
DeleteNode(2);
DeleteNode(5);
PrintAllList();
printf("Flush List\n\n");
DeleteAll();
printf("Input 6, 5, 4\n");
OrderedInsert(6);
OrderedInsert(5);
OrderedInsert(4);
PrintAllList();
printf("Input 3, 2, 1\n");
OrderedInsert(3);
OrderedInsert(2);
OrderedInsert(1);
PrintAllList();
printf("Flush List\n\n");
DeleteAll();
printf("Input 1, 3, 5\n");
OrderedInsert(1);
OrderedInsert(3);
OrderedInsert(5);
PrintAllList();
printf("Input 2, 4, 6\n");
OrderedInsert(2);
OrderedInsert(4);
OrderedInsert(6);
PrintAllList();
return 0;
}
[MMF(Memory Mapped File)]
이번 글에서 다룰 마지막 주제는 Memory Mapped File (이하 MMF)입니다.
우리가 지금까지 파일 입출력을 하면서 파일 입출력을 위한 스트림을 열고 닫는 번거로운 일을 했었죠.
파일을 읽어들여서 버퍼에 저장하고, 데이터를 편집해서 다시 버퍼를 통해 파일로 출력하는 일을 했습니다.
그런데 MMF를 쓰면 그런 번거로움이 좀 줄어들게 됩니다.
[MMF의 이해]
MMF는 Memory Mapped File의 약자인 것은 위에서 소개를 드렸었죠.
단어의 뜻 그대로 File을 Memory에 Mapping하기 때문에 MMF라고 합니다.
다시 말하면 파일의 일부 영역을 가상 메모리 일부에 연결시키는 메커니즘을 MMF라고 보시면 됩니다.
위 그림은 MMF가 가지고 있는 특성을 보여주고 있습니다.
가상 메모리 중 파일에 연결되어 있는 영역에 데이터를 저장합니다.
그러면 메모리에 있는 데이터만 바뀌는 것이 아니라 파일의 데이터에도 영향을 미치게 됩니다.
다시 말해서 메모리에만 데이터가 저장되는 것이 아니라 메모리에 연결된 파일에 실제 데이터가 저장되는 것이죠.
이를 통해서 얻을 수 있는 이점이 있습니다.
1) 프로그래밍하기 편하다
앞서 말했듯이 파일에 저장된 데이터를 조작하는 것보다는 메모리 상에서 데이터를 조작하는 것이 훨씬 간단합니다.
파일에 저장되어 있는 데이터를 조작하기 위해서는 먼저 메모리로 파일을 읽어들어 와야 합니다.
그리고 메모리에서 조작한 파일을 다시 파일로 저장하는 과정을 거쳐야 합니다.
MMF는 메모리 상에 저장된 데이터만 조작하면 파일 내의 데이터까지 조작할 수 있으므로 굉장히 간편해집니다.
2) 성능의 향상
그림을 보시면 메모리는 중간에서 캐시 메모리의 역할을 수행하고 있습니다.
다시 말해서 직접 파일에 접근하는 것보다 효율적인 접근이 이뤄지게 됩니다.
프로세스는 메모리에 데이터를 읽고 쓰기 때문에 최신의 데이터는 메모리에 있게 됩니다.
그래서 파일에는 매번 데이터를 반영할 필요가 없이 주기적으로 또는 특정 상황에 놓일 때 파일에 저장만 하면 됩니다.
캐시의 Locality 특성을 만족시킬 수만 있다면 성능의 향상을 기대해볼 수도 있습니다.
물론, 메모리에 저장된 데이터가 변경될 때마다 파일에 바로 반영하는 것도 가능합니다.
다만 이 경우에는 앞서 설명한 구현의 편의성만 얻게 되고 성능 향상 부분에 있어서는 큰 이점을 얻지 못하게 됩니다.
[MMF의 구현 과정]
1) 파일 개방
메모리에 파일을 매핑하는 것이기 때문에 당연히 매핑하기 위한 파일이 존재해야 합니다.
그래서 CreateFile 함수를 통해 파일을 개방하고 파일에 대한 핸들을 얻어올 필요가 있습니다.
2) 파일 연결 오브젝트 생성
이 과정에서는 메모리에 연결할 파일 정보를 담고 있는 커널 오브젝트를 생성해야 합니다.
해당 오브젝트는 '파일 연결 오브젝트' 또는 '파일 매핑 오브젝트'라고도 합니다.
이때 사용되는 함수는 CreateFileMapping이라는 함수입니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/winbase/nf-winbase-createfilemappinga
CreateFileMappingA 함수(winbase.h) - Win32 apps
지정된 파일에 대한 명명되거나 명명되지 않은 파일 매핑 개체를 만들거나 엽니다. (CreateFileMappingA)
learn.microsoft.com
https://learn.microsoft.com/ko-kr/windows/win32/api/memoryapi/nf-memoryapi-createfilemappingw
CreateFileMappingW 함수(memoryapi.h) - Win32 apps
지정된 파일에 대한 명명되거나 명명되지 않은 파일 매핑 개체를 만들거나 엽니다. (CreateFileMappingW)
learn.microsoft.com
이 때 필요한 것이 앞의 과정에서 얻게 된 파일의 핸들입니다.
파일 핸들은 파일을 읽고 쓰는 데에 필요하다면, 파일 연결 오브젝트는 메모리에 매핑시킬 때 사용합니다.
3) 가상 메모리에 파일 연결
이제 가상 메모리에 파일을 연결하는 과정만 남았습니다.
이때 사용되는 함수는 MapViewOfFile이라는 함수입니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/memoryapi/nf-memoryapi-mapviewoffile
MapViewOfFile 함수(memoryapi.h) - Win32 apps
파일 매핑 보기를 호출 프로세스의 주소 공간에 매핑합니다.
learn.microsoft.com
이 함수를 통해 반환되는 포인터를 가지고 메모리에 접근해서 데이터를 조작하는 것이 가능합니다.
해당 포인터를 이용해 데이터를 저장하거나 변경하면 연결된 파일에도 반영됩니다.
그리고 파일과 연결된 메모리는 작업이 끝나고 나면 연결을 해제하는 과정을 거쳐야 합니다.
이 때 사용되는 함수는 UnmapViewOfFile함수입니다.
https://learn.microsoft.com/ko-kr/windows/win32/api/memoryapi/nf-memoryapi-unmapviewoffile
UnmapViewOfFile 함수(memoryapi.h) - Win32 apps
호출 프로세스의 주소 공간에서 파일의 매핑된 뷰를 매핑 해제합니다.
learn.microsoft.com
[MMF의 구성 예제]
이제 앞에서 설명했던 내용들을 토대로 MMF를 이용한 간단한 예제 코드를 보겠습니다.
[MemoryMappedFileRead.cpp]
/*
* Windows System Programming - 메모리 관리(Virtual Memory, Heap, MMF)
* 파일명: MemoryMappedFileRead.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: Memory Mapped File 기본 예제
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain(int argc, TCHAR* argv[])
{
// 파일 핸들 생성
HANDLE hFile = CreateFile(TEXT("data.dat"), GENERIC_READ | GENERIC_WRITE, NULL, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE)
_tprintf(TEXT("Could not open file\n"));
TCHAR fileData[] = TEXT("Simple Test String :)");
DWORD numOfByteWritten = 0;
WriteFile(hFile, fileData, sizeof(fileData), &numOfByteWritten, NULL);
// 파일 연결 오브젝트 생성
HANDLE hMapFile = CreateFileMapping(
hFile, // 파일의 핸들
NULL, // 보안 속성(상속)
PAGE_READONLY, // 읽기, 쓰기 권한 지정
0, // 연결할 메모리 최대 크기의 상위 4바이트 지정(4GB 이상)
0, // 연결할 메모리 최대 크기의 하위 4바이트 지정(4GB 미만)
NULL // 이름을 지정할 때 사용. 안쓰므로 NULL
);
// 메모리에 연결
TCHAR* pWrite = (TCHAR*)MapViewOfFile(
hMapFile, // 앞에서 생성한 파일 연결 오브젝트의 핸들
FILE_MAP_READ, // 메모리의 접근 권한 지정(읽기, 쓰기)
0, // 파일의 일부 영역만 선택해서 연결하는 것도 가능. 파일 오프셋의 상위 4바이트
0, // 파일 오프셋의 하위 4바이트
0 // 메모리에 연결할 실제 크기를 바이트 단위로 지정, 0으로 지정하면 오프셋부터 파일의 끝까지 메모리에 연결
);
if (pWrite == NULL)
_tprintf(TEXT("Could not map view of file\n"));
_tprintf(TEXT("String in file: %s\n"), pWrite);
UnmapViewOfFile(pWrite);
CloseHandle(hMapFile);
CloseHandle(hFile);
_tprintf(TEXT("Jobs Done!\n"));
return 0;
}
[읽고 쓰기 위한 MMF]
앞의 예제에서는 읽어들이는 과정만 수행했고, 이번에는 읽고 쓰는 것과 더불어서 저장된 데이터를 정렬해보겠습니다.
[MemoryMappedFileSort.cpp]
/*
* Windows System Programming - 메모리 관리(Virtual Memory, Heap, MMF)
* 파일명: MemoryMappedFileSort.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: Memory Mapped File 기본 예제(2) - 데이터 정렬
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
void SortIntData(int* pSortArr, int num);
int _tmain(int argc, TCHAR* argv[])
{
// 파일 핸들 생성
HANDLE hFile = CreateFile(TEXT("data.dat"), GENERIC_READ | GENERIC_WRITE, NULL, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE)
_tprintf(TEXT("Could not open file\n"));
TCHAR fileData[] = TEXT("Simple Test String :)");
DWORD numOfByteWritten = 0;
WriteFile(hFile, fileData, sizeof(fileData), &numOfByteWritten, NULL);
// 파일 연결 오브젝트 생성
HANDLE hMapFile = CreateFileMapping(
hFile, // 파일의 핸들
NULL, // 보안 속성(상속)
PAGE_READWRITE, // 읽기, 쓰기 권한 지정
0, // 연결할 메모리 최대 크기의 상위 4바이트 지정(4GB 이상)
1024 * 10, // 연결할 메모리 최대 크기의 하위 4바이트 지정(4GB 미만), 10KByte로 지정했으므로 그 이상 증가하지 않음
NULL // 이름을 지정할 때 사용. 안쓰므로 NULL
);
// 메모리에 연결
int* pWrite = (int*)MapViewOfFile(
hMapFile, // 앞에서 생성한 파일 연결 오브젝트의 핸들
FILE_MAP_ALL_ACCESS, // 메모리의 접근 권한 지정(읽기, 쓰기)
0, // 파일의 일부 영역만 선택해서 연결하는 것도 가능. 파일 오프셋의 상위 4바이트
0, // 파일 오프셋의 하위 4바이트
0 // 메모리에 연결할 실제 크기를 바이트 단위로 지정, 0으로 지정하면 오프셋부터 파일의 끝까지 메모리에 연결
);
if (pWrite == NULL)
_tprintf(TEXT("Could not map view of file\n"));
pWrite[0] = 1, pWrite[1] = 3, pWrite[2] = 0;
pWrite[3] = 2, pWrite[4] = 4, pWrite[5] = 5;
pWrite[6] = 8, pWrite[7] = 6, pWrite[8] = 7;
SortIntData(pWrite, 9);
_tprintf(TEXT("%d %d %d\n"), pWrite[0], pWrite[1], pWrite[2]);
_tprintf(TEXT("%d %d %d\n"), pWrite[3], pWrite[4], pWrite[5]);
_tprintf(TEXT("%d %d %d\n"), pWrite[6], pWrite[7], pWrite[8]);
UnmapViewOfFile(pWrite);
CloseHandle(hMapFile);
CloseHandle(hFile);
_tprintf(TEXT("Jobs Done!\n"));
return 0;
}
void SortIntData(int* pSortArr, int num)
{
//Bubble Sort
int temp;
for (int i = 0; i < num - 1; i++)
{
for (int j = 0; j < (num - i) - 1; j++)
{
if (pSortArr[j] > pSortArr[j + 1])
{
temp = pSortArr[j];
pSortArr[j] = pSortArr[j + 1];
pSortArr[j + 1] = temp;
}
}
}
}
앞선 예제에서는 WriteFile 함수를 통해 문자열을 파일에 저장하고 그걸 출력하는 방식이었습니다.
그런데 이번에는 파일에 아무것도 저장하지 않은 상태에서 MMF를 구성했습니다.
MMF를 구성한 이후에 파일에 데이터를 저장하는 방식을 사용했다는 차이점이 있다는 것 정도만 염두에 두시면 됩니다.
[Copy-On-Write(COW)]
Copy-On-Write (이하 COW소)는 MMF와 관련이 있어서 짚고 넘어가는 부분입니다.
(저는 개인적으로는 데이터베이스의 View와도 비슷한 개념으로 이해를 했던 개념입니다.)
COW는 간단하게 생각해보면 "Write 할 때에 Copy하라"라는 개념입니다.
그러니까 데이터를 쓸 때 복사를 하라는 말이죠.
저자는 COW는 일반 개발자들이 쓰는 기술은 아니라고 합니다.
MMF같이 시스템 함수 수준에서 제공하는 경우에 사용할 수도 있습니다.
실제로는 OS같은 고급 소프트웨어를 구현할 때 내부적으로 적용하는 최적화(Optimization) 기술이라고 합니다.
멀티 쓰레드 기반 프로그램에서 쓰레드 생성 시 각각의 쓰레드가 참조할 테이블 형태의 데이터가 있다고 가정하겠습니다.
기본 테이블에 채워진 데이터는 각각의 쓰레드에 의해서 변경될 수도 있습니다.
그래서 모든 쓰레드는 자신만의 테이블을 별도로 지닐 필요가 있게 됩니다.
다시 말해서 쓰레드를 생성 시 기본 테이블의 복사가 발생하게 됩니다.
그런데 여기서 한 가지 특징이 있습니다.
대부분의 쓰레드가 기본 테이블 정보를 변경하는 것이 아니라 참조만 하고 있는 것입니다.
데이터의 변경을 하기는 합니다.
단지 변경을 하는 빈도가 굉장히 적다는 것이죠.
그래서 얼마 없는 데이터 변경 때문에 모든 쓰레드에 독립된 테이블 정보를 지니도록 할 필요가 없다고 느끼게 됩니다.
왜냐면 매번 테이블을 복사하는 것은 메모리를 낭비하는 것이니까요.
이게 COW 최적화 기법을 도입하게 된 배경입니다.
그래서 쓰레드를 생성할 때마다 기본 테이블을 복사해서 할당하는 방식을 바꿔버립니다.
모든 쓰레드들이 하나의 기본 테이블을 공유하도록 프로그램을 설계한 것이죠.
그런데 테이블의 데이터를 변경하려고 하는 쓰레드가 있다면?
그 때 기본 테이블을 복사해서 해당 쓰레드에게 넘겨줍니다.
그리고 그 쓰레드는 테이블의 복사본에 있는 데이터를 변경하는 것이죠.
이후에는 복사본에 해당되는 테이블을 참조하게 됩니다.
위와 같은 방식은 말 그대로 "Write할 때 Copy를 하는" COW 최적화 기법입니다.
메모리를 최대한 절약할 수 있는 구조가 되었고, OS에서도 가상 메모리를 관리하는 데 사용되는 기술 중 하나입니다.
그렇다면 MMF에서는 COW를 어떻게 사용하는지 예제 코드를 통해서 보도록 하겠습니다.
[MemoryMappedFile_CopyOnWrite.cpp]
/*
* Windows System Programming - 메모리 관리(Virtual Memory, Heap, MMF)
* 파일명: MemoryMappedFile_CopyOnWrite.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-14
* 이전 버전 작성 일자:
* 버전 내용: Memory Mapped File 기본 예제(2) - 데이터 정렬
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain(int argc, TCHAR* argv[])
{
// 파일 핸들 생성
HANDLE hFile = CreateFile(TEXT("data.dat"), GENERIC_READ | GENERIC_WRITE, NULL, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE)
_tprintf(TEXT("Could not open file\n"));
// 파일 연결 오브젝트 생성
HANDLE hMapFile = CreateFileMapping(
hFile, // 파일의 핸들
NULL, // 보안 속성(상속)
PAGE_WRITECOPY, // 읽기, 쓰기 권한 지정
0, // 연결할 메모리 최대 크기의 상위 4바이트 지정(4GB 이상)
0, // 연결할 메모리 최대 크기의 하위 4바이트 지정(4GB 미만)
NULL // 이름을 지정할 때 사용. 안쓰므로 NULL
);
// 메모리에 연결
int* pWrite = (int*)MapViewOfFile(
hMapFile, // 앞에서 생성한 파일 연결 오브젝트의 핸들
FILE_MAP_COPY, // 메모리의 접근 권한 지정(읽기, 쓰기)
0, // 파일의 일부 영역만 선택해서 연결하는 것도 가능. 파일 오프셋의 상위 4바이트
0, // 파일 오프셋의 하위 4바이트
0 // 메모리에 연결할 실제 크기를 바이트 단위로 지정, 0으로 지정하면 오프셋부터 파일의 끝까지 메모리에 연결
);
if (pWrite == NULL)
_tprintf(TEXT("Could not map view of file\n"));
pWrite[0] = 1, pWrite[1] = 3, pWrite[2] = 0;
pWrite[3] = 2, pWrite[4] = 4, pWrite[5] = 5;
pWrite[6] = 8, pWrite[7] = 6, pWrite[8] = 7;
_tprintf(TEXT("%d %d %d\n"), pWrite[0], pWrite[1], pWrite[2]);
_tprintf(TEXT("%d %d %d\n"), pWrite[3], pWrite[4], pWrite[5]);
_tprintf(TEXT("%d %d %d\n"), pWrite[6], pWrite[7], pWrite[8]);
UnmapViewOfFile(pWrite);
CloseHandle(hMapFile);
CloseHandle(hFile);
_tprintf(TEXT("Jobs Done!\n"));
return 0;
}
여기서 CreateFileMapping 함수의 인자에 PAGE_WRITECOPY라고 지정을 했습니다.
파일 연결 오브젝트 생성 시의 인자에도 FILE_MAP_COPY를 전달했고요.
파일 핸들 역시 Read/Write 모드로 개방되어야 합니다.
실행 결과를 보시면 COW 최적화 방식이 어떻게 적용되었는지 확인해보시면 됩니다.
콘솔에서는 코드에서 바꾼 결과가 출력되는 것을 확인할 수 있습니다.
그런데 원본 파일에서는?
정렬된 결과가 바뀌지 않은 것을 확인하실 수 있을겁니다.