MessagePackWriter 는 최악 크기를 먼저 잡고 쓴 만큼만 전진한다
var abw = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(abw);
writer.Write(200);
writer.Write("가나다");
writer.Flush();
정수 하나를 쓸 때마다 IBufferWriter<byte>.GetSpan 과 Advance 를 부르면 인터페이스 호출이 값마다 두 번씩 붙습니다. 문자열은 더 까다롭습니다. 헤더에는 UTF-8 바이트 수가 들어가야 하는데, 그 수는 인코딩해 보기 전에는 모릅니다.
MessagePackWriter 는 그 값이 가질 수 있는 최악 크기를 먼저 잡고, 받은 Span 을 쥔 채 실제로 쓴 만큼만 전진하는 인코더입니다. 위 코드를 몇 번 돌려도 힙에는 아무것도 남지 않습니다. 기준 판본은 MessagePack-CSharp v3.1.9(커밋 aa16e71)입니다.
모든 Write 는 같은 세 줄이다
// src/MessagePack/MessagePackWriter.cs
public void Write(int value)
{
Span<byte> span = this.writer.GetSpan(5);
AssumesTrue(MessagePackPrimitives.TryWrite(span, value, out int written));
this.writer.Advance(written);
}
int가 가질 수 있는 최대 크기(헤더 1 + 값 4)를 먼저 확보합니다.- 순수 함수
MessagePackPrimitives.TryWrite가 찍습니다. 공간을 이미 확보했으니 실패할 수 없어 반환값을AssumesTrue로 단언합니다. - 실제로 쓴 만큼만 전진합니다. 200 이면
CC C8두 바이트만Advance합니다.
요청은 크기의 하한일 뿐이라 더 잡아 두는 비용은 없습니다. 남은 3 바이트는 다음 Write 가 씁니다. 요청 크기는 bool 과 nil 이 1, int 와 배열 헤더가 5, long 과 double 이 9, DateTime 이 15 입니다. 인코딩 규칙은 MessagePackPrimitives 한 곳에만 있고 writer 는 버퍼 확보만 합니다.
Span 을 쥐고 있어 인터페이스 호출은 조각마다 한 번이다
안쪽의 BufferWriter 가 마지막으로 받은 Span 을 필드로 들고, 그 안에서 해결합니다.
// src/MessagePack/BufferWriter.cs (줄임)
public void Advance(int count)
{
_buffered += count; // 아직 _output 에 알리지 않은 바이트 수
_span = _span.Slice(count); // 인터페이스 호출 없음
}
public void Ensure(int count = 0)
{
if (_span.Length < count)
{
EnsureMore(count); // 드문 경로만 밖으로 (NoInlining)
}
}
작은 값 셋을 이어 쓸 때 IBufferWriter 가 몇 번 불리는지 봅니다. 받은 버퍼를 8 바이트로 줄여 그렸습니다.
재사용한 ArrayBufferWriter 에 Write(int) 100회와 Flush 를 돌린 할당은 0.0 B, Write("hello, world") 도 0.0 B 였습니다(Windows 11, .NET SDK 10.0.401, net10.0 Release). 대신 _buffered 는 Commit 전까지 IBufferWriter 가 모르는 바이트입니다. writer 를 직접 만들었다면 마지막에 Flush() 가 있어야 그 바이트가 알려집니다.
문자열은 헤더 폭을 글자 수로 추정하고 한 번만 인코딩한다
정석은 GetByteCount 로 한 번 세고 헤더를 쓴 뒤 GetBytes 로 다시 훑는 것입니다. MessagePackWriter 는 한 번만 훑습니다.
// src/MessagePack/MessagePackWriter.cs (줄임)
// ensure buffer by MaxByteCount(faster than GetByteCount)
bufferSize = StringEncoding.UTF8.GetMaxByteCount(characterLength) + 5;
ref byte buffer = ref this.writer.GetPointer(bufferSize);
if (characterLength <= MessagePackRange.MaxFixStringLength) useOffset = 1;
else if (characterLength <= byte.MaxValue && !this.OldSpec) useOffset = 2;
else if (characterLength <= ushort.MaxValue) useOffset = 3;
else useOffset = 5;
최악 크기를 먼저 잡고, 글자 수로 헤더 폭을 추정해 그 뒤에 바로 인코딩합니다. 실제 바이트 수가 다른 폭을 요구하면 본문을 옮긴 뒤 헤더를 씁니다.
본문이 움직이는 비용은 시간으로 보입니다. 헤더 폭이 한 단계 넓어지는 경계에서 잰 값입니다.
| 문자열 | UTF-8 바이트 | 헤더 | 추정과 실제 | ns/회 |
|---|---|---|---|---|
"가" × 10 |
30 | BE |
같음 | 118.3 |
"가" × 11 |
33 | D9 21 |
다름, 본문 이동 | 154.6 |
"가" × 85 |
255 | D9 FF |
같음 | 277.1 |
"가" × 86 |
258 | DA 01 02 |
다름, 본문 이동 | 299.4 |
| ASCII 31 / 32 | 31 / 32 | BF / D9 20 |
같음 | 58.5 / 58.1 |
추정이 틀리는 두 줄에서만 시간이 뛰었고, 할당은 모두 0 B 였습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release, 100,000회 묶음 10개의 중앙값). 문자열을 한 번만 훑고 버퍼에 직접 인코딩하니 임시 byte[] 가 없습니다.
대가는 긴 문자열에서 옵니다. GetMaxByteCount(n) 은 대략 3(n+1) 이라 그만큼을 한 조각으로 요청합니다. byte[] 를 돌려받는 입구로 워밍업 뒤 1,000번 직렬화했습니다.
| 문자열 | 결과 | B/op | 연속 요청 (GetMaxByteCount(n) + 5) |
|---|---|---|---|
| ASCII 30,000자 | 30,003 B | 30,032 B | 90,008 B |
| ASCII 300,000자 | 300,005 B | 1,200,064 B | 900,008 B |
기본 풀의 조각 배열 풀은 128 KB 버킷까지만 풀링해서, 90 KB 요청은 풀에서 나왔고 900 KB 요청은 호출마다 새 배열이 됐습니다. 결과가 300 KB 인 직렬화가 1.2 MB 를 할당하고, 두 배열 모두 LOH 로 갑니다. 경계는 ASCII 로 약 4만 4천 자입니다.
Span 을 쥐고 있으려고 ref struct 가 됐다
Span<T> 는 스택에만 살 수 있어, 그것을 필드로 가진 BufferWriter 도, 그것을 가진 MessagePackWriter 도 ref struct 여야 합니다. Span 을 캐시하지 않으면 “여유가 있으면 인터페이스 호출 0회” 가 불가능합니다. 대가로 컴파일러가 막는 것들입니다(C# 14, net10.0).
| 하려던 것 | 결과 |
|---|---|
클래스 필드 MessagePackWriter writer; |
CS8345 |
| 람다에서 캡처 | CS8175 |
object o = writer; 박싱 |
CS0029 |
List<MessagePackWriter> |
CS9244 |
async 메서드에서 await 를 건너 사용 |
CS4007 |
그래서 포매터 계약이 void Serialize(ref MessagePackWriter writer, ...) 처럼 동기이고 ref 입니다. 비동기 직렬화는 메시지를 풀의 버퍼에 동기로 전부 쓴 뒤 조각마다 WriteAsync 하는 방식이라, 메시지 전체가 한 번 메모리에 올라갑니다.
주의할 점
1. 직접 만든 writer 는 Flush 로 끝낸다
안 지키면 생기는 문제. BufferWriter 는 쓴 바이트 수를 _buffered 에 모았다가 버퍼가 찰 때나 Flush() 때만 IBufferWriter.Advance 로 알립니다. new MessagePackWriter(bufferWriter) 로 쓰고 Flush() 를 빠뜨리면 마지막 조각이 알려지지 않아 WrittenSpan 이 짧게 나옵니다. 예외는 없습니다.
이렇게 쓴다. 직접 만든 writer 는 쓰기가 끝나는 자리에서 writer.Flush() 를 부릅니다. 가능하면 MessagePackSerializer.Serialize(IBufferWriter<byte>, value) 로 Flush 를 라이브러리에 맡깁니다.
2. writer 는 ref 로만 넘긴다
안 지키면 생기는 문제. 도우미 메서드가 MessagePackWriter 를 값으로 받으면 writer 가 복사되고, 복사본의 _buffered 와 _span 위치는 원본에 반영되지 않습니다. w.Write(0); W(w); w.Write(3); w.Flush(); 에서 W 가 값으로 받아 01 02 를 쓴 판은 00 03(2 B)이었습니다. 원본의 Write(3) 이 복사본이 쓴 자리를 덮었고 02 는 사라졌습니다. 컴파일러는 값 전달을 막지 않고, 예외도 경고도 없었습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release).
이렇게 쓴다. writer 를 받는 모든 메서드는 ref MessagePackWriter writer 로 선언합니다. ref 판은 00 01 02 03(4 B)이었습니다.
3. 아주 긴 문자열은 한 조각으로 요청된다는 것을 안다
안 지키면 생기는 문제. Write(string) 은 글자 수의 약 세 배를 연속 메모리로 요청합니다. ASCII 로 약 4만 4천 자를 넘는 문자열은 쓸 때마다 풀 밖의 큰 배열을 새로 할당하고, 30만 자 한 번에 결과 300 KB 와 별도로 900 KB 가 더 할당됐습니다. 로그 본문이나 큰 JSON 문자열을 필드에 통째로 넣는 메시지가 여기에 걸립니다.
이렇게 쓴다. 큰 텍스트는 UTF-8 byte[] 로 미리 바꿔 bin 으로 보내거나 여러 조각의 문자열 배열로 나눕니다.
정리
MessagePackWriter 는 최악 크기를 먼저 잡고, 쥐고 있는 Span 에 쓴 만큼만 전진합니다.
- 모든
Write는 크기 확보, 순수 함수 인코딩, 실제 길이만큼 전진의 세 줄입니다. Span캐시 덕에IBufferWriter는 조각마다 한 번만 불리고, 모아 둔 바이트는Flush()로 알립니다.- 문자열은 글자 수로 헤더 폭을 추정해 한 번만 인코딩하고, 대가로 긴 문자열은 세 배 크기를 연속으로 요청합니다.
Span을 쥐는 대가로 writer 는ref struct가 됐습니다.