MemoryPack 의 byte[] 직렬화는 GC 가 치울 배열을 매번 남기고, 스레드마다 256KB 를 쥔다
게임 서버에서 초당 수만 번 도는 직렬화라면, 호출마다 남는 메모리를 GC 가 치워야 합니다. GC 가 자주, 무겁게 돌면 그동안 서버가 멈칫합니다.
MemoryPack 으로 패킷을 byte[] 로 직렬화하면 두 가지 메모리가 생깁니다. 호출마다 새로 생기는 결과 배열과, 스레드마다 한 번 생기는 256KB 작업 공간입니다. 작은 패킷이면 문제가 없지만, 큰 패킷과 오래 사는 스레드에서는 GC 부담이 됩니다. 그래서 자주 도는 전송 경로는 재사용 버퍼로 보냅니다. 기준 판본은 1.21.4 입니다.
Serialize 한 번에 생기는 두 가지
byte[] bytes = MemoryPackSerializer.Serialize(packet);
이 한 줄이 안에서 하는 일입니다.
| 순서 | 하는 일 | 생기는 메모리 |
|---|---|---|
| 1 | 이 스레드에서 처음이면 256KB 작업 공간을 만든다 | 작업 공간. 스레드마다 한 번, 스레드가 끝날 때까지 남는다 |
| 2 | 작업 공간에 패킷을 쓴다. 다 쓰고 나서야 크기를 안다 | 없음 (작업 공간을 재사용) |
| 3 | 그 크기의 byte[] 를 새로 만들어 복사하고 돌려준다 |
결과 배열. 호출마다 새로 생기고, 다 쓰면 GC 가 치운다 |
StringBuilder 로 치면 작업 공간은 재사용하는 안쪽 버퍼이고, 결과 배열은 부를 때마다 새로 생기는 ToString() 결과입니다.
새 스레드에서 18 B 패킷을 두 번 직렬화하면 이렇게 나옵니다.
| 호출 | 할당 |
|---|---|
| 그 스레드의 첫 호출 | 262,536 B (작업 공간 + 결과 배열) |
| 두 번째부터 | 48 B (결과 배열만) |
작업 공간을 쓰는 패킷, 안 쓰는 패킷
작업 공간은 2번처럼 “다 쓰기 전에는 크기를 모를 때” 필요합니다. 그래서 기준은 패킷 타입의 크기가 직렬화 전에 정해지느냐입니다.
| 패킷 타입 | 크기가 미리 정해지나 | 작업 공간 |
|---|---|---|
숫자만 든 struct (Vec3 { float X, Y, Z; }) |
정해진다 | 안 쓴다. 새 스레드 첫 호출도 40 B |
멤버가 전부 숫자인 [MemoryPackable] 클래스 |
정해진다 | 안 쓴다 |
string 이 든 패킷 (ChatPacket { string Nickname; string Message; }) |
글자 수에 따라 달라진다 | 쓴다 |
List<T> 가 든 패킷 (InventoryPacket { List<ItemSlot> Items; }) |
원소 수에 따라 달라진다 | 쓴다 |
받는 쪽 Deserialize 는 받은 바이트를 그대로 읽으니, 타입과 상관없이 작업 공간을 쓰지 않습니다.
문제 1. 큰 패킷의 결과 배열은 gen2 GC 를 부른다
서버가 월드 스냅샷(List<int> 30,000개, 120,004 B)을 틱마다 byte[] 로 직렬화해 2,000번 보냈습니다.
| 방법 | 호출당 할당 | 2,000회 동안 gen2 GC | 호출당 시간 |
|---|---|---|---|
byte[] bytes = Serialize(list) |
120,032 B (결과 배열) | 74회 | 33,238 ~ 34,612 ns |
재사용 ArrayBufferWriter |
0 B | 0회 | 2,052 ~ 2,102 ns |
byte[] 로 받은 쪽은 gen2 GC 가 74번 돌았고, 열 배 넘게 느렸습니다.
원인
결과 배열의 크기입니다. .NET 은 85,000 B 이상인 배열을 따로 두고, 가장 비싼 GC 로만 치웁니다.
| 배열 크기 | 두는 곳 | 치우는 GC |
|---|---|---|
| 85,000 B 미만 | 일반 힙 | gen0 GC. 자주, 싸게 치운다 |
| 85,000 B 이상 | 대형 개체 힙 (LOH) | gen2 GC 때만. 힙 전체를 훑는다 |
120 KB 결과 배열이 틱마다 LOH 에 쌓이니 gen2 GC 가 계속 돕니다. 메모리가 모자라 에러가 나는 것이 아니라, 치우는 일이 잦아서 느려집니다. 숫자만 든 int[30000] 처럼 크기가 정해진 패킷도, 작업 공간만 안 쓸 뿐 결과 배열은 120 KB 라 같은 일이 생깁니다.
예방
결과를 담을 버퍼를 한 번 만들어 재사용하면, 새 배열이 생기지 않으니 GC 가 치울 것도 없습니다.
var buffer = new ArrayBufferWriter<byte>(65536); // 한 번 만들어 재사용
buffer.ResetWrittenCount();
MemoryPackSerializer.Serialize(buffer, snapshot);
socket.Send(buffer.WrittenSpan);
이 경로는 작업 공간도 만들지 않습니다. PipeWriter, 재사용 MemoryStream 도 같습니다.
문제 2. 작업 공간은 스레드가 사는 동안 남는다
전용 스레드 64개가 각자 Serialize 를 한 번 부르고 살아 있을 때, GC 가 옮기지 못하는 고정 영역이 16,778,752 B(256KB × 64) 늘었습니다. 스레드가 모두 끝나고 GC 가 돈 뒤에는 원래대로 돌아왔습니다.
원인
작업 공간은 [ThreadStatic] 필드라 스레드마다 하나씩 생기고, 스레드가 끝나야 GC 가 치웁니다. 그런데 서버 스레드는 대개 끝나지 않습니다. 스레드 풀 스레드나 연결마다 둔 전용 스레드는 서버가 도는 내내 살아 있어서, 그 수만큼 256KB 가 계속 잡혀 있습니다.
예방
문제 1 의 재사용 버퍼로 보내면 작업 공간 자체가 생기지 않습니다. 숫자만 든 작은 값은 그런 패킷 타입으로 정의하면 처음부터 작업 공간을 쓰지 않습니다.
문제 1, 2 예방 정리: 패킷마다 보내는 방법
문제 1 은 결과 배열이 클 때, 문제 2 는 작업 공간을 쓰는 패킷일 때 생깁니다. 그래서 패킷 모양에 따라 보내는 방법을 나눕니다.
| 패킷 | 어떻게 | 막는 문제 |
|---|---|---|
| 위치, 회전처럼 숫자만 든 작은 값 | 숫자만 든 패킷 타입(Vec3 같은 struct)으로 정의한다. 그러면 Serialize(packet) 그대로 보내도 된다 |
문제 1, 2 둘 다 생기지 않는다. 작업 공간을 안 쓰고, 결과 배열이 작아 LOH 로 가지 않는다 |
| 채팅, 인벤토리처럼 문자열이나 리스트가 든 패킷 | 재사용 버퍼로 직렬화한다 | 문제 2. 패킷이 크면 문제 1 도 |
| 스냅샷, 맵처럼 큰 데이터 | 재사용 버퍼로 직렬화한다 | 문제 1, 2 |
모든 패킷을 숫자만 든 struct 로 바꾸는 방법은 쓰기 어렵습니다. 닉네임과 아이템 목록은 길이가 매번 다르고, 숫자만 든 struct 는 필드를 더하면 옛 버전과 주고받을 수 없기 때문입니다(Generator 편 문제 4).
문제 3. 받는 쪽에서 List 를 덮어 읽으면 원소가 재사용되지 않는다
받는 쪽은 이미 있는 객체를 넘겨 덮어 읽으면, 새로 만들지 않고 값만 채워 할당을 줄일 수 있습니다.
MemoryPackSerializer.Deserialize(bytes, ref party); // party 를 새로 만들지 않고 채운다
그런데 party.Members 가 List<Member> 이면, 같은 인원을 받을 때 Clear() 가 불려 원소가 재사용되지 않습니다.
| 받은 인원 (기존 3명) | Clear() |
결과 |
|---|---|---|
| 3명 (같다) | 부른다 | 기존 원소를 비우고 Member 3개를 새로 만든다 |
| 2명 (줄었다) | 안 부른다 | 앞 2개는 재사용. 3번째 원소는 리스트 안쪽 배열에 숨은 채 남아 GC 되지 않는다 |
틱마다 같은 인원을 받는 가장 흔한 경우에 덮어 읽기가 아무 효과가 없습니다.
원인
MemoryPack 의 List<T> 읽기는 받은 개수가 기존 개수와 같을 때만 Clear() 를 부릅니다. Clear() 는 칸을 비우니 원소를 새로 만들어야 합니다. 개수가 다르면 Clear() 없이 개수만 바꿔서, 줄어든 칸 뒤의 원소가 그대로 남습니다.
예방
덮어 읽기를 할 패킷에는 List<T> 대신 배열(Member[])을 씁니다. 배열은 길이가 같으면 배열과 원소가 모두 재사용됩니다. List<T> 를 써야 하면 덮어 읽기 전에 직접 Clear() 해서 숨은 원소를 없앱니다.
직접 확인하는 테스트 코드
MemoryPack 을 참조한 콘솔 프로젝트에 넣고 AllocTest.Run(); 을 부릅니다. 할당은 GC.GetAllocatedBytesForCurrentThread 로 잽니다. 2번은 2,000번을 도니 Release 로 실행합니다.
// Alloc 편 실험: MemoryPack 이 호출마다, 스레드마다 무엇을 할당하는가
using System.Buffers;
using MemoryPack;
public static class AllocTest
{
public static void Run()
{
Console.WriteLine("==== 두 가지 메모리: 새 스레드에서 byte[] 로 받을 때 ====");
var small = new AlcSmall { Id = 1, Name = "hello" };
var point = new AlcPoint { X = 1, Y = 2 };
MemoryPackSerializer.Serialize(point); // 타입 첫 등록 비용을 빼려고 메인 스레드에서 한 번 쓴다
RunOnNewThread(() =>
{
Console.WriteLine($" AlcSmall 첫 호출: {Measure(() => MemoryPackSerializer.Serialize(small)):N0} B");
Console.WriteLine($" AlcSmall 두 번째: {Measure(() => MemoryPackSerializer.Serialize(small)):N0} B");
});
RunOnNewThread(() =>
{
Console.WriteLine($" 고정 크기 AlcPoint 첫 호출: {Measure(() => MemoryPackSerializer.Serialize(point)):N0} B");
});
Console.WriteLine();
Console.WriteLine("==== 문제 1. List<int> 30,000 개를 2,000 번 직렬화 ====");
var list = Enumerable.Range(0, 30_000).ToList();
MemoryPackSerializer.Serialize(list); // 워밍업
int gen2 = GC.CollectionCount(2);
long bytes = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 2_000; i++)
MemoryPackSerializer.Serialize(list);
Console.WriteLine($" byte[] 로 받기: 호출당 {(GC.GetAllocatedBytesForCurrentThread() - bytes) / 2_000:N0} B, gen2 GC {GC.CollectionCount(2) - gen2} 회");
var buffer = new ArrayBufferWriter<byte>(200_000);
buffer.ResetWrittenCount();
MemoryPackSerializer.Serialize(buffer, list); // 워밍업
gen2 = GC.CollectionCount(2);
bytes = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 2_000; i++)
{
buffer.ResetWrittenCount();
MemoryPackSerializer.Serialize(buffer, list);
}
Console.WriteLine($" 재사용 ArrayBufferWriter: 호출당 {(GC.GetAllocatedBytesForCurrentThread() - bytes) / 2_000:N0} B, gen2 GC {GC.CollectionCount(2) - gen2} 회");
Console.WriteLine();
Console.WriteLine("==== 문제 3. List 에 덮어 읽기 ====");
OverwriteList(3, 3);
OverwriteList(3, 2);
}
// 원소 oldCount 개인 리스트에, 원소 newCount 개 데이터를 덮어 읽는다
static void OverwriteList(int oldCount, int newCount)
{
var target = MakeParent(oldCount);
AlcChild[] oldChildren = target.Children!.ToArray();
byte[] data = MemoryPackSerializer.Serialize(MakeParent(newCount));
MemoryPackSerializer.Deserialize(data, ref target!);
int reused = 0;
for (int i = 0; i < target.Children!.Count; i++)
{
if (ReferenceEquals(target.Children[i], oldChildren[i]))
reused++;
}
// List<T> 의 내부 배열을 꺼내, Count 밖에 옛 원소가 남았는지 본다
var items = (AlcChild?[])typeof(List<AlcChild>)
.GetField("_items", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(target.Children)!;
bool hidden = target.Children.Count < oldCount && ReferenceEquals(items[oldCount - 1], oldChildren[oldCount - 1]);
Console.WriteLine($" {oldCount}개 -> {newCount}개: 재사용된 원소 {reused}개, 새로 만든 원소 {target.Children.Count - reused}개, Count 밖에 남은 옛 원소 {(hidden ? "있음" : "없음")}");
}
static AlcParent MakeParent(int count) => new AlcParent
{
Children = Enumerable.Range(0, count).Select(i => new AlcChild { Value = i }).ToList(),
};
static long Measure(Action action)
{
long before = GC.GetAllocatedBytesForCurrentThread();
action();
return GC.GetAllocatedBytesForCurrentThread() - before;
}
static void RunOnNewThread(Action action)
{
var thread = new Thread(() => action());
thread.Start();
thread.Join();
}
}
[MemoryPackable]
public partial class AlcSmall
{
public int Id { get; set; }
public string? Name { get; set; }
}
// 모든 멤버가 숫자라 크기가 고정된 클래스
[MemoryPackable]
public partial class AlcPoint
{
public int X { get; set; }
public int Y { get; set; }
}
[MemoryPackable]
public partial class AlcParent
{
public List<AlcChild>? Children { get; set; }
}
[MemoryPackable]
public partial class AlcChild
{
public int Value { get; set; }
}
Linux 의 .NET 10 Release 에서 돌린 결과입니다.
==== 두 가지 메모리: 새 스레드에서 byte[] 로 받을 때 ====
AlcSmall 첫 호출: 262,536 B
AlcSmall 두 번째: 48 B
고정 크기 AlcPoint 첫 호출: 40 B
==== 문제 1. List<int> 30,000 개를 2,000 번 직렬화 ====
byte[] 로 받기: 호출당 120,032 B, gen2 GC 74 회
재사용 ArrayBufferWriter: 호출당 0 B, gen2 GC 0 회
==== 문제 3. List 에 덮어 읽기 ====
3개 -> 3개: 재사용된 원소 0개, 새로 만든 원소 3개, Count 밖에 남은 옛 원소 없음
3개 -> 2개: 재사용된 원소 2개, 새로 만든 원소 0개, Count 밖에 남은 옛 원소 있음
주의할 점
1. 자주 도는 전송 경로는 재사용 버퍼로 보낸다
안 지키면 생기는 문제. 큰 결과 배열은 gen2 GC 를 부르고, 스레드마다 256KB 가 남습니다.
이렇게 쓴다. ArrayBufferWriter<byte> 나 PipeWriter 를 재사용해 Serialize(bufferWriter, value) 로 씁니다.
2. 덮어 읽기로 List 원소가 재사용된다고 기대하지 않는다
안 지키면 생기는 문제. 같은 개수면 Clear() 가 불려 원소를 전부 새로 만들고, 개수가 줄면 숨은 원소가 GC 되지 않습니다.
이렇게 쓴다. 배열을 쓰거나, 덮어 읽기 전에 직접 Clear() 합니다.
정리
MemoryPack 의 byte[] 직렬화는 호출마다 결과 배열을 새로 만들고, 스레드마다 256KB 작업 공간을 둡니다.
- 작업 공간은 문자열이나 리스트처럼 크기가 매번 다른 패킷을 보낼 때 생기고, 스레드가 사는 동안 남습니다.
- 큰 결과 배열은 크기가 정해진 패킷이어도 LOH 로 가서 gen2 GC 를 부릅니다.
- 문자열, 리스트가 든 패킷과 큰 데이터는 재사용 버퍼로 보내면 두 가지가 모두 사라집니다.
- 받는 쪽의
List덮어 읽기는 같은 개수면Clear()로 원소를 새로 만드니, 덮어 읽을 패킷에는 배열을 씁니다.