---
title: "MessagePipe 의 동시 해제는 남의 구독을 지운다"
description: "같은 MessagePipe 구독을 두 스레드가 동시에 Dispose 하면 해제 검사가 락 밖이라 둘 다 통과합니다. 목록은 칸 번호만 보고 지우므로 KeyNotFoundException 이 나거나, 그사이 칸을 받은 다른 구독이 조용히 사라집니다."
author: "유니얼"
pubDate: 2026-09-21
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-messagepipe-dispose-race/
---
```csharp
LoadAsync().ContinueWith(_ => subscription.Dispose());   // 스레드 풀에서 해제
void OnDestroy() => subscription.Dispose();              // 메인 스레드에서도 해제
```

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

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

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

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

```csharp
// 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` 는 락 안에서 칸이 비었는지만 확인합니다. 비었으면 예외를 던지고, 차 있으면 누구든 지웁니다.

<x-flow>
<script type="application/json">
{
  "label": "같은 구독을 두 스레드가 동시에 해제할 때",
  "lines": ["if (!isDisposed)", "    isDisposed = true;", "    lock (core.gate)", "        handlers.Remove(1);", "", "// FreeList.Remove", "ref var v = ref values[1];", "if (v == null) throw new KeyNotFoundException();", "v = null; freeIndex.Enqueue(1);"],
  "cases": [
    {
      "label": "칸이 빈 채로 남아 있다",
      "state": "구독 D 가 1번 칸에 있다. 스레드 1 과 2 가 D 의 Subscription 을 동시에 Dispose",
      "path": [
        { "line": 0, "why": "스레드 1 이 isDisposed 를 읽습니다. 아직 false 입니다.", "verdict": "true" },
        { "line": 0, "why": "스레드 1 이 표시를 쓰기 전에 스레드 2 도 읽습니다. 역시 false 입니다.", "verdict": "true" },
        { "line": 1, "why": "둘 다 안으로 들어왔습니다. 이제 true 로 바꿔도 늦었습니다." },
        { "line": 2, "why": "스레드 1 이 락을 먼저 잡습니다. 스레드 2 는 락 앞에서 기다립니다." },
        { "line": 6, "why": "스레드 1: 1번 칸에는 D 가 있습니다." },
        { "line": 7, "why": "비어 있지 않으니 통과합니다.", "verdict": "false" },
        { "line": 8, "why": "스레드 1 이 칸을 비우고 번호 1 을 큐에 반납한 뒤 락을 놓습니다." },
        { "line": 6, "why": "스레드 2 가 락을 잡고 같은 1번 칸을 봅니다. 방금 비워졌습니다." },
        { "line": 7, "why": "비어 있으니 던집니다.", "verdict": "true", "out": "KeyNotFoundException" }
      ],
      "note": "예외가 나니 적어도 눈에는 띕니다."
    },
    {
      "label": "그사이 칸이 재사용됐다",
      "state": "위와 같은데, 스레드 1 이 칸을 비운 직후 스레드 3 이 새 구독 E 를 만든다",
      "path": [
        { "line": 0, "why": "스레드 1 이 false 를 읽습니다.", "verdict": "true" },
        { "line": 0, "why": "스레드 2 도 false 를 읽습니다.", "verdict": "true" },
        { "line": 1, "why": "둘 다 안으로 들어왔습니다." },
        { "line": 2, "why": "스레드 1 이 락을 먼저 잡습니다." },
        { "line": 6, "why": "스레드 1: 1번 칸에는 D 가 있습니다." },
        { "line": 7, "verdict": "false" },
        { "line": 8, "why": "스레드 1 이 칸을 비우고 번호 1 을 반납합니다. 큐에 다른 빈 칸이 없으면 1 이 맨 앞이 됩니다." },
        { "line": 2, "why": "락이 풀린 틈에 스레드 3 이 먼저 들어와 새 구독 E 를 1번 칸에 넣습니다. 그다음 스레드 2 가 락을 잡습니다." },
        { "line": 6, "why": "스레드 2 가 보는 1번 칸에는 이제 D 가 아니라 E 가 있습니다." },
        { "line": 7, "why": "비어 있지 않으니 통과합니다.", "verdict": "false" },
        { "line": 8, "why": "E 의 칸을 비웁니다. E 를 구독한 쪽은 아무것도 모릅니다.", "out": "E 가 조용히 사라짐" }
      ],
      "note": "예외도 로그도 없습니다. E 의 구독자는 그 뒤로 메시지를 영영 못 받습니다."
    }
  ]
}
</script>
</x-flow>

같은 경합을 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 10~26번, 유니티 44~81번 났습니다.