반응형

[PART11.비동기와 동시성(2/12)] Task vs Thread — 무엇이 다른가

Thread는 OS가 스케줄링하는 실행 흐름이고, Task는 "언젠가 끝날 작업"을 표현하는 추상이다. 두 개념은 레벨이 다르기 때문에 비교가 아니라 선택의 문제다. 이 글은 Thread가 왜 비싼지, Task가 ThreadPool 위에서 어떻게 스레드를 재사용하는지, async/await 상태 기계가 IL에서 어떤 모습인지, 그리고 Unity 모바일에서 new Thread()를 쓰면 안 되는 이유를 정리한다.

1. 직접 스레드를 만들면 안 되는 이유

Unity에서 레벨 로딩 중 거대한 맵 데이터를 파싱해야 한다고 하자. 메인 스레드에서 돌리면 5초 동안 화면이 얼어붙는다. "그러면 스레드를 50개 만들어서 병렬로 처리하자"는 아이디어가 떠오른다. 코드 한 줄이면 된다.

C#
for (int i = 0; i < 50; i++)
{
    new Thread(() => ParseChunk(i)).Start();
}

이 코드는 저사양 안드로이드 단말에서 OOM(Out Of Memory, 메모리 부족)으로 죽거나, CPU를 100% 써도 작업이 10초씩 걸린다. 이유는 두 가지다.

  • new Thread()가상 메모리 1MB의 콜 스택을 잡는다. 50개면 50MB가 스레드 스택만으로 증발한다. 모바일 게임 프로세스에게 50MB는 치명적이다.
  • 모바일 CPU 코어는 기껏해야 6~8개다. 스레드 50개는 8개 코어에서 컨텍스트 스위치(Context Switch, OS가 CPU에게 현재 스레드를 중단시키고 다른 스레드를 실행시키는 전환 작업)만 시킨다. 스위치 한 번마다 CPU 레지스터를 저장·복원하고 캐시가 날아간다. 병렬성은 0이고 오버헤드만 쌓인다.

"그럼 스레드 개수를 코어 수에 맞춰야 하나? 코어 수는 어떻게 구하나? 스레드는 다 끝나고 어떻게 정리하지? 결과값은 어떻게 받지? 하나가 예외를 던지면?"

Task는 이 질문들에 대한 답을 미리 해놓은 도구다. Thread를 직접 쓰는 것은 매번 이 답을 다시 쓰는 것과 같다.


2. Thread와 Task — 추상화 레벨의 차이

Thread는 "일꾼"이고, Task는 "작업 지시서"다. 지시서는 일꾼 없이도 쓸 수 있고, 일꾼이 정해진 다음에도 다른 일꾼으로 교체될 수 있다.

추상화 레벨

두 API의 실제 호출 IL을 비교하면 "같은 일을 하는 다른 문법"이 아니라 완전히 다른 경로를 타는 코드라는 것이 드러난다. 아래는 new Thread(Work).Start()Task.Run(Work)의 IL 디컴파일 결과다.

C#
public class ThreadVsTask
{
    public void StartWithThread()
    {
        var t = new Thread(Work);
        t.Start();
    }

    public void StartWithTask()
    {
        Task.Run(Work);
    }

    private static void Work() { }
}
IL
.method instance void StartWithThread () cil managed
{
    .locals init ([0] class System.Threading.Thread)

    IL_000b: ldftn void ThreadVsTask::Work()
    IL_0011: newobj instance void System.Threading.ThreadStart::.ctor(object, native int)  // 델리게이트 생성
    IL_001c: newobj instance void System.Threading.Thread::.ctor(ThreadStart)               // ← OS 스레드용 객체 할당
    IL_0022: ldloc.0
    IL_0023: callvirt instance void System.Threading.Thread::Start()                         // ← OS 스레드 생성·시작
    IL_0029: ret
}

.method instance void StartWithTask () cil managed
{
    IL_000b: ldftn void ThreadVsTask::Work()
    IL_0011: newobj instance void System.Action::.ctor(object, native int)                  // 델리게이트 생성
    IL_001c: call class System.Threading.Tasks.Task System.Threading.Tasks.Task::Run(Action) // ← ThreadPool에 작업 큐잉
    IL_0022: ret
}

IL 해설

  1. StartWithThreadnewobj System.Threading.Thread::.ctorThread 객체를 힙에 할당하고, callvirt Thread::StartOS에 새 커널 스레드 생성을 요청한다. 즉 한 번의 호출이 "객체 생성 + 1MB 스택 예약 + 시스템 콜"을 전부 유발한다.
  2. StartWithTaskTask::Runstatic call로 호출할 뿐이다. 이 안에서 런타임은 이미 살아 있는 ThreadPool 워커 스레드의 큐에 작업을 밀어 넣는다. 스레드 생성 자체가 없다.
  3. 두 경로 모두 System.Action/ThreadStart 델리게이트 객체 하나를 할당하지만(newobj), 컴파일러가 static 필드(<>O)에 캐싱해서 두 번째 호출부터는 ldsfld로 재사용한다. IL의 brtrue.s IL_001c가 그 분기다.

요약하면 Thread는 "OS 리소스"를 직접 찍어내고, Task는 "이 작업을 누가 해주세요"라고 런타임에 의뢰한다. 추상화 레벨이 다르므로 두 API는 교체 관계가 아니라 위아래 관계다.


3. ThreadPool — 스레드를 재사용하는 구조

Task가 가볍다는 말의 본질은 "Task 객체가 싸다"가 아니라 그 뒤에 있는 ThreadPool이 스레드를 재활용한다는 사실이다. ThreadPool은 CLR이 프로세스 단위로 관리하는 워커 스레드 풀이다.

ThreadPool 내부 구조

ThreadPool의 핵심 설계는 세 가지다.

  • 전역 큐 1개 + 워커별 로컬 큐 N개. 외부에서 Task.Run을 부르면 전역 큐로 들어가고, 워커 스레드 안에서 파생된 작업(continuation, Task.WhenAll 하위 작업 등)은 자신의 로컬 큐에 쌓인다. 로컬 큐는 LIFO라서 최근 작업이 CPU 캐시 위에 그대로 있어 지역성(locality)이 좋다.
  • Work-stealing. 자기 로컬 큐가 비면, 워커는 다른 워커의 로컬 큐 꼬리에서 작업을 훔쳐 온다. 락 경합을 줄이면서 부하 불균형을 해소한다.
  • Hill-climbing 기반 크기 조절. 처리량이 좋아지면 스레드를 더 만들고, 나빠지면 줄인다. ThreadPool.SetMinThreads로 하한을 둘 수 있지만 Unity에선 손대지 않는 게 정답이다.

결과적으로 Task 100만 개를 만들어도 살아 있는 OS 스레드는 코어 수에 가깝게 유지된다. 각 Task는 "컨티뉴에이션을 담은 작은 상태 객체"일 뿐이라서 수십 바이트 수준이다. Thread 100만 개를 만들려고 하면 스택만으로 1TB가 필요하고, 당연히 OS가 거부한다.

ThreadPool — CLR 스레드 풀 런타임이 프로세스 시작과 함께 소수의 워커 스레드를 만들어 두고, Task.Run이나 QueueUserWorkItem으로 들어온 작업을 번갈아 처리한다. 새 스레드를 만드는 비용(스택 할당 + 커널 호출)을 최초 한 번만 치르는 셈이다.
예시: ThreadPool.QueueUserWorkItem(_ => DoWork()); Task의 내부도 결국 이 큐 위에서 돈다.

4. Task.Run과 스케줄러 — Task는 어떤 스레드에서 실행되는가

Task.Run(action)은 실질적으로 "이 델리게이트를 기본 TaskScheduler에 예약" 이라는 뜻이다. 기본 TaskScheduler는 TaskScheduler.Default이고, 이건 ThreadPoolTaskScheduler다. 그래서 Task.Run의 코드는 워커 스레드에서 실행된다.

그런데 UI 프레임워크에선 다른 스케줄러가 등장한다. WinForms·WPF·Unity 메인 스레드에는 SynchronizationContext가 설치되어 있고, 이걸 감싼 SynchronizationContextTaskScheduler가 "현재 스레드의 스케줄러"가 된다. 이 때문에 TaskScheduler.FromCurrentSynchronizationContext()로 UI 스레드에 작업을 되돌려 보낼 수 있다.

C#
// Before — 결과를 UI에 직접 바르려다 크래시
public void OnClickBad()
{
    Task.Run(() =>
    {
        var result = HeavyCompute();
        statusText.text = result;  // ❌ 백그라운드 스레드에서 Unity API 호출 → 예외
    });
}

// After — await로 자연스럽게 원래 스레드 복귀
public async void OnClickGood()
{
    string result = await Task.Run(() => HeavyCompute());  // 워커 스레드에서 계산
    statusText.text = result;  // ✅ 다시 메인 스레드
}

await가 "무엇을 기다리는지"는 5절에서 다룬다. 여기서 기억할 사실은 하나다.

  • Task.Run은 항상 ThreadPool 워커 스레드에서 실행된다. (Unity도 예외 아님)
  • await 뒤의 코드는 원래 캡처된 SynchronizationContext(Unity의 경우 메인 스레드)로 돌아간다 — 단, ConfigureAwait(false)가 없을 때만.

Unity의 UnitySynchronizationContext는 매 프레임 큐잉된 continuation들을 메인 스레드에서 실행한다. 덕분에 await Task.Run(...) 뒤에서 transform.position 같은 Unity API를 안전하게 쓸 수 있다.


5. async/await — Task와 분리해서 보는 상태 기계

"Task = 비동기" 같은 등식은 혼란을 부른다. Task는 결과 담는 객체일 뿐이고, 실제 비동기 실행은 async/await가 컴파일러 수준에서 만들어 내는 상태 기계가 담당한다. 상태 기계는 Task로 결과를 감싸서 외부에 노출한다.

async / await — 비동기 메서드 구성 키워드 async 메서드는 컴파일러가 내부적으로 상태 기계(state machine) 구조체/클래스로 변환한다. await 지점마다 실행을 일시 중단하고, 기다리는 Task가 끝나면 다음 상태로 이어서 실행한다. 호출자는 Task 하나만 받고 중단 여부는 모른다.
예시: int n = await FetchAsync(); FetchAsync가 아직 안 끝났으면 호출한 스레드를 점유하지 않고 반환한다.
C#
public class AsyncDemo
{
    public async Task<int> FetchAsync()
    {
        int local = 10;
        await Task.Delay(100);
        return local + 1;
    }
}
IL
// 컴파일러가 nested class로 생성한 상태 기계
.class nested sealed '<FetchAsync>d__0'
    implements System.Runtime.CompilerServices.IAsyncStateMachine
{
    .field public int32 '<>1__state'                                       // 현재 상태 번호 (-1: 초기, 0: Delay 대기 중, -2: 완료)
    .field public AsyncTaskMethodBuilder`1<int32> '<>t__builder'           // Task 생성·완료를 담당
    .field public AsyncDemo '<>4__this'                                    // 원래 인스턴스 참조
    .field private int32 '<local>5__1'                                     // 지역변수 local이 필드로 승격
    .field private TaskAwaiter '<>u__1'                                    // await 중인 awaiter
}

.method virtual instance void MoveNext () cil managed
{
    IL_000e: nop
    IL_000f: ldarg.0
    IL_0010: ldc.i4.s 10
    IL_0012: stfld int32 '<local>5__1'                                     // local = 10을 필드에 저장
    IL_0017: ldc.i4.s 100
    IL_0019: call System.Threading.Tasks.Task::Delay(int32)                // Task.Delay(100)
    IL_001e: callvirt valuetype TaskAwaiter Task::GetAwaiter()
    IL_0023: stloc.2
    IL_0026: call instance bool TaskAwaiter::get_IsCompleted()             // ← 이미 끝났으면 동기 경로
    IL_002b: brtrue.s IL_006d
    IL_002d: ldarg.0
    IL_002e: ldc.i4.0
    IL_0031: stfld int32 '<>1__state'                                      // 상태를 0으로 바꾸고
    IL_0049: call AsyncTaskMethodBuilder`1::AwaitUnsafeOnCompleted(...)    // ← awaiter 완료 시 MoveNext 재호출 예약
    IL_004f: leave.s IL_00af                                               // 이 MoveNext는 일단 종료
    // ... 다음 번 MoveNext는 상태 0에서 재진입해 IL_0051로 점프
}

IL 해설

  1. async Task<int> 메서드 하나가 <FetchAsync>d__0이라는 nested class 전체로 팽창한다. 지역변수 local<local>5__1 필드로, 현재 위치는 <>1__state로, 대기 중 awaiter는 <>u__1로 각각 필드화된다.
  2. 핵심은 AwaitUnsafeOnCompleted다. 이 호출이 "awaiter가 끝나면 이 상태 기계의 MoveNext를 다시 불러라"고 등록한 뒤, 현재 스레드는 leave로 빠져나간다. 스레드는 차단되지 않는다. ThreadPool 워커라면 바로 다른 Task를 집어 든다.
  3. 상태 기계가 담긴 Task는 AsyncTaskMethodBuilder<int>가 만들어 외부로 돌려준다. return local + 1이 실행되면 이 빌더가 Task를 RanToCompletion 상태로 전환한다.

async 메서드에서 await실제로 하는 일은 "Task가 끝날 때 MoveNext를 또 호출하도록 콜백을 거는 것"이다. 이 콜백이 도는 스레드는 SynchronizationContext가 결정한다 — 없으면 ThreadPool, 있으면 그 컨텍스트(Unity면 메인 스레드).

이 구조 때문에 얻는 결론:

  • async는 새 스레드를 만들지 않는다. CPU 바운드 작업을 백그라운드로 보내려면 Task.Run이 필요하다.
  • await는 스레드를 차단하지 않는다. 차단되는 건 "동기 경로"(예: .Result, .Wait())뿐이다.
  • Task 하나는 참조 타입(class) 인스턴스다. 초당 수만 번 async 메서드를 호출하면 힙 할당이 누적된다. 이 경우 ValueTask(같은 PART 7편)로 최적화한다.

6. 실전 적용 — Unity 백그라운드 오프로드 패턴

Unity 모바일에서 Task가 빛나는 전형적 시나리오는 "CPU 바운드 작업은 백그라운드, 결과 적용은 메인 스레드" 다. 길찾기, 맵 생성, JSON 파싱, 이미지 후처리 등이 모두 이 패턴을 따른다.

Before — Thread 직접 사용

C#
using UnityEngine;
using System.Threading;

public class PathfinderBad : MonoBehaviour
{
    public void Find(Vector3 from, Vector3 to)
    {
        Thread t = new Thread(() =>
        {
            var path = HeavyPathfinding.Search(from, to);
            // ❌ 1. Unity API를 백그라운드에서 호출 → 예외
            transform.position = path[0];
            // ❌ 2. 이 Thread는 재사용 안 됨. 다음 호출 때 또 새로 만든다.
            // ❌ 3. 예외 발생 시 아무도 받지 못하고 프로세스가 죽을 수 있다.
        });
        t.Start();
    }
}

세 가지가 동시에 망가진다. 스레드 생성 비용, Unity API 제약 위반, 예외 불안전성.

After — Task + async/await

C#
using UnityEngine;
using System.Threading.Tasks;

public class PathfinderGood : MonoBehaviour
{
    public async void Find(Vector3 from, Vector3 to)
    {
        try
        {
            // 메인 스레드 → 백그라운드로 오프로드
            Vector3[] path = await Task.Run(() =>
                HeavyPathfinding.Search(from, to));

            // ✅ await 뒤는 다시 Unity 메인 스레드
            transform.position = path[0];
        }
        catch (System.Exception ex)
        {
            // ✅ async/await는 catch로 예외를 잡을 수 있다
            Debug.LogError($"길찾기 실패: {ex.Message}");
        }
    }
}

Task.Run 안에서는 Unity API를 절대 만지지 않는다. 필요한 입력을 람다의 으로 가져가고, 결과만 배열·구조체로 돌려준다.

컴파일러가 람다 캡처를 어떻게 처리하는지 IL로 확인한다.

C#
public class CaptureDemo
{
    public Task RunCaptured(int enemyId)
    {
        return Task.Run(() => ComputePath(enemyId));
    }

    private static int ComputePath(int id) => id * 2;
}
IL
// 컴파일러가 생성한 클로저 클래스
.class nested sealed '<>c__DisplayClass0_0'
    extends System.Object
{
    .field public int32 enemyId                                  // ← 캡처된 변수가 필드로

    .method instance int32 '<RunCaptured>b__0' ()                // 람다 본체
    {
        IL_0000: ldarg.0
        IL_0001: ldfld int32 '<>c__DisplayClass0_0'::enemyId     // 필드에서 enemyId 로드
        IL_0006: call int32 CaptureDemo::ComputePath(int32)
        IL_000b: ret
    }
}

.method instance Task RunCaptured (int32 enemyId)
{
    IL_0000: newobj instance void '<>c__DisplayClass0_0'::.ctor()  // ← 매 호출마다 힙 할당!
    IL_0005: stloc.0
    IL_0006: ldloc.0
    IL_0007: ldarg.1
    IL_0008: stfld int32 '<>c__DisplayClass0_0'::enemyId           // 인자를 필드에 복사
    IL_000f: ldftn instance int32 '<>c__DisplayClass0_0'::'<RunCaptured>b__0'()
    IL_0015: newobj instance void Func`1<int32>::.ctor(object, native int)  // 델리게이트 할당
    IL_001a: call Task`1<int> Task::Run<int32>(Func`1<int32>)
    IL_0023: ret
}

IL 해설

  1. Task.Run(() => ComputePath(enemyId)) 한 줄이 두 개의 힙 할당을 만든다. 하나는 캡처된 enemyId를 담는 <>c__DisplayClass0_0 클로저 객체(newobj), 다른 하나는 그 위의 람다를 가리키는 Func<int> 델리게이트다.
  2. 게임 루프 안에서 이 메서드를 프레임마다 부르면 프레임마다 힙 2개씩 늘어난다. Unity의 Boehm GC(또는 Incremental GC)가 이걸 회수할 때 프레임이 튄다.
  3. 반면 캡처가 없는 람다(Task.Run(() => DoStatic()))는 2절 첫 IL처럼 컴파일러가 static 필드에 델리게이트를 캐싱해서 할당이 최초 1회로 줄어든다.

Unity 핫패스 원칙: 매 프레임 Task.Run(() => ...)을 호출하지 않는다. 캐싱 가능한 람다를 필드에 넣어 재사용하거나, 작업 자체를 큐잉 방식으로 바꾼다.


7. 함정과 주의사항

Task.Result·.Wait()로 동기 블로킹

C#
// ❌ 잘못된 예 — Unity 메인 스레드에서 데드락 위험
public void LoadScene()
{
    string data = FetchDataAsync().Result;  // 메인 스레드 블로킹
    ApplyScene(data);
}

// ✅ 올바른 예
public async void LoadScene()
{
    string data = await FetchDataAsync();
    ApplyScene(data);
}

.Result는 Task가 끝날 때까지 호출 스레드를 블로킹한다. Unity 메인 스레드에서 쓰면 await로 돌아와야 할 continuation이 영영 실행되지 못해 데드락이 난다. 같은 시리즈의 "async deadlock — 왜 일어나고 어떻게 피하는가"에서 상세히 다룬다.

async void 남용

C#
// ❌ 예외 시 프로세스가 죽는다 — 캐처 불가
async void OnClick() { await DoAsync(); throw new Exception(); }

// ✅ 이벤트 핸들러가 꼭 필요할 때가 아니면 Task 반환
async Task OnClickAsync() { await DoAsync(); }

async void 메서드에서 던져진 예외는 호출자 Task에 담기지 않고 SynchronizationContext로 바로 전파된다. Unity에서는 로그는 찍히지만 스택 트레이스가 잘리고 호출한 코드가 catch할 수 없다. UI 이벤트 핸들러 외에는 쓰지 않는다.

❌ ThreadPool에서 장시간 CPU 점유

C#
// ❌ 예: 30초짜리 이미지 처리
Task.Run(() => ProcessHugeImage());
// ThreadPool 워커 하나가 30초간 묶인다 → 다른 Task의 지연 증가

// ✅ LongRunning 힌트로 전용 스레드 생성
Task.Factory.StartNew(
    ProcessHugeImage,
    CancellationToken.None,
    TaskCreationOptions.LongRunning,   // ← ThreadPool 밖에 새 스레드 생성
    TaskScheduler.Default);

ThreadPool은 짧은 작업을 전제로 설계됐다. 수십 초 이상 CPU를 쥐는 작업이 워커를 차지하면 다른 Task의 대기시간이 늘고, 힐클라이머가 스레드를 추가로 만들어 오히려 오버헤드가 커진다. TaskCreationOptions.LongRunning이나 아예 new Thread() + 전용 스레드가 이런 상황에선 정답이다. Unity에서도 이 경우는 예외적으로 Thread가 유효하다.

❌ 백그라운드에서 Unity API 접근

C#
// ❌ 예외 "can only be called from the main thread"
await Task.Run(() => Debug.Log(transform.position));

// ✅ 값을 캡처하거나 복귀 후 접근
Vector3 pos = transform.position;              // 메인 스레드에서 미리 복사
await Task.Run(() => HeavyCompute(pos));

Unity 엔진 대부분의 API는 메인 스레드 전용이다. 백그라운드에서 Transform, GameObject, MonoBehaviour 멤버에 접근하면 런타임이 예외를 던진다. 허용되는 것은 Mathf, Vector3, Quaternion 같은 순수 값 연산뿐이다.

❌ Unity IL2CPP 환경에서 Thread 우선순위 만지기

Thread.Priority는 플랫폼마다 동작이 다르고 IL2CPP·iOS에서는 사실상 무시된다. "게임 스레드를 우선시한다"는 기대는 검증 안 된 채로 쓰지 않는다. 필요하면 ThreadPool.SetMaxThreads나 작업 단위에서 Task.Yield로 양보한다.


8. C# 버전별 변화

C# 1.0 (.NET 1.0, 2002) — Thread만 존재

C#
// 결과 전달은 필드·플래그로 직접 해야 했다
string result = null;
var done = new ManualResetEvent(false);

var t = new Thread(() =>
{
    result = Download();
    done.Set();
});
t.Start();
done.WaitOne();                // 블로킹으로 결과 대기
Console.WriteLine(result);

비동기 호출을 위한 공식 패턴은 APM(Asynchronous Programming Model)이었는데, BeginXxx/EndXxx 쌍과 IAsyncResult로 이어지는 콜백 지옥이었다.

C# 4.0 (.NET 4.0, 2010) — TPL과 Task 등장

C#
// Task<T>로 결과를 들고 다닐 수 있게 됨
Task<string> t = Task.Factory.StartNew(() => Download());
t.ContinueWith(prev => Console.WriteLine(prev.Result));

TPL(Task Parallel Library)과 함께 Task가 표준이 되고, ThreadPool 내부는 work-stealing·로컬 큐로 재설계됐다. 하지만 ContinueWith 콜백 체인은 여전히 읽기 어려웠다.

C# 5.0 (.NET 4.5, 2012) — async/await

C#
// 컴파일러가 상태 기계를 만들어 준다
public async Task<string> DownloadAsync()
{
    string html = await httpClient.GetStringAsync(url);
    return html.Substring(0, 100);
}

상태 기계 변환이 언어 레벨에서 들어와 "동기 코드처럼 보이는 비동기 코드"가 가능해졌다. 5절의 IL이 이때부터의 결과물이다.

C# 7.0 / .NET Core 2.0 (2017) — ValueTask

C#
public ValueTask<int> GetCachedAsync()
{
    if (_cache != null) return new ValueTask<int>(_cache.Value);  // 힙 할당 없음
    return new ValueTask<int>(LoadFromDiskAsync());               // 필요할 때만 Task
}

Task는 class라서 매번 힙 할당된다. 동기적으로 즉시 완료되는 경우가 많은 API에서는 ValueTask<T>(struct)로 할당을 없앨 수 있게 됐다. 자세한 내용은 같은 PART 7편 참고.

.NET 6 / C# 10 (2021) — Task.WaitAsync, WhenEach

C#
// 타임아웃을 Task 자체에 걸 수 있음
string html = await httpClient.GetStringAsync(url).WaitAsync(TimeSpan.FromSeconds(3));

Task API가 "나머지 Task 정리"·"타임아웃"·"비동기 열거"를 표준으로 흡수하며 실전 사용성이 완성됐다. Thread API는 사실상 이 시점 이후 거의 건드리지 않는 레거시가 됐다.


9. 정리

  • Thread는 OS 스레드 래퍼, Task는 "끝날 작업"의 추상이다. 비교가 아니라 레벨이 다르다.
  • new Thread()는 스택 1MB + 커널 스레드 생성을 유발한다. 모바일에서는 거의 항상 잘못된 선택이다.
  • Task는 ThreadPool 위에서 돈다. ThreadPool은 work-stealing·로컬 큐·힐클라이머로 스레드를 재사용한다.
  • Task.Run은 워커 스레드에서 실행되고, await 뒤는 SynchronizationContext(Unity면 메인 스레드)로 복귀한다.
  • async 메서드는 상태 기계로 변환된다. IL의 MoveNext + AwaitUnsafeOnCompleted 패턴이 그 증거다.
  • Unity 모바일 실전 패턴: 무거운 CPU 연산은 await Task.Run(...), UI는 await 복귀 후 건드린다. 핫패스에서 람다 캡처·Task.Run 반복 호출은 GC 스파이크를 만든다.
  • 예외적으로 Thread가 유효한 경우: 수십 초 이상의 CPU 점유 작업. 이때도 TaskCreationOptions.LongRunning을 먼저 고려한다.
반응형

+ Recent posts