MessagePipe 의 버퍼드와 키 브로커는 일반 브로커에 규칙 하나를 얹는다
publisher.Publish(new Score(20)); // IPublisher<Score>
bufferedSubscriber.Subscribe(s => label.text = $"{s.Value}"); // 늦게 붙은 점수판. 20 이 올까?
늦게 붙은 구독자에게 마지막 값을 주는 버퍼드 브로커를 썼는데 점수판이 20 을 받지 못합니다. 몬스터 ID 를 키로 구독하고 해제했더니 구독 한 번에 300 B 가 넘게 듭니다.
버퍼드 브로커와 키 브로커는 일반 브로커의 구독 목록을 그대로 쓰고 규칙 하나만 얹습니다. 버퍼드는 자기 발행 경로에서만 마지막 값을 한 칸에 적고, 키 브로커는 키마다 목록을 두고 비는 순간 지웁니다.
마지막 값은 버퍼드 발행자만 적는다
버퍼드 Core 는 구독 목록을 따로 두지 않습니다. 일반 Core 를 생성자로 받고, 가진 것은 lastMessage 한 칸뿐입니다.
// Runtime/MessageBroker.cs (BufferedMessageBrokerCore, 줄임)
readonly MessageBrokerCore<TMessage> core; // IPublisher<T> 뒤에 있는 그 Core
TMessage lastMessage;
public void Publish(TMessage message)
{
lastMessage = message; // 한 칸에 적고
core.Publish(message); // 일반 Core 로 넘긴다
}
public IDisposable Subscribe(IMessageHandler<TMessage> handler)
{
if (IsValueType || lastMessage != null)
handler.Handle(lastMessage); // 구독 즉시 마지막 값
return core.Subscribe(handler);
}
그래서 전달은 공유하고 기억은 공유하지 않습니다. IPublisher 로 보낸 값도 이미 구독 중인 버퍼드 구독자에게는 가지만, 한 칸에 적는 줄은 버퍼드 발행 경로에만 있습니다.
| 한 일 | 결과 |
|---|---|
IPublisher 로 발행 |
일반 구독자, 버퍼드 구독자 둘 다 받는다 |
| 그 뒤 새 버퍼드 구독자 | 재생 없음 |
IBufferedPublisher 로 발행 |
둘 다 받는다 |
| 그 뒤 새 버퍼드 구독자 | 재생된다 |
값 타입은 발행한 적이 없어도 구독 즉시 불린다
구독 조건 IsValueType || lastMessage != null 은 값 타입이면 늘 참입니다. “아직 값 없음” 을 표현할 방법이 참조 타입의 null 하나뿐이라서입니다.
| 메시지 타입 | 아무도 발행하지 않은 상태에서 구독하면 |
|---|---|
Damage (struct) |
즉시 호출된다. default(Damage) 가 온다 |
RefMessage (class) |
호출 없음 |
키 브로커는 조회만 잠그고, 비면 키를 지운다
키 브로커는 Dictionary<TKey, HandlerHolder> 로 키마다 일반 구독 목록 하나를 둡니다. 딕셔너리는 쓰는 동안 읽기를 보장하지 않으니 조회는 락 안에서 하고, 핸들러는 락을 푼 뒤에 부릅니다.
| 발행 1회 | .NET 10 | Mono | IL2CPP |
|---|---|---|---|
| 키 없는 브로커 (구독자 1) | 7.7 ns | 14.2 ns | 8.1 ns |
| 키 브로커, 구독자 있는 키 | 49.4 ns | 35.7 ns | 54.4 ns |
| 키 브로커, 구독자 없는 키 | 23.4 ns | 24.0 ns | 42.1 ns |
락과 해시 조회 몫으로 2.5~6.7배가 들고, 발행 할당은 0 입니다(유니티는 플레이어 빌드).
해제 쪽 규칙도 하나 바뀝니다. 마지막 구독자가 나가면 그 키를 딕셔너리에서 지웁니다. 키가 계속 새로 생겨도 딕셔너리가 커지지 않는 대신, 같은 키에 구독 하나를 붙였다 떼면 붙일 때마다 HandlerHolder 와 그 안의 목록을 새로 만듭니다.
| 구독 + 해제 1회 (람다 구독) | .NET 10 | Mono | IL2CPP |
|---|---|---|---|
| 키 없는 브로커 | 56 B | 56 B | 64 B |
| 키 브로커, 해제할 때마다 키가 비는 경우 | 304 B | 312 B | 320 B |
그 키에 오래 사는 구독자가 하나 있으면 키가 지워지지 않아 .NET 에서 40 B 로 내려갑니다.
두 규칙은 함께 쓸 수 없어서 버퍼드에는 키 버전(IBufferedPublisher<TKey, T>)이 없습니다. 빈 키를 지우면 마지막 값이 사라지고, 남기면 한 번 쓰고 버린 키의 값이 영원히 남습니다. 소스 주석도 이 누수를 이유로 적었습니다.
주의할 점
1. 버퍼를 쓰는 메시지 타입은 발행 쪽도 전부 IBufferedPublisher 로 받는다
안 지키면 생기는 문제. 한 곳은 IPublisher 로, 다른 곳은 IBufferedPublisher 로 보내면 버퍼에는 버퍼드 쪽이 보낸 값만 남습니다. 점수가 20 까지 올랐어도 늦게 붙은 점수판은 버퍼드 쪽이 마지막으로 보낸 10 을 받습니다. 이미 구독 중인 쪽은 멀쩡하니 늦게 붙는 UI 에서만 틀립니다.
이렇게 쓴다. 버퍼를 쓰기로 한 메시지 타입은 발행하는 클래스가 모두 IBufferedPublisher<T> 를 받게 맞춥니다.
2. 버퍼드 구조체 메시지의 첫 호출은 default 일 수 있다
안 지키면 생기는 문제. 값 타입 메시지는 아무도 발행하지 않았어도 구독하는 즉시 default 로 불립니다. 체력 메시지라면 체력 0 이 먼저 와서 시작하자마자 체력 바가 비거나 사망 처리가 돕니다.
이렇게 쓴다. 시작할 때 초기값을 한 번 발행해 둡니다. 어렵다면 메시지를 참조 타입으로 만들어 “아직 없음” 을 null 로 나타냅니다.
3. 키 하나에 구독자 하나를 붙였다 떼는 구조는 할당을 계산에 넣는다
안 지키면 생기는 문제. 몬스터 ID 를 키로 스폰 때 구독하고 죽을 때 해제하면, 키가 빌 때마다 목록을 새로 만들어 유니티에서 한 번에 312~320 B 가 듭니다. 자주 태어나고 죽는 게임이면 GC 가 늘어납니다.
이렇게 쓴다. 그 키에 오래 사는 구독자를 하나 두거나, 매니저 하나가 키 없는 브로커로 받아 ID 로 나눠 줍니다.
정리
버퍼드와 키 브로커는 일반 구독 목록 위에 규칙 하나만 얹습니다.
- 버퍼드는 버퍼드 발행자만 마지막 값을 적습니다. 일반 발행은 늦게 온 구독자에게 재생되지 않고, 값 타입은 처음에
default가 옵니다. - 키 브로커는 딕셔너리 조회만 잠그고 핸들러는 락 밖에서 부릅니다. 발행은 2.5~6.7배, 할당은 0 입니다.
- 키가 비면 지우므로 구독 하나를 붙였다 떼면 매번 300 B 남짓이 들고, 버퍼드 키 버전은 없습니다.