VitalRouter 의 할당 0 은 기다리지 않는 발행에서만이다
Esc

    VitalRouter 의 할당 0 은 기다리지 않는 발행에서만이다

    2026년 9월 25일 수정–Unity라이브러리메시징메모리
    router.AddFilter(new LoggingInterceptor());          // 로그 하나 달았을 뿐인데
    await router.PublishAsync(new MoveInput(dir));       // 매 프레임 발행

    VitalRouter 는 패키지 설명 첫 문장에 zero-allocation 을 적었고, README 는 C# 이벤트를 직접 부르는 것과 같은 수준이라 메시지를 아주 잘게 나눠 써도 된다고 말합니다. 매 프레임 발행하는 커맨드를 믿고 올렸다가 프로파일러에 GC 가 찍힐 수 있습니다.

    주장은 아무것도 기다리지 않는 발행에서 정확히 맞습니다. 인터셉터를 달거나 구독자가 실제로 기다리면 할당이 붙습니다.

    발행 1회 (.NET 10) 할당
    구독자 0명, 동기 구독자 1명, 3명 0 B
    비동기 구독자 1명, 2명, 바로 끝남 0 B
    동기 구독자 1명 + 인터셉터 1개 또는 2개 24 B
    비동기 구독자 1명, 실제로 기다림 223.9 B

    유니티 플레이어 빌드(Mono, IL2CPP)에서도 동기 0 B, 인터셉터 24 B 는 한 바이트도 다르지 않았습니다.

    동기 발행은 빌린 문맥을 그 자리에서 돌려준다

    기다릴 구독자가 없으면 발행은 이미 끝난 default 를 돌려주고 끝납니다. 발행마다 쓰는 PublishContext 는 풀에서 빌려 그 자리에서 돌려줍니다. 풀의 첫 칸은 스레드마다 하나라서, 같은 스레드에서 빌리고 돌려주는 흔한 경우에는 원자적 연산조차 없습니다.

    풀은 커맨드 타입마다 따로 있습니다. 쓴 적 없는 커맨드 타입의 첫 발행 한 번은 제네릭 인스턴스의 첫 실행 비용이 섞여 Mono 약 1.76 KB, IL2CPP 약 1.52 KB 였습니다.

    인터셉터를 달면 개수와 상관없이 24 B

    인터셉터가 하나라도 있으면 발행이 다른 경로로 들어가고, 거기서 구조체 PublishCore 가 인터페이스 매개변수로 넘어가며 박싱됩니다. 참조 필드 하나짜리 구조체라 헤더 16 B 에 참조 8 B, 24 B 입니다. 체인 자체는 커서 하나로 돌아서 인터셉터가 몇 개든 더 만드는 객체가 없습니다.

    같은 Rent 경로에 넘긴 것 (유니티 세 곳 같음) 발행 1회 할당
    참조 필드 1개짜리 구조체를 매번 (라이브러리와 같은 모양) 24 B
    같은 구조체를 미리 한 번 박싱해 둔 참조 0 B
    같은 모양의 클래스 0 B

    미리 박싱하거나 클래스를 넘기면 0 B 라, 24 B 는 박싱 하나입니다.

    실제로 기다리면 VitalRouter 몫만 408 B

    비동기 구독자가 그 자리에서 끝나면 0 B 입니다. 기다림이 하나면 그 태스크를 그대로 돌려주고, 둘 이상일 때만 풀에서 모음 객체를 빌립니다. 하지만 실제로 기다리게 되면 발행한 쪽으로 돌아가는 async 상태 머신이 힙으로 옮겨 갑니다.

    유니티 플레이어 빌드에서 UniTask.Yield() 로 기다리는 구독자를 두고, 라우터 없이 같은 핸들러만 부른 값을 빼서 나눴습니다.

    발행 1회 (Mono / IL2CPP) 발행 전체 핸들러 몫 VitalRouter 몫
    기다리는 구독자 1명 952 / 968 B 544 / 552 B 408 / 416 B
    3명 2,040 / 2,072 B 1,632 / 1,656 B 408 / 416 B

    VitalRouter 몫은 인원과 무관하게 같고, 늘어나는 것은 핸들러 쪽 상태 머신입니다.

    Extensions 를 한 번 쓰면 그 커맨드 타입의 발행이 계속 비싸진다

    PublishContext.Extensions 는 처음 접근할 때 ConcurrentDictionary 를 만들어 문맥에 붙이고, 문맥을 돌려줄 때 Clear() 만 합니다. 유니티에서는 이 Clear() 한 번이 416 B 입니다.

    인터셉터 경로 발행 1회 .NET 10 Mono IL2CPP
    Extensions 를 건드리기 전 24 B 24 B 24 B
    Extensions 를 한 번 건드린 뒤 24 B 440 B 440 B

    딕셔너리가 붙은 문맥이 풀을 돌며 그 커맨드 타입의 모든 발행에 쓰이니, Extensions 를 쓰지 않는 발행까지 프로세스가 끝날 때까지 416 B 를 더 냅니다. 딕셔너리가 비어 있으면 Clear() 를 건너뛰게 소스를 고쳐 빌드하니 24 B 로 돌아왔습니다.

    주의할 점

    1. 매 프레임 발행하는 커맨드의 라우터에는 인터셉터를 달지 않는다

    안 지키면 생기는 문제. 인터셉터가 하나라도 있으면 발행마다 24 B 가 붙습니다. 매 프레임 발행하는 커맨드라면 60 fps 에서 커맨드 하나당 초에 1.4 KB 쯤이 쌓입니다.

    이렇게 쓴다. 로그나 권한 확인이 필요한 커맨드와 매 프레임 도는 커맨드를 라우터로 나누고, 매 프레임 커맨드용 라우터에는 인터셉터를 달지 않습니다.

    2. 자주 발행하는 커맨드의 구독자는 기다리지 않게, 라우트는 void 로 쓴다

    안 지키면 생기는 문제. 구독자가 실제로 await 하면 발행 한 번에 유니티에서 1 KB 가까이 듭니다. 바로 끝나는 일이라도 async UniTask On(T, PublishContext) 로 쓰면 비동기 경로를 거쳐 void 라우트의 1.8~2.7배 시간이 듭니다(Mono 71.2 ns 대 192.6 ns, 바이트는 둘 다 0 B).

    이렇게 쓴다. 그 자리에서 끝낼 수 있는 일은 void On(T) 로 씁니다. 실제로 기다려야 하는 일(연출, 로딩)은 가끔 발행되는 커맨드에만 둡니다.

    3. PublishContext 는 발행 안에서만 쓴다

    안 지키면 생기는 문제. 문맥은 풀에서 빌린 것이라, 필드에 담아 두면 두 번째 발행 때 같은 객체가 다른 발행에 쓰이고 넣어 둔 Extensions 값은 지워져 있습니다(세 런타임 같음). 유니티에서 Extensions 를 한 번이라도 쓰면 그 커맨드 타입의 발행이 모두 416 B 씩 더 씁니다.

    이렇게 쓴다. 필요한 값(취소 토큰 등)만 핸들러 안에서 꺼내 씁니다. 발행 사이에 값을 나르려면 Extensions 대신 커맨드 구조체의 필드로 넣습니다.

    정리

    VitalRouter 의 할당 0 은 아무것도 기다리지 않는 발행에서 맞습니다.

    • 동기 발행은 빌린 문맥을 그 자리에서 돌려줘서, 첫 발행 이후 0 B 입니다.
    • 인터셉터를 달면 PublishCore 박싱으로 개수와 상관없이 24 B 가 붙습니다.
    • 구독자가 실제로 기다리면 VitalRouter 몫만 유니티에서 408 B 이고, Extensions 를 한 번 쓰면 그 타입의 발행마다 416 B 가 더 붙습니다.