Unity

[Unity] JobSystem(1) 특징과 인터페이스 종류

usingsystem 2024. 9. 6. 13:47
728x90

JobSystem 생성 배경

https://usingsystem.tistory.com/539

 

[Unity] DOTS 시스템과 Unity JobSystem, Burst, ECS 개념

DOTS의 주요 목표와 적용 요약oop로 인해 메모리가 무분별하게 퍼져있는 메모리를 일관성 있게 메모리에 쌓아서 cpu가 캐시 l1, l2, l3에 캐시적중(캐시 적중 시 메모리를 정말 빠르게 읽을 수 있다.)

usingsystem.tistory.com

 

Unity의 Job System은 게임 엔진의 성능을 극대화하기 위해 설계된 멀티스레딩 프레임워크로, 작업을 작게 나누어 여러 스레드에서 동시에 처리하여 CPU 리소스를 효율적으로 활용하고 게임의 퍼포먼스를 크게 향상시킵니다. 

Job System의 주요 개념 및 동작 방식

  1. Job (작업):
    • Job은 특정 작업을 수행하는 작은 코드 블록으로, 비동기적으로 실행되어 CPU 집약적인 작업을 빠르게 처리하도록 설계되었습니다.
    • Job은 다양한 인터페이스(IJob, IJobParallelFor, IJobParallelForTransform 등)를 통해 정의되며, 각 인터페이스는 작업 유형에 맞게 최적화되어 있습니다.
    • Job 작성 시에는 C# 인터페이스를 구현하고, Execute() 메서드를 통해 작업을 정의합니다.
  2. Scheduler (스케줄러):
    • Job을 관리하고 실행합니다.
    • 스케줄러는 Job을 큐에 추가하고, 시스템의 가용 스레드를 사용하여 Job을 효율적으로 분배합니다. 작업이 완료되면 결과를 반환하고 다음 작업을 준비합니다.
    • Job은 Schedule() 메서드를 통해 스케줄링되며, 이 메서드는 Job을 스케줄러 큐에 추가하고 실행 시점을 결정합니다.
  3. JobHandle (작업 핸들):
    • JobHandle은 Job의 상태를 추적하고 관리하며, 작업 완료 여부를 확인하여 작업이 끝날 때까지 대기시킨다.
    • JobHandle을 통해 특정 작업의 완료 후 다른 작업이 실행되도록 보장할 수 있습니다. 예를 들어, 물리 연산이 끝난 후 애니메이션 계산이 시작되도록 설정할 수 있습니다.
    • Complete() 메서드를 사용하여 작업이 완료될 때까지 대기하거나, 종속성을 설정하여 작업 순서를 조정합니다.
  4. Burst Compiler:
    • Burst Compiler는 Job System과 함께 사용되어 C# 코드를 고성능 네이티브 코드로 컴파일하여 실행 속도를 극대화합니다.
    • CPU 명령어 수준에서 코드를 최적화해, 반복적이고 연산이 많은 작업에서 특히 큰 성능 향상을 제공합니다.
  5. 작업 완료 후 처리:
    • Job이 완료되면 결과를 사용하며, NativeArray와 같은 메모리 자원을 해제하여 메모리 누수를 방지해야 합니다.

워크스레드 (Worker Threads)

워크스레드는 Unity의 Job System에서 Job을 병렬로 처리하기 위해 사용되는 실제 스레드입니다. Unity는 메인 스레드 외에도 여러 개의 워크스레드를 활용하여 Job을 동시에 실행합니다. 

워크스레드의 특징:

  • 병렬 처리: 여러 개의 Job이 동시에 실행될 수 있도록 도와주며, 각 Job은 워크스레드에서 비동기적으로 처리됩니다.
  • 자원 관리: 워크스레드는 스케줄러에 의해 효율적으로 관리되어 시스템 리소스를 최대한 활용합니다.
  • 스레드 풀: Unity는 기본적으로 워크스레드의 수를 시스템의 CPU 코어 수에 맞게 자동으로 조정합니다. 즉, 시스템의 코어가 많을수록 더 많은 Job을 병렬로 처리할 수 있습니다.
  • 데이터 경합 방지: 워크스레드는 각 Job이 독립적으로 실행되도록 보장하지만, 동시에 동일한 데이터를 접근할 경우 데이터 경합 문제가 발생할 수 있습니다. 이를 방지하기 위해 NativeContainer와 같은 스레드 안전한 구조를 사용합니다.

Job에서의 데이터 교환 NativeContainer

NativeContainer는 Unity의 Job System에서 사용하는 특수한 데이터 구조로, 멀티스레드 환경에서 안전하게 데이터를 공유할 수 있도록 설계되었습니다. NativeArray, NativeList, NativeQueue 등이 NativeContainer의 대표적인 예입니다.

NativeContainer의 특징:

  • 스레드 안전성: NativeContainer는 멀티스레드 환경에서 안전하게 사용할 수 있도록 설계되었습니다. Job System에서 데이터를 공유할 때, 스레드 간의 충돌이나 데이터 손상을 방지하는 기능을 갖추고 있습니다.
  • 빠른 성능: 메모리 할당과 관리를 효율적으로 처리하여 성능을 극대화합니다. 특히, Burst Compiler와 결합하여 사용하면 최적의 퍼포먼스를 끌어낼 수 있습니다.
  • 고정 크기 메모리: 대부분의 NativeContainer는 고정 크기의 메모리를 할당받아 사용하며, 이를 통해 메모리 관리의 예측 가능성과 성능을 개선합니다.
  • Dispose 필수: NativeContainer는 Native 메모리를 직접 사용하기 때문에 사용 후 반드시 Dispose를 호출하여 메모리를 해제해야 합니다.

워크스레드와 NativeContainer의 관계

  • 데이터 공유 및 경합 방지: 워크스레드는 NativeContainer를 사용해 Job 간 데이터를 안전하게 공유하며, 여러 워크스레드가 동일한 데이터를 접근할 때도 데이터 무결성을 유지합니다.
  • 퍼포먼스 최적화: 워크스레드는 CPU 자원을 최대한 활용하고, NativeContainer는 스레드 간 안전하고 빠른 데이터 접근을 지원하여 전체 Job System의 성능을 극대화합니다.

Job과 static 변수에 접근

병목 현상으로 인해 크러쉬가 일어날 가능성이 있어 기존 멀티 스레드 상황과 동일하게 같은 메모리에 여러 스레드가 접근하는 경우를 자제하며 사용해야한다.


주요 인터페이스 (IJob, IJobParallelFor, IJobParallelForTransform)

우선 들어가기 앞서 멀티스레딩과 병렬 처리의 차이를 알아보자.

병렬 처리와 멀티스레딩의 차이점

  • 멀티스레딩: 여러 스레드를 사용하여 작업을 동시에 처리하지만, 작업 자체는 분할되지 않을 수 있습니다.
  • 병렬 처리: 작업을 여러 개의 작은 작업으로 나누어 여러 스레드에서 동시에 실행하여 성능을 극대화합니다.

1. IJob

IJob 인터페이스는 단일 스레드에서 실행될 수 있는 작업을 정의할 때 사용됩니다. 이 인터페이스는 작업을 병렬로 처리하지 않으며, 작업 내용은 단순한 경우에 적합합니다. 그렇다면 Ijob과 코루틴의 차이점은 뭘까?

 

IJob vs 코루틴

 

  • IJob: 멀티스레드 환경에서 실행되는 단일 작업을 정의하는 인터페이스로, 주로 CPU 집약적인 작업을 처리하기 위해 사용됩니다. 병렬 처리는 하지 않지만, 작업을 메인 스레드가 아닌 다른 스레드에서 실행하여 메인 스레드의 부담을 줄입니다. 대량의 계산 작업, 성능 최적화가 중요한 경우 Burst Compiler를 통해 네이티브 코드로 최적화하여 성능을 극대화, 병렬 처리가 필요 없는 단일 작업을 비동기로 처리하여 메인 스레드의 부하를 줄이는 경우.
  • 코루틴: 메인 스레드에서 비동기 작업을 수행하는 방법으로, 주로 타이밍 제어, 반복 작업, 또는 프레임마다 작업을 분산 처리하는 데 사용됩니다. 코루틴은 Unity의 메인 스레드에서 동작하므로 Unity API와의 상호작용이 가능합니다. 애니메이션, 지연된 작업, 특정 시간 간격으로 실행해야 하는 작업 등., 메인 스레드와의 상호작용이 필요한 경우 UI 업데이트, 오브젝트의 위치나 속성 변경 등 Unity API를 직접적으로 호출해야 하는 작업.  

 

특성 IJob 코루틴
     
실행 환경 멀티스레드 (메인 스레드 외 스레드에서 실행) 메인 스레드
병렬 처리 지원 X (단일 작업) X
주요 사용 목적 CPU 집약적인 작업 분리 비동기 처리, 반복 작업, 타이밍 제어
Unity API 사용 제한적 (메인 스레드와 상호작용 어려움) 가능 (메인 스레드에서 실행)
성능 최적화 Burst Compiler 사용 가능 최적화는 제한적
구현 난이도 상대적으로 복잡 상대적으로 쉬움
작업 제어 작업의 예약 및 제어가 가능 yield 키워드로 간단히 흐름 제어

 

 

  • Execute 메서드를 구현하여 작업의 내용을 정의합니다.
  • Schedule 메서드를 통해 작업을 예약하고 실행할 수 있습니다.

 

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

[BurstCompile]
public struct SimpleJob : IJob
{
    public int a;
    public int b;
    public NativeArray<int> result;

    public void Execute()
    {
        result[0] = a + b;
    }
}

public class JobExample : MonoBehaviour
{
    void Start()
    {
        NativeArray<int> result = new NativeArray<int>(1, Allocator.TempJob);
        SimpleJob job = new SimpleJob
        {
            a = 5,
            b = 10,
            result = result
        };

        JobHandle handle = job.Schedule();
        handle.Complete();

        Debug.Log("Result: " + result[0]);

        result.Dispose();
    }
}

2. IJobParallelFor

IJobParallelFor는 배열과 같은 데이터에 대해 병렬로 작업을 수행할 때 사용됩니다. 각 인덱스에 대해 독립적인 작업을 수행할 수 있으며, 이를 통해 성능을 크게 향상시킬 수 있습니다.

  • Execute 메서드를 구현하며, 이 메서드는 배열의 인덱스를 인자로 받습니다.
  • Schedule 메서드를 통해 작업을 예약할 때 배열의 크기를 지정하여 병렬 작업을 수행합니다.
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

[BurstCompile]
public struct ParallelJob : IJobParallelFor
{
    public NativeArray<int> data;

    public void Execute(int index)
    {
        data[index] = data[index] * 2;
    }
}

public class ParallelJobExample : MonoBehaviour
{
    void Start()
    {
        NativeArray<int> data = new NativeArray<int>(10, Allocator.TempJob);

        for (int i = 0; i < data.Length; i++)
        {
            data[i] = i;
        }

        ParallelJob job = new ParallelJob
        {
            data = data
        };

        JobHandle handle = job.Schedule(data.Length, 1);
        handle.Complete();

        for (int i = 0; i < data.Length; i++)
        {
            Debug.Log("Data[" + i + "] = " + data[i]);
        }

        data.Dispose();
    }
}

3. IJobParallelForTransform

IJobParallelForTransform는 TransformAccessArray를 사용하여 여러 개의 Transform을 병렬로 작업할 때 사용됩니다. 주로 게임 오브젝트의 위치, 회전 등을 동시에 업데이트할 때 유용합니다.

 

IJobParallelForTransform vs UniTask

 

  • IJobParallelForTransform  : 대량의 Transform 업데이트와 같은 작업을 멀티스레드에서 자동으로 병렬 처리하여 성능을 극대화하며, 반복적이고 병렬 처리가 필요한 작업에 적합합니다.
  • UniTask : UniTask는 Unity에서 비동기 프로그래밍을 더 쉽고 효율적으로 구현하기 위한 라이브러리로, C#의 async/await 패턴을 활용합니다. 메인 스레드에서의 비동기 작업 처리를 주로 하며, 필요에 따라 멀티스레드 작업도 지원합니다.

 

특성 UniTask IJobParallelForTransform
     
비동기 처리 방식 async/await 패턴 멀티스레드에서 병렬 작업 수행
멀티스레드 사용 UniTask.Run으로 명시적으로 사용 가능 기본적으로 멀티스레드에서 병렬 처리
Unity API 사용 자유롭게 사용 가능 (메인 스레드에서 실행) 제한적 (메인 스레드와 상호작용 어려움)
성능 최적화 제한적 (사용자가 직접 최적화 필요) 자동 최적화 (Unity Job System 활용)
적합한 작업 네트워크, 파일 IO, UI 업데이트 대량의 Transform 업데이트, 물리 계산
복잡도 및 사용 난이도 사용이 쉽고 C#의 비동기 패턴에 친숙함 설정과 관리가 필요, 상대적으로 복잡함

 

  • Execute 메서드는 인덱스와 해당 인덱스의 TransformAccess를 받아 작업을 수행합니다.
  • Schedule 메서드를 통해 TransformAccessArray와 함께 작업을 예약합니다.
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;
using UnityEngine.Jobs;

[BurstCompile]
public struct TransformJob : IJobParallelForTransform
{
    public float deltaTime;

    public void Execute(int index, TransformAccess transform)
    {
        transform.position += Vector3.up * deltaTime;
    }
}

public class TransformJobExample : MonoBehaviour
{
    public Transform[] transforms;

    void Update()
    {
        TransformAccessArray transformAccessArray = new TransformAccessArray(transforms);
        
        TransformJob job = new TransformJob
        {
            deltaTime = Time.deltaTime
        };

        JobHandle handle = job.Schedule(transformAccessArray);
        handle.Complete();

        transformAccessArray.Dispose();
    }
}

 

NativeConainer과 TransformAccessArray 차이점

https://usingsystem.tistory.com/544

 

[Unity] JobSystem(2) NativeContainer VS TransformAccessArray

Job System과 Native Collections 특징Native collections는 성능 최적화와 메모리 관리 효율성을 위해 제공하는 데이터 구조입니다. 이들은 기본적으로 관리되지 않는 메모리(heap)에 저장되어 있으며, 높은

usingsystem.tistory.com

 

728x90