콘텐츠로 이동

C# unsafe 코드와 stackalloc으로 고성능 메모리 관리

C#은 기본적으로 안전한 관리 코드(managed code)를 실행하지만, 성능이 극도로 중요한 상황에서는 unsafe 컨텍스트를 통해 포인터 연산, 스택 메모리 할당, 직접 메모리 조작을 수행할 수 있습니다.


.csproj
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
unsafe void ProcessData(byte* data, int length)
{
for (int i = 0; i < length; i++)
data[i] ^= 0xFF;
}
// 또는 클래스/메서드 전체에 적용
unsafe class NativeProcessor { }

힙 할당 없이 스택에 배열을 할당합니다. GC 압력 없음, 함수 종료 시 자동 해제.

// unsafe 없이도 Span<T>와 함께 사용 가능 (C# 7.2+)
Span<int> buffer = stackalloc int[256];
// 초기화와 함께
Span<byte> zeros = stackalloc byte[64]; // 자동 0 초기화
// unsafe 포인터 방식
unsafe
{
int* arr = stackalloc int[10];
for (int i = 0; i < 10; i++)
arr[i] = i * i;
}
// 스택 크기는 보통 1MB — 큰 배열은 힙 사용
int size = GetDynamicSize(); // 런타임 크기
Span<byte> buf = size <= 1024
? stackalloc byte[size]
: new byte[size]; // 크면 힙 할당

GC가 객체를 이동하지 못하도록 고정하여 포인터를 안전하게 사용합니다.

byte[] managedArray = new byte[1024];
unsafe
{
fixed (byte* ptr = managedArray)
{
// ptr은 GC 이동으로부터 보호됨
ProcessNativeData(ptr, managedArray.Length);
}
// fixed 블록 종료 시 고정 해제
}
// 문자열 고정
string text = "Hello, World!";
unsafe
{
fixed (char* p = text)
{
char* current = p;
while (*current != '\0')
{
Console.Write(*current);
current++;
}
}
}

unsafe
{
int[] arr = { 10, 20, 30, 40, 50 };
fixed (int* p = arr)
{
int* current = p;
int* end = p + arr.Length;
while (current < end)
{
Console.Write(*current + " ");
current++; // sizeof(int) = 4 바이트 이동
}
}
// 포인터 캐스팅
float f = 3.14f;
float* fp = &f;
int* ip = (int*)fp; // 비트 패턴 재해석
Console.WriteLine($"float bits: {*ip:X8}");
}

현대 C#에서는 unsafe 대신 Span<T>를 통해 안전하게 저수준 메모리를 다룰 수 있습니다.

// Span으로 스택 메모리 래핑 (unsafe 불필요)
void ProcessBuffer()
{
Span<byte> buffer = stackalloc byte[256];
// 슬라이싱
Span<byte> header = buffer[..4];
Span<byte> body = buffer[4..];
// 구조체를 바이트로 재해석
Span<int> intView = System.Runtime.InteropServices.MemoryMarshal
.Cast<byte, int>(buffer);
intView[0] = 0x12345678;
}
using System.Runtime.InteropServices;
// 구조체를 바이트 스팬으로 변환
struct Header { public int Magic; public int Version; }
Header h = new() { Magic = 0xDEADBEEF, Version = 1 };
Span<byte> bytes = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref h, 1));
// bytes에 직접 접근하여 직렬화/역직렬화
// 타입 캐스팅
float[] floats = { 1.0f, 2.0f, 3.0f };
Span<byte> rawBytes = MemoryMarshal.AsBytes<float>(floats);

6. NativeMemory — .NET 6+ 비관리 메모리

섹션 제목: “6. NativeMemory — .NET 6+ 비관리 메모리”
using System.Runtime.InteropServices;
// 비관리 힙 할당
void* ptr = NativeMemory.Alloc(1024);
NativeMemory.Clear(ptr, 1024);
try
{
// ptr 사용
unsafe
{
byte* data = (byte*)ptr;
data[0] = 42;
}
}
finally
{
NativeMemory.Free(ptr); // 반드시 해제
}
// 정렬 메모리
void* aligned = NativeMemory.AlignedAlloc(256, 64); // 64바이트 정렬
NativeMemory.AlignedFree(aligned);

using BenchmarkDotNet.Attributes;
public class MemoryBenchmarks
{
private const int Size = 1024;
[Benchmark(Baseline = true)]
public int ArrayAlloc()
{
int[] arr = new int[Size];
int sum = 0;
for (int i = 0; i < Size; i++) sum += arr[i];
return sum;
}
[Benchmark]
public int StackAlloc()
{
Span<int> arr = stackalloc int[Size];
int sum = 0;
for (int i = 0; i < Size; i++) sum += arr[i];
return sum;
}
}
// stackalloc은 GC 할당 없음 → 짧은 배열에서 빠름
// 단, 큰 배열(수십 KB 이상)은 스택 오버플로 위험

// 1. stackalloc 크기는 작게 유지 (일반적으로 1KB 이하 권장)
// 2. fixed 블록 내에서 예외 발생 시도 고정 해제됨 (try/finally 불필요)
// 3. 포인터를 fixed 블록 외부로 반환하면 UB
unsafe
{
byte[] arr = new byte[10];
byte* dangerous;
fixed (byte* p = arr)
{
dangerous = p; // 위험: fixed 블록 밖에서 사용하면 GC 이동 후 무효
}
// dangerous 사용 금지
}

기법용도GC 영향
stackalloc짧은 수명의 소형 버퍼없음
fixed관리 배열/객체 포인터 전달일시 고정
NativeMemory대형 비관리 버퍼없음 (수동 해제)
Span<T> + MemoryMarshal안전한 저수준 메모리 접근최소화