스레드를 직접 만들지 않고 일을 맡긴다
멀티스레드로 성능을 올리려면 보통 스레드를 만듭니다. 그런데 스레드를 만드는 것 자체가 비쌉니다.
만드는 비용도 있고, 만들고 나면 OS 가 코어 하나를 여러 스레드에 번갈아 배정하면서 컨텍스트 스위칭이 일어납니다. 실행 중이던 상태를 저장하고 다른 상태를 불러오는 일입니다. 코어가 여덟인데 스레드를 마흔 개 만들면 계산보다 자리 바꾸는 데 시간을 씁니다.
잡 시스템은 반대로 합니다. 스레드를 만들지 않습니다.
스레드는 이미 떠 있다
유니티는 시작할 때 세 종류의 스레드를 준비합니다.
| 무엇을 하나 | |
|---|---|
| 메인 스레드 | 게임 로직, 입력, UI, Update, 씬 관리 |
| 워커 스레드 | 잡을 받아 처리합니다. 논리 코어 수만큼 미리 만들어 둡니다 |
| 렌더 스레드 | 그래픽스 명령을 GPU 로 보냅니다 |
유니티 자신도 이 워커들을 씁니다. 우리 잡만 도는 자리가 아닙니다.
그래서 워커가 비어 있다는 보장이 없습니다. 잡을 아무리 잘 쪼개도, 같은 프레임에 유니티가 렌더 명령을 만들고 있으면 자리를 나눠 씁니다. 프로파일러에서 워커 타임라인이 빽빽한데 내 잡이 늦게 시작한다면 대개 이것입니다.
워커 스레드는 이미 떠 있고 일감을 기다립니다. 우리가 할 일은 스레드를 만드는 게 아니라 일감을 큐에 넣는 것입니다.
여기서 결론이 하나 나옵니다. 잡을 1만 개 만들어도 스레드가 1만 개 생기지 않습니다. 워커 넷이 1만 개를 나눠 가질 뿐입니다.
잡의 종류
잡은 메서드가 아니라 구조체입니다. 인터페이스를 구현하고 Execute 를 채웁니다.
| 인터페이스 | 무엇 |
|---|---|
IJob |
워커 하나에서 한 번 실행합니다. 메인 스레드를 안 막고 싶은 단일 작업 |
IJobParallelFor |
배열 인덱스를 나눠 병렬로 돕니다. 가장 많이 씁니다 |
IJobParallelForTransform |
Transform 여러 개를 병렬로 갱신합니다 |
IJobEntity · IJobChunk |
ECS 쪽. 엔티티와 청크 단위로 돕니다 |
struct AddJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float> A;
[ReadOnly] public NativeArray<float> B;
public NativeArray<float> Result;
public void Execute(int i) => Result[i] = A[i] + B[i];
}
Execute(int i) 가 인덱스마다 한 번씩 불립니다.
어느 워커가 어느 인덱스를 맡을지는 스케줄러가 정하고 우리는 모릅니다.
그래서 인덱스 사이에 순서를 기대하면 안 됩니다.
필드는 값이 복사된다
잡은 구조체라 워커마다 복사본이 갑니다. 그러니 잡 필드에 담은 값을 실행 중에 바꿔도 원본에 안 돌아옵니다.
결과를 받아 오려면 NativeContainer 가 필요합니다.
NativeArray 나 NativeList 같은 것들인데, 이들은 구조체 안에 포인터를 들고 있어서
복사돼도 같은 메모리를 가리킵니다.
var handle = new AddJob { A = a, B = b, Result = result }.Schedule(a.Length, 64);
handle.Complete();
// 여기서부터 result 를 읽을 수 있습니다
Schedule(length, batchSize) 의 두 번째 인자는 한 워커가 한 번에 가져갈 개수입니다.
너무 작으면 나눠 주는 오버헤드가 커지고, 너무 크면 일이 고르게 안 퍼집니다.
계산이 가벼우면 크게, 무거우면 작게 잡습니다.
메모리는 직접 관리한다
NativeContainer 는 관리 힙이 아니라 네이티브 메모리를 씁니다. GC 대상이 아니고, 그래서 직접 해제해야 합니다.
| Allocator | 수명 | 언제 |
|---|---|---|
Temp |
1프레임 미만 | 가장 빠릅니다. 함수 안에서 잠깐 쓰고 버릴 때. 자동 해제됩니다 |
TempJob |
4프레임 미만 | 잡에 넘길 데이터의 기본값. 안 지우면 경고가 뜹니다 |
Persistent |
앱 전체 | 가장 느리게 할당됩니다. 매 프레임 재사용할 것에만. Dispose() 필수 |
Temp 는 잡에 못 넘깁니다. 잡이 끝나기 전에 사라질 수 있어서입니다.
매 프레임 새로 할당하는 코드를 짜고 있다면 Persistent 로 한 번 잡아 두고
Clear() 해서 재사용하는 편이 낫습니다.
안전 장치가 나머지 절반이다
잡 시스템의 절반은 성능이 아니라 사고 방지입니다.
읽기와 쓰기를 추적합니다. 두 잡이 같은 배열에 동시에 쓰려고 하면 실행 전에 예외를 던집니다. 경합이 일어난 뒤에 이상한 값이 나오는 게 아니라 아예 못 돌게 막습니다.
[ReadOnly] public NativeArray<float> A; // 읽기만 한다고 선언
[ReadOnly] 는 최적화 힌트가 아니라 선언입니다.
읽기 전용이라고 말해 두면 다른 잡과 병렬로 돌 수 있고,
안 말해 두면 스케줄러가 안전하게 직렬화합니다.
병렬 쓰기도 따로 말해야 합니다.
Queue = queue.AsParallelWriter() // 여럿이 동시에 넣습니다
그냥 넘기면 거부당합니다. 같은 이유입니다.
뒤에서는 AtomicSafetyHandle 과 DisposeSentinel 이 돕니다.
전자는 누가 이 컨테이너를 읽고 쓰는지 추적하고,
후자는 해제 안 된 컨테이너를 잡아 경고를 냅니다.
에디터에서만 도는 검사라 빌드에서는 이 비용이 없습니다.
반대로 말하면 에디터에서 안 걸린 문제가 빌드에서 조용히 터질 수 있다는 뜻이기도 합니다.
의존성
잡 A 의 결과를 잡 B 가 써야 하면 순서를 지켜야 합니다.
JobHandle 을 넘기면 스케줄러가 알아서 기다립니다.
var h1 = jobA.Schedule();
var h2 = jobB.Schedule(h1); // A 가 끝난 뒤에 시작합니다
h2.Complete();
여기서 Complete() 를 언제 부르느냐가 성능을 가릅니다.
스케줄은 일찍, Complete 는 늦게 부르는 것이 원칙입니다.
그 사이에 메인 스레드가 다른 일을 하게 두는 것이 잡 시스템을 쓰는 이유입니다.
ECS 안에서는 state.Dependency 가 이 체인을 자동으로 걸어 줍니다.
직접 Complete() 를 부를 자리는 결과를 지금 당장 읽어야 할 때뿐입니다.