VitalRouter 의 연속 발행은 백그라운드 스레드에서 멈출 수 있다
await Task.Run(async () =>
{
for (var i = 0; i < 2000; i++)
await router.PublishAsync(new SaveCommand(i)); // 구독자 둘이 await 하면 어느 순간 돌아오지 않는다
});
같은 커맨드를 UI, 사운드, 저장이 각각 비동기로 받는 구성은 흔합니다. 이것을 백그라운드 스레드에서 연달아 발행하면 어느 순간 await 가 돌아오지 않습니다. 예외도 로그도 없습니다.
원인은 VitalRouter 2.8.0 이 기다림을 모으는 풀 객체에서 continuation 을 먼저 게시하고 state 를 뒤에 채우는 순서입니다. 그 틈에 다른 스레드가 깨우면 깨우기가 조용히 실패합니다. 발행하는 쪽에 SynchronizationContext 가 있는 유니티 메인 스레드에서는 걸리지 않았습니다.
기다리는 구독자가 둘부터 멈춘다
Task.Yield() 로 한 번 양보하는 비동기 구독자를 N 명 두고 연달아 발행했습니다(.NET 10 콘솔).
| 시나리오 | 결과 |
|---|---|
| 기다리는 구독자 1명 | 2,000번 모두 끝남 |
| 기다리는 구독자 2명 | 17번째에서 멈춤 (8초 기다려도 안 끝남) |
| 기다리는 구독자 3명 | 2번째에서 멈춤 |
| 2명, 이미 끝난 Task 를 돌려줌 | 2,000번 모두 끝남 |
| 2명, 발행마다 새 Router | 첫 번째에서 멈춤 |
몇 번째에서 멈추는지는 돌릴 때마다 달랐습니다(열 번에 39, 5, 5, 14, 8, 6, 3, 4, 2, 0). 경합입니다. 기다림이 하나면 그 태스크를 그대로 돌려주고, 둘부터 풀에서 ReusableWhenAllSource 를 빌립니다. 풀이 static 이라 라우터를 새로 만들어도 피할 수 없습니다.
게시와 채움 사이의 틈에 깨우면 삼켜진다
await 가 기다리겠다고 등록하는 OnCompleted 는 이 순서입니다.
// src/VitalRouter/Internal/ReusableWhenAllSource.cs (OnCompleted, 줄임)
var c = Interlocked.CompareExchange(ref this.continuation, continuation, Available); // ① 먼저 게시
...
if ((flags & UseSchedulingContext) != 0) synchronizationContext = SynchronizationContext.Current;
continuationState = state; // ② 그 뒤에 채움
①과 ② 사이에 구독자 둘이 끝난 스레드가 끼어들면 continuation(null) 이 불립니다. 런타임이 던진 예외를 바로 바깥의 catch 가 삼키고, 자리는 이미 “완료” 로 바뀌어 다시 깨울 수도 없습니다. 소스 복사본에 추적 로그를 심어 이 순서로 멈추는 순간을 잡았습니다.
state 를 먼저 채우고 continuation 을 마지막에 게시하면 틈이 없어집니다. .NET 런타임의 ManualResetValueTaskSourceCore 가 이 순서입니다. 2.8.0 이 최신 릴리스이고, 현재 master 에도 이 코드가 그대로 있습니다.
유니티 메인 스레드에서는 걸리지 않았다
| 발행하는 곳 | SynchronizationContext | 2명 | 3명 |
|---|---|---|---|
| .NET 10 콘솔 | 없음 | 17번째에서 멈춤 | 2번째에서 멈춤 |
| 유니티 Mono, 백그라운드 스레드 | 없음 | 24번째에서 멈춤 | 10번째에서 멈춤 |
| 유니티 IL2CPP, 백그라운드 스레드 | 없음 | 첫 발행에서 멈춤 | 112번째에서 멈춤 |
| 유니티 Mono, IL2CPP 메인 스레드 | 있음 | 500번 끝남 | 500번 끝남 |
메인 스레드에서는 기다리는 구독자 2명으로 20,000번을 돌려도 Mono, IL2CPP 모두 끝났습니다. 핸들러만 스레드풀에서 재개하게 해도 끝났으니, 가르는 것은 발행하는 쪽에 SynchronizationContext 가 있느냐입니다.
주의할 점
1. 기다리는 구독자가 둘 이상인 커맨드는 메인 스레드에서 발행한다
안 지키면 생기는 문제. Task.Run 안이나 스레드풀로 넘긴 뒤처럼 SynchronizationContext 가 없는 곳에서 반복 발행하면 await router.PublishAsync(cmd) 가 영영 끝나지 않을 수 있습니다. 예외가 라이브러리 안에서 삼켜져 콘솔에는 아무것도 남지 않고, 발행 뒤의 저장이나 다음 단계가 조용히 멈춥니다.
이렇게 쓴다. 백그라운드에서 일을 마쳤으면 메인 스레드로 돌아온 뒤 발행합니다.
2. 메인 스레드가 없는 곳에서는 기다리는 구독자를 하나로 합친다
안 지키면 생기는 문제. 서버 코드처럼 돌아갈 메인 스레드가 없으면 1번을 쓸 수 없고, 기다리는 구독자가 둘 이상이면 같은 멈춤이 납니다.
이렇게 쓴다. 기다리는 구독자가 하나면 풀 객체를 빌리지 않아 멈추지 않습니다. 비동기 구독자를 하나로 합쳐 그 안에서 차례로 기다리거나, 나머지 구독자는 동기로 끝나게 씁니다.
3. 기다리는 시간을 늘려 피해진 것을 고친 것으로 보지 않는다
안 지키면 생기는 문제. 구독자를 Task.Delay(1) 로 기다리게 바꾸면 .NET 에서는 514번째로 늦춰졌고, 유니티 백그라운드에서는 멈추지 않았습니다. 틈에 걸릴 확률이 바뀐 것일 뿐이라 기기와 부하에 따라 다시 나타납니다.
이렇게 쓴다. 1번이나 2번으로 조건 자체를 피합니다. 버전을 올린 뒤에는 ReusableWhenAllSource.OnCompleted 에서 continuationState = state; 가 CompareExchange 보다 앞에 왔는지부터 봅니다.
정리
VitalRouter 2.8.0 은 기다리는 구독자가 둘 이상인 발행을 백그라운드 스레드에서 연달아 하면 멈출 수 있습니다.
- 둘부터 풀의
ReusableWhenAllSource를 빌리고, 풀이static이라 라우터를 새로 만들어도 걸립니다. - 원인은
continuation을 먼저 게시하고state를 뒤에 채우는 순서이고, 깨우다 난 예외가 삼켜져 흔적이 없습니다. - 발행하는 쪽에
SynchronizationContext가 있는 유니티 메인 스레드에서는 20,000번까지 끝났습니다.