Chapter 2. 게임 성능 최적화 - Job System

개발하는 운동인·2024년 12월 11일

⭐ Jobs

  • C#에서의 멀티쓰레딩은 Task를 사용하지만, Jobs에 익숙해지는 것이 좋다. 유니티에서의 멀티테스킹을 할 때 Jobs을 사용하는 것이 좋다.

⭐ Jobs 환경 설정

    1. 아래의 패키지를 Install 한다.
  • 그리고, 혹시 모르니 DOTS 그래픽 용 (ECS 에서 사용할)으로 아래 패키지 또한 Install한다.
  • Install 시 아래 사진 처럼 해당 스크립트를 생성할 수 있다.

✅ Jobs 코드 예제

using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

public class JobTest : MonoBehaviour
{
    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
        NativeArray<int> array = new NativeArray<int>(1, Allocator.TempJob); //Temp,TempJob,Presistent 을 자주 사용. Temp는 1 프레임 내에 모든 일이 결정, TempJob은 Job에서 사용할 것이다. 4프레임 내에 결정. Presistent는 쭉 사용 .


        SimpleJob job = new SimpleJob //심플잡을 인스턴스화
        {
            a = 1,
            b = 2,
            result = array //언로케이션을 로케이션으로 해줘야 함. 
        };

        JobHandle handle = job.Schedule(); //잡을 스케줄에 넣음 -> 멀티쓰레드로 만듦. handle이 모두 드,ㄹ고 있음.

        handle.Complete(); //handle이 들고있는 잡이 끝날 때까지 기다린다. 즉, Execute()가 끝날 때까지 기다린다.  (handle에 묶여있는  Job들의 업무가 끝나길 기다린다.)

        //Debug.Log(job.c);
        Debug.Log(job.result[0]);


        array.Dispose(); //free 할 때는 Dis
    }

    public struct SimpleJob : IJob
    {
        //Job이라는 쓰레드를 만들기 위해 구조체를 사용한다. 또한, IJob 인터페이스를 구현하게 한다.

        public int a;
        public int b;
        public int c;

        public NativeArray<int> result;

        public void Execute() //IJob 인터페이스의 Execute()을 구현해야 함.
        {
            // c = a + b;
            result[0] = a + b;
        }
    }
}
  • 실행 시 , 정상적으로 결과 3이 나온다.

🤔 Job 개념

  • Job System은 멀티스레딩을 쉽게 사용할 수 있도록 해주는 Unity의 기능이다.
  • 기본적으로 Unity의 메인 스레드는 단일 스레드로 작동하지만, Job System을 사용하면 Worker Threads에서 작업을 병렬로 처리할 수 있어 성능 향상이 가능하다.

⭐ 위 코드 핵심 개념

    1. IJob 인터페이스 : 작업(Job)을 정의하기 위해 필요한 인터페이스. Execute() 메서드를 반드시 구현해야 한다.
      - IJob은 단일 작업을 정의하는 인터페이스로, 하나의 스레드에서 작업을 수행합니다.
      - Execute(): 이 메서드 안에 Job의 실제 작업을 작성합니다. Execute는 자동으로 Worker Thread에서 실행됩니다. 여러 작업을 병렬로 처리하고 싶다면 IJobParallelFor을 사용해야 합니다.
      - 멀티스레드 환경에서는 a, b, result와 같은 변수들이 공유 메모리로 전달되지 않고, 독립적인 데이터로 복사됩니다.

    1. JobHandle: 스케줄링한 Job의 완료 상태를 추적하고 제어하는 핸들이다.
    1. Schedule(): Job을 스케줄에 등록합니다. 등록된 Job은 별도의 Worker Thread에서 실행된다.
    1. Complete(): 등록한 모든 Job이 완료될 때까지 대기합니다.
    1. NativeArray: 작업 간에 데이터를 교환할 때 사용하는 Native 메모리 배열입니다. Unity의 관리 메모리(C#의 List, Array)가 아닌 직접 할당한 메모리입니다.

📄 NativeArray

  • NativeArray는 "고정 메모리 공간"으로, 메인 메모리(힙)가 아니라 네이티브 메모리 영역에 할당됩니다. Unity의 가비지 컬렉션(GC)의 영향을 받지 않고 Job 사이에서 데이터를 공유할 수 있습니다.

📄 NativeArray의 할당자(Allocator)

  • Allocator.Temp: 1 프레임 내에만 유효한 메모리. 프레임이 끝나면 자동으로 해제.
  • Allocator.TempJob: Job에서 사용하는 메모리. 4프레임 동안 유효. Job이 끝나면 Dispose()로 수동 해제.
  • Allocator.Persistent: 지속적으로 사용할 메모리. 명시적으로 Dispose()를 호출하기 전까지 유지됩니다.

📄 로케이터와 언로케이터

  • 로케이터(Locator): Unity가 원하는 메모리 위치(Heap이 아닌 네이티브 메모리)에 메모리를 할당하는 것.
  • 언로케이터(Unlocator): 할당된 메모리를 다시 관리 메모리(Heap)로 반환하는 것.
    간단히 말하면, NativeArray는 "로케이터"가 할당한 고정된 메모리 위치에 있고, Dispose()를 호출하면 이 메모리가 반환됩니다.

⭐ 실행 흐름

  • 1️⃣ NativeArray 생성 : 로케이터를 만듦. 즉, 메모리를 할당함.
NativeArray<int> array = new NativeArray<int>(1, Allocator.TempJob);
  • (1, Allocator.TempJob); 크기가 1인 배열 생성 , 4프레임 동안 메모리가 유지됨.

  • 즉, 이 배열은 잡(Job) 간의 데이터 교환을 위해 공유된다.

  • 2️⃣ SimpleJob 생성 및 초기화 : 구조체의 인스턴스화 (객체를 생성)

SimpleJob job = new SimpleJob
{
    a = 1,
    b = 2,
    result = array 
};
  • result = array: 연산 결과를 저장할 NativeArray를 연결한다.

  • 3️⃣ Job 스케줄링 : Job의 작업을 스케줄에 등록한다.

JobHandle handle = job.Schedule();
  • 유니티는 이 작업을 Worker Thread로 전송한다. 메인 스레드는 계속 실행 할 수 있으며, JobHandle의 객체인 handle에 작업의 상태를 저장한다.

  • 4️⃣ 작업 완료 대기 : 모든 Job의 작업이 끝날 때 까지 대기한다.

handle.Complete();
  • IJob 인터페이스를 구현한 SimpleJob 구조체의 Execute메서드가 완료 될 때까지 기다린 후에 다음 코드를 실행한다.

  • 5️⃣ 결과 확인

Debug.Log(job.result[0]);
  • 6️⃣ NativeArray 해제 : 할당한 메모리를 반환(언로케이터)한다.
array.Dispose();
  • 이전에 로케이터를 만들었을 때 TempJob의 로케이터 타입을 만들었었다. 반환을 꼭 해야하고, 반환하지 않는다면 메모리 누수가 발생한다.

⭐ 요약

  • Job System: 멀티스레드 작업을 쉽게 처리하기 위한 시스템.
  • NativeArray: 고정 메모리로 할당된 메모리. Job에서 공유하기 위해 사용.
  • IJob: 작업의 정의를 위한 인터페이스. Execute()에서 연산을 정의.
  • JobHandle: Job의 완료 상태를 추적합니다. Schedule()로 등록하고 Complete()로 완료까지 대기합니다.

🤔 Q&A

❓ GC(가비지 컬렉터)의 정확한 개념은?

  • GC는 더 이상 참조되지 않는 메모리(객체)를 자동으로 정리하는 시스템이다.
  • Unity, C#, Java 같은 메모리 관리가 자동화된 언어에서 자주 사용된다.

작동 방식

    1. 힙 메모리에 있는 객체 중에 더 이상 참조되지 않는 객체를 탐색한다.
    1. 탐색한 객체를 정리(삭제)해서 메모리를 확보한다.
    1. 가비지 컬렉션 과정 중, 프로그램의 실행을 일시 정지 시킨다.

❓ 왜 GC(가비지 컬렉터)가 성능을 느리게 할까?

  • GC의 작업 방식 때문이다.

⭐ 1. 정지 시간

  • GC는 메모리를 정리하기 위해 프로그램을 일시정지 한다. 예를 들어, 게임 중에 순간적으로 멈추는 현상이 발생할 수 있다.
  • GC는 힙 메모리를 검사하고, 참조되지 않는 객체(메모리)를 정리해야 하기 때문에, 많은 객체(메모리)가 존재할 수록
    정지 시간이 길어진다.

위 내용이 왜 문제가 될까?

  • 게임의 프레임(프레임 레이트, fps)에 영향을 미친다.
  • 만약 GC가 작동하면, 몇 밀리초(ms) 동안 모든 스레드가 멈춘다.
  • 예를 들어, 60fps 게임의 경우, 한 프레임이 16.6ms 내에 처리되어야 한다. 만약 GC 작업이 5~10ms를 차지하면, 그 프레임이 느려지고, 렉(버벅임)이 발생한다.

⭐ 2. 힙 메모리 스캔 비용

  • 위에서 설명했듯이, GC는 힙 메모리를 스캔하여 더 이상 참조되지 않는 객체를 찾는다. 그러므로 많은 메모리 할당과 해제가 자주 발생하면, GC의 스캔 작업이 자주 발생한다.
  • 즉, 힙에 많은 객체가 쌓일 수록 걸리는 시간이 길어진다

위 내용이 왜 문제가 될까?

  • 메모리 할당이 많을수록 GC의 스캔 시간이 늘어난다.
  • 특히, 오브젝트 풀링을 하지 않고 매번 Instantiate(), Destroy()를 호출하면 GC의 힙 메모리 스캔 작업이 빈번하게 발생한다.

⭐ 3. 동적 메모리 할당 비용

  • 클래스는 힙 메모리에 동적 메모리로 할당된다. GC의 관리 대상은 힙 메모리에 할당된 동적 메모리이다.
  • 메모리 할당 자체의 비용은 크지 않지만, 할당 후 해제할 때 GC가 개입하기 때문에 느려진다.

위 내용이 왜 문제가 될까?

  • 게임에서는 객체를 자주 생성/파괴 한다. 예를 들어 총알 발사 같은 경우, 매번 Instantiate()와 Destroy()를 하면 힙에 새로운 객체가 할당되고, GC가 이를 나중에 정리해야 한다. 이 문제를 해결하기 위해 풀링을 사용하여, 재사용 가능한 객체를 미리 만들어두고 재사용 해야 한다.

⭐ 4. 대용량 메모리 할당 및 조각화

  • 큰 객체(대용량 메모리)는 힙 메모리에 연속적으로 배치되지 않고, 빈 공간을 찾는 조각화가 발생한다.
  • 조각화가 심해지면, 메모리 할당이 느려지며, 새로운 객체를 위한 공간을 찾는 데 더 오랜 시간이 걸린다.

위 내용이 왜 문제가 될까?

  • 메모리 조각화는 메모리의 사용 가능한 공간이 쪼개져서 큰 데이터를 할당할 수 없는 상황을 초래한다.
  • 새로운 객체를 할당 할 때, 메모리 블록을 찾는데 시간이 걸리며, GC의 스캔 작업이 자주 발생할 수 있다.

📄 GC(가비지 컬렉터)의 성능 문제를 해결하는 방법

    1. 오브젝트 풀링을 사용하여 Instantiate() 와 Destroy()를 피하고, 미리 생성한 객체를 재사용한다. 즉, 메모리 할당과 해제의 빈도를 줄여서 GC의 개입을 줄인다.
    1. 구조체를 사용하여, GC의 관리 대상을 피한다. 구조체는 힙 메모리가 아닌 스택 메모리에 할당되기 때문에 GC의 관리 대상이 아니다.
    1. 메모리 할당을 최소화하여 매 프레임마다 동적 할당을 피하는 것이 중요하다. 예를 들어, 매 프레임마다 새로운 List, Dictionary 같은 컨테이너를 할당하지 말고, 리스트를 재사용해야 한다.
List.Clear() // 이 코드를 사용하여 기존 리스트를 초기하고 , 새로운 리스트를 할당하지 않는 방식으로 최적화 할 수 있다. 

❓ Job 클래스가 아닌 구조체를 사용하는 이유는?

    public struct SimpleJob : IJob
    {
        //Job이라는 쓰레드를 만들기 위해 구조체를 사용한다. 또한, IJob 인터페이스를 구현하게 한다.

        public int a;
        public int b;
        public int c;
        
        public void Execute() //IJob 인터페이스의 Execute()을 구현해야 함.
        {
            c = a + b;
        }
    }
  • 간단히 말하자면, 성능 최적화와 메모리 효율성 때문이다.
  • 클래스는 참조 타입이지만, 구조체는 값 타입이다. 즉, 클래스는 데이터를 메모리에 할당하며, GC(가비지 컬렉터)가 관리한다. 하지만, 구조체는 데이터를 스택 메모리에 할당하거나 Job System이 직접 관리하는 네이티브 메모리에 할당된다.

📄 Job과 Thread의 차이점

❓ 누가 일을 관리하나요 ?

  • Job System : Unity가 자동으로 작업을 나눠서 처리한다. EX) 자동 분업 회사: 회사가 알아서 직원들에게 일을 나눠준다.
  • Thread(스레드) : 개발자가 직접 스레드를 만들고, 언제 시작하고 끝내야 할지를 직접 관리해야 한다. EX) 내가 직접 직원을 뽑고, 그들에게 지시를 내려야 한다.

❓ 얼마나 빠른가요 ?

  • Job System : 작고 반복적인 작업을 빠르게 처리한다. (ex. 수천 개의 적 경로 계산 , 100개의 공 정리)
  • Thread(스레드) : 큰 작업이나 시간이 오래 걸리는 작업에 적합하다. (ex. 파일 다운로드, 무거운 짐 옮기기)

❓ 메모리를 어떻게 쓰나요 ?

  • Job System : 매번 같은 크기의 바구니에 물건을 담는 것 (EX.매번 같은 크기의 바구니에 물건을 담는 것 , 깔끔하고 빠름)
  • Thread : 힙 메모리(GC가 관리하는 메모리)을 사용한다. (EX. 필요한 만큼 바구니를 늘려서 물건을 담는 것 , 유연하지만 더 느림)

❓ 컨텍스트 스위칭(작업 전환)은 어떻게 하나요?

  • Job System : 작업 전환 비용이 없다, 한명(Worker)이 계속 작업을 이어서 하는 것.(일을 멈추지 않는다)
  • Thread : 작업 전환 비용이 발생 -> CPU가 스레드를 바꿀 때 시간이 걸린다, 한명이 하다 멈추고, 다른 사람이 이어서 하는 것(작업 교체 시간 발생)

❓ 어떤 데이터를 접근할 수 있나요?

  • Job System: Unity의 GameObject, Transform 접근불가. Job 안에서는 NativeArray, NativeList 같은 특수 데이터만 사용 가능.
  • Thread(스레드) : GameObject , Transform에 접근 가능.

❓ 언제 Job을 사용하고 언제 Thread를 사용할까?

📄 IJob , IJobFor , IJobParallelFor 인터페이스의 차이

  • 공통 : Unity의 JobHandle을 사용하여 완료를 대기하거나 스케줄링을 관리할 수 있습니다.

⭐ IJob : 단일 작업을 정의하는 기본 인터페이스

  • 1개의 Job을 하나의 Worker가 처리합니다.
  • 한 번에 1개의 작업만 수행합니다.
  • 모든 작업이 끝나야 다음 작업을 수행할 수 있습니다.
  • 내부의 Execute() 메서드는 한 번만 실행됩니다.
  • 데이터 병렬화 없음, 즉, 배열이나 여러 데이터에 대해 반복 작업을 하지 않습니다.
  • 예: 단순한 연산, 단일 데이터 계산, 간단한 물리 연산

스케줄링 방식 : 단일 스레드에서 처리

JobHandle handle = job.Schedule();
// 단일 작업 실행한다. 

⭐ IJobParallelFor : 배열의 작업을 병렬로 처리하는 인터페이스

  • 데이터(배열)를 여러 부분으로 나눠서 여러 Worker가 처리한다..
  • 작업을 나누기 때문에 속도가 더 빠릅니다.
  • 병렬 스레드로 실행하며, 스레드는 각 인덱스를 병렬로 계산합니다.
  • IJobParallelFor는 작업을 64개 단위로 나눠서 병렬로 실행합니다.
  • 예: 수천 개의 적 경로 계산, 수백 개의 물리 충돌 연산

스케줄링 방식

JobHandle handle = job.ScheduleParallel (array.Length, 64 , defalut); //64개 단위로 작업 분할
//예를 들어 , 작업 크기(배열의 크기)가 1000일 경우 64개로 나눠서 총 16개의 스레드가 실행된다. 
  • IJobParallelFor는 병렬 작업을 실행하기 위해 설계된 인터페이스이고, ScheduleParallel()은 이를 병렬 처리하도록 하는 메서드입니다.
  • ex ) 설계도 -> IJobParallelFor , 설계도에 있는 내용 작업 -> ScheduleParallel()

⭐ IJobFor : 더 최적화된 데이터 병렬 작업을 수행하는 인터페이스

  • IJobParallelFor보다 더 빠르고 최적화된 데이터 병렬 처리를 수행합니다.
  • 자동으로 작업 단위를 최적화하여 더 효율적인 분할을 수행합니다.
  • 작업 단위를 더 유연하게 나누기 때문에 메모리 소비가 적습니다.
  • Burst 컴파일러와 함께 최적화된 방식으로 실행됩니다.
  • IJobParallelFor는 고정 크기(64)로 분할하지만, IJobFor는 Unity가 작업 크기를 동적으로 결정합니다.
  • IJobFor는 IJobParallelFor의 단점을 보완한 버전으로, 자동으로 더 효율적인 작업 단위를 나눕니다.

스케줄링 방식

JobHandle handle = job.ScheduleParallel (array.Length, 64 , defalut); //64개 단위로 작업 분할
//예를 들어 , 작업 크기(배열의 크기)가 1000일 경우 64개로 나눠서 총 16개의 스레드가 실행된다. 

📄 예시 : 작업 분배 시나리오

  • 아래 스케줄링하는 코드가 있다면 작업 분배가 시작된다. ex) array.Length = 1000
JobHandle handle = job.ScheduleParallel (array.Length, 64 , defalut); 
  • 데이터 크기는 1000개이고 , 작업 단위를 64로 쪼갠다. 즉, 1000 % 64 = 16(반올림). 즉, 총 16개의 작업 수가 생성된다.

⭐ 1. Unity의 스케줄러가 각 작업 단위를 Worker Thread에 분배한다.

  • Thread 1: 작업 1~4 담당
  • Thread 2: 작업 5~8 담당
  • Thread 3: 작업 9~12 담당
  • Thread 4: 작업 13~16 담당

⭐ 2. 각 Worker가 할당 받은 작업 단위를 동시에 수행한다. 작업이 끝나면 메인 스레드가 결과를 통합한다.

  • 아래 코드가 있다면 병렬 작업 완료를 대기한다. 즉, JobHandle에 묶인 모든 병렬 작업이 끝날 때 까지 메인 스레드가 기다리게 만든다.
  • Unity에서 NativeArray와 같은 객체는 C#의 Garbage Collector(GC) 에 의해 자동으로 관리되지 않기 때문에, 개발자가 명시적으로 메모리를 해제해야 한다.
handle.Complete(); // Complete()를 호출하면 모든 작업이 끝난 후, 결과를 메인 스레드에서 안전하게 통합하거나 읽을 수 있게 됩니다.

⭐ 3. 모든 작업을 마치고 결과를 통합하고 나서 NativeColletion 객체가 메모리에서 안전하게 해제 되도록 해야한다.

  • 아래 코드가 있다면 위 내용을 실행한다.
NativeArray<int> array = new NativeArray<int>(100, Allocator.TempJob);
array.Dispose(); //위 내용 실행.

❓클래스 NativeArray vs 구조체 NativeArray

📄 클래스의 NativeArray : Job에 데이터를 전달하기 위해 생성한다. 즉, Job의 입력 데이터로 전달하기 위해 사용한다.

public class JobTest : MonoBehaviour
{
    void Start()
    {
        // 🔥 1. NativeArray 생성 
        NativeArray<int> array = new NativeArray<int>(1, Allocator.TempJob); 
    }
}

흐름

    1. 작업을 하기 위해 메모리가 필요하다.
    1. 메모리를 얻으려면 메모리를 할당해야 한다.
    1. 성능 향상 및 최적화를 위해 GC에 관리 대상으로부터 벗어나야 되므로 JobSystem을 활용한다.
    1. 이 시스템은 일반적인 배열을 사용할 수 없고 NativeArray를 사용해야 한다.
    1. NativeArray를 사용함으로써 네이티브 메모리를 할당하게 된다.
  • 결론 : 지역 변수로 NativeArray를 할당하여 메모리를 생성해서 메모리에 데이터를 저장한 후 , Job에 데이터를 전달하거나 출력값을 가져오기 위한 용도로 사용한다. 할당한 메모리는 Job 작업이 끝난 후 (Complete()) 이 후 Dispose()을 통해 메모리를 해제 해야 한다.

📄 구조체 NativeArray: Job의 작업 결과를 외부로 전달하기 위해 사용한다. 즉, Job의 출력 데이터로 전달하기 위해 사용한다.

    public struct SimpleJob : IJob
    {
        public int a;
        public int b;

        // NativeArray가 결과를 저장하는 용도로 사용됨 (클래스의 array와 연결됨)
        public NativeArray<int> result;

        public void Execute()
        {
            // Execute()는 IJob 인터페이스의 메서드
            result[0] = a + b; // a + b의 결과를 result 배열에 기록
        }
    }

흐름 및 구조체 NativeArray 사용 이유

    1. Execute()에서 출력 데이터를 저장하고 , 작업의 최종 결과를 기록한다. 위 코드에서는 a + b의 계산 결과를 result[0] 에 저장하는 구조이다. 또한, Execute() 의 반환 타입은 void이므로 반환타입이 없다. 그러므로 외부에서 최종 결과 값을 찾ㅁ조할 수 있도록 해야 한다. -> ⭐ 구조체에서 NativeArray을 선언하는 이유임. (NativeArray 는 참조타입이다. 일반적인 배열과 동일한 참조타입임)

❓ 그럼 클래스의 NativeArray 을 구조체의 NativeArray 로 참조해야 하는데 어떻게 해야 할까?

  • 로케이터를 언로케이터로 연결해야 한다. 즉, 참조해야 한다. NativeArray는 참조타입이므로 대입 과정을 통해 통신할 수 있다.
public class JobTest : MonoBehaviour
{

	void Start()
	{
   	 // 생략
    	SimpleJob job = new SimpleJob 
    	{
       	 	a = 1,
        	b = 2,
        	result = array // 📌 "array"라는 로케이터를 "result"라는 언로케이터에 연결
    	};

    // 생략
	}
}

🤔 왜? NativeArray 을 사용하는걸까?

  • NativeArray는 메모리 효율성과 멀티스레드 작업을 위해 사용하는 특수한 배열이다. 일반적인 C# 배열(int[] , float[])와 달리, NativeArray는 GC에 의해 관리 되지 않고 Unity의 Job System과 Burst Complier에 최적화 되어 있어서 활용할 수 있다.
  • GC에 관리 대상이 아니라는 것이 매우 중요한 포인트이다.
  • 또한, JobSystem을 사용하는 순간 int[] , float[] 사용할 수 없기 때문에 아래 코드처럼 선언할 수 있다.
public NativeArray<int> result;

  • NativeArray을 통해 힙 메모리가 아닌 네이티브 메모리에 저장할 수 있다. <- GC에 관리 대상이 되지 않음.

⭐ Jobs 코드 심화 예제(1)

✅ Job의 실행 순서(의존성)와 쓰레드 간의 작업 흐름

  • Job 간의 순서를 명시하고, 의존성을 통해 작업 순서를 제어하며, 메인 스레드와 워커 스레드 간의 작업 완료 대기까지 이루어지는 순서에 따른 작업 흐름을 보여줍니다.
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

public class JobTest : MonoBehaviour
{

    void Start()
    {
        NativeArray<int> array = new NativeArray<int>(1, Allocator.TempJob); 
        NativeArray<int> array2 = new NativeArray<int>(1, Allocator.TempJob);  //1. 생성

        SimpleJob job = new SimpleJob 
        {
            a = 1,
            b = 2,
            result = array 
        };

        SimpleJob job2 = new SimpleJob //2. 인스턴스화
        {
            a = 1,
            b = 2,
            result = array2 
        };

        JobHandle handle = job.Schedule(); 
        JobHandle handle2 = job2.Schedule(handle);     //3. handle에 들어있는 Job의 업무를 모두 끝내고 실행

        handle2.Complete();  // 최종적으로 handle.Complete()가 아닌 handle2.Complete(); 해서 handle

        Debug.Log(job.result[0]);


        array.Dispose(); //free 할 때는 Dis
        array2.Dispose(); //free 할 때는 Dis
    }

    public struct SimpleJob : IJob
    {
        //Job이라는 쓰레드를 만들기 위해 구조체를 사용한다. 또한, IJob 인터페이스를 구현하게 한다.

        public int a;
        public int b;
        public int c;

        public NativeArray<int> result;

        public void Execute() //IJob 인터페이스의 Execute()을 구현해야 함.
        {
            // c = a + b;
            result[0] = a + b;
        }
    }
}

핵심 : handle에 의존하는 handle2를 스케줄링 했으므로, Job2는 Job1이 완료된 후에만 실행됩니다.

Main Thread

1️⃣ Job1 (SimpleJob) - 스케줄링(handle) → Worker Thread 1 실행

JobHandle handle = job.Schedule(); //WorkerThread가 Job의 Execute()을 수행 

↓ (WorkerThread가 Job의 작업을 완료 후 )
2️⃣ Job2 (SimpleJob) - 스케줄링(handle2) → Worker Thread 2 실행

JobHandle handle2 = job2.Schedule(handle); //WorkerThread가 Job2의 Execute()을 수행 


3️⃣ handle2.Complete() - 메인 스레드는 Job, Job2의 모든 작업 완료 대기

handle2.Complete(); //Worker Thread의 작업 결과를 메인 스레드로 통합된다. 


4️⃣ Debug.Log() - Job1 결과 출력, array와 array2의 메모리 해제

        array.Dispose(); //free 할 때는 Dis
        array2.Dispose(); //free 할 때는 Dis

✅ 병렬 연산을 수행하는 방법 - NativeReference 사용

using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

public class JobTest : MonoBehaviour
{
    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {

        NativeReference<int> reference = new NativeReference<int>(Allocator.TempJob);
        NativeReference<int> reference2 = new NativeReference<int>(Allocator.TempJob);
        SimpleJob job = new SimpleJob //심플잡을 인스턴스화
        {
            a = 1,
            b = 2,
            result = reference //언로케이션을 로케이션으로 해줘야 함. 
        };

        SimpleJob job2 = new SimpleJob //심플잡을 인스턴스화
        {
            a = 2,
            b = 3,
            result = reference2 //언로케이션을 로케이션으로 해줘야 함. 
        };

        JobHandle handle = job.Schedule(); //잡을 스케줄에 넣음 -> 멀티쓰레드로 만듦. handle이 모두 드,ㄹ고 있음.
        JobHandle handle2 = job2.Schedule(handle);  

        handle2.Complete(); 

        //Debug.Log(job.c);
        Debug.Log(job.result.Value);
        Debug.Log(job2.result.Value);


        reference.Dispose(); //free 할 때는 Dis
        reference2.Dispose(); //free 할 때는 Dis
    }

    public struct SimpleJob : IJob
    {
        //Job이라는 쓰레드를 만들기 위해 구조체를 사용한다. 또한, IJob 인터페이스를 구현하게 한다.

        public int a;
        public int b;
        public int c;

        public NativeReference<int> result;

        public void Execute() //IJob 인터페이스의 Execute()을 구현해야 함.
        {
            // c = a + b;
            result.Value = a + b;
        }
    }
}

📄 NativeReference

  • NativeReference는 단일 값(하나의 변수)에 대해 참조(Pointer) 역할을 합니다. 또한, NativeArray 다르게 값 하나만 저장할 수 있는 점이 핵심 차이입니다.

목적

  • 예를 들어, 결과 값 하나만 반환해야 하는 경우, 굳이 배열을 사용할 필요가 없으므로, NativeReference로 단일 메모리 공간만 할당해도 됩니다.
  • Job System 내부에서 단일 값을 공유하거나 읽고 쓰는 작업을 할 때 유용합니다.
using Unity.Collections;
using Unity.Entities.UniversalDelegates;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;

public class JobTest2 : MonoBehaviour
{
    [SerializeField]
    private bool usejob;

    
    // Update is called once per frame
    public void Update()
    {
        float startTime = Time.realtimeSinceStartup;
        
        if(usejob)
        {
            NativeList<JobHandle> jobHandleList = new NativeList<JobHandle>();

            for (int i = 0; i < 10; i++)
            {
                JobHandle handle = ToughTaskJob();
                jobHandleList.Add(handle);
            }
            JobHandle.CompleteAll(jobHandleList.AsArray());
            jobHandleList.Dispose();
        }
        else
        {
            for(int i = 0; i< 10; i++)
            {
                ToughTask();
            }
        }

        Debug.Log((Time.realtimeSinceStartup - startTime) * 1000f + "ms");
    }

    void ToughTask()
    {
        float value = 0f;

        for(int i = 0; i<10000; i++)
        {
            value = math.exp10(math.sqrt(value));
        }
    }

    JobHandle ToughTaskJob()
    {
        ToughJob job = new ToughJob();
        return job.Schedule();
    }

}

public struct ToughJob : IJob
{
    public void Execute()
    {
        float value = 0f;

        for (int i = 0; i < 1000; i++)
        {
            value = math.exp10(math.sqrt(value));
        }
    }
}

job을 사용하지 않고 좀비 생성

using System.Collections.Generic;
using Unity.Collections;
using Unity.Entities.UniversalDelegates;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;

public class JobTest2 : MonoBehaviour
{
    [SerializeField]
    private bool usejob;

    [SerializeField]
    GameObject[] zombiePrefab;
    List<Zombie> zombieList;

    public class Zombie
    {
        public Transform transform;
        public float moveY;
        public float rotateY;
    }

    private void Start()
    {
        zombieList = new List<Zombie>();

        for(int i = 0; i<1000; i++)
        {
            GameObject go = Instantiate(zombiePrefab[UnityEngine.Random.Range(0, zombiePrefab.Length)], //게임오브젝트 배열에 저장된 좀비 1 , 좀비 2 둘중 하나를 랜덤으로 생성
                new Vector3(UnityEngine.Random.Range(-8f,8f), //위치 
                UnityEngine.Random.Range(-4f,4f), 
                UnityEngine.Random.Range(-8f,8f)),
                Quaternion.identity
                ); //유니티 엔진 랜덤을 사용

            zombieList.Add(new Zombie
            {
                transform = go.transform,
                moveY = UnityEngine.Random.Range(1f, 2f),
                rotateY = UnityEngine.Random.Range(180f,360f)
            }); //좀비 리스트에 위 데이터를 추가.
        }
    }

    private void Update()
    {
        float startTime = Time.realtimeSinceStartup;
        
        if(usejob)
        {
            //NativeList<JobHandle> jobHandleList = new NativeList<JobHandle>();

            //for(int i = 0; i< 10; i++)
            //{
            //    JobHandle handle = ToughTaskJob();
            //    jobHandleList.Add(handle);
            //}
            //JobHandle.CompleteAll(jobHandleList.AsArray());
            //jobHandleList.Dispose();
        }
        else
        {
            foreach(Zombie zombie in zombieList)
            {
                zombie.transform.position += new Vector3(0, zombie.moveY * Time.deltaTime, 0); //y축으로 움직임.
                if(zombie.transform.position.y > 4f)
                {
                    zombie.moveY = -math.abs(zombie.moveY);
                }
                if (zombie.transform.position.y > -4f)
                {
                    zombie.moveY = math.abs(zombie.moveY);
                }

                zombie.transform.Rotate(new Vector3(0, zombie.rotateY * Time.deltaTime, 0));
                ToughTask();
            }
        }

        Debug.Log((Time.realtimeSinceStartup - startTime) * 1000f + "ms");
    }

    void ToughTask()
    {
        float value = 0f;

        for(int i = 0; i<1000; i++)
        {
            value = math.exp10(math.sqrt(value));
        }
    }

    JobHandle ToughTaskJob()
    {
        ToughJob job = new ToughJob();
        return job.Schedule();
    }

}

public struct ToughJob : IJob
{
    public void Execute()
    {
        float value = 0f;

        for (int i = 0; i < 1000; i++)
        {
            value = math.exp10(math.sqrt(value));
        }
    }
}

job을 사용하여 좀비 생성

using System.Collections.Generic;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities.UniversalDelegates;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.UIElements;
using static JobTest2;

public class JobTest2 : MonoBehaviour
{
    [SerializeField]
    private bool usejob;

    [SerializeField]
    GameObject[] zombiePrefab;
    List<Zombie> zombieList;

    public class Zombie
    {
        public Transform transform;
        public float moveY;
        public float rotateY;
    }

    private void Start()
    {
        zombieList = new List<Zombie>();

        for(int i = 0; i<1000; i++)
        {
            GameObject go = Instantiate(zombiePrefab[UnityEngine.Random.Range(0, zombiePrefab.Length)], //게임오브젝트 배열에 저장된 좀비 1 , 좀비 2 둘중 하나를 랜덤으로 생성
                new Vector3(UnityEngine.Random.Range(-8f,8f), //위치 
                UnityEngine.Random.Range(-4f,4f), 
                UnityEngine.Random.Range(-8f,8f)),
                Quaternion.identity
                ); //유니티 엔진 랜덤을 사용

            zombieList.Add(new Zombie
            {
                transform = go.transform,
                moveY = UnityEngine.Random.Range(1f, 2f),
                rotateY = UnityEngine.Random.Range(180f,360f)
            }); //좀비 리스트에 위 데이터를 추가.
        }
    }

    private void Update()
    {
        float startTime = Time.realtimeSinceStartup;
        
        if(usejob)
        {
            NativeArray<float3> positionArray = new NativeArray<float3>(zombieList.Count, Allocator.TempJob);
            NativeArray<float> moveYArray = new NativeArray<float>(zombieList.Count, Allocator.TempJob);

            NativeArray<float3> rotationArray = new NativeArray<float3>(zombieList.Count, Allocator.TempJob);
            NativeArray<float> rotateYArray = new NativeArray<float>(zombieList.Count, Allocator.TempJob);

            for (int i = 0; i<zombieList.Count; i++)
            {
                positionArray[i] = zombieList[i].transform.position;
                moveYArray[i] = zombieList[i].moveY;
            }

            ZombieMovingJobFor zombieMovingJobFor = new ZombieMovingJobFor
            {
                positionArray = positionArray,
                moveYArray = moveYArray,
                deltaTime = Time.deltaTime
            };

            JobHandle zombieMovingForHandle = zombieMovingJobFor.ScheduleParallel(zombieList.Count, 50,default);
            zombieMovingForHandle.Complete();

            for(int i = 0; i< zombieList.Count; i++)
            {
                zombieList[i].transform.position = positionArray[i];
                zombieList[i].moveY = moveYArray[i];
                zombieList[i].transform.eulerAngles = rotationArray[i];
            }

            positionArray.Dispose();
            moveYArray.Dispose();
            rotationArray.Dispose();
        }
        else
        {
            foreach(Zombie zombie in zombieList)
            {
                zombie.transform.position += new Vector3(0, zombie.moveY * Time.deltaTime, 0); //y축으로 움직임.
                if(zombie.transform.position.y > 4f)
                {
                    zombie.moveY = -math.abs(zombie.moveY);
                }
                if (zombie.transform.position.y > -4f)
                {
                    zombie.moveY = math.abs(zombie.moveY);
                }

                zombie.transform.Rotate(new Vector3(0, zombie.rotateY * Time.deltaTime, 0));
                ToughTask();
            }
        }

        Debug.Log((Time.realtimeSinceStartup - startTime) * 1000f + "ms");
    }

    void ToughTask()
    {
        float value = 0f;

        for(int i = 0; i<1000; i++)
        {
            value = math.exp10(math.sqrt(value));
        }
    }

    JobHandle ToughTaskJob()
    {
        ToughJob job = new ToughJob();
        return job.Schedule();
    }

}

[BurstCompile]
public struct ToughJob : IJob
{
    public void Execute()
    {
        float value = 0f;

        for (int i = 0; i < 1000; i++)
        {
            value = math.exp10(math.sqrt(value));
        }
    }
}
[BurstCompile]
public struct ZombieMovingJobFor : IJobFor // IJob과 다르게 Execute 메서드에 인덱스 매개변수가 필요함. 
{
    //인덱스를 통해 인덱스에 맞는 연산을 해라!!!!

    public NativeArray<float3> positionArray;
    public NativeArray<float> moveYArray;

    public NativeArray<float3> rotationArray;
    public NativeArray<float> rotateYArray;

    public float deltaTime;
    public void Execute(int index)
    {
        positionArray[index] += new float3(0, moveYArray[index] * deltaTime, 0);

        if (positionArray[index].y > 4f)
        {
            moveYArray[index] = -math.abs(moveYArray[index]);
        }
        if (positionArray[index].y > -4f)
        {
            moveYArray[index] = math.abs(moveYArray[index]);
        }

        rotationArray[index] += new float3(0, rotateYArray[index] * deltaTime, 0);

         float value = 0f;

        for (int i = 0; i < 1000; i++)
        {
            value = math.exp10(math.sqrt(value));
        }
    }
}

  • 0부터 999개까지 실행하지만,
  • 각 워커가 1개의 덩어리를 맡아서 총 20개의 덩어리를 20명의 워커가 일을 작업한다.

O(n2) 소요 시간. -> IJOB 사용해야함

using UnityEngine;

public class FindNearast : MonoBehaviour
{
    void Update()
    {
        foreach(Transform seekerTransform in Spawner.seekerTransforms)
        {
            Vector3 seekerPos = seekerTransform.position;

            Vector3 nearestTarget = default;

            float nearestDist = float.MaxValue;


            foreach(Transform targetTransform in Spawner.targetTransforms)
            {
                Vector3 offset = targetTransform.position - seekerPos;

                float dist = offset.sqrMagnitude;

                if(dist < nearestDist)
                {
                    nearestDist = dist;
                    nearestTarget = targetTransform.position;


                }
            }

            Debug.DrawLine(seekerPos, nearestTarget);
        }
    }

    //위 코드는 O(n2)이 소요되는 시간이 된다. 

    //매 프레임마다 O(n2)을 계산하면 성능면에서 안 좋다. -> 최적화 필요함


}

0개의 댓글