[PART13.비동기와 스레딩 기초(4/15)] ThreadPool — 스레드를 재사용하는 공용 풀
매번 새 스레드를 만들지 않고 미리 만든 작업 큐를 돌려쓴다 / Task.Run이 내부적으로 쓰는 풀 / 개수와 스케줄링은 런타임이 관리
목차
1. [문제 제기] — 100개의 적을 동시에 처리하려면
Unity 모바일 게임에서 적이 100명 등장하는 전투 씬을 만든다고 해봅시다. 각 적의 AI 판단(시야 검사, 경로 계산, 다음 행동 결정)은 메인 스레드를 잠깐만 점유해도 프레임이 떨어집니다. "그러면 적마다 스레드를 하나씩 띄워서 비동기로 처리하자"라고 생각하는 순간 게임이 망가집니다.
// 위험한 발상: 적마다 스레드 생성
foreach (var enemy in enemies)
{
var thread = new Thread(() => enemy.Think());
thread.Start();
}
이 코드의 문제는 두 가지입니다.
첫째, 스레드 생성은 비싼 작업입니다. 운영체제(OS, Operating System)에 새 커널 스레드를 요청하고, 1MB 안팎의 스택 메모리를 할당받고, 스케줄러 자료구조에 등록되어야 합니다. 한 번 만드는 데 수 밀리초가 걸리고, 다 쓰면 다시 파괴해야 합니다. 60FPS 게임에서 한 프레임은 16.6ms뿐인데, 그 안에 100개의 스레드를 만들고 파괴하면 프레임이 통째로 날아갑니다.
둘째, 스레드는 OS가 직접 관리하는 자원입니다. CPU 코어가 8개인 모바일 기기에 100개의 스레드가 동시에 실행 가능 상태가 되면, OS는 매우 짧은 간격으로 스레드를 갈아 끼우는 컨텍스트 스위칭(Context Switching, 실행 중인 스레드를 멈추고 다른 스레드로 CPU를 넘기는 작업)을 반복합니다. 정작 일은 안 하고 갈아 끼우는 비용만 지출합니다.
자주 쓰고 자주 버리는 자원은 풀(Pool)에 모아두고 재사용한다.
이 원칙을 스레드에 적용한 것이 ThreadPool입니다. 미리 적당한 수의 스레드를 만들어두고, 작업이 들어오면 빈 스레드에 할당했다가 끝나면 다시 풀로 돌려보냅니다. 우리가 평소에 쓰는 Task.Run도 사실 이 ThreadPool 위에서 돌아갑니다.
이 글에서는 ThreadPool이 어떻게 스레드를 재사용하는지, 워커 스레드와 IOCP 스레드는 왜 따로 있는지, Task.Run과의 관계는 무엇인지, 그리고 Unity 모바일에서 ThreadPool을 쓸 때 어떤 함정이 있는지 살펴봅니다.
2. [개념 정의] — 식당 주방의 요리사 풀
2.1. 비유: 미리 채용해둔 요리사들
ThreadPool은 24시간 운영하는 식당의 주방과 비슷합니다.
- 손님(작업 요청)이 올 때마다 그 자리에서 요리사(스레드)를 채용·해고하면 비효율적입니다.
- 대신 처음부터 적당한 수의 요리사를 고용해두고, 주문이 들어오면 빈 요리사에게 맡깁니다.
- 요리가 끝난 요리사는 휴식하지 않고 바로 다음 주문을 받습니다.
- 갑자기 주문이 폭주하면 매니저(런타임)가 요리사를 천천히 더 채용합니다.
- 한가하면 일부 요리사를 잠시 내보냅니다.
C#의 ThreadPool은 이 매니저 역할을 .NET 런타임이 자동으로 합니다. 개발자는 "이 일감을 풀에 넣어달라"라고 큐(Queue, 순서대로 줄 서는 자료구조)에 작업을 던지기만 하면 됩니다.
2.2. 구조 시각화

2.3. 가장 단순한 사용법 — QueueUserWorkItem
=>— 람다 식 (Lambda Expression) 익명 함수를 짧게 표현하는 문법.(파라미터) => 본문형태로 쓰며, 컴파일러가 내부적으로 메서드와 델리게이트(delegate, 메서드를 가리키는 참조 타입)를 자동으로 만든다.
예시:state => Console.WriteLine(state)state하나를 받아 출력하는 익명 메서드
using System;
using System.Threading;
class Program
{
static void Main()
{
ThreadPool.QueueUserWorkItem(state =>
{
int n = (int)state;
Console.WriteLine($"Worker: {n}");
}, 42);
}
}
QueueUserWorkItem은 ThreadPool에 작업 하나를 넣고 즉시 반환합니다. 두 번째 인자(42)는 작업에 전달할 상태값(state)입니다. 풀 안의 어느 스레드가 잡아서 실행할지는 런타임이 결정합니다.
이 코드의 IL을 보면 ThreadPool이 어떻게 작업을 받는지 명확해집니다.
.method private hidebysig static
void Main () cil managed
{
.entrypoint
// 람다를 캐시한 정적 필드를 먼저 확인 (재사용)
IL_0000: ldsfld class [System.Threading.ThreadPool]System.Threading.WaitCallback Program/'<>c'::'<>9__0_0'
IL_0005: dup
IL_0006: brtrue.s IL_001f
// 처음 호출이면 컴파일러 생성 클래스의 인스턴스 메서드를 WaitCallback으로 감싸 캐시
IL_0008: pop
IL_0009: ldsfld class Program/'<>c' Program/'<>c'::'<>9'
IL_000e: ldftn instance void Program/'<>c'::'<Main>b__0_0'(object)
IL_0014: newobj instance void [System.Threading.ThreadPool]System.Threading.WaitCallback::.ctor(object, native int)
IL_0019: dup
IL_001a: stsfld class [System.Threading.ThreadPool]System.Threading.WaitCallback Program/'<>c'::'<>9__0_0'
// 두 번째 인자 42를 int → object로 박싱 (heap 할당 발생)
IL_001f: ldc.i4.s 42
IL_0021: box [System.Runtime]System.Int32
// ThreadPool에 작업 등록
IL_0026: call bool [System.Threading.ThreadPool]System.Threading.ThreadPool::QueueUserWorkItem(class [System.Threading.ThreadPool]System.Threading.WaitCallback, object)
IL_002b: pop
IL_002c: ret
}
IL에서 두 가지를 보세요.
첫째, IL_0021: box [System.Runtime]System.Int32 — 정수 42를 object로 변환하면서 힙(Heap, 동적 메모리 영역)에 박싱(Boxing) 객체가 만들어집니다. WaitCallback 시그니처가 void(object)이기 때문입니다. 이 한 줄이 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 압박을 만드는 원인이 됩니다.
둘째, IL_001f까지의 람다 캐싱 — 클로저(closure, 외부 변수를 캡처하지 않는 람다)는 정적 필드 <>9__0_0에 한 번만 만들어두고 재사용합니다. 같은 자리에서 람다를 반복 호출해도 델리게이트 객체는 새로 생기지 않습니다.
박싱을 피하려면 .NET 4.5에서 추가된 제네릭 오버로드를 씁니다.
ThreadPool.QueueUserWorkItem(static (int n) =>
{
Console.WriteLine($"Worker: {n}");
}, 42, preferLocal: false);
IL_001f: ldc.i4.s 42
IL_0021: ldc.i4.0
IL_0022: call bool [System.Threading.ThreadPool]System.Threading.ThreadPool::QueueUserWorkItem<int32>(class [System.Runtime]System.Action`1<!!0>, !!0, bool)
box 명령이 사라졌습니다. QueueUserWorkItem<TState>는 TState가 그대로 전달되므로 값 타입이 박싱되지 않습니다. Unity 핫패스(hot path, 매 프레임마다 자주 호출되는 코드 경로)에서 ThreadPool을 쓸 일이 있다면 반드시 제네릭 오버로드를 선택해야 합니다.
2.4. 고수준 입구 — Task.Run
QueueUserWorkItem은 가장 원시적인 입구이고, 우리가 실제로 가장 많이 쓰는 것은 Task.Run입니다.
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
Task.Run(() => Console.WriteLine("Hello"));
}
}
.method private hidebysig static
void Main () cil managed
{
.entrypoint
// 람다 캐시 확인
IL_0000: ldsfld class [System.Runtime]System.Action Program/'<>c'::'<>9__0_0'
IL_0005: dup
IL_0006: brtrue.s IL_001f
IL_0008: pop
IL_0009: ldsfld class Program/'<>c' Program/'<>c'::'<>9'
IL_000e: ldftn instance void Program/'<>c'::'<Main>b__0_0'()
IL_0014: newobj instance void [System.Runtime]System.Action::.ctor(object, native int)
IL_0019: dup
IL_001a: stsfld class [System.Runtime]System.Action Program/'<>c'::'<>9__0_0'
// Task.Run 호출 — 내부에서 ThreadPool에 작업을 큐잉
IL_001f: call class [System.Runtime]System.Threading.Tasks.Task [System.Runtime]System.Threading.Tasks.Task::Run(class [System.Runtime]System.Action)
IL_0024: pop
IL_0025: ret
}
겉보기에는 Task::Run 호출 한 줄이지만, Run 내부는 Task 객체를 만들고 그 Task를 ThreadPool 큐에 넣는 일을 합니다. Task.Run(() => ...)의 결과로 받는 Task 인스턴스는 그 작업의 진행 상태(완료/실패/예외)를 추적할 수 있고, await도 가능합니다.
핵심은 이것입니다.
Task.Run이 새 스레드를 만드는 것이 아닙니다. 이미 풀에서 돌고 있는 워커 스레드 중 하나에 작업을 던지는 것입니다.
스레드 자체는 OS가 만든 것이고, 풀이 그 스레드를 빌려와 굴리고 있을 뿐입니다.
3. [내부 동작] — 큐, 스레드, 휴리스틱
3.1. 워커 스레드 vs IOCP 스레드 — 두 종류의 스레드
ThreadPool 안에는 두 종류의 스레드가 있습니다. 이 구분이 ThreadPool 설계의 핵심입니다.

이 둘이 분리되어 있는 이유는 단순합니다. 데드락(Deadlock, 교착 상태) 회피입니다.
만약 ThreadPool에 한 종류의 스레드만 있다고 가정해봅시다. 모든 워커 스레드가 네트워크 응답을 기다리며 블로킹되어 있다면, 정작 응답이 도착했을 때 그 응답을 처리할 콜백을 실행할 스레드가 없습니다. I/O 완료 신호를 처리하는 풀을 별도로 둠으로써 이 문제를 회피합니다.
ThreadPool.GetAvailableThreads(out int worker, out int io)로 두 풀의 현재 가용 스레드 수를 확인할 수 있습니다.
3.2. 글로벌 큐와 로컬 큐 — 락 경합을 줄이는 큐 분리
.NET 4.0의 TPL(Task Parallel Library, 작업 병렬 라이브러리) 도입과 함께 ThreadPool 큐는 두 계층 구조로 바뀌었습니다.
- 글로벌 큐: 메인 스레드나 UI 스레드처럼 ThreadPool 외부에서 들어온 작업이 처음 들어가는 곳. 모든 워커가 접근하므로 락(Lock, 공유 자원에 한 번에 한 스레드만 접근하게 만드는 동기화 장치)이 필요합니다.
- 로컬 큐: 각 워커 스레드는 자기만의 큐를 갖습니다. 워커가 실행 중인 작업 안에서 또 다른
Task.Run을 호출하면, 그 자식 작업은 글로벌 큐가 아니라 자기 로컬 큐로 들어갑니다. 자기 큐는 자기만 건드리므로 락이 필요 없습니다.

워커 스레드의 작업 선택 우선순위는 다음과 같습니다.
- 자기 로컬 큐 (LIFO, Last-In-First-Out) — 가장 최근에 넣은 작업을 꺼냅니다. 방금 만든 데이터가 CPU 캐시에 남아있을 확률이 높아 효율적입니다.
- 다른 워커의 로컬 큐 (FIFO, First-In-First-Out으로 훔치기) — 자기 큐가 비면 다른 워커의 큐에서 가장 오래된 작업을 가져옵니다. 이를 워크 스틸링(Work Stealing)이라고 합니다. 큐의 양 끝에서 접근하므로 락 경합이 줄어듭니다.
- 글로벌 큐 — 모든 로컬 큐가 비어있을 때만 확인합니다.
이 우선순위 덕분에 작업이 만들어진 곳 근처에서 처리되어 캐시 적중률이 높아지고, 한 워커가 일이 몰리면 다른 워커들이 자연스럽게 분담합니다.
3.3. Hill Climbing — 스레드 개수 자동 튜닝
ThreadPool은 정해진 개수의 스레드를 그대로 쓰는 게 아니라 Hill Climbing(언덕 오르기) 휴리스틱으로 동적 조절합니다.
알고리즘은 단순합니다.
- 현재 스레드 개수에서 처리량(throughput, 단위 시간당 완료된 작업 수)을 측정합니다.
- 스레드를 하나 추가하고 다시 처리량을 측정합니다.
- 처리량이 늘었으면 변경을 유지하고 또 하나 추가합니다.
- 처리량이 줄었으면 원래대로 되돌립니다.
이름 그대로 처리량이라는 "언덕"을 한 발씩 올라가다가, 더 올라갈 곳이 없으면 멈춥니다. 이것이 .NET이 "이 부하에 맞는 최적 스레드 수"를 동적으로 찾아내는 방식입니다.
using System;
using System.Threading;
class Program
{
static void Main()
{
// 현재 풀의 한도 확인
ThreadPool.GetMinThreads(out int minWorker, out int minIo);
ThreadPool.GetMaxThreads(out int maxWorker, out int maxIo);
Console.WriteLine($"Min Worker / IO : {minWorker} / {minIo}");
Console.WriteLine($"Max Worker / IO : {maxWorker} / {maxIo}");
// 가용 스레드 (현재 비어 있는 수)
ThreadPool.GetAvailableThreads(out int avWorker, out int avIo);
Console.WriteLine($"Avail Worker / IO : {avWorker} / {avIo}");
}
}
MinThreads는 보통 CPU 논리 코어 수와 같습니다. 처음에는 이 수까지 즉시 채워지고, 그 이상은 초당 약 1~2개 속도로 천천히 늘어납니다. 이 느린 증가 속도가 다음 절에서 다룰 ThreadPool 기아 문제의 근본 원인입니다.
SetMinThreads를 함부로 만지지 마세요. 매뉴얼은 일반 사용에서SetMinThreads/SetMaxThreads를 호출하지 말 것을 권합니다. 잘못 늘리면 컨텍스트 스위칭 비용이 폭증하고, 잘못 줄이면 데드락이 발생할 수 있습니다.
3.4. .NET 6+의 PortableThreadPool
.NET Framework와 .NET Core 초기에는 ThreadPool 구현이 OS마다 달랐습니다. Windows는 OS의 IOCP를, Linux/macOS는 따로 만든 mono 기반 구현을 사용했습니다.
.NET 6부터 기본값이 된 PortableThreadPool은 ThreadPool의 핵심 로직을 100% C# 관리 코드(managed code, .NET 런타임 위에서 실행되는 코드)로 재작성한 것입니다.
- 모든 OS에서 동일한 큐·스케줄링·Hill Climbing이 동작합니다.
- 동작과 성능이 OS 간에 일관됩니다.
- 워커 스레드는 여전히 OS 스레드지만, "어떤 작업을 어느 스레드에 줄지"의 결정은 관리 코드에서 합니다.
Unity는 IL2CPP(Intermediate Language to C++, IL을 C++로 변환해 빌드하는 Unity 백엔드)와 Mono 런타임을 쓰므로 PortableThreadPool과는 다른 구현이 들어가 있지만, 큰 그림(글로벌 큐 + 로컬 큐 + 워크 스틸링)은 동일합니다.
4. [실전 적용] — Unity에서 ThreadPool 활용하기
4.1. 기본 원칙: 메인 스레드 보호 + Unity API 금지
Unity에서 ThreadPool을 쓸 때 절대 잊지 말아야 할 두 가지 규칙이 있습니다.
- 메인 스레드는 렌더링용. CPU 무거운 계산은 풀로 던진다.
- 워커 스레드에서 Unity API 호출 금지.
Transform,GameObject,MonoBehaviour같은UnityEngine객체는 메인 스레드에서만 만질 수 있다.
4.2. Before/After — 무거운 경로 계산을 풀로 옮기기
Before — 메인 스레드에서 모든 경로 계산
Unity에서 100명의 적이 매 프레임 가장 가까운 플레이어를 찾고 경로를 계산한다고 해봅시다.
using UnityEngine;
using System.Collections.Generic;
public class EnemyManager_Before : MonoBehaviour
{
public List<Enemy> enemies;
public Transform player;
void Update()
{
// 모두 메인 스레드에서 처리 → 프레임 드랍
foreach (var enemy in enemies)
{
Vector3 path = ComputeHeavyPath(enemy.position, player.position);
enemy.SetPath(path);
}
}
Vector3 ComputeHeavyPath(Vector3 from, Vector3 to)
{
// 무거운 A* 비슷한 계산 (5ms 가정)
float sum = 0;
for (int i = 0; i < 1_000_000; i++) sum += Mathf.Sqrt(i);
return (to - from).normalized * sum;
}
}
이 코드의 IL은 평범하지만(반복문 + 호출), 문제는 모든 호출이 메인 스레드에서 직렬로 일어난다는 것입니다. 100명 × 5ms = 500ms — 단 한 프레임 만에 끝나지 않습니다.
.method private hidebysig
instance void Update () cil managed
{
// 단순 foreach 반복 — 모두 메인 스레드에서 동기 실행
IL_0000: ldarg.0
IL_0001: ldfld class [mscorlib]System.Collections.Generic.List`1<Enemy> EnemyManager_Before::enemies
IL_0006: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0> [mscorlib]System.Collections.Generic.List`1<Enemy>::GetEnumerator()
// … 반복문 내부에서 ComputeHeavyPath 호출
IL_001f: call instance valuetype [UnityEngine]UnityEngine.Vector3 EnemyManager_Before::ComputeHeavyPath(...)
// 모든 호출이 같은 스레드에서 순차 실행
}
특이사항은 없습니다 — 이게 문제입니다. 메인 스레드는 렌더링까지 같이 처리해야 하므로 5ms × 100을 다 받아내면 16.6ms 예산을 한참 넘깁니다.
After — 풀에서 계산하고 결과만 메인 스레드에 적용
using UnityEngine;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Threading.Tasks;
public class EnemyManager_After : MonoBehaviour
{
public List<Enemy> enemies;
public Transform player;
// 워커 스레드 → 메인 스레드 결과 전달용
readonly ConcurrentQueue<(Enemy e, Vector3 path)> results = new();
void Update()
{
// 1. 입력값을 메인 스레드에서 미리 복사 (Vector3는 값 타입이라 안전)
Vector3 playerPos = player.position;
foreach (var enemy in enemies)
{
Vector3 enemyPos = enemy.position;
Enemy target = enemy;
// 2. 풀에 계산 위임 (ThreadPool에서 동작)
Task.Run(() =>
{
Vector3 path = ComputeHeavyPath(enemyPos, playerPos);
results.Enqueue((target, path));
});
}
// 3. 이전 프레임에 끝난 결과들을 메인 스레드에서 적용
while (results.TryDequeue(out var r))
{
r.e.SetPath(r.path);
}
}
static Vector3 ComputeHeavyPath(Vector3 from, Vector3 to)
{
float sum = 0;
for (int i = 0; i < 1_000_000; i++) sum += Mathf.Sqrt(i);
return (to - from).normalized * sum;
}
}
이 패턴의 핵심은 세 단계입니다.
- 입력 캡처: 워커 스레드에 넘기기 전에 메인 스레드에서
Vector3(값 타입) 같은 안전한 데이터로 복사합니다.Transform.position을 워커에서 직접 읽으면 안 됩니다. - 계산 위임:
Task.Run으로 ThreadPool에 던집니다. 워커 스레드에서 Unity API는 건드리지 않고 순수 계산만 합니다. - 결과 적용:
ConcurrentQueue<T>(스레드 안전한 큐)에 결과를 모아두고, 다음Update()에서 메인 스레드가 꺼내 적용합니다.
Task.Run의 IL을 보면 익명 메서드와 ThreadPool 큐잉의 조합이 명확해집니다.
// Task.Run에 람다 전달 (외부 변수 캡처가 있으면 closure 객체 newobj)
// 외부 변수 enemyPos, playerPos, target을 캡처하는 closure 인스턴스 생성
IL_0030: newobj instance void EnemyManager_After/'<>c__DisplayClass3_0'::.ctor()
IL_0035: stloc.s 4
// 로컬 변수들을 closure 필드에 저장
IL_0037: ldloc.s 4
IL_0039: ldloc.s enemyPos
IL_003b: stfld valuetype Vector3 EnemyManager_After/'<>c__DisplayClass3_0'::enemyPos
// … (다른 캡처 변수들도 마찬가지)
// closure의 메서드를 Action으로 묶어 Task.Run에 전달
IL_004a: ldftn instance void EnemyManager_After/'<>c__DisplayClass3_0'::'<Update>b__0'()
IL_0050: newobj instance void [System.Runtime]System.Action::.ctor(object, native int)
IL_0055: call class [System.Runtime]System.Threading.Tasks.Task [System.Runtime]System.Threading.Tasks.Task::Run(class [System.Runtime]System.Action)
여기서 주의할 점은 클로저 객체 할당입니다. 람다가 외부 변수를 잡으면 컴파일러가 <>c__DisplayClass3_0 같은 익명 클래스를 만들어 newobj로 할당합니다. Update가 매 프레임 호출되고 적이 100명이면 프레임마다 100개의 클로저 객체가 GC 힙에 생깁니다. 이것이 모바일에서 GC 스파이크(Spike, 갑작스러운 GC 발동으로 프레임이 튀는 현상)의 흔한 원인입니다.
4.3. 핫패스에서 ThreadPool 박싱 줄이기
GC 압박을 더 줄이려면 람다가 변수를 캡처하지 않도록 상태값을 별도로 전달합니다. QueueUserWorkItem<TState> 제네릭 오버로드가 이 용도에 적합합니다.
// ❌ 캡처 → 매번 closure 할당
ThreadPool.QueueUserWorkItem(_ =>
{
DoWork(enemyPos, playerPos); // 외부 변수 캡처
});
// ✅ TState로 전달 → closure 없음, 박싱 없음
ThreadPool.QueueUserWorkItem(static (state) =>
{
DoWork(state.from, state.to);
}, (from: enemyPos, to: playerPos), preferLocal: false);
static 람다는 외부 변수를 절대 캡처하지 않음을 컴파일러가 강제하므로, 클로저 객체가 생성되지 않습니다. 값 타입 튜플 (from, to)는 제네릭 TState로 전달되어 박싱도 없습니다.
4.4. Task.Run vs QueueUserWorkItem — 무엇을 언제 쓸까
| 항목 | ThreadPool.QueueUserWorkItem |
Task.Run |
|---|---|---|
| 반환값 | bool (큐잉 성공 여부) |
Task / Task<T> |
| 예외 추적 | 직접 try/catch 필요 | Task.Exception으로 자동 보존 |
await 가능 |
불가능 | 가능 |
| 후속 작업 | 직접 콜백 처리 | ContinueWith/await |
| 메모리 오버헤드 | 거의 없음 | Task 객체 할당 |
| 권장 사용처 | 핫패스의 fire-and-forget | 일반적 비동기 작업 |
Unity 게임 로직에서는 거의 항상 Task.Run이 정답입니다. 예외 처리가 자동이고 await로 결과를 메인 스레드와 자연스럽게 동기화할 수 있기 때문입니다. QueueUserWorkItem은 진짜로 매 프레임 수백 번 호출되는데 결과를 기다릴 필요가 없는 극단적 핫패스에서만 고려합니다.
5. [함정과 주의사항] — 신입이 자주 빠지는 함정
5.1. 함정 1: ThreadPool 기아(Starvation)
가장 흔하면서 가장 위험한 실수입니다. 워커 스레드 안에서 동기 블로킹 호출을 하면, 그 스레드는 깨어날 때까지 풀로 돌아가지 못합니다. Hill Climbing이 새 스레드를 추가하는 속도는 초당 1~2개뿐이라, 풀 안의 모든 스레드가 동시에 블로킹되면 큐의 다른 작업들은 수십 초씩 기다리게 됩니다.
❌ 잘못된 패턴 — .Result로 비동기를 동기로 강제
public class BadDataLoader : MonoBehaviour
{
void Start()
{
// ThreadPool에서 비동기 호출을 만든 뒤
// 메인 스레드에서 .Result로 강제 동기화
for (int i = 0; i < 32; i++)
{
Task.Run(async () =>
{
// 워커 스레드 안에서 또 다른 비동기를 동기 대기
string data = LoadAsync().Result; // 데드락 위험
ProcessData(data);
});
}
}
async Task<string> LoadAsync()
{
await Task.Delay(1000);
return "data";
}
void ProcessData(string s) { }
}
이 코드의 IL은 외형상 평범하지만, 문제는 런타임 동작입니다.
// .Result는 GetResult를 호출하며 완료까지 동기 블로킹
IL_xxxx: callvirt instance !0 class [System.Runtime]System.Threading.Tasks.Task`1<string>::get_Result()
// 이 시점에 워커 스레드는 LoadAsync 완료까지 못 빠져나옴
get_Result는 작업이 끝날 때까지 호출 스레드를 블로킹합니다. 32개 작업이 동시에 모든 워커를 점유한 채 Task.Delay(1000)을 기다리면, 그 1초 동안 다른 모든 작업이 큐에 묶입니다.
✅ 올바른 패턴 — await로 풀에 스레드 반납
public class GoodDataLoader : MonoBehaviour
{
async void Start()
{
for (int i = 0; i < 32; i++)
{
// Task.Run 내부에서도 동기 블로킹 대신 await 사용
_ = Task.Run(async () =>
{
string data = await LoadAsync(); // 풀에 스레드 반납
ProcessData(data);
});
}
}
async Task<string> LoadAsync()
{
await Task.Delay(1000);
return "data";
}
void ProcessData(string s) { }
}
await을 만나면 컴파일러가 상태 머신(state machine)을 만들어 메서드를 즉시 종료하고 워커 스레드를 풀로 반납합니다. 1초 후 Task.Delay가 끝나면 다시 풀의 빈 스레드에서 이어서 실행됩니다. 스레드는 결과를 기다리는 동안 풀로 돌아가 다른 일을 할 수 있습니다.
// async/await의 핵심: AsyncStateMachine 인터페이스를 구현한 상태 머신
.class private auto ansi sealed serializable beforefieldinit '<LoadAsync>d__1'
extends [mscorlib]System.ValueType
implements [mscorlib]System.Runtime.CompilerServices.IAsyncStateMachine
// MoveNext 안에서 awaiter.IsCompleted == false면 AwaitUnsafeOnCompleted로 컨티뉴에이션 등록
// 즉, 스레드는 즉시 풀로 반납됨
async/await— 비동기 메서드 / 비동기 대기 (Asynchronous / Await)async로 표시한 메서드 안에서await키워드를 만나면, 컴파일러는 메서드를 상태 머신으로 변환하여 작업이 끝날 때까지 스레드를 점유하지 않고 즉시 반환한다. 작업이 완료되면 후속 코드가 자동으로 이어 실행된다.
예시:string data = await LoadAsync();LoadAsync가 완료될 때까지 호출 스레드를 차지하지 않는다.
5.2. 함정 2: 워커 스레드에서 Unity API 호출
// ❌ Unity API를 워커에서 호출 → 예측 불가능한 오류
Task.Run(() =>
{
// transform은 메인 스레드 전용
enemy.transform.position = new Vector3(1, 2, 3);
});
// ✅ 결과만 받아 메인 스레드에서 적용
Vector3 newPos = await Task.Run(() => ComputeNewPosition());
enemy.transform.position = newPos; // 메인 스레드에서 안전하게 적용
UnityEngine 네임스페이스의 거의 모든 클래스(Transform, GameObject, MonoBehaviour, Renderer 등)는 스레드 안전하지 않습니다. 워커에서 호출하면 에디터 멈춤, 빌드 시 무작위 크래시, 데이터 손상 같은 진단 어려운 버그가 발생합니다.
Vector3, Quaternion, Mathf.Sqrt 같은 순수 값 타입과 정적 함수만 워커에서 안전합니다. Transform.position을 읽는 것조차 메인 스레드에서만 해야 합니다.
5.3. 함정 3: 클로저로 인한 GC 스파이크
핫패스(매 프레임 또는 매우 자주 호출되는 코드)에서 람다가 외부 변수를 잡으면 매 호출마다 클로저 객체가 힙에 할당됩니다.
// ❌ 매 프레임 클로저 객체 할당 → GC 스파이크
void Update()
{
int frame = Time.frameCount;
foreach (var enemy in enemies)
{
Vector3 pos = enemy.position; // 캡처
Task.Run(() => Calculate(pos, frame)); // 매번 newobj
}
}
// ✅ static 람다 + TState로 캡처 제거
void Update()
{
int frame = Time.frameCount;
foreach (var enemy in enemies)
{
Vector3 pos = enemy.position;
ThreadPool.QueueUserWorkItem(static (state) =>
{
Calculate(state.pos, state.frame);
}, (pos, frame), preferLocal: false);
}
}
static 람다는 컴파일러가 캡처를 막아주므로 IL에 newobj 클로저 할당이 사라집니다. Unity Profiler의 GC.Alloc 컬럼이 0B/frame으로 떨어집니다.
5.4. 함정 4: ThreadPool은 LongRunning 작업에 부적합
ThreadPool은 짧은 작업이 빠르게 끝났다가 풀로 돌아가는 것을 가정하고 만들어졌습니다. 수 분~수 시간 걸리는 작업을 풀에 던지면 그 스레드를 그동안 풀이 못 쓰게 되어 다른 작업이 밀립니다.
// ❌ 풀의 워커를 1시간 동안 점유 → 다른 작업 기아
Task.Run(() => RunLongJob()); // 1시간 걸림
// ✅ 전용 스레드를 새로 만들어 풀에 부담 안 줌
Task.Factory.StartNew(
() => RunLongJob(),
CancellationToken.None,
TaskCreationOptions.LongRunning, // 풀 대신 새 스레드
TaskScheduler.Default);
TaskCreationOptions.LongRunning은 .NET 런타임에 "이 작업은 풀의 워커를 쓰지 말고 새 OS 스레드를 만들어 달라"고 힌트를 줍니다. 데이터베이스 백업, 파일 일괄 처리, 긴 시뮬레이션처럼 명확히 오래 걸리는 작업에만 사용합니다.
TaskCreationOptions.LongRunning— 작업 생성 옵션Task.Factory.StartNew에 전달하는 플래그. 런타임이 ThreadPool 워커 대신 새 전용 스레드를 만들도록 힌트를 준다. 짧은 작업에 쓰면 오히려 스레드 생성 비용이 낭비된다.
6. [C# 버전별 변화] — ThreadPool API의 발전사
6.1. .NET Framework 1.0 — QueueUserWorkItem(WaitCallback)
가장 원시적인 형태. WaitCallback 시그니처는 void(object)였습니다.
// .NET Framework 1.0 ~ 4.0
ThreadPool.QueueUserWorkItem(state =>
{
int n = (int)state; // object → int 캐스팅 (unbox 필요)
Console.WriteLine(n);
}, 42); // 42를 box해서 전달
// 모든 state 인자가 object로 전달 → 값 타입은 box 필수
IL_001f: ldc.i4.s 42
IL_0021: box [System.Runtime]System.Int32 // 박싱
IL_0026: call bool ThreadPool::QueueUserWorkItem(WaitCallback, object)
값 타입을 넘길 때마다 box 발생 → GC 압박.
6.2. .NET Framework 4.0 — Task.Run (TPL 도입)
Task 객체로 작업을 추상화하면서 비동기 프로그래밍의 표준이 됩니다.
// .NET Framework 4.0
Task<int> task = Task.Run(() =>
{
return ComputeSomething();
});
int result = task.Result; // 결과 받기
내부적으로는 여전히 ThreadPool을 사용하지만, Task 추상화 덕분에 예외 추적·결과 반환·연속 작업이 가능해집니다.
6.3. .NET Framework 4.5 — 제네릭 QueueUserWorkItem<TState>
값 타입 박싱을 피하기 위한 제네릭 오버로드가 추가됩니다.
// .NET 4.5+
ThreadPool.QueueUserWorkItem<int>(static n =>
{
Console.WriteLine(n); // unbox 없음
}, 42, preferLocal: false);
// box 명령 없이 int를 그대로 전달
IL_001f: ldc.i4.s 42
IL_0022: call bool ThreadPool::QueueUserWorkItem<int32>(Action`1<!!0>, !!0, bool)
box 명령이 사라졌습니다.
6.4. .NET 6 — PortableThreadPool 기본화
OS별로 분기되어 있던 네이티브 ThreadPool 구현이 100% 관리 코드로 통합됩니다. 모든 플랫폼에서 동일한 큐 관리, 동일한 Hill Climbing이 동작합니다.
게임 개발자에게 의미하는 바: Unity Editor(Mono)와 IL2CPP 빌드, 모바일 백엔드 서버(.NET 8)가 모두 거의 동일한 ThreadPool 동작을 가집니다. 한 환경에서 본 동작을 다른 환경에 거의 그대로 적용할 수 있게 되었습니다.
6.5. .NET 9 — UnsafeQueueUserWorkItem<TState>(IThreadPoolWorkItem) 직접 인터페이스 구현 가능
작업 객체를 매번 할당하지 않고 재사용하기 위한 인터페이스가 공개됩니다.
class MyWork : IThreadPoolWorkItem
{
public Vector3 from, to;
public void Execute() // 워커 스레드에서 실행됨
{
Compute(from, to);
}
}
// 같은 객체를 재사용해서 풀에 던질 수 있음
var work = new MyWork { from = a, to = b };
ThreadPool.UnsafeQueueUserWorkItem(work, preferLocal: false);
이 패턴은 게임 서버처럼 매우 높은 처리량이 필요한 경우, 작업 객체 풀링과 결합해 GC 할당을 거의 0으로 만들 수 있습니다. 일반 Unity 클라이언트 개발에서는 잘 쓰지 않지만, ThreadPool API가 어디까지 발전했는지 보여주는 마지막 조각입니다.
7. [정리] — 핵심 체크리스트
ThreadPool은 한 줄로 요약하면 "미리 만들어둔 OS 스레드에 작업을 배분하는 공용 풀"입니다. 우리가 직접 호출하는 일은 드물어도 Task.Run을 쓰는 순간 이 풀 위에서 동작합니다.
다음 체크리스트로 정리합니다.
- 풀의 구조
- 글로벌 큐 1개 + 워커마다 로컬 큐 1개 + 워크 스틸링 = 락 경합 최소화
- 워커 스레드(CPU 작업) + IOCP 스레드(I/O 완료 콜백)가 분리되어 있다
- Hill Climbing으로 최적 스레드 수를 동적으로 찾는다 (초당 1~2개씩 천천히 추가)
- API 선택 기준
- 일반적 비동기 작업 →
Task.Run - 핫패스 fire-and-forget + GC 최소화 →
ThreadPool.QueueUserWorkItem<TState>(제네릭 오버로드) - 매우 긴 작업 →
Task.Factory.StartNew+TaskCreationOptions.LongRunning - 객체 재사용까지 →
IThreadPoolWorkItem
- 일반적 비동기 작업 →
- Unity에서 지킬 것
- 워커 스레드에서 Unity API 절대 금지 (
Transform,GameObject,MonoBehaviour) - 메인 스레드 → 워커 전달은
Vector3같은 값 타입으로 복사 - 워커 → 메인 결과 전달은
ConcurrentQueue<T>+ 다음Update()적용 static람다 +TState로 클로저 할당 제거
- 워커 스레드에서 Unity API 절대 금지 (
- 반드시 피할 것
.Result,.Wait()로 비동기를 동기 대기 → ThreadPool 기아- 풀에서
Thread.Sleep, 동기 I/O → 풀 전체가 멈춘다 SetMinThreads/SetMaxThreads함부로 변경 → 데드락이나 컨텍스트 스위칭 폭증- 매 프레임 람다에 외부 변수 캡처 → GC 스파이크
- IL 레벨 핵심 명령
box [System.Int32]— 값 타입을object로 변환 (구식QueueUserWorkItem사용 시 발생)newobj <closure>::.ctor()— 외부 변수 캡처 시 클로저 객체 할당call ThreadPool::QueueUserWorkItem<!!0>— 제네릭 오버로드는 박싱 없음call Task::Run(Action)— 내부적으로 ThreadPool에 작업을 큐잉
ThreadPool은 우리가 거의 매번 사용하지만 거의 안 보이는 인프라입니다. 이 인프라가 어떻게 일하는지 이해하면, Task.Run을 쓸 때 왜 갑자기 응답이 느려지는지, GC가 왜 스파이크를 내는지, 비동기 코드가 왜 데드락에 빠지는지를 IL과 런타임 동작 수준에서 설명할 수 있게 됩니다.
'C# 기초' 카테고리의 다른 글
| [PART13.비동기와 스레딩 기초(6/15)] async / await — 비동기 흐름을 동기처럼 쓰는 문법 (0) | 2026.05.08 |
|---|---|
| [PART13.비동기와 스레딩 기초(5/15)] Task와 Task<T> — "언젠가 끝날 작업" 객체 (0) | 2026.05.08 |
| [PART13.비동기와 스레딩 기초(3/15)] Thread 클래스 — 스레드를 직접 다루는 원시 도구 (0) | 2026.05.08 |
| [PART13.비동기와 스레딩 기초(2/15)] 프로세스 · 스레드 · Task — 용어 정리 (0) | 2026.05.08 |
| [PART13.비동기와 스레딩 기초(1/15)] 동기(Synchronous) vs 비동기(Asynchronous) — 두 가지 실행 모델 (0) | 2026.05.08 |