VitalRouter 의 할당 0 은 기다리지 않는 발행에서만이다
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 가 더 붙습니다.