MessagePipe 의 동시 해제는 남의 구독을 지운다
Esc

    MessagePipe 의 동시 해제는 남의 구독을 지운다

    2026년 9월 25일 수정–Unity라이브러리메시징동시성
    LoadAsync().ContinueWith(_ => subscription.Dispose());   // 스레드 풀에서 해제
    void OnDestroy() => subscription.Dispose();              // 메인 스레드에서도 해제

    한 스레드에서 같은 구독을 두 번 Dispose 하는 것은 안전합니다. 두 번째는 아무 일도 하지 않습니다. 그런데 두 스레드가 동시에 부르면 KeyNotFoundException 이 나거나, 예외도 없이 전혀 다른 구독이 끊깁니다.

    MessagePipe 의 해제는 “이미 해제했나” 검사가 락 밖에 있고, 목록은 칸 번호만 보고 지웁니다. 둘이 겹치면 같은 칸 번호로 두 번 지우게 되고, 그 칸에 그사이 누가 들어왔느냐가 결과를 가릅니다.

    이미 해제했는지 검사하는 줄이 락 밖에 있다

    해제용 객체 Subscription 은 자기 칸 번호와 해제 표시 하나를 듭니다.

    // Runtime/MessageBroker.cs (Subscription, 줄임)
    public void Dispose()
    {
        if (!isDisposed)                    // ① 락 밖에서 검사
        {
            isDisposed = true;              // ② 락 밖에서 표시
            lock (core.gate)
            {
                if (!core.isDisposed)
                    core.handlers.Remove(subscriptionKey, true);
            }
        }
    }

    이 표시가 막는 것은 순서대로 두 번입니다. 두 스레드가 거의 동시에 오면 둘 다 ①에서 false 를 읽고 안으로 들어갑니다. 검사와 표시 사이의 틈입니다.

    두 번째 Remove 는 칸에 누가 있는지 보지 않는다

    목록의 Remove 는 락 안에서 칸이 비었는지만 확인합니다. 비었으면 예외를 던지고, 차 있으면 누구든 지웁니다.

    같은 경합을 50,000번씩 돌린 결과입니다. 유니티는 플레이어 빌드입니다.

    같은 구독을 동시에 Dispose, KeyNotFoundException 횟수 .NET 10 Mono IL2CPP
    스레드 2개 26번 45번 59번
    스레드 4개 10번 44번 65번
    스레드 8개 15번 69번 81번

    유니티가 .NET 의 2~5배 자주 터졌습니다. 틈이 if (!isDisposed) 한 줄이라 스레드를 늘려도 빈도는 거의 늘지 않습니다.

    남의 구독이 사라지는 쪽은 .NET 에서 50,000번 중 19번 나왔고, 유니티에서는 재현되지 않았습니다. 세 번째 스레드가 딱 그 틈에 칸을 받아야 해서 조건이 더 좁을 뿐, 틈은 같은 코드에 있습니다.

    칸 번호를 고정해서 해제를 싸게 만든 구조가, 번호가 다른 구독에게 넘어간 뒤에는 그 구독을 지우게 합니다.

    주의할 점

    1. 같은 구독의 해제는 한 스레드로 모은다

    안 지키면 생기는 문제. 비동기 작업의 콜백이 스레드 풀에서 해제하고 OnDestroy 도 메인 스레드에서 해제하면 두 해제가 겹칠 수 있습니다. 운이 나쁘면 KeyNotFoundException, 더 나쁘면 그사이 들어온 다른 구독이 지워집니다. 지워진 쪽은 그 뒤로 메시지를 받지 못하고 로그도 남지 않습니다.

    이렇게 쓴다. 해제는 메인 스레드 한 곳에서 합니다. 비동기 콜백에서 해제해야 하면 메인 스레드로 돌아온 뒤에 합니다.

    2. 해제용 객체를 모은 묶음도 한 스레드에서 Dispose 한다

    안 지키면 생기는 문제. DisposableBag 이 만든 묶음을 두 스레드가 Dispose 하면 안의 구독 하나하나가 같은 경합을 겪습니다. 묶었다고 경합이 사라지지 않습니다.

    이렇게 쓴다. 묶음의 Dispose 도 소유한 오브젝트의 메인 스레드 정리 코드(OnDestroy 등) 한 곳에서만 부릅니다.

    정리

    MessagePipe 구독을 두 스레드가 동시에 해제하면 같은 칸을 두 번 지웁니다.

    • 해제 표시의 검사와 쓰기가 락 밖이라 둘 다 안으로 들어갑니다.
    • 칸이 비어 있으면 KeyNotFoundException, 다른 구독이 들어와 있으면 그 구독이 조용히 사라집니다.
    • 예외는 50,000번 중 .NET 1026번, 유니티 4481번 났습니다.