자물쇠가 없어도 데드락은 걸린다
버튼을 눌렀더니 화면이 얼어붙습니다. 예외도 없고 로그도 없습니다.
코드를 뒤져봐도 lock 이 한 줄도 없습니다.
void OnClick()
{
var data = 불러오기().Result;
화면갱신(data);
}
이 한 줄이면 충분합니다. 자물쇠 없이도 데드락이 걸립니다.
멈추는지 재봤다
메인 스레드 하나에 16ms 짜리 프레임 루프를 돌리는 하네스를 만들었습니다.
그 위에서 불러오기() 를 여러 방식으로 부르고, 2초 안에 안 끝나면 데드락으로 봅니다.
불러오기() 안은 await Task.Delay(100); return 42; 두 줄뿐입니다.
| 결과 | 큐에 남은 continuation | |
|---|---|---|
A. 그냥 .Result |
2초가 지나도 안 끝남 | 1개 |
B. .GetAwaiter().GetResult() |
2초가 지나도 안 끝남 | 1개 |
C. Task.Run 으로 감싼 뒤 .Result |
104ms 에 정상 종료 | 0개 |
D. ConfigureAwait(false) + .Result |
102ms 에 정상 종료 | 0개 |
E. 프레임 루프를 돌리면서 await |
130ms 에 정상 종료 | 0개 |
오른쪽 칸을 보세요. 일은 이미 끝났습니다.
이어서 실행될 코드가 메인 스레드 대기열에 얌전히 한 개 들어와 있습니다.
그런데 그 대기열을 비울 수 있는 유일한 스레드가 .Result 에서 서 있습니다.
네 단계로 보면 빈틈이 없다
기다리는 두 당사자를 적어 보면 이렇습니다.
메인 스레드 -> Task 가 끝나기를 기다림 (.Result 에서 서 있음)
이어갈 코드 -> 메인에서 실행될 차례를 기다림 (대기열에 1개)
데드락은 스레드 둘이 엇갈린 것이 아닙니다. 스레드 하나와 차례 하나가 엇갈린 것입니다.
실제로 이 재현에는 워커 스레드가 등장하지도 않습니다.
Task.Delay 의 기다림은 타이머가 하니까 스레드를 안 씁니다. 스레드는 메인 하나뿐입니다.
자물쇠 둘을 엇갈리게 잡아야만 데드락이 나는 것이 아닙니다.
왜 하필 메인으로 돌아오나
await 뒤를 어디서 이어갈지는 환경이 정합니다.
Unity 는 항상 메인 스레드로 되돌립니다.
Transform 이나 GameObject 를 워커에서 건드리면 터지니까 그러라고 있는 장치입니다.
여기에 뒷면이 있습니다. 되돌릴 곳이 메인 하나로 정해져 있으니, 그 메인이 막혀 있으면 돌아올 자리가 사라집니다.
고약한 점이 하나 더 있습니다. 되돌리는 장치가 없는 환경에서는 같은 코드가 멀쩡히 돕니다. 콘솔 프로그램이나 테스트에서는 통과하고 에디터에서 누르는 순간 얼어붙습니다.
두 코드의 차이는 .Result 를 await 로 바꾼 것뿐입니다.
await 는 메서드를 그 줄에서 빼내고, .Result 는 스레드를 붙잡은 채로 세워 둡니다.
같은 “기다린다” 인데 스레드를 놓느냐 붙잡느냐가 반대입니다.
안 걸리는 방법이 셋인데 쓸 것은 하나다
앞의 둘은 증상만 옮깁니다. async 로 시작했으면 부르는 쪽까지 async 로 올라가는 것이 원칙이고,
중간에 한 곳이라도 블로킹으로 끊으면 그 지점이 데드락이나 병목이 됩니다.
이벤트 핸들러처럼 반환값을 못 주는 자리는 async void 가 불가피합니다.
그때는 안에서 예외를 전부 잡아야 합니다. 밖으로 나가면 아무도 못 받습니다.
async void OnClick()
{
try { var data = await 불러오기(); 화면갱신(data); }
catch (Exception e) { Debug.LogException(e); }
}
로딩이 걸리면 더 나쁘다
UnityWebRequest 나 SceneManager.LoadSceneAsync 같은 AsyncOperation 계열은
완료 통지 자체를 프레임 루프가 합니다.
프레임 N | 요청을 띄우고 바로 반환. 네트워크 대기는 스레드를 안 쓴다
프레임 N+1 | 프레임 루프가 isDone 을 확인. 아직 아님
... | 대기
프레임 N+40 | 프레임 루프가 isDone 을 확인 -> 완료 이벤트 -> 이어갈 코드를 대기열에
프레임 N+41 | 대기열에서 꺼내 실행
앞서 본 Task.Delay 는 타이머가 메인과 무관하게 끝을 알려 줍니다.
그래서 메인이 막혀도 Task 는 완료된 상태이고, 이어갈 코드만 대기열에 갇힙니다.
AsyncOperation 은 다릅니다. 프레임이 안 돌면 끝났다는 것을 알아차릴 사람조차 없습니다.
Task 가 완료되지도 않습니다. 로딩 바가 0퍼센트에서 안 움직이는 모양이 이것입니다.
자주 걸리는 자리 넷
| 자리 | 무엇을 하다가 | 어떻게 보이나 |
|---|---|---|
Awake · Start |
var cfg = 설정받기().Result; 로 초기화를 기다림 |
첫 씬에서 그대로 멈춤 |
OnDestroy · OnApplicationQuit |
정리하려고 Wait() |
에디터가 플레이 종료에서 멈춤. 강제 종료해야 함 |
SemaphoreSlim |
잡은 채로 await 를 건넜다가 같은 것을 다시 잡음 |
자기 자신을 기다림 |
| 에디터 도메인 리로드 | 취소 토큰 없이 띄운 Task 가 안 죽음 |
스크립트 컴파일 후 멈춘 것처럼 보임 |
lock 은 컴파일러가 await 를 건너뛰는 것을 막아 줍니다.
SemaphoreSlim 은 안 막습니다. 그래서 세 번째 줄이 눈에 잘 안 띕니다.
멈췄을 때 보는 순서
하나. .Result, .Wait(), .GetAwaiter().GetResult() 를 전체 검색합니다.
유니티 프로젝트에서 이 셋은 원칙적으로 0개여야 합니다.
둘. 0개인데 멈추면 SemaphoreSlim.Wait(), ManualResetEventSlim, Monitor.Wait, Thread.Join 을 봅니다.
셋. 그것도 아니면 취소 토큰 없이 띄워 둔 Task 가 종료 경로에서 안 죽는 것입니다.
UniTask 를 쓰면 이 종류가 구조적으로 덜 생깁니다. 되돌리는 장치를 안 타고 PlayerLoop 에 직접 붙기 때문입니다.
다만 GetAwaiter().GetResult() 는 UniTask 에도 있고 거기서도 똑같이 멈춥니다.
도구가 바꾸는 것은 기본 동작이지 막아 주는 것이 아닙니다.
직접 재보기
“메인 역할” 스레드를 하나 띄우고 Join 이 시간 안에 돌아오는지로 판정합니다.
class MainThreadContext : SynchronizationContext
{
readonly ConcurrentQueue<(SendOrPostCallback cb, object? st)> _q = new();
public int Pending => _q.Count;
public override void Post(SendOrPostCallback d, object? state) => _q.Enqueue((d, state));
}
static async Task<int> LoadAsync()
{
await Task.Delay(100);
return 42;
}
static void Main()
{
var ctx = new MainThreadContext();
var th = new Thread(() =>
{
SynchronizationContext.SetSynchronizationContext(ctx);
int v = LoadAsync().Result; // 여기서 영영 안 나온다
Console.WriteLine(v);
});
th.IsBackground = true;
th.Start();
Console.WriteLine(th.Join(2000) ? "정상 종료" : "데드락");
Console.WriteLine($"대기열에 남은 것 {ctx.Pending}개");
}
Join 이 false 를 돌려주고 남은 것이 1개로 찍히면 그것이 데드락입니다.
IsBackground = true 가 없으면 프로그램 자체가 안 끝나니 꼭 넣으세요.
.Result 를 await 로 바꾸려면 저 스레드 안에서 프레임 루프를 돌려야 합니다.
while (!t.IsCompleted) { 대기열비우기(); Thread.Sleep(16); } 한 줄이면 130ms 에 끝납니다.
Unity 가 매 프레임 하는 일이 바로 이것입니다.
정리
.Result와.Wait()는 기다리는 것이 아니라 막는 것입니다. 스레드를 붙잡은 채로 세워 둡니다- 메인에서 부르면 이어갈 코드가 대기열에 갇히고, 그 대기열을 비울 스레드가 서 있어서 영원히 안 풀립니다
- 이 데드락에는 워커 스레드도 자물쇠도 없어도 됩니다. 스레드 하나와 차례 하나면 충분합니다
- Unity 가
await뒤를 메인으로 되돌리기 때문에 생깁니다. 되돌리지 않는 환경에서는 같은 코드가 멀쩡히 돕니다 ConfigureAwait(false)는 데드락을 예외로 바꿉니다.Task.Run으로 감싸면 스레드 둘이 같이 놉니다AsyncOperation은 완료 통지도 프레임 루프가 해서 Task 가 완료되지도 않습니다- 답은 하나입니다. 블로킹을 지우고 부르는 쪽까지
await로 올립니다