MemoryPack 의 byte[] 직렬화는 GC 가 치울 배열을 매번 남기고, 스레드마다 256KB 를 쥔다
Esc

    MemoryPack 의 byte[] 직렬화는 GC 가 치울 배열을 매번 남기고, 스레드마다 256KB 를 쥔다

    –C#라이브러리직렬화GC

    게임 서버에서 초당 수만 번 도는 직렬화라면, 호출마다 남는 메모리를 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() 로 원소를 새로 만드니, 덮어 읽을 패킷에는 배열을 씁니다.