await 는 기다림을 넘길 뿐 일을 넘기지 않는다
Esc

    await 는 기다림을 넘길 뿐 일을 넘기지 않는다

    –C#Unity동시성

    무거운 계산이 프레임을 잡아먹어서 async 를 붙였습니다. 그런데 화면은 그대로 멈춥니다. 안에 await 를 하나 넣으면 괜찮다는 말을 듣고 넣어봤는데도 멈춥니다.

    재봤습니다. 멈추는 게 맞습니다. 그리고 await 를 넣었다고 안 멈추는 것도 아닙니다.

    재는 방법

    Unity 메인 스레드를 흉내내는 하네스를 만들었습니다. Unity 처럼 await 뒤를 한 스레드로 되돌리는 컨텍스트 하나에 16ms 짜리 프레임 루프를 돌립니다. 작업은 1500ms 짜리 CPU 연산이고, 2000ms 동안 프레임이 몇 개나 그려지는지 셉니다. 아무것도 안 하면 약 125프레임이 나와야 합니다.

    Sleep 이 아니라 실제로 Math.Sqrt 를 돌려서 CPU 를 태웁니다. Sleep 으로 재면 스레드를 놓는 것이라 결과가 정반대로 나옵니다.

    다섯 가지를 같은 조건으로 돌린 결과입니다.

    프레임 최대 끊김 총 소요 작업 스레드
    기준(작업 없음) 125개 17ms
    A. await 없음 31개 1516ms 1500ms 메인
    B. await 가 맨 뒤 31개 1519ms 1500ms 메인
    C. Task.Yield() 10조각 31개 318ms 1500ms 메인
    D. Task.Yield() 100조각 64개 47ms 3117ms 메인
    E. await Task.Run 124개 19ms 1500ms 워커

    읽을 것이 세 가지 있습니다.

    A 와 B 가 같습니다. B 는 await Task.Delay(1) 이 들어 있는데도 1519ms 를 통째로 멈췄습니다. await 가 있느냐 없느냐는 아무 상관이 없었습니다.

    A 부터 D 까지 전부 메인 스레드에서 돌았습니다. 스레드가 바뀐 것은 E 하나뿐입니다.

    D 는 안 멈추는 대신 총 시간이 두 배가 됐습니다. 1500ms 짜리 일이 3117ms 걸렸습니다.

    왜 멈추나

    await 가 스레드를 놓는다는 말은 맞습니다. 다만 놓는 것이 “기다림” 이지 “일” 이 아닙니다.

    메서드 하나를 세 구간으로 쪼개면 보입니다.

    1구간이 A 와 B 를 설명합니다. async 를 붙였다고 메서드가 다른 데로 가지 않습니다. await 를 만나기 전까지는 부른 스레드가 한 줄씩 그냥 실행합니다. 무거운 계산을 맨 앞에 두면 그 계산이 끝날 때까지 부른 쪽은 아무것도 못 합니다. await 를 뒤에 붙여봐야 이미 다 끝난 뒤라 양보할 시간이 남아 있지 않습니다.

    돌아오는 곳이 다시 메인이다

    3구간이 제일 안 보이는 자리입니다. await 에서 제어를 놓은 것까지는 맞는데, 멈춘 자리를 이어받는 스레드가 다시 메인 이라 그 뒤의 계산도 똑같이 프레임을 먹습니다.

    Unity 가 그렇게 되돌려 줍니다. await 뒤는 항상 메인 스레드에서 이어집니다. Transform 이나 GameObject 를 워커에서 건드리면 터지니까 그러라고 있는 장치입니다.

    같은 코드를 Unity 에서와 그렇지 않은 곳에서 돌려봤습니다. 계산 300ms, await Task.Delay(200), 계산 600ms 순서입니다.

    두 로그의 앞 두 줄은 같습니다. 갈리는 것은 세 번째 줄입니다.

    Unity 에서는 await 뒤가 메인에서 이어지고, 프레임은 13에서 멈춘 채 600ms 를 버팁니다. 그렇지 않은 곳에서는 워커에서 이어지고, 그동안 프레임은 12에서 49까지 계속 굴러갑니다.

    메인 스레드가 멈추는 것은 async 때문이 아닙니다. await 뒤를 메인 스레드로 친절하게 되돌려주기 때문입니다.

    Task.Yield 가 하는 일

    그래서 await Task.Yield() 가 무엇인지도 분명해집니다. 스레드를 바꾸는 것이 아니라 나머지를 다음 프레임으로 미루는 것입니다. 일은 여전히 메인 스레드가 합니다. 한 번에 다 하지 않을 뿐입니다.

    그러면 남는 문제는 하나뿐입니다. 한 조각이 얼마나 큰가.

    C 는 150ms 짜리 10조각이었습니다. 프레임은 돌았지만 끊김이 318ms 입니다. 6fps 면 사용자 눈에는 그냥 멈춘 것입니다. await 를 넣었는데도 결과가 A 와 똑같은 31프레임인 이유입니다.

    D 는 15ms 짜리 100조각입니다. 끊김이 47ms 까지 내려갔습니다. 대신 프레임마다 한 조각씩만 처리하니 프레임 수만큼 대기가 끼어들어 총 3117ms 가 걸렸습니다.

    두 막대가 서로 반대로 움직입니다. 잘게 자를수록 부드러워지고 그만큼 늦게 끝납니다. 쪼개기는 프리즈를 지연과 맞바꾸는 일입니다. 공짜로 빨라지는 것이 아닙니다.

    그래서 기준은 둘뿐이다

    작업 방법 왜
    Unity API 를 안 건드리는 계산 await Task.Run(...) 진짜로 다른 스레드가 대신합니다. 총 시간도 안 늘어납니다
    Unity API 를 만져야 하는 일 조각내고 await Task.Yield() 메인에서 할 수밖에 없으니 프레임 예산 안으로 잘라 넣습니다
    파일, 네트워크, DB 대기 그냥 await 기다리는 데는 원래 스레드가 안 듭니다. Task.Run 으로 감싸면 손해입니다

    경로 탐색, 직렬화, 노이즈 생성, 압축처럼 순수 계산이면 첫째 줄입니다. 수천 개 오브젝트를 생성하거나 Transform 을 건드려야 하면 둘째 줄입니다.

    둘째 줄을 고를 때 조각 크기의 기준은 프레임 예산에서 남는 몫 입니다. 60fps 목표면 한 프레임이 16.6ms 인데 렌더와 물리와 스크립트가 이미 먹고 있습니다. 남는 것이 4ms 라면 조각도 4ms 여야 합니다. 조각 하나가 프레임 예산을 통째로 쓰면 쪼갠 의미가 없습니다.

    게임이 멈추는 자리

    실제 게임에서 프레임이 멎는 이유는 결국 하나로 모입니다. 메인 스레드가 한 프레임 안에 안 끝나는 일을 붙잡고 있는 것. 붙잡는 방식만 다릅니다.

    붙잡는 방식 자리 처방
    계산이 길다 맵 생성, 경로 탐색, 세이브 직렬화, 큰 JSON 파싱 Unity API 를 안 쓰면 Task.Run, 쓰면 조각내기
    한 프레임에 너무 많이 만든다 Instantiate 수백 개, 목록 UI 를 한 번에 채우기 프레임당 개수를 정해 나눠 만들기, 오브젝트 풀
    동기 로딩 Resources.Load, SceneManager.LoadScene, Addressables 의 WaitForCompletion 비동기 버전으로 바꾸고 await
    동기 입출력 File.ReadAllBytes, 동기 소켓 읽기 비동기 API 로 바꾸고 await
    블로킹 .Result, .Wait() 쓰지 않습니다. 기다리는 데 메인 스레드를 통째로 붙잡습니다
    할당이 몰린다 프레임마다 문자열 조립, 박싱, new 재사용합니다. 수거가 도는 동안 전부 멈춥니다

    앞의 다섯 줄은 내가 붙잡은 것이고 마지막 줄은 수거가 붙잡은 것입니다. 원인은 다른데 증상은 같습니다. 프레임 루프가 한 바퀴를 못 돕니다.

    여기서 async 가 고쳐 주는 것은 기다리는 줄뿐입니다. 로딩과 입출력입니다. 계산하는 줄은 async 를 붙여도 그대로입니다. Task.Run 이나 조각내기로만 풀립니다. 이 글에서 잰 A 와 B 가 그 이야기입니다.

    한 가지 갈라 둘 것이 있습니다. 에디터에서만 멈추는 것은 따로 봅니다. 첫 재생 때의 셰이더 컴파일이나 스크립트를 고친 뒤의 도메인 리로드는 빌드에 안 나타납니다. 빌드에서도 멈추는지 먼저 확인하고 원인을 찾는 편이 빠릅니다.

    직접 재보기

    하네스 핵심입니다. 콘솔 프로젝트에 그대로 붙여도 돕니다.

    // 메인 스레드 흉내: 단일 스레드 컨텍스트 + 프레임 루프
    class MainThreadContext : SynchronizationContext
    {
        readonly ConcurrentQueue<(SendOrPostCallback cb, object? st)> _q = new();
        public override void Post(SendOrPostCallback d, object? state) => _q.Enqueue((d, state));
        // 한 프레임에는 그 프레임이 시작될 때 쌓여 있던 것만 처리합니다.
        // 처리하다가 새로 들어온 것은 다음 프레임으로 넘어갑니다.
        public void Pump()
        {
            int budget = _q.Count, n = 0;
            while (n < budget && _q.TryDequeue(out var it)) { it.cb(it.st); n++; }
        }
    }
    
    static void Burn(int ms)   // Sleep 이 아니라 CPU 를 실제로 태웁니다
    {
        var s = Stopwatch.StartNew(); double x = 0;
        while (s.ElapsedMilliseconds < ms) for (int i = 0; i < 10000; i++) x += Math.Sqrt(i);
    }
    
    static void Measure(string name, Func<Task> job)
    {
        SynchronizationContext.SetSynchronizationContext(Ctx);
        var sw = Stopwatch.StartNew();
        int frames = 0; long worst = 0, last = 0;
    
        Task t = job();                        // 첫 await 까지는 여기서 동기로 돕니다
        while (sw.ElapsedMilliseconds < 2000)
        {
            Ctx.Pump();                        // 메인으로 돌아온 continuation 처리
            Thread.Sleep(16);                  // 렌더 흉내
            frames++;
            worst = Math.Max(worst, sw.ElapsedMilliseconds - last);
            last = sw.ElapsedMilliseconds;
        }
        Console.WriteLine($"{name} 프레임 {frames}개 최대끊김 {worst}ms");
    }

    Pump 의 budget 이 중요합니다. 이걸 빼고 큐가 빌 때까지 돌리면 Task.Yield() 가 다시 큐에 넣은 것까지 한 프레임 안에서 연달아 처리해버립니다. 그러면 쪼갠 코드도 안 쪼갠 것과 똑같이 멈추고, 측정이 통째로 틀립니다. Unity 의 PlayerLoop 도 프레임당 한 번 훑는 방식이라 budget 쪽이 실제에 가깝습니다.

    정리

    • async 를 붙여도 첫 await 전까지는 부른 스레드가 그냥 실행합니다. 그 구간은 동기 메서드와 같습니다
    • await 가 놓아주는 것은 기다리는 시간 입니다. CPU 연산에는 기다리는 시간이 없어서 놓을 것이 없습니다
    • Unity 는 await 뒤를 항상 메인 스레드로 되돌립니다. 그래서 그 뒤의 계산도 똑같이 프레임을 먹습니다
    • 그래서 await 의 위치 가 전부입니다. 무거운 계산 뒤에 붙은 await 는 아무 일도 하지 않습니다
    • Task.Yield() 는 스레드를 바꾸지 않고 다음 프레임으로 미룹니다. 조각이 프레임 예산보다 크면 쪼개도 멈춥니다
    • 쪼개면 총 시간이 늘어납니다. 1500ms 짜리 일이 100조각이면 3117ms 입니다
    • 계산을 진짜로 치우는 것은 Task.Run 하나뿐입니다. 다만 Unity API 는 거기서 못 만집니다
    • 게임이 멈추는 자리는 결국 하나입니다. 메인이 한 프레임 안에 안 끝나는 일을 붙잡고 있는 것입니다