Job System 유튜브 링크
Job의 구조
프로파일러를 사용해보았다면, 메인 쓰레드와 동시에 추가적으로 워커 쓰레드가 생기는 것을 알 수 있습니다.
Job은 워커 쓰레드에서 실행되는 작업입니다.
[BurstCompile]
public struct SuqaresJob : IJob
{
public NativeArray<int> Nums;
public void Execute()
{
for (int i = 0; i < Nums.Length; i++)
Nums[i] *= Nums[i];
}
}
다음은 배열의 모든 원소의 제곱을 계산하는 간단한 예제입니다.
- 구조체 형식으로 IJob 인터페이스를 상속 받고 Execute라는 필수 메소드를 정의합니다. Execute라는 메소드는 워커 스레드에서 돌아가는 메소드입니다.
- Execute 메소드는 배열에 있는 모든 원소의 제곱을 계산합니다.
- 배열은 평소에 우리가 사용하는 managed array가 아니고 Unity API에 있는 unmanaged native array입니다. managed array를 사용할 수도 있지만, 특별한 주의가 필요하기 때문에 대체로 unmanaged array를 사용하는 것이 좋습니다. 또한 managed array는 BurstCompiled Code에서 사용할 수 없고 BurstCompilation의 이점을 살리기 위해서는 managed array를 사용하면 안됩니다.
- 이렇게 정의한 Job을 실행하기 위해서는 생성하고 스케줄해야합니다.
var job = new SquaresJob { Nums = myArray };
JobHandle handle = job.Schedule();
다음 코드는 생성하고 스케줄하는 코드입니다.
- Monobehaviour Update 등과 같은 메인 쓰레드에서 돌아가는 메소드에서 위의 코드가 포함되어 있어야합니다.
- 우리가 제곱하고 싶은 원소이 들어있는 배열로 Job을 생성합니다.
- Schedule 메소드를 호출하여 Global Job queue으로 Job Instance를 집어넣습니다. 그러면 얼마 지나지 않아 Idle Worker Thread가 queue에서 꺼내어 실행하게 됩니다.
handle.Complete();
- Execute 메소드가 끝날 때까지 Complete 메소드는 반환되지 않습니다. Complete 메소드는 메인 쓰레드와 동기화하기 위해서 사용합니다.
- Complete 메소드가 하는 또다른 일은 Job queue에 기록되어 있는 것을 지웁니다. 그렇기 때문에 Job을 Complete해야 메모리 리크를 회피할 수 있습니다.
Job의 주요 제한 사항
- 메인 쓰레드에서만 Job을 Schedule하고 Complete할 수 있음
- Job이 혼자 Job을 Schedule하고 Complete할 수 없음
- 메인 쓰레드에서 현재 Schedule되어 있는 Job의 데이터에 접근하면 안 됨
- 서로 다른 두 Job이 동일 자원에 접근하면 안 됨
- [Safety check exception]
- 거의 동시에 접근하기에 Race condition을 야기할 수 있음
- 이는 dependency를 통하여 해결할 수 있음
- 한 Job을 다른 Job의 dependency로 두면 한 Job이 끝날 때까지 다른 Job이 실행되지 않음
- 이러한 dependency를 설정하는 것은 Job의 순서를 정하는 것과도 같음
- 서로 다른 두 Job이 서로를 dependency로 둔다면 Deadlock을 야기할 것 같지만, 우선 스케줄되는 Job이 우선이기 때문에 고려하지 않아도 됨
Parallel Job
- 병렬처리를 하기 위해선 IJob 대신 IJobParallelFor 인터페이스를 이용
- batch 사이즈 크기만큼의 영역으로 잡을 나눠서 처리
- ex) batch = 100, array length = 250 일 때,
3개의 잡으로 만들어져서 처리, 0~99 / 100~199 / 200 ~ 249
- 더많은 워커 쓰레드에 나눠서 처리하고 싶다면 배치를 더 작은 값으로 지정
- 너무 잘게 쪼개면(too many small batches) 잡 시스템 오버헤드 야기함
[BurstCompile]
public struct SquaresJob : IJobParallelFor
{
public NativeArray<int> Nums;
public void Execute(int index)
{
Nums[index] *= Nums[index];
}
}
var job = new SquaresJob { Nums = myArray; }
JobHandle handle = job.Schedule(arr.Length, 100);
handle.Complete();
var job = new SquaresJob { Nums = myArray; }
JobHandle handle = job.Schedule(arr.Length, arr.Length);
handle.Complete();
- 위의 프로파일링 결과는 8개의 워커 쓰레드에 분배되게 배치 사이즈를 만든 것과 배치 사이즈를 배열 크기만큼 쪼개 하나의 싱글 쓰레드에서 처리되게 끔 만든 것, 두 개의 결과
- 배치 사이즈를 배열 크기만큼 쪼개면 하나의 큰 배치로 만들게 되어 하나의 코어에서만 처리하게 됨
- 병렬 처리를 함에도 불구하고 두 결과 다 3.00ms로 비슷한 것을 볼 수 있음
- 이러한 결과가 나온 이유는 정말 단순한 연산을 처리하는 것이기에 보틀넥이 발생하는 곳은 메모리 엑세스 부분이기 때문
- 8개의 코어가 배열을 읽고 쓰는 것이 하나의 싱글 코어가 메모리 액세스하며 처리하는 것보다 빠를 수 없기 때문에 이런 결과가 나옴