스레드를 직접 만들지 않고 일을 맡긴다
Esc

    스레드를 직접 만들지 않고 일을 맡긴다

    –UnityDOTS동시성

    멀티스레드로 성능을 올리려면 보통 스레드를 만듭니다. 그런데 스레드를 만드는 것 자체가 비쌉니다.

    만드는 비용도 있고, 만들고 나면 OS 가 코어 하나를 여러 스레드에 번갈아 배정하면서 컨텍스트 스위칭이 일어납니다. 실행 중이던 상태를 저장하고 다른 상태를 불러오는 일입니다. 코어가 여덟인데 스레드를 마흔 개 만들면 계산보다 자리 바꾸는 데 시간을 씁니다.

    잡 시스템은 반대로 합니다. 스레드를 만들지 않습니다.

    스레드는 이미 떠 있다

    유니티는 시작할 때 세 종류의 스레드를 준비합니다.

    무엇을 하나
    메인 스레드 게임 로직, 입력, UI, Update, 씬 관리
    워커 스레드 잡을 받아 처리합니다. 논리 코어 수만큼 미리 만들어 둡니다
    렌더 스레드 그래픽스 명령을 GPU 로 보냅니다

    유니티 자신도 이 워커들을 씁니다. 우리 잡만 도는 자리가 아닙니다.

    메인 스레드 렌더 요청 렌더 요청 렌더 요청 명령 요청 워커 1 워커 2 워커 3 명령 목록을 만든다 명령 목록을 만든다 명령 목록을 만든다 만든 명령을 넘김 렌더 스레드 받은 순서대로 그래픽스 API 를 호출 그래픽스 장치 (드라이버 · GPU)
    렌더링도 이 워커들 위에서 돕니다. 우리가 잡을 던지는 자리와 같은 자리입니다.

    그래서 워커가 비어 있다는 보장이 없습니다. 잡을 아무리 잘 쪼개도, 같은 프레임에 유니티가 렌더 명령을 만들고 있으면 자리를 나눠 씁니다. 프로파일러에서 워커 타임라인이 빽빽한데 내 잡이 늦게 시작한다면 대개 이것입니다.

    워커 스레드는 이미 떠 있고 일감을 기다립니다. 우리가 할 일은 스레드를 만드는 게 아니라 일감을 큐에 넣는 것입니다.

    메인 스레드 Schedule 잡 큐 스케줄러 워커 1 워커 2 워커 3 워커 4 워커는 논리 코어 수만큼 미리 떠 있습니다. 우리가 만들지 않습니다. 그래서 잡을 아무리 많이 만들어도 스레드 수는 안 늘고 컨텍스트 스위칭도 안 늘어납니다.
    스레드를 만드는 대신 일감을 나눠 주는 구조입니다.

    여기서 결론이 하나 나옵니다. 잡을 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 Schedule 메인 스레드가 그냥 기다림 이어서 할 일 워커가 잡을 처리 워커가 도는 동안 메인 스레드가 놀고 있습니다. 병렬로 만든 의미가 없습니다. 스케줄은 일찍, Complete 는 늦게 Schedule 메인 스레드는 다른 일을 함 Complete 결과 사용 워커가 잡을 처리 같은 잡, 같은 시간인데 메인 스레드가 그만큼 더 일했습니다.
    둘의 차이는 잡이 아니라 Complete 를 부른 위치뿐입니다.

    스케줄은 일찍, Complete 는 늦게 부르는 것이 원칙입니다. 그 사이에 메인 스레드가 다른 일을 하게 두는 것이 잡 시스템을 쓰는 이유입니다.

    ECS 안에서는 state.Dependency 가 이 체인을 자동으로 걸어 줍니다. 직접 Complete() 를 부를 자리는 결과를 지금 당장 읽어야 할 때뿐입니다.