MessagePack-CSharp 의 Serialize 는 스레드마다 둔 64 KB 배열에 먼저 쓴다
byte[] bytes = MessagePackSerializer.Serialize(new Small { Id = 1, Name = "hello" });
// 워밍업 뒤 할당: byte[8] 하나, 32 B
결과가 몇 바이트가 될지 모르는 채로 쓰기 시작했는데, 힙에 남은 것은 딱 맞는 결과 배열 하나뿐입니다. 쓰는 동안의 버퍼는 어디서 왔을까요.
답은 스레드마다 하나씩 둔 64 KB 배열입니다. 여기에 먼저 쓰고 다 쓴 만큼만 새 배열로 복사해 돌려줍니다. 기준 판본은 MessagePack-CSharp v3.1.9(커밋 aa16e71)입니다.
모든 입구는 한 허브로 모인다
Serialize 오버로드는 여럿이지만 버퍼만 다르게 준비해서 같은 메서드로 넘깁니다.
// src/MessagePack/MessagePackSerializer.cs (줄임)
public static void Serialize<T>(ref MessagePackWriter writer, T value, MessagePackSerializerOptions? options = null)
{
options = options ?? DefaultOptions;
try
{
if (options.Compression.IsCompression() && !PrimitiveChecker<T>.IsMessagePackFixedSizePrimitive)
{
// 풀에서 빌린 버퍼에 평문을 다 쓴 뒤 LZ4 로 writer 에 옮긴다
}
else
{
options.Resolver.GetFormatterWithVerify<T>().Serialize(ref writer, value, options);
}
}
catch (Exception ex)
{
throw new MessagePackSerializationException($"Failed to serialize {typeof(T).FullName} value.", ex);
}
}
압축은 포매터 바깥에서 처리하고, 포매터 안에서 무엇이 터지든 호출자가 보는 것은 MessagePackSerializationException 하나입니다. 원래 예외는 InnerException 에 있습니다. 입구마다 다른 것은 버퍼뿐이라 할당도 입구에 따라 갈립니다. 결과 17 B 인 작은 객체와 100,007 B 인 큰 객체를 200회 워밍업한 뒤 2,000회 평균입니다.
byte[] 입구의 할당은 정확히 결과 배열 크기입니다. 오버헤드는 없지만 결과 배열 자체는 없앨 수 없고, 85,000 B 이상이면 대형 객체 힙(LOH)으로 갑니다. 100 KB 결과 배열의 GC.GetGeneration 은 2였습니다(Windows 11, .NET SDK 10.0.401, net10.0 Release, GC.GetAllocatedBytesForCurrentThread()).
byte[] 입구는 스크래치에 쓰고 결과만 복사한다
// src/MessagePack/MessagePackSerializer.cs
[ThreadStatic]
private static byte[]? scratchArray;
public static byte[] Serialize<T>(T value, MessagePackSerializerOptions? options = null, CancellationToken cancellationToken = default)
{
byte[]? array = scratchArray;
if (array == null)
{
scratchArray = array = new byte[65536];
}
options = options ?? DefaultOptions;
var msgpackWriter = new MessagePackWriter(options.SequencePool, array) { CancellationToken = cancellationToken };
Serialize(ref msgpackWriter, value, options);
return msgpackWriter.FlushAndGetArray();
}
[ThreadStatic] 필드는 스레드마다 따로 있습니다. 처음 부를 때 64 KB 를 한 번 만들고 계속 재사용합니다. 이 writer 는 처음에 스크래치 배열만 들고 있다가, 공간이 모자라는 순간에만 풀에서 Sequence<byte> 를 빌려 옮겨 탑니다.
결과가 64 KB 미만이면 풀에 손도 대지 않습니다. SequencePool.Rent 의 lock 조차 없습니다. 넘어도 풀이 따뜻하면 할당은 결과 배열 하나였습니다. README 는 결과가 “내부 버퍼 풀에서 복사된다” 고 적지만, 64 KB 미만에서는 풀이 아니라 이 스레드 정적 배열에서 복사됩니다.
치르는 것은 상주 메모리입니다. 한 번이라도 이 입구를 부른 스레드는 스레드가 죽을 때까지 65,560 B(배열 헤더 포함)를 쥐고 있습니다. 워밍업된 프로세스에서 새 스레드의 첫 호출은 65,760 B, 두 번째 호출은 48 B 였습니다. 64 KB 를 넘겨 Sequence 를 빌린 뒤 포매터가 던지면 반납이 없지만 누수는 아닙니다. GC 가 거두고, 예외 경로의 추가 할당은 호출당 1 KB 남짓이었습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release, 1,000회).
async 와는 충돌하지 않습니다. writer 는 ref struct 라 await 너머로 가지 못하니 스크래치는 한 동기 구간 안에서만 쓰이고, SerializeAsync 는 스크래치 대신 풀의 Sequence 를 씁니다.
포매터 안에서 같은 입구를 다시 부르면 앞부분이 덮인다
byte[] 입구에는 “이 스레드의 스크래치가 쓰이는 중인가” 를 확인하는 코드가 없습니다. 바깥 호출이 스크래치에 쓰는 도중 포매터가 같은 입구를 부르면, 안쪽 호출이 같은 배열의 0번부터 덮어씁니다. 자식 객체를 byte[] 로 만들어 bin 으로 넣는 봉투(envelope) 포매터가 딱 이 모양입니다.
public void Serialize(ref MessagePackWriter writer, Envelope? value, MessagePackSerializerOptions options)
=> writer.Write(MessagePackSerializer.Serialize(value!.Value, options)); // 안쪽 byte[] 호출
Outer { Before = "AAAAAAAAAA", Inner = Envelope(Small { 7, "inner", 1.5 }), After = 99 } 를 바깥 입구만 바꿔 두 번 직렬화했습니다.
찍힌 바이트는 앞 12 바이트만 다릅니다.
| 바깥 입구 | 앞 12 바이트 | 나머지 |
|---|---|---|
byte[] |
9307A5696E6E6572CB3FF800 (안쪽 결과로 덮임) |
C411 9307A5696E6E6572CB3FF8000000000000 63 |
IBufferWriter |
93AA41414141414141414141 (Before 그대로) |
C411 9307A5696E6E6572CB3FF8000000000000 63 |
직렬화할 때는 예외가 없고 역직렬화할 때에야 터집니다. 따로 한 실험에서는 앞 10 B 가 덮여 NullReferenceException 이 났고, 덮인 바이트가 우연히 말이 되면 엉뚱한 값으로 읽힐 수도 있습니다. 바깥이 안쪽 호출 전에 이미 64 KB 를 넘겨 Sequence 로 옮겨 탔다면 덮여도 상관이 없어서, 70,000자 문자열을 먼저 쓴 실험은 정상이었습니다. 큰 페이로드로 테스트하면 이 버그가 숨습니다.
주의할 점
1. 포매터 안에서 byte[] 를 돌려주는 Serialize 를 부르지 않는다
안 지키면 생기는 문제. 바깥 호출이 byte[] 입구로 스크래치에 쓰는 중이면, 안쪽 호출이 같은 스크래치의 0번부터 덮어써 바깥 출력의 앞부분이 조용히 바뀝니다. 직렬화는 성공하고 받는 쪽에서 Unexpected msgpack code 나 NullReferenceException 이 납니다.
이렇게 쓴다. 포매터와 콜백 안에서는 받은 ref writer 에 바로 씁니다. 자식을 bin 으로 감싸야 하면 지역 ArrayBufferWriter<byte> 에 MessagePackSerializer.Serialize(abw, child, options) 로 쓴 뒤 writer.Write(abw.WrittenSpan) 합니다. 봉투 포매터의 테스트는 64 KB 미만의 작은 메시지로 왕복까지 확인합니다.
2. 짧게 사는 스레드에서 byte[] 입구를 부르지 않는다
안 지키면 생기는 문제. 스크래치는 스레드마다 하나라, 새 스레드의 첫 호출은 64 KB 를 새로 할당합니다. 스레드를 자주 만들고 버리는 코드에서는 호출마다 65 KB 남짓이 새로 생기고, 오래 사는 스레드가 많으면 스레드 수 × 64 KB 가 계속 상주합니다.
이렇게 쓴다. 그런 경로는 IBufferWriter<byte> 입구로 바꿔 스크래치를 쓰지 않게 합니다. byte[] 입구를 여러 스레드에서 부르는 구조라면 스레드 수 × 64 KB 를 상주 메모리로 잡아 둡니다.
3. 취소와 실패를 MessagePackSerializationException 으로 잡는다
안 지키면 생기는 문제. 허브는 모든 예외를 감쌉니다. 이미 취소된 토큰으로 int[3] 을 직렬화하면 OperationCanceledException 이 아니라 MessagePackSerializationException 이 나와서, catch (OperationCanceledException) 만 둔 코드는 취소를 실패로 처리합니다. 토큰은 컬렉션 포매터의 원소 루프에서만 검사되므로 같은 토큰으로 Serialize(1) 은 멈추지 않고 정상으로 1 바이트를 썼습니다.
이렇게 쓴다. catch (MessagePackSerializationException ex) when (ex.InnerException is OperationCanceledException) 으로 가릅니다. 긴 작업을 끊고 싶으면 데이터를 원소 단위 컬렉션으로 나눠 직렬화합니다.
정리
byte[] 를 돌려주는 Serialize 는 스레드마다 둔 64 KB 배열에 먼저 쓰고 결과만 복사합니다.
- 모든 입구는 한 허브로 모이고, 할당은 입구가 준비하는 버퍼에 따라 결과 배열 하나 또는 0 B 입니다.
- 64 KB 미만이면 풀을 건드리지 않고, 넘으면 풀의
Sequence로 옮겨 탑니다. 대가로 스레드마다 64 KB 가 상주합니다. - 포매터 안에서 같은 입구를 다시 부르면 바깥 출력의 앞부분이 예외 없이 덮이고, 64 KB 를 넘는 테스트에서는 숨습니다.