자물쇠가 없어도 데드락은 걸린다
Esc

    자물쇠가 없어도 데드락은 걸린다

    –C#Unity동시성

    버튼을 눌렀더니 화면이 얼어붙습니다. 예외도 없고 로그도 없습니다. 코드를 뒤져봐도 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 로 올립니다