이 아티클은 원작자 Richard Meredith의 허락을 얻은 후 원문인 Simple Multithreading for Unity를 한글로 번역되었습니다. 아직 초벌 번역이므로 오히려 불편하신 분들은 원문을 참고하시면 됩니다.
Introduction
나는 최근에 Bad North의 flow field(흐름을 나타내는 필드)의 알고리즘의 비용을 줄이기 위해 유니티에서의 multithreading의 기초에 대해 살펴보았다.
모바일 환경에서 메인스레드가 처리하는데 걸리는 시간을 2.75ms 에서 0.25ms까지 낮추게 되면서 나는 아주 기초적인 유니티에서의 multithreading에 대해 글을 남기는 게 좋겠다고 생각했다.
나는 이 글이 비록 이전에 threaded code에 대한 경험이 없더라도 threading가 무엇인지에 대해 그리고 유니티/C# 스크립트에 대한 조금의 이라도 도움이 될수 있도록 누구나 접근가능했으면 좋겠다.
Important Disclaimers
1) 나는 알고리즘 자체를 별렬화 하지는 않았다. 내가 한 것은 게임의 남는 시간동안 다른 thread에서 이것이 돌도록 밀어넣은 것 뿐이다.
2) 이것은 C#/.NET환경에서 어떤것을 threading 나의 첫번째이다.(C++에서는 몇번 경험해 봤지만…) 그래서 더 개선의 여지가 있을 수 있다.
The Flow Field Algorithm
나는 알고리즘의 상세한 부분에 대해서 이야기 하지는 않을 것이다. 하지만 대략적으로 flow field는 다음에 언급되어 있는 네비메쉬에 걸쳐 펼쳐져 있는 데이터이다.
– 근처에 얼마나 많은 agent들이 있는지
– 그들이 얼마나 가까이 있는가
– 그들이 어느 방향에 있는가
매 프레임마다. 모든 에이전트들은 “액체”를 flow field에 떨어트린다. 이 것을 가까운 버텍스들에 축적한다. 이 액체는 네비메쉬 vertex들 주변에 따라 흐르고 또 증발한다. 이런 효과들은 아래의 gif에서 파란 혹은 주황 색의 디버그 그래픽들로 시각화되어있다.
Identifying Actions
다음은 flow field 정보들에게 일어나는 액션들이다.
1)필드기반의 시스템 샘플 데이터
2)에이전트들이 filed의 데이터에 영향을 준다.
3)전파(전달 혹은 flow 업데이트)가 일어난다.
이 과정은 main thread에서 다음과 순서로 일어난다.
이 포스트에 있는 모든 thread diagrams(스레드 도식)은 단순화 되어 있고 수평축(시간축)에 축적되지 않는다.
다양한 시스템/스크립트들이 여러 프레임에 걸쳐 flow field에 쓰여지고 읽혀진다.(초록색 화살표처럼) flow 업데이트 동안 많은 읽기와 쓰기가 flow field에 행해진다. flow update는 꽤 많은 비용이 들어가는 부분이고 이 부분이 우리가 다른 스레드로 분리하고 싶은 부분이다. 이걸 분리하면 생길 몇가지 있겠지만 이부분은 조금 더 뒤에 걱정하도록 하자.
자 우선 thread를 하나 만들자.
Creating The Thread
나는 threaded system을 약간의 반복 시행하여 살펴보았고 작동하지 않는 명백한 해결법을 동작하지 않는 이유로 인해 폐기했다. 나의 첫 시도는 매 프레임마다 새로운 thread를 생성하여 작업을 수행하고 사라지도록 하는 것이었습니다.
하지만 thread를 생성하는 것은 매우 느리고(flow field를 업데이트 하는 것과 거의 비슷할 정도로) 매 프레임마다 500바이트의 Garbage를 생성했다.
내 두번째 시도는 셋업 시간을 피하기 위해 thread들을 풀링하는 BackgroundWorker시스템을 이용하는 것이다. 빠르긴 하지만 여전히 500바이트의 Garbage를 매 프레임 생성한다.
Chosen Solution
나의 해결책은 조금 더 로우 레벨로 가는 것이었다. 나는 간단하게 동작하는 무한루프인 하나의 thread를 생성하고 main Thread의 Unity Update()와 동기화 시켰다. 이는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
using System.Threading;
using UnityEngine;
public class ThreadedBehaviour : MonoBehaviour
{
Thread ChildThread = null;
EventWaitHandle ChildThreadWait = new EventWaitHandle(true, EventResetMode.ManualReset);
EventWaitHandle MainThreadWait = new EventWaitHandle(true, EventResetMode.ManualReset);
void ChildThreadLoop()
{
ChildThreadWait.Reset();
ChildThreadWait.WaitOne();
while(true)
{
ChildThreadWait.Reset();
// Do Update
WaitHandle.SignalAndWait(MainThreadWait, ChildThreadWait);
}
}
void Awake()
{
ChildThread = new Thread(ChildThreadLoop);
ChildThread.Start();
}
void Update()
{
MainThreadWait.Reset();
WaitHandle.SignalAndWait(ChildThreadWait, MainThreadWait);
}
}
|
cs |
Script Overview
여기서 중요한것은 thread들을 동기화 하기 위한 두개의 EventWaitHandle 변수이다. 만일 thread가 reset된 waithandle에 의해 기다리기를 요청 받는다면 다른 thread가 Set을 콜할 때까지 block(기다릴)될 것이다.(13번 라인 참조)
SignalAndWait() 함수는 두 파라미터의 Set()과 WaitOne()를 차례로 콜한 것과 같다.(효과적으로 다른 thread의 block을 해제하고 현재의 thread를 block할)
ThreadedBehaviour 클래스의 Awake()에서 child thread를 생성하고 ChildThreadLoop()내에서 코드를 시작할 것이고 즉시 ChildThreadWait를 기다릴 것이다. 이것은 Update() 함수가 불릴 때까지 block 상태로 남아있을 것이다.
Script Behaviour
Update()에서 우리는 child thread의 block을 해제하고 child block이 작업을 완료할 때까지 main thread 자신을 block할 것이다. 이 후 child thread가 자신의 작업을 완료하면 main thread는 block이 해제될 것이고 child thread가 block될 것이다.(21줄) 아래에 보이는 것 처럼…
이제 우리는 flow update를 main thread가 아닌 다른 thread에서 하고 있다. 하지만 여전히 다음작업은 flow update가 완료되기를 기다리고 있다. 우리는 main thread에서 어떠한 시간도 절약하지 못하고 있다. 실제로는 동기화를 하기 위한 약간의 오버헤드로 인해 더욱 느려졌다.
하지만 우리는 작업을 다른 thread로 넘기고 있고 thread들을 동기화 하는 방법을 알게 되었다. 우리는 병렬화하기위한 커다란 한걸음을 내딛은 것이다.
Parallelisation
이제 Flow Update는 다른 분리된 thread에서 동작하지만 main thread를 block하고 있기때문에 이건 제대로 된 multthreading도 아니고 시간도 절약하지 못하고 있다.
thread들을 병렬로 동작하도록 하기 위해서는 Update()함수를 다음과 같이 바꾸어 main thread의 block을 풀어주어야 한다.
|
void Update()
{
ChildThreadWait.Set();
}
|
cs |
매우간단하게 우리는 thread들이 다음과 같이 동작하도록 만들었다.
최소한 우리가 바라는대로 돌아가도록 만든 것처럼 보이지만 실제로는 그렇지 않다. 이게 아마도 multithreading에서 가장 중용한 부분이지 않을까하는데 이 문제를 확인하고 풀기위해 우리는 한가지 사고 실험을 할 것이다.
Problems with Parallelisation
우리는 flow update가 얼마나 걸릴지 알 수가 없다. 특히 당신은 알수 없다 다른 스레드와의 관계가 얼마나 걸릴지. 당신이 이걸 테스트 해보고 제 시간에 끝나는 걸 확인했다면 당신은 이것이 아마 제 시간안에 끝날거라고 생각할 것이다.
하지만 multithread는 예측 불가능하고 테스트로는 안정적이라고 증명할 수 없다. 안정적인 디자인을 필요로 한다.
예를 들어 flow update가 기대한것 보다 오래걸린다고 가정해 보자.
두번째 프레임에 우린 두 thread등이 동시에 flow field에서 데이터를 읽고 쓰는 걸 볼 수 있다. 이건 정의되지 않았고 문제가 될만한 행위이다. 우린 또한 main thread가 child thread가 작업을 마치기도 전에 다시 시작하려고 시도하는 걸 볼 수 있는데 이건 반복될 것이고, 이건 괜찮지 않다.
Re-Synchronising The Threads
그래서 main thread를 update함수가 시작할 때 block하여 child thread가 main thread보다 더 시간이 걸리지 않도록 보장하는 것을 시작하자.
|
void Update()
{
MainThreadWait.WaitOne();
MainThreadWait.Reset();
ChildThreadWait.Set();
}
|
cs |
첫 업데이트에서 우린 MainThreadWait 변수를 Set상태로 두어 대기 하지 않음을 알아두자. 반복되는 프레임에서 만일 child thread가 여전히 동작중이라면 main thread는 다음과 같이 기다릴 것이다.
이제 우리 두 loop들은 동기화 되어있지만, 여전히 두 thread들은 같은 data를 이용하며 병렬로 돌아가고 있다. 이걸 해결할 방법이 몇가지 있지만, 내가 선택한 방법은 thread들 간에 어떠한 것도 직접적으로 공유되지 않도록 데이터 구조를 조정하는 방법을 가지고 있다.
Restructuring Data
본래의 구현은 작업하는 하나의 데이터 세트만 가지고 있었다. 요약해서 이 데이터는 우리가 다음의 세가지 상호작용을 하던 데이터이다.
1)field로 부터의 System 샘플 데이터
2)Agent들이 필드에 추가한 데이터
3)전파 발생(Data의 전파, 즉 flow update)
우리는 이제 데이터를 다음 동작을 하는 것에 따라 3개의 세트로 나눌 것이다.
1)알고리즘의 결과(샘플링에 사용될)
2)미완료된 변화들(추가에 사용될)
3)사용되는 데이터(전파되는데 사용될 다양한 데이터)
Results
이것은 당신의 데이터 상태의 스냅샷이다. 즉 flow field이다. main thread와 child thread가 각각 자신이 사용할 정보를 가지고 있다. child thread는 자신의 data를 업데이트 할 것이고 그 정보를 매 프레임 main thread에게 복사 해 줄것이다.
중요 : 결과 값과 계산되지 않은 중간 값들은 value type이나 deep copy 이용하라. 데이터가 변하지 않는다는 것을 장담할 수 있더라도 referece를 복사하는 것은 절대 안된다.
Working Data
이 부분은 미적용된 데이터와 데이터를 가공하기 위한 여러 다른 단계를 거친 연산의 결과일 것이다.
Update Order
데이터구조는 어떤 프로그램을 짜느냐에 따라 달라지기 때문에 데이터 구조를 특정화하여 보여주진 않을 것이다. 그러나 main thread의 명령 순서는 다음과 같다:
31
32
33
34
35
36
37
38
39
40
|
void Update()
{
MainThreadWait.WaitOne();
MainThreadWait.Reset();
// Copy Results out of the thread
// Copy pending changes into the thread
ChildThreadWait.Set();
}
|
cs |
그리고 여기 더 복잡한 thread diagram이 있다.
이 그래프의 시간축은 단순한 시간의 흐름이라는 것을 명심(얼마나 긴지 길이의 비율은 각 명령에 걸리는 시간과 비례하지 않는다는 말)하고 실제 복사 명령은 굉장히 빠르게 일어난다. 값들의 동기 명령을 조작하는데 main thread가 담당하도록하는 것이 어떠한 충돌의 위험도 없다는 것을 쉽게 할수 있기 때문에 child thread의 데이터에서 copy를 해오고 해주는 작업에는 main thread를 사용한다.
일반적으로 우린 child thread가 flow update를 하는데 main thread보다 많은 시간이 걸리지 않을 거라고 생각하는데 우리는 단지 사고 실험을 통해 이것이 일어날 수도 있다고 가정을 하였다. 하지만 만일 실제로 일어나더라도 우린 더이상 두 thread들 사이에 어떠한 충돌도 일어나지 않는다. child thread는 이제 자신의 데이터만 읽고 쓰고 두 thread들은 적절하게 동기화 되어있다. 이제 multithread로 동작하게 되었고 data들도 thread safe(다른 스레드가 값을 사용하는도중에 또 다른 스레드가 값을 변경하거나 해서 충돌이 일어날 염려가 없다라는 것) 하다.
보통과 같이 flow update가 빠르게 끝나더라도 각 프레임에 thread들은 이렇게 동작할 것이다.
Summary/Thoughts
이건 내가 main thread에서 떼어내어 flow field update를 수행하기 위해 사용한 테크닉이다. 무조건 이 방법으로만 다른 thread로 data를 넘겨줄 수 있는건 아니지만 염두에 두어야 할 것이 몇가지 있다.
Main Thread Cost
다음에 대한 main thread가 지어야 할 몇몇 thread cost들이 있다.
0 미적용된 변화 데이터를 넘겨주는것
0 변화 값을 카피해 오는 것
0 결과 값을 카피해 주는 것
0 thread 동기화
대부분 스레드 동기화 입니다. EventWaitHandle에 대한 나의 이해가 단순한 thread 신호 주고 받기라는 것이기 때문에 나는 꽤나 놀랐다. 아마도 더 나은 방법도 있을거다.
Pipeline Delay
이 테크닉이 1프레임 지연하여 실행하는 것을 이해하는 것도 중요하다. 변화가 x라는 프레임에 대기열에 들어간다면 결과는 x+2까지 넘어가지는 않고 x+1프레임에 나온다. (설계상 1프레임 지연까지 결과가 나오지 않으면 대기하도록 되어있다.)
flow field의 경우에는 몇프레임에 걸쳐 전파되는 것이기 때문에 한프레임정도 지연 계산되더라도 상관없었다. 이건 왠만한 경우에 다 그럴 거라 여겨진다.
Garbage Collection
한가지 주의할 점은 카피를 할 때 Garbage를 생성하지 않도록 해야 한다는 것이다. 미적용된 변화 데이터들은 List 였고 이걸 복사하는 것은 단순히 list referece들을 복사하고 main thread를 위한 새로운 List를 만들면 되는 것이었다. 나는 리스트들의 각각의 요소들을 복사하는 것과 재사용가능한 버퍼로 그 것들을 다루어 Garbage가 생성되는 것을 피했다.
Exiting the Thread
여기에 포스팅한 예시에서를 thread를 죽이는 경우가 없다. 게임 내에서 thread의 종료에 대한 컨트롤을 하기 위해서 꽤나 투박한 방법을 사용하는데 (while loop에서 bool 변수를 이용해서 컨트롤하고 있다.), 유니티의 프레임워크를 이용해서 더 깔끔한 thread 종료 방법을 찾는 게 좋을 것 같다.
Thread Priority
난 flow update를 Late Update의 거의 마지막 부분에서 시작한다. 이렇게 하는게 프로젝트의 다른 스크립트와 관련된 부분들을 굉장히 예측하기 쉽기 때문이다. 하지만 이건 렌더링과 물리 업데이트와 겹치게 된다는 걸 의미하는데 두 부분에 대해서는 유니티에서 이미 multithreading을 하고 있다.(대부분의 타겟 플랫폼에서 그렇게 동작한다.) 최소한 유니티 5.5버전에서는 main thread와 child thread는 우선 순위가 제일 낮게 되어 있는데 아마 더 개선될 여지는 없어 보인다.(Thread 우선순위에 대한 개선 사항에 대한 이야기. 우선 순위가 높을 경우 더욱 개선할 방향이 있나보다)
Conclusion
copy에 대한 부하와 프레임 지연 연산에도 불구하고, 난 이 방법이 좋다. 엄청 간단하고 깔끔하다. background 작업은 모든 프레임에 업데이트 하고 thread들 간의 상호작용은 제한되어있다. 게다가 multithread화된 환경에서 copy와 sync를 하기 위한 부분을 제외하면 별다른 신경쓰지 않고 main thread는 데이터의 복사본과 상호작용할 수 있다.
한 프레임 지연과 데이터 복사가 필요없는 대안을 가진 접근방법도 있지만 꽤나 많은 단점을 가진 방법이다. thread를 종료하는 방법에 대한 몇몇 질문에 대한 답변은 별도의 포스트로 작정하겠다.
원글에 대한 저작권은 Richard Meredith에게 있습니다.
Copyright © 2017 Richard Meredith.