VitalRouter 의 구독과 해제는 목록을 처음부터 찾는다
void OnDestroy()
{
foreach (var s in subscriptions) s.Dispose(); // 4,000개
}
C# event 의 -= 는 델리게이트 목록에서 핸들러를 찾아 뺍니다. VitalRouter 의 해제도 비슷하게 목록에서 찾는데, 구독도 해제도 배열 처음부터 찾습니다.
그래서 구독이나 해제 한 번의 비용이 라우터에 붙어 있는 구독자 수에 비례하고, N 명을 하나씩 정리하는 총비용은 N 의 제곱으로 늘어납니다. 라우터를 Dispose 하면 찾지 않고 통째로 비웁니다.
빈 칸도, 해제할 구독자도 처음부터 찾는다
구독 목록 FreeList<T> 는 해제한 칸을 null 로 비워 두는 배열입니다. 빈 칸 번호를 모아 두는 큐가 없어서 구독할 때마다 처음부터 첫 null 을 찾고(참조 배열을 정수 배열로 읽어 IndexOf(0)), 해제할 때는 칸 번호가 아니라 구독자 객체로 처음부터 찾습니다.
구독자 N 명이 붙어 있을 때 구독 하나를 붙였다 떼는 한 번의 비용입니다(유니티는 플레이어 빌드).
| 상주 구독자 N | .NET 10 | Mono | IL2CPP |
|---|---|---|---|
| 500 | 821.6 ns | 769.5 ns | 1,480.1 ns |
| 4,000 | 3,473.0 ns | 3,955.7 ns | 8,956.9 ns |
| 상주 구독자 1명당 | 0.87 ns | 0.99 ns | 2.24 ns |
한 번이 N 에 비례하니 N 명을 차례로 정리하면 1 + 2 + … + N, 곧 N 의 제곱입니다.
순서를 바꿔 지워도 줄지 않는다
앞에서부터 지우면 늘 맨 앞에서 바로 찾을 것 같지만, 해제한 칸이 당겨지지 않으니 i 번째로 지우는 대상은 여전히 i 번 칸에 있습니다.
| 4,000명 해제 | 구독한 순서대로 | 역순으로 |
|---|---|---|
| .NET 10 | 7.3~8.5 ms | 7.4~8.7 ms |
| Mono | 4.3~4.7 ms | 4.3~4.5 ms |
| IL2CPP | 3.0~3.2 ms | 3.3 ms |
세 런타임 모두 차이가 없었습니다.
라우터째 버리면 찾지 않는다
Router.Dispose() 는 목록을 하나씩 지우지 않고 쓰인 범위를 한 번에 0 으로 채웁니다.
하나씩 해제 대 router.Dispose() |
4,000명 |
|---|---|
| .NET 10 | 7.13 ms 대 0.0008 ms (8,915배) |
| Mono | 4.46 ms 대 0.0017 ms (2,626배) |
| IL2CPP | 3.13 ms 대 0.0056 ms (559배) |
다만 Dispose 는 목록을 비우기만 하고 라우터를 닫지는 않습니다. Dispose 한 라우터에 다시 구독하고 발행하니 예외 없이 구독자가 불렸습니다.
구독 핸들은 구조체다
Subscribe, SubscribeAwait, MapTo 는 모두 Subscription 구조체를 돌려줍니다. 핸들 자체는 할당이 없습니다. 대신 Dispose 가 “이미 풀었다” 는 표시로 필드를 null 로 바꾸는 것이 복사본에는 보이지 않습니다. 그리고 해제 대상은 객체로 찾습니다.
| 두 번 구독한 것 | 핸들 복사본으로 두 번 Dispose 한 뒤 발행 |
|---|---|
같은 ICommandSubscriber 객체 |
0번 불림 (두 칸 모두 지워짐) |
| 같은 람다 | 1번 불림 (람다는 구독마다 새 래퍼라 한 칸만 지워짐) |
세 런타임이 같았습니다. [Routes] 의 MapTo 는 먼저 UnmapRoutes() 를 불러 같은 객체가 두 번 구독되지 않게 합니다.
구조체라서 IDisposable 을 받는 곳으로 넘기면 박싱됩니다. README 가 권하는 AddTo(destroyCancellationToken) 이 그런 확장 메서드라, 구독 한 번이 Subscribe + Dispose 만일 때 32 B 에서 토큰 등록까지 붙어 120 B 가 됩니다(Mono, IL2CPP 같음).
주의할 점
1. 함께 사라지는 구독은 라우터째 버린다
안 지키면 생기는 문제. 씬을 나갈 때 구독자 수천 명을 하나씩 Dispose 하면 비용이 N 의 제곱으로 늘어, 4,000명에 IL2CPP 3.13 ms 를 정리에만 씁니다. 순서를 바꿔도 줄지 않습니다.
이렇게 쓴다. 화면이나 기능처럼 함께 사라지는 단위로 라우터를 따로 두고 그 라우터를 Dispose 합니다. Dispose 한 라우터는 다시 써도 알려 주지 않으니, 담았던 필드를 null 로 바꾸고 필요하면 새로 만듭니다.
2. 구독 핸들을 복사해 두지 않는다
안 지키면 생기는 문제. 한쪽에서 Dispose 해도 다른 복사본은 아직 해제 전이라고 압니다. 복사본으로 다시 Dispose 하면, 같은 객체를 두 번 구독해 둔 경우 살려 두려던 두 번째 구독까지 지워집니다. 람다 구독에서는 안 생기는 일이라 구독 방식에 따라 같은 코드가 다르게 동작합니다.
이렇게 쓴다. 핸들은 필드 하나에만 두고 그 필드로만 Dispose 합니다. 같은 객체를 두 번 구독하지 않습니다.
3. 풀에서 꺼낼 때마다 구독하면 AddTo 를 쓰지 않는다
안 지키면 생기는 문제. 꺼낼 때마다 AddTo(destroyCancellationToken) 으로 묶으면 매번 120 B 가 쌓입니다. 게다가 그 토큰은 오브젝트가 파괴될 때만 취소되니, 풀에 넣을 때는 해제되지 않고 구독이 계속 쌓입니다.
이렇게 쓴다. AddTo 는 Start 에서 한 번 구독하는 곳에만 씁니다. 풀에서 쓰는 쪽은 핸들을 필드에 두고 직접 풉니다.
Subscription subscription;
void OnSpawn() => subscription = router.Subscribe<Tick>(OnTick);
void OnDespawn() => subscription.Dispose();
정리
VitalRouter 의 구독과 해제는 목록을 처음부터 찾아서 한 번이 상주 구독자 수에 비례합니다.
- N 명을 하나씩 정리하면 N 의 제곱이고, 지우는 순서를 바꿔도 같습니다.
- 라우터를
Dispose하면 찾지 않고 비워서 4,000명 기준 IL2CPP 559배 빠릅니다. 버린 라우터는 다시 쓰지 않습니다. Subscription은 구조체라 복사본에는 해제 표시가 안 보이고,IDisposable로 넘기면 박싱됩니다.