MessagePipe 는 구독자를 칸 번호가 고정된 배열에 둔다
Esc

    MessagePipe 는 구독자를 칸 번호가 고정된 배열에 둔다

    2026년 9월 25일 수정–Unity라이브러리메시징동시성

    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칸을 훑습니다.