MessagePack-CSharp 의 스트림 리더는 메시지 끝을 건너뛰어 보며 찾는다
Esc

    MessagePack-CSharp 의 스트림 리더는 메시지 끝을 건너뛰어 보며 찾는다

    –C#라이브러리직렬화
    MessagePackSerializer.Serialize(stream, new[] { 1, 2, 3 });
    MessagePackSerializer.Serialize(stream, "hello");
    MessagePackSerializer.Serialize(stream, new Dictionary<string, int> { ["a"] = 1 });
    // 93 01 02 03 | A5 68 65 6C 6C 6F | 81 A1 61 01

    쓰는 쪽은 그냥 이어 씁니다. 메시지 사이에 길이도 구분자도 없는 14바이트를 받은 쪽은, 어디까지가 첫 메시지인지 알아야 하나씩 역직렬화할 수 있습니다.

    MessagePack-CSharp 의 MessagePackStreamReader 는 값을 만들지 않고 건너뛰어 보면서 메시지의 끝을 찾습니다. 접두사가 필요 없고 할당도 없지만, 모자라서 실패할 때마다 처음부터 다시 훑습니다. 기준 판본은 v3.1.9(커밋 aa16e71)이고, 실측은 Windows 11, .NET SDK 10.0.401, net10.0 Release 입니다.

    Deserialize(Stream) 은 끝까지 읽고 첫 값만 돌려준다

    먼저 떠오르는 것은 Deserialize<T>(Stream) 을 세 번 부르는 코드입니다.

    // MessagePack/MessagePackSerializer.cs (Deserialize<T>(Stream) 발췌)
    if (TryDeserializeFromMemoryStream(stream, options, cancellationToken, out T? result))
        return result!;
    
    do
    {
        Span<byte> span = sequence.GetSpan(stream.CanSeek ? (int)Math.Min(options.SuggestedContiguousMemorySize, stream.Length - stream.Position) : 0);
        bytesRead = stream.Read(span);
        sequence.Advance(bytesRead);
    }
    while (bytesRead > 0);
    return DeserializeFromSequenceAndRewindStreamIfPossible<T>(stream, options, sequence, cancellationToken);

    Read 가 0 을 돌려줄 때까지, 곧 스트림 끝까지 다 읽고 첫 값 하나를 역직렬화합니다.

    탐색 가능한 스트림은 남은 바이트만큼 되감지만, 네트워크 스트림은 탐색이 안 됩니다. 게다가 상대가 연결을 열어 둔 소켓에서는 메시지가 이미 와 있어도 끝이 오지 않으니 반환하지 않습니다. 루프백 TCP 로 [1,2,3] 하나를 보내고 연결을 유지하자 5초 동안 끝나지 않다가, 보낸 쪽이 닫은 뒤 0.9 ms 만에 돌아왔습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release). 시크 가능한 스트림에서는 남은 길이만큼(1 MB 한도) 연속 공간을 한 번에 요청하므로, 1 MB 파일을 이 입구로 읽으면 결과 배열 말고도 호출마다 1 MB 버퍼가 새로 잡혔습니다.

    TrySkip 은 값을 만들지 않고 남은 개수만 센다

    msgpack 은 모든 값의 크기가 첫 바이트와 길이 필드로 정해지는 자기 구분적 포맷이라, 값을 만들지 않고도 끝을 찾을 수 있습니다.

    // MessagePack/MessagePackReader.cs (TrySkip 발췌)
    long remainingStructures = 1;
    while (remainingStructures > 0)
    {
        if (this.reader.Remaining == 0) return false;
        remainingStructures--;
        byte code = this.NextCode;
        switch (code)
        {
            case byte x when MessagePackCode.IsFixArray(x): ...
                remainingStructures = checked(remainingStructures + count);
                break;
            ...
        }
    }
    return true;

    재귀 없이 “아직 건너뛸 값이 몇 개 남았나” 카운터 하나로 돕니다.

    MessagePackStreamReader.ReadAsync 는 버퍼 맨 앞에서 이것을 부르고, 성공하면 버퍼를 그 위치까지 잘라서(slice) 돌려주고, 실패하면 스트림에서 더 읽어 다시 시도합니다. 14바이트 세 메시지를 한 바이트씩 줘도 4 B, 6 B, 4 B 세 메시지가 정확히 나왔고, 꼬리가 잘린 스트림에서는 null 뒤에 RemainingBytes 로 미완성 꼬리를 보여줬습니다. 스택을 쓰지 않아 0x91 을 10만 겹 쌓은 입력도 Skip 이 통과했습니다.

    using var reader = new MessagePackStreamReader(stream, leaveOpen: true);
    while (await reader.ReadAsync(cancellationToken) is ReadOnlySequence<byte> msgpack)
    {
        var item = MessagePackSerializer.Deserialize<Item>(msgpack, options, cancellationToken);
    }

    조각나 도착하면 처음부터 다시 훑는다

    실패한 TrySkip 이 센 카운터는 지역 변수라 함께 버려지고, 다음 시도는 버퍼 맨 앞을 가리키는 새 리더로 시작합니다. 리더가 ref struct 라 await 너머로 들고 있을 수 없기 때문입니다. 소스 주석도 이 자리를 개선 여지로 적어 둡니다.

    int[N] 메시지를 한 바이트씩 흘려 잰 값입니다(1회 관측).

    메시지 바이트 한 바이트씩 MemoryStream 한 번에
    int[1000] 3,003 4.24 ms 0.007 ms
    int[2000] 6,003 17.65 ms 0.019 ms
    int[4000] 12,003 78.89 ms 0.044 ms
    int[8000] 24,003 293.24 ms 0.137 ms
    int[16000] 48,003 1,224.08 ms 0.351 ms

    N 을 두 배로 하면 네 배, O(n²) 입니다. 같은 바이트 수의 bin 하나는 24,003 B 에 2.39 ms 로 선형이었습니다. bin 본문은 길이 한 번에 건너뛰니 토큰이 하나뿐입니다. 비용은 바이트가 아니라 토큰 수 × 조각 수를 따라갑니다. 최상위 큰 배열은 ReadArrayAsync 로 헤더를 먼저 소비하고 원소를 하나씩 꺼내면 재스캔 범위가 원소 하나로 줄어듭니다.

    메시지 크기 한도가 없다

    생성자 인자는 stream, leaveOpen, sequencePool 뿐이라 최대 메시지 크기가 없고, 역직렬화 옵션을 받지 않으니 UntrustedData 도 닿지 않습니다. TrySkip 은 배열 헤더를 검사 없는 TryReadArrayHeader 로 읽어, 모자라면 그저 더 읽습니다. 배열 헤더 DD 7F FF FF FF(원소 21억 개) 뒤로 1 KB 씩 끝없이 주는 스트림을 걸었습니다.

    시점 넘겨준 누적 바이트 GC.GetTotalMemory 증가 ReadAsync
    1초 578,560 B 600,688 B 진행 중
    5초 1,316,864 B 1,348,744 B 진행 중
    10초 1,866,752 B 1,916,200 B 진행 중

    힙은 받은 만큼 계속 늘었고, 받아들이는 속도는 재스캔 때문에 시간이 갈수록 떨어졌습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release, 10초 뒤 취소 토큰으로 끊음). 헤더가 42억 개를 넘는 DD FF FF FF FF 는 오히려 checked 변환에서 곧바로 OverflowException 이었습니다.

    주의할 점

    1. 여러 메시지를 Deserialize(Stream) 으로 읽지 않는다

    안 지키면 생기는 문제. Deserialize<T>(Stream) 은 스트림 끝까지 읽고 첫 값만 돌려줍니다. 탐색이 안 되는 스트림에서는 뒤 메시지가 사라지고, 열린 소켓에서는 상대가 닫을 때까지 반환하지 않습니다. MemoryStream 으로 한 테스트는 통과해서 잘 드러나지 않습니다.

    이렇게 쓴다. 한 스트림에 메시지가 여러 개면 MessagePackStreamReader 를 씁니다. Deserialize(Stream) 은 파일 하나에 값 하나처럼 스트림 전체가 메시지 하나일 때만 씁니다.

    2. ReadAsync 가 준 시퀀스를 다음 호출 뒤까지 들고 있지 않는다

    안 지키면 생기는 문제. 돌려받은 ReadOnlySequence<byte> 는 리더 내부 버퍼의 한 구간이고, 다음 ReadAsync 첫 줄에서 반납됩니다. 받은 시퀀스를 큐에 넣고 나중에 역직렬화하면 이미 다른 바이트로 채워진 자리를 읽습니다. 리더는 스레드 안전하지도 않습니다.

    이렇게 쓴다. 받은 루프 안에서 바로 Deserialize 하고, 넘겨야 하면 객체나 ToArray() 복사본을 넘깁니다. 리더 하나는 읽는 작업 하나가 소유하고, 스트림을 더 쓸 거면 leaveOpen: true 로 만듭니다. 기본 생성자는 Dispose 때 스트림도 닫습니다.

    3. 신뢰할 수 없는 연결에는 크기 한도를 바깥에 둔다

    안 지키면 생기는 문제. 거대한 배열 헤더를 보내고 바이트를 조금씩 흘리는 연결 하나가 메모리를 계속 키우고, 조각마다 처음부터 다시 훑어 CPU 를 제곱으로 씁니다. 48 KB int[] 를 한 바이트씩 받는 데 1.2초가 걸렸습니다.

    이렇게 쓴다. 프로토콜을 정할 수 있으면 메시지 앞에 길이를 붙이고 한도를 넘는 길이는 바로 끊습니다. 바꿀 수 없으면 읽은 총 바이트와 경과 시간을 세는 래퍼 스트림을 리더 밑에 끼웁니다.

    정리

    MessagePackStreamReader 는 값을 만들지 않고 건너뛰어 보며 메시지 끝을 찾습니다.

    • Deserialize(Stream) 은 끝까지 읽고 첫 값만 돌려주므로 이어 붙은 메시지에는 맞지 않습니다.
    • TrySkip 은 재귀 없이 카운터 하나로 끝을 찾아 할당이 없고, 돌려주는 메시지는 다음 호출 전까지만 유효한 버퍼 조각입니다.
    • 실패할 때마다 처음부터 다시 훑어 비용이 토큰 수 × 조각 수이고, 크기 한도가 없으니 신뢰할 수 없는 연결에는 바깥에서 막습니다.