MessagePipe 는 구독자를 칸 번호가 고정된 배열에 둔다
HP 를 깎는 구독자를 먼저, 체력 바를 그리는 구독자를 나중에 구독했습니다. 처음에는 그 순서로 불리다가, 창을 몇 번 열고 닫은 뒤에는 체력 바가 먼저 불립니다. 구독자가 한 명뿐인데 발행 한 번에 수백 ns 가 들기도 합니다.
둘 다 MessagePipe 의 구독 목록이 해제해도 칸을 당기지 않고 비워 두는 배열이라서 생깁니다. 칸 번호가 바뀌지 않으니 발행은 락 없이 싸게 돌고, 대신 호출 순서와 배열 크기는 그동안의 구독과 해제 이력이 정합니다.
구독 목록은 칸을 비워 두는 배열이다
메시지 타입마다 하나 있는 구독 목록은 배열과 빈 칸 번호 큐입니다.
// Runtime/Internal/FreeList.cs (줄임)
T[] values; // 구독자 칸. 빈 칸은 null
FastQueue<int> freeIndex; // 빈 칸 번호들. 처음엔 0, 1, 2, 3
- 구독하면 큐 맨 앞 번호를 꺼내 그 칸에 핸들러를 넣습니다. 그 번호가 구독 키입니다.
- 해제하면 그 칸을
null로 비우고 번호를 큐 맨 뒤에 붙입니다. - 해제용 객체는 이 번호 하나만 듭니다. 뒤엣것을 당기지 않으니 다른 구독자의 번호가 바뀌지 않습니다.
발행은 배열 참조를 한 번 읽고 끝까지 돕니다. 빈 칸은 ?. 로 건너뜁니다.
// Runtime/MessageBroker.cs
public void Publish(TMessage message)
{
var array = handlers.GetValues(); // 락 없이 참조만 읽는다
for (int i = 0; i < array.Length; i++)
array[i]?.Handle(message);
}
락이 없어도 안전한 것은 구독 쪽이 배열을 바꾸는 방식 덕분입니다. 빈 칸에 넣을 때는 같은 배열의 칸 하나만 바꾸고, 꽉 차서 늘릴 때는 두 배 배열을 다 채운 뒤에 Volatile.Write 로 참조를 바꿉니다. 발행은 옛 배열 아니면 새 배열을 온전히 봅니다. 구독과 해제만 락 안에서 합니다.
발행 할당은 구독자 1·8·64명 모두 0 B 였고, 구독자 8명 발행 한 번은 .NET 48.7 ns, Mono 33.4 ns, IL2CPP 30.0 ns 였습니다(유니티는 플레이어 빌드).
호출 순서는 칸 번호가 정한다
A, B, C 를 구독하고 B 를 해제한 뒤 D, E 를 구독하면, B 가 비운 1번에 D 가 들어갈 것 같습니다. 실제로는 A, E, C, D 입니다.
큐가 FIFO 라서 한 번도 안 쓴 칸이 해제로 비운 칸보다 먼저 나옵니다. 발행은 0번부터 도니 순서는 구독 순서가 아니라 칸 번호 순서입니다. .NET, Mono, IL2CPP 에서 같았습니다.
핸들러 안에서 구독을 바꿀 때도 같은 규칙이 결과를 가릅니다. 새 구독이 발행 루프가 아직 안 지난 칸에 앉으면 이번 발행에 불리고, 지난 칸에 앉으면 다음 발행부터 불립니다.
| 배치 | 첫 발행 | 두 번째 발행 |
|---|---|---|
| A·B·C 에서 A 가 C 를 해제하고 D 를 구독 | A, B, D |
A, B, D |
| X·A·B·Z 로 칸을 채우고 X 를 해제, A 가 D 를 구독 | A, B, Z |
D, A, B, Z |
핸들러 안에서 구독하다 배열이 두 배로 늘면 새 구독은 새 배열에 들어가 이번 발행에는 불리지 않습니다. 세 런타임이 같았습니다.
배열은 다 비어야 줄어든다
해제 코드가 배열을 줄이는 조건은 한 줄입니다.
// Runtime/Internal/FreeList.cs (Remove 의 끝)
if (shrinkWhenEmpty && count == 0 && values.Length > MinShrinkStart)
Initialize(); // 칸 네 개짜리 새 배열로 되돌린다
구독이 하나라도 남으면 배열은 그대로입니다. 줄이려면 남은 구독자를 옮겨야 하고, 옮기면 칸 번호가 바뀌어 해제용 객체가 엉뚱한 칸을 지우게 됩니다.
구독자 1,000명이면 배열은 1,024칸까지 늘어납니다. 999명이 빠져도 발행은 1,024칸을 전부 돕니다.
| 발행 1회 | .NET 10 | Mono | IL2CPP |
|---|---|---|---|
| 구독자 1,000명 | 3,656.0 ns | 3,134.3 ns | 3,026.4 ns |
| 999명 해제 뒤 1명 남음 (1,024칸 그대로) | 446.0 ns | 507.4 ns | 467.0 ns |
| 전부 해제해 줄어든 뒤 1명 다시 구독 | 9.9 ns | 16.2 ns | 7.8 ns |
같은 “구독자 1명” 인데 31~60배 차이입니다. 한때 몰렸던 칸 수가 그 뒤 발행 비용을 정합니다.
주의할 점
1. 구독자끼리 호출 순서를 기대하지 않는다
안 지키면 생기는 문제. 순서는 칸 번호 순서이고 칸 번호는 이력이 정합니다. HP 를 깎는 구독자를 먼저 구독해도, 해제와 재구독이 끼면 체력 바가 먼저 불려 깎이기 전 값을 그립니다. 처음엔 맞다가 창을 몇 번 열고 닫은 뒤에만 틀립니다.
이렇게 쓴다. 순서가 중요한 일은 핸들러 하나 안에서 차례대로 부릅니다. 또는 앞 단계가 끝난 뒤 다른 메시지를 발행해 다음 단계가 그것을 받게 합니다.
2. 핸들러 안에서 같은 메시지 타입의 구독을 바꾸지 않는다
안 지키면 생기는 문제. 핸들러 안에서 붙인 구독자가 이번 발행에 불리는지는 칸 위치와 배열이 늘었는지로 갈립니다. 같은 코드가 칸이 얼마나 찼는지에 따라 다르게 동작합니다. 아직 안 불린 구독자를 해제하면 그 구독자는 이번 발행을 받지 못합니다.
이렇게 쓴다. 이번 발행에 불리든 안 불리든 결과가 같게 짜거나, 구독 변경을 다음 프레임으로 미룹니다.
3. 구독자가 몰렸다 빠지는 흐름은 브로커를 따로 둔다
안 지키면 생기는 문제. 전투 중 적 1,000마리가 구독했다가 한 마리만 남아도 1,024칸을 계속 훑습니다. 발행이 IL2CPP 467 ns 로, 줄어든 뒤(7.8 ns)의 60배입니다.
이렇게 쓴다. 그 흐름의 브로커를 스코프 수명으로 등록해(o.InstanceLifetime = InstanceLifetime.Scoped) 흐름이 끝날 때 스코프째 버립니다.
정리
MessagePipe 는 구독자를 칸 번호가 고정된 배열에 둡니다.
- 해제는 칸을 비우기만 하니 발행은 배열 참조 하나를 락 없이 읽고, 할당은 0 입니다.
- 빈 칸 큐가 FIFO 라 호출 순서는 구독 순서가 아니라 칸 번호 순서입니다.
- 배열은 구독이 0개일 때만 줄어서, 몰렸다 빠지면 남은 한 명에게도 1,024칸을 훑습니다.