paint-brush
Unity 개발을 위한 완벽한 비동기 프로그래밍 입문서~에 의해@dmitrii
5,692 판독값
5,692 판독값

Unity 개발을 위한 완벽한 비동기 프로그래밍 입문서

~에 의해 Dmitrii Ivashchenko31m2023/03/30
Read on Terminal Reader
Read this story w/o Javascript

너무 오래; 읽다

이 글에서는 그러한 문제를 피하는 방법에 대해 이야기하겠습니다. 이러한 작업을 별도의 스레드에서 수행하여 메인 스레드가 다른 작업을 자유롭게 수행할 수 있도록 비동기 프로그래밍 기술을 권장합니다. 이는 원활하고 반응성이 뛰어난 게임 플레이를 보장하고 (희망적으로) 게이머에게 만족을 주는 데 도움이 될 것입니다.
featured image - Unity 개발을 위한 완벽한 비동기 프로그래밍 입문서
Dmitrii Ivashchenko HackerNoon profile picture
0-item

게임 개발의 일부 작업은 동기적이지 않고 비동기적입니다. 이는 게임 코드 내에서 선형적으로 실행되지 않음을 의미합니다. 이러한 비동기 작업 중 일부는 완료하는 데 꽤 오랜 시간이 걸릴 수 있는 반면, 일부는 집중적인 계산과 관련되어 있습니다.


가장 일반적인 게임 비동기 작업 중 일부는 다음과 같습니다.

  • 네트워크 요청 수행

  • 장면, 리소스 및 기타 자산 로드

  • 파일 읽기 및 쓰기

  • 의사결정을 위한 인공지능

  • 긴 애니메이션 시퀀스

  • 대량의 데이터 처리

  • 길 찾기


이제 결정적으로 모든 Unity 코드는 하나의 스레드에서 실행되므로 위에서 언급한 작업 중 하나와 같은 작업이 동기식으로 수행되면 메인 스레드가 차단되어 프레임이 삭제됩니다.


안녕하세요 여러분, 제 이름은 Dmitrii Ivashchenko이고 MY.GAMES의 개발 팀장입니다. 이 글에서는 그러한 문제를 피하는 방법에 대해 이야기하겠습니다. 이러한 작업을 별도의 스레드에서 수행하여 메인 스레드가 다른 작업을 자유롭게 수행할 수 있도록 비동기 프로그래밍 기술을 권장합니다. 이는 원활하고 반응성이 뛰어난 게임 플레이를 보장하고 (희망적으로) 게이머에게 만족을 주는 데 도움이 될 것입니다.

코루틴

먼저 코루틴에 대해 알아보겠습니다. 이는 .NET에 async/await가 나타나기 전인 2011년에 Unity에 도입되었습니다. Unity에서 코루틴을 사용하면 명령을 한꺼번에 실행하는 대신 여러 프레임에 걸쳐 일련의 명령을 수행할 수 있습니다. 스레드와 유사하지만 가볍고 Unity의 업데이트 루프에 통합되어 게임 개발에 매우 적합합니다.


(그런데 역사적으로 코루틴은 Unity에서 비동기 작업을 수행하는 최초의 방법이었기 때문에 인터넷에 있는 대부분의 기사는 코루틴에 관한 것입니다.)


코루틴을 만들려면 IEnumerator 반환 유형을 사용하여 함수를 선언해야 합니다. 이 함수에는 코루틴이 실행하기를 원하는 모든 논리가 포함될 수 있습니다.


코루틴을 시작하려면 MonoBehaviour 인스턴스에서 StartCoroutine 메서드를 호출하고 코루틴 함수를 인수로 전달해야 합니다.

 public class Example : MonoBehaviour { void Start() { StartCoroutine(MyCoroutine()); } IEnumerator MyCoroutine() { Debug.Log("Starting coroutine"); yield return null; Debug.Log("Executing coroutine"); yield return null; Debug.Log("Finishing coroutine"); } }


Unity에는 WaitForSeconds , WaitForEndOfFrame , WaitForFixedUpdate , WaitForSecondsRealtime , WaitUntil 등 몇 가지 항복 명령을 사용할 수 있습니다. 이를 사용하면 할당이 발생하므로 가능하면 재사용해야 한다는 점을 기억하는 것이 중요합니다.


예를 들어 문서에서 다음 방법을 고려해보세요.

 IEnumerator Fade() { Color c = renderer.material.color; for (float alpha = 1f; alpha >= 0; alpha -= 0.1f) { ca = alpha; renderer.material.color = c; yield return new WaitForSeconds(.1f); } }


루프가 반복될 때마다 new WaitForSeconds(.1f) 의 새 인스턴스가 생성됩니다. 대신 생성을 루프 외부로 이동하고 할당을 피할 수 있습니다.

 IEnumerator Fade() { Color c = renderer.material.color; **var waitForSeconds = new WaitForSeconds(0.2f);** for (float alpha = 1f; alpha >= 0; alpha -= 0.1f) { ca = alpha; renderer.material.color = c; yield return **waitForSeconds**; } }


주목해야 할 또 다른 중요한 속성은 AsyncOperationYieldInstruction 의 자손이기 때문에 yield return Unity에서 제공하는 모든 Async 메서드와 함께 사용될 수 있다는 것입니다.

 yield return SceneManager.LoadSceneAsync("path/to/scene.unity");

코루틴의 몇 가지 가능한 함정

코루틴에는 주의해야 할 몇 가지 단점도 있습니다.


  • 긴 작업의 결과를 반환하는 것은 불가능합니다. 코루틴에 전달되고 코루틴에서 데이터 추출이 완료되면 호출되는 콜백이 여전히 필요합니다.
  • 코루틴은 코루틴을 실행하는 MonoBehaviour 와 엄격하게 연결되어 있습니다. GameObject 가 꺼지거나 파괴되면 코루틴 처리가 중지됩니다.
  • 항복 구문이 있기 때문에 try-catch-finally 구조를 사용할 수 없습니다.
  • 다음 코드 실행이 시작되기 전에 yield return 후 적어도 하나의 프레임이 통과됩니다.
  • 람다 및 코루틴 자체 할당

약속

Promise는 비동기 작업을 보다 읽기 쉽게 구성하고 만들기 위한 패턴입니다. 많은 타사 JavaScript 라이브러리에서 사용되어 인기를 얻었으며 ES6부터 기본적으로 구현되었습니다.


Promise를 사용하면 비동기 함수에서 객체를 즉시 반환합니다. 이를 통해 호출자는 작업의 해결(또는 오류)을 기다릴 수 있습니다.


본질적으로 이는 비동기 메서드가 값을 반환하고 동기 메서드처럼 "작동"할 수 있도록 합니다. 즉, 최종 값을 즉시 반환하는 대신 나중에 값을 반환할 것이라는 "약속"을 제공합니다.


Unity에는 여러 가지 Promise 구현이 있습니다:


Promise와 상호작용하는 주요 방법은 콜백 함수를 이용하는 것입니다.


Promise가 해결될 때 호출될 콜백 함수와 Promise가 거부될 경우 호출될 또 다른 콜백 함수를 정의할 수 있습니다. 이러한 콜백은 비동기 작업의 결과를 인수로 수신하며, 이후 추가 작업을 수행하는 데 사용할 수 있습니다.


Promises/A+ 조직 사양 에 따르면 Promise는 다음 세 가지 상태 중 하나일 수 있습니다.


  • Pending : 초기 상태로, 비동기 작업이 아직 진행 중이며 작업 결과가 아직 알려지지 않았음을 의미합니다.
  • Fulfilled ( Resolved ): 해결된 상태에는 작업 결과를 나타내는 값이 함께 제공됩니다.
  • Rejected : 어떤 이유로든 비동기 작업이 실패하면 약속이 "거부"되었다고 합니다. 거부된 상태에는 실패 이유가 함께 표시됩니다.

약속에 대한 추가 정보

또한 Promise를 서로 연결하여 한 Promise의 결과를 사용하여 다른 Promise의 결과를 결정할 수 있습니다.


예를 들어, 서버에서 일부 데이터를 가져오는 Promise를 생성한 다음 해당 데이터를 사용하여 일부 계산 및 기타 작업을 수행하는 또 다른 Promise를 생성할 수 있습니다.

 var promise = MakeRequest("https://some.api") .Then(response => Parse(response)) .Then(result => OnRequestSuccess(result)) .Then(() => PlaySomeAnimation()) .Catch(exception => OnRequestFailed(exception));


다음은 비동기 작업을 수행하는 메서드를 구성하는 방법에 대한 예입니다.

 public IPromise<string> MakeRequest(string url) { // Create a new promise object var promise = new Promise<string>(); // Create a new web client using var client = new WebClient(); // Add a handler for the DownloadStringCompleted event client.DownloadStringCompleted += (sender, eventArgs) => { // If an error occurred, reject the promise if (eventArgs.Error != null) { promise.Reject(eventArgs.Error); } // Otherwise, resolve the promise with the result else { promise.Resolve(eventArgs.Result); } }; // Start the download asynchronously client.DownloadStringAsync(new Uri(url), null); // Return the promise return promise; }


Promise 로 코루틴을 감쌀 수도 있습니다.

 void Start() { // Load the scene and then show the intro animation LoadScene("path/to/scene.unity") .Then(() => ShowIntroAnimation()) .Then( ... ); } // Load a scene and return a promise Promise LoadScene(string sceneName) { // Create a new promise var promise = new Promise(); // Start a coroutine to load the scene StartCoroutine(LoadSceneRoutine(promise, sceneName)); // Return the promise return promise; } IEnumerator LoadSceneRoutine(Promise promise, string sceneName) { // Load the scene asynchronously yield return SceneManager.LoadSceneAsync(sceneName); // Resolve the promise once the scene is loaded promise.Resolve(); }


물론, ThenAll / Promise.AllThenRace / Promise.Race 를 사용하여 약속 실행 순서의 조합을 구성할 수 있습니다.

 // Execute the following two promises in sequence Promise.Sequence( () => Promise.All( // Execute the two promises in parallel RunAnimation("Foo"), PlaySound("Bar") ), () => Promise.Race( // Execute the two promises in a race RunAnimation("One"), PlaySound("Two") ) );

약속의 "가망 없는" 부분

모든 사용 편의성에도 불구하고 Promise에는 몇 가지 단점도 있습니다.


  • 오버헤드 : Promise 생성에는 코루틴과 같은 다른 비동기 프로그래밍 방법을 사용하는 것에 비해 추가 오버헤드가 포함됩니다. 어떤 경우에는 이로 인해 성능이 저하될 수 있습니다.
  • 디버깅 : 약속 디버깅은 다른 비동기 프로그래밍 패턴을 디버깅하는 것보다 더 어려울 수 있습니다. 실행 흐름을 추적하고 버그의 원인을 식별하는 것이 어려울 수 있습니다.
  • 예외 처리 : 다른 비동기 프로그래밍 패턴에 비해 약속을 사용하면 예외 처리가 더 복잡할 수 있습니다. Promise 체인 내에서 발생하는 오류와 예외를 관리하는 것은 어려울 수 있습니다.

비동기/대기 작업

async/await 기능은 버전 5.0(2012)부터 C#의 일부였으며 Unity 2017에서 .NET 4.x 런타임 구현과 함께 도입되었습니다.


.NET의 역사에서는 다음 단계로 구분할 수 있습니다.


  1. EAP (이벤트 기반 비동기 패턴): 이 접근 방식은 작업 완료 시 트리거되는 이벤트와 이 작업을 호출하는 일반 메서드를 기반으로 합니다.
  2. APM (비동기 프로그래밍 모델): 이 접근 방식은 두 가지 방법을 기반으로 합니다. BeginSmth 메서드는 IAsyncResult 인터페이스를 반환합니다. EndSmth 메서드는 IAsyncResult 사용합니다. EndSmth 호출 시 작업이 완료되지 않으면 스레드가 차단됩니다.
  3. TAP (작업 기반 비동기 패턴): 이 개념은 async/await 및 TaskTask<TResult> 유형을 도입하여 개선되었습니다.


이전 접근 방식은 마지막 접근 방식의 성공으로 인해 더 이상 사용되지 않습니다.

비동기 메서드를 만들려면 메서드를 async 키워드로 표시하고 내부에 await 포함해야 하며 반환 값은 Task , Task<T> 또는 void (권장하지 않음)여야 합니다.

 public async Task Example() { SyncMethodA(); await Task.Delay(1000); // the first async operation SyncMethodB(); await Task.Delay(2000); // the second async operation SyncMethodC(); await Task.Delay(3000); // the third async operation }


이 예에서는 다음과 같이 실행됩니다.


  1. 먼저, 첫 번째 비동기 작업( SyncMethodA ) 호출 이전의 코드가 실행됩니다.
  2. 첫 번째 비동기 작업이 await Task.Delay(1000) 실행되며 실행될 것으로 예상됩니다. 한편, 비동기 작업이 완료될 때("계속") 호출될 코드가 저장됩니다.
  3. 첫 번째 비동기 작업이 완료된 후 "계속" — 다음 비동기 작업( SyncMethodB )까지의 코드가 실행되기 시작합니다.
  4. 두 번째 비동기 작업( await Task.Delay(2000) )이 시작되고 실행될 것으로 예상됩니다. 동시에 연속 작업, 즉 두 번째 비동기 작업( SyncMethodC ) 다음의 코드가 유지됩니다.
  5. 두 번째 비동기 작업이 완료된 후 SyncMethodC 실행되고 이어서 await Task.Delay(3000) 세 번째 비동기 작업이 실행될 때까지 기다립니다.


이는 간단한 설명입니다. 실제로 async/await는 비동기 메서드를 편리하게 호출하고 완료를 기다리는 데 도움이 되는 구문적 설탕이기 때문입니다.


WhenAllWhenAny 사용하여 실행 순서의 조합을 구성할 수도 있습니다.

 var allTasks = Task.WhenAll( Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }) ); allTasks.ContinueWith(t => { Console.WriteLine("All the tasks are completed"); }); var anyTask = Task.WhenAny( Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }) ); anyTask.ContinueWith(t => { Console.WriteLine("One of tasks is completed"); });

IAsyncState머신

C# 컴파일러는 async/await 호출을 비동기 작업을 완료하기 위해 수행해야 하는 순차적 작업 집합인 IAsyncStateMachine 상태 시스템으로 변환합니다.


대기 작업을 호출할 때마다 상태 시스템은 작업을 완료하고 해당 작업이 완료될 때까지 기다린 후 계속해서 다음 작업을 실행합니다. 이를 통해 기본 스레드를 차단하지 않고 백그라운드에서 비동기 작업을 수행할 수 있으며 비동기 메서드 호출을 더 간단하고 읽기 쉽게 만듭니다.


따라서 Example 메서드는 [AsyncStateMachine(typeof(ExampleStateMachine))] 주석을 사용하여 상태 기계를 생성하고 초기화하는 것으로 변환되며, 상태 기계 자체는 대기 호출 수와 동일한 수의 상태를 갖습니다.


  • 변환된 방법의 예 Example

     [AsyncStateMachine(typeof(ExampleStateMachine))] public /*async*/ Task Example() { // Create a new instance of the ExampleStateMachine class ExampleStateMachine stateMachine = new ExampleStateMachine(); // Create a new AsyncTaskMethodBuilder and assign it to the taskMethodBuilder property of the stateMachine instance stateMachine.taskMethodBuilder = AsyncTaskMethodBuilder.Create(); // Set the currentState property of the stateMachine instance to -1 stateMachine.currentState = -1; // Start the stateMachine instance stateMachine.taskMethodBuilder.Start(ref stateMachine); // Return the Task property of the taskMethodBuilder return stateMachine.taskMethodBuilder.Task; }


  • 생성된 상태 머신의 예 ExampleStateMachine

     [CompilerGenerated] private sealed class ExampleStateMachine : IAsyncStateMachine { public int currentState; public AsyncTaskMethodBuilder taskMethodBuilder; private TaskAwaiter taskAwaiter; public int paramInt; private int localInt; void IAsyncStateMachine.MoveNext() { int num = currentState; try { TaskAwaiter awaiter3; TaskAwaiter awaiter2; TaskAwaiter awaiter; switch (num) { default: localInt = paramInt; // Call the first synchronous method SyncMethodA(); // Create a task awaiter for a delay of 1000 milliseconds awaiter3 = Task.Delay(1000).GetAwaiter(); // If the task is not completed, set the current state to 0 and store the awaiter if (!awaiter3.IsCompleted) { currentState = 0; taskAwaiter = awaiter3; // Store the current state machine ExampleStateMachine stateMachine = this; // Await the task and pass the state machine taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter3, ref stateMachine); return; } // If the task is completed, jump to the label after the first await goto Il_AfterFirstAwait; case 0: // Retrieve the awaiter from the taskAwaiter field awaiter3 = taskAwaiter; // Reset the taskAwaiter field taskAwaiter = default(TaskAwaiter); currentState = -1; // Jump to the label after the first await goto Il_AfterFirstAwait; case 1: // Retrieve the awaiter from the taskAwaiter field awaiter2 = taskAwaiter; // Reset the taskAwaiter field taskAwaiter = default(TaskAwaiter); currentState = -1; // Jump to the label after the second await goto Il_AfterSecondAwait; case 2: // Retrieve the awaiter from the taskAwaiter field awaiter = taskAwaiter; // Reset the taskAwaiter field taskAwaiter = default(TaskAwaiter); currentState = -1; break; Il_AfterFirstAwait: awaiter3.GetResult(); // Call the second synchronous method SyncMethodB(); // Create a task awaiter for a delay of 2000 milliseconds awaiter2 = Task.Delay(2000).GetAwaiter(); // If the task is not completed, set the current state to 1 and store the awaiter if (!awaiter2.IsCompleted) { currentState = 1; taskAwaiter = awaiter2; // Store the current state machine ExampleStateMachine stateMachine = this; // Await the task and pass the state machine taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter2, ref stateMachine); return; } // If the task is completed, jump to the label after the second await goto Il_AfterSecondAwait; Il_AfterSecondAwait: // Get the result of the second awaiter awaiter2.GetResult(); // Call the SyncMethodC SyncMethodC(); // Create a new awaiter with a delay of 3000 milliseconds awaiter = Task.Delay(3000).GetAwaiter(); // If the awaiter is not completed, set the current state to 2 and store the awaiter if (!awaiter.IsCompleted) { currentState = 2; taskAwaiter = awaiter; // Set the stateMachine to this ExampleStateMachine stateMachine = this; // Await the task and pass the state machine taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); return; } break; } // Get the result of the awaiter awaiter.GetResult(); } catch (Exception exception) { currentState = -2; taskMethodBuilder.SetException(exception); return; } currentState = -2; taskMethodBuilder.SetResult(); } void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { /*...*/ } }

동기화컨텍스트

AwaitUnsafeOnCompleted 호출에서 현재 동기화 컨텍스트 SynchronizationContext 가져옵니다. 동기화Context 는 일련의 비동기 작업 실행을 제어하는 컨텍스트를 나타내는 데 사용되는 C#의 개념입니다. 여러 스레드에서 코드 실행을 조정하고 코드가 특정 순서로 실행되도록 하는 데 사용됩니다. SynchronizationContext의 주요 목적은 다중 스레드 환경에서 비동기 작업의 예약 및 실행을 제어하는 방법을 제공하는 것입니다.


다양한 환경에서 SynchronizationContext 는 서로 다른 구현을 갖습니다. 예를 들어 .NET에는 다음이 있습니다.


  • WPF : System.Windows.Threading.DispatcherSynchronizationContext
  • WinForms : System.Windows.Forms.WindowsFormsSynchronizationContext
  • WinRT : System.Threading.WinRTSynchronizationContext
  • ASP.NET : System.Web.AspNetSynchronizationContext


Unity에는 자체 동기화 컨텍스트 UnitySynchronizationContext 도 있는데, 이를 통해 PlayerLoop API에 바인딩하여 비동기 작업을 사용할 수 있습니다. 다음 코드 예제에서는 Task.Yield() 사용하여 각 프레임에서 객체를 회전하는 방법을 보여줍니다.

 private async void Start() { while (true) { transform.Rotate(0, Time.deltaTime * 50, 0); await Task.Yield(); } }


Unity에서 async/await를 사용하여 네트워크 요청을 하는 또 다른 예:

 using UnityEngine; using System.Net.Http; using System.Threading.Tasks; public class NetworkRequestExample : MonoBehaviour { private async void Start() { string response = await GetDataFromAPI(); Debug.Log("Response from API: " + response); } private async Task<string> GetDataFromAPI() { using (var client = new HttpClient()) { var response = await client.GetStringAsync("https://api.example.com/data"); return response; } } }


UnitySynchronizationContext 덕분에 이 코드의 실행이 기본 Unity 스레드에서 계속되므로 비동기 작업이 완료된 직후 UnityEngine 메서드(예: Debug.Log() )를 안전하게 사용할 수 있습니다.

작업완료소스<T>

이 클래스를 사용하면 Task 개체를 관리할 수 있습니다. 이는 기존 비동기 방식을 TAP에 적용하기 위해 만들어졌지만 일부 이벤트에 따라 장기 실행 작업을 Task 으로 래핑하려는 경우에도 매우 유용합니다.


다음 예에서 taskCompletionSource 내부의 Task 개체는 시작 후 3초 후에 완료되며 Update 메서드에서 해당 결과를 가져옵니다.

 using System.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { private TaskCompletionSource<int> taskCompletionSource; private void Start() { // Create a new TaskCompletionSource taskCompletionSource = new TaskCompletionSource<int>(); // Start a coroutine to wait 3 seconds // and then set the result of the TaskCompletionSource StartCoroutine(WaitAndComplete()); } private IEnumerator WaitAndComplete() { yield return new WaitForSeconds(3); // Set the result of the TaskCompletionSource taskCompletionSource.SetResult(10); } private async void Update() { // Await the result of the TaskCompletionSource int result = await taskCompletionSource.Task; // Log the result to the console Debug.Log("Result: " + result); } }

취소 토큰

취소 토큰은 C#에서 작업을 취소해야 함을 알리는 데 사용됩니다. 토큰은 작업이나 작업에 전달되며, 작업이나 작업 내의 코드는 토큰을 주기적으로 확인하여 작업이나 작업을 중지해야 하는지 여부를 결정할 수 있습니다. 이를 통해 작업이나 작업을 갑자기 종료하는 대신 깔끔하고 우아하게 취소할 수 있습니다.


취소 토큰은 사용자가 장기 실행 작업을 취소할 수 있는 상황이나 사용자 인터페이스의 취소 버튼과 같이 작업이 더 이상 필요하지 않은 경우에 일반적으로 사용됩니다.


전체 패턴은 TaskCompletionSource 의 사용과 유사합니다. 먼저 CancellationTokenSource 가 생성된 다음 해당 Token 비동기 작업에 전달됩니다.

 public class ExampleMonoBehaviour : MonoBehaviour { private CancellationTokenSource _cancellationTokenSource; private async void Start() { // Create a new CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); // Get the token from the CancellationTokenSource CancellationToken token = _cancellationTokenSource.Token; try { // Start a new Task and pass in the token await Task.Run(() => DoSomething(token), token); } catch (OperationCanceledException) { Debug.Log("Task was cancelled"); } } private void DoSomething(CancellationToken token) { for (int i = 0; i < 100; i++) { // Check if the token has been cancelled if (token.IsCancellationRequested) { // Return if the token has been cancelled return; } Debug.Log("Doing something..."); // Sleep for 1 second Thread.Sleep(1000); } } private void OnDestroy() { // Cancel the token when the object is destroyed _cancellationTokenSource.Cancel(); } }


작업이 취소되면 OperationCanceledException 이 발생하고 Task.IsCanceled 속성이 true 로 설정됩니다.

Unity 2022.2의 새로운 비동기 기능

Task 개체는 Unity가 아닌 .NET 런타임에 의해 관리되며 작업을 실행하는 개체가 삭제되는 경우(또는 게임이 편집기에서 게임 모드를 종료하는 경우) 작업은 Unity가 실행하는 대로 계속 실행된다는 점에 유의하는 것이 중요합니다. 취소할 방법이 없습니다.


항상 해당 CancellationToken 과 함께 await Task 수행해야 합니다. 이로 인해 코드가 일부 중복되며 Unity 2022.2에서는 MonoBehaviour 수준과 전체 Application 수준의 내장 토큰이 나타났습니다.


MonoBehaviour 개체의 destroyCancellationToken 사용할 때 이전 예제가 어떻게 변경되는지 살펴보겠습니다.

 using System.Threading; using System.Threading.Tasks; using UnityEngine; public class ExampleMonoBehaviour : MonoBehaviour { private async void Start() { // Get the cancellation token from the MonoBehaviour CancellationToken token = this.destroyCancellationToken; try { // Start a new Task and pass in the token await Task.Run(() => DoSomething(token), token); } catch (OperationCanceledException) { Debug.Log("Task was cancelled"); } } private void DoSomething(CancellationToken token) { for (int i = 0; i < 100; i++) { // Check if the token has been cancelled if (token.IsCancellationRequested) { // Return if the token has been cancelled return; } Debug.Log("Doing something..."); // Sleep for 1 second Thread.Sleep(1000); } } }


더 이상 CancellationTokenSource 수동으로 생성하고 OnDestroy 메서드에서 작업을 완료할 필요가 없습니다. 특정 MonoBehaviour 와 연관되지 않은 작업의 경우 UnityEngine.Application.exitCancellationToken 사용할 수 있습니다. 그러면 플레이 모드(에디터 내)를 종료하거나 애플리케이션을 종료할 때 작업이 종료됩니다.

유니태스크

.NET 태스크가 제공하는 기능과 사용 편의성에도 불구하고 Unity에서 사용할 경우 다음과 같은 심각한 단점이 있습니다.


  • Task 개체는 너무 번거롭고 많은 할당을 유발합니다.
  • Task Unity 스레딩(단일 스레드)과 일치하지 않습니다.


UniTask 라이브러리는 스레드나 SynchronizationContext 사용하지 않고 이러한 제한을 우회합니다. UniTask<T> 구조체 기반 유형을 사용하여 할당이 없도록 합니다.


UniTask에는 .NET 4.x 스크립팅 런타임 버전이 필요하며 Unity 2018.4.13f1이 공식적으로 가장 낮은 지원 버전입니다.


또한 확장 메서드를 사용하여 모든 AsyncOperations UnitTask 로 변환할 수 있습니다.

 using UnityEngine; using UniTask; public class AssetLoader : MonoBehaviour { public async void LoadAsset(string assetName) { var loadRequest = Resources.LoadAsync<GameObject>(assetName); await loadRequest.AsUniTask(); var asset = loadRequest.asset as GameObject; if (asset != null) { // Do something with the loaded asset } } }


이 예제에서 LoadAsset 메서드는 Resources.LoadAsync 사용하여 자산을 비동기적으로 로드합니다. 그런 다음 AsUniTask 메서드는 LoadAsync 에서 반환된 AsyncOperation 기다릴 수 있는 UniTask 로 변환하는 데 사용됩니다.


이전과 마찬가지로 UniTask.WhenAllUniTask.WhenAny 사용하여 실행 순서의 조합을 구성할 수 있습니다.

 using System.Threading; using Cysharp.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { private async void Start() { // Start two Tasks and wait for both to complete await UniTask.WhenAll(Task1(), Task2()); // Start two Tasks and wait for one to complete await UniTask.WhenAny(Task1(), Task2()); } private async UniTask Task1() { // Do something } private async UniTask Task2() { // Do something } }


UniTask에는 더 나은 성능을 위해 UnitySynchronizationContext 대체하는 데 사용할 수 있는 UniTaskSynchronizationContext 라는 또 다른 SynchronizationContext 컨텍스트 구현이 있습니다.

대기 가능한 API

Unity 2023.1의 첫 번째 알파 버전에서는 Awaitable 클래스가 도입되었습니다. 대기 가능한 코루틴은 Unity에서 실행되도록 설계된 비동기/대기 호환 작업 유형입니다. .NET 작업과 달리 런타임이 아닌 엔진에 의해 관리됩니다.

 private async Awaitable DoSomethingAsync() { // awaiting built-in events await Awaitable.EndOfFrameAsync(); await Awaitable.WaitForSecondsAsync(); // awaiting .NET Tasks await Task.Delay(2000, destroyCancellationToken); await Task.Yield(); // awaiting AsyncOperations await SceneManager.LoadSceneAsync("path/to/scene.unity"); // ... }


이를 기다리고 비동기 메서드의 반환 유형으로 사용할 수 있습니다. System.Threading.Tasks 와 비교하면 덜 정교하지만 Unity 관련 가정을 기반으로 성능 향상 지름길을 사용합니다.


.NET 작업과 비교한 주요 차이점은 다음과 같습니다.


  • Awaitable 객체는 한 번만 기다릴 수 있습니다. 여러 비동기 함수에서는 기다릴 수 없습니다.
  • Awaiter.GetResults() 완료될 때까지 차단되지 않습니다. 작업이 완료되기 전에 호출하는 것은 정의되지 않은 동작입니다.
  • ExecutionContext 를 캡처하지 마십시오. 보안상의 이유로 .NET 작업은 비동기 호출 전체에 가장 컨텍스트를 전파하기 위해 대기할 때 실행 컨텍스트를 캡처합니다.
  • 절대로 SynchronizationContext 캡처하지 마세요. 코루틴 연속은 완료를 발생시키는 코드에서 동기적으로 실행됩니다. 대부분의 경우 이는 Unity 메인 프레임에서 발생합니다.
  • Awaitable은 과도한 할당을 방지하기 위해 풀링된 개체입니다. 이는 참조 유형이므로 다양한 스택에서 참조하고 효율적으로 복사할 수 있습니다. 비동기 상태 시스템에서 생성된 일반적인 가져오기/해제 시퀀스에서 Stack<T> 범위 검사를 방지하기 위해 ObjectPool 이 개선되었습니다.


시간이 오래 걸리는 작업의 결과를 얻으려면 Awaitable<T> 유형을 사용할 수 있습니다. TaskCompletitionSource 와 유사하게 AwaitableCompletionSourceAwaitableCompletionSource<T> 사용하여 Awaitable 완료를 관리할 수 있습니다 .

 using UnityEngine; using Cysharp.Threading.Tasks; public class ExampleBehaviour : MonoBehaviour { private AwaitableCompletionSource<bool> _completionSource; private async void Start() { // Create a new AwaitableCompletionSource _completionSource = new AwaitableCompletionSource<bool>(); // Start a coroutine to wait 3 seconds // and then set the result of the AwaitableCompletionSource StartCoroutine(WaitAndComplete()); // Await the result of the AwaitableCompletionSource bool result = await _completionSource.Awaitable; // Log the result to the console Debug.Log("Result: " + result); } private IEnumerator WaitAndComplete() { yield return new WaitForSeconds(3); // Set the result of the AwaitableCompletionSource _completionSource.SetResult(true); } }


때로는 게임이 중단될 수 있는 대규모 계산을 수행해야 하는 경우도 있습니다. 이를 위해서는 Awaitable 메서드( BackgroundThreadAsync()MainThreadAsync() 를 사용하는 것이 좋습니다. 이를 통해 메인 스레드를 종료하고 다시 돌아갈 수 있습니다.

 private async Awaitable DoCalculationsAsync() { // Awaiting execution on a ThreadPool background thread. await Awaitable.BackgroundThreadAsync(); var result = PerformSomeHeavyCalculations(); // Awaiting execution on the Unity main thread. await Awaitable.MainThreadAsync(); // Using the result in main thread Debug.Log(result); }


이러한 방식으로 Awaitables는 .NET 작업 사용의 단점을 제거하고 PlayerLoop 이벤트 및 AsyncOperations 대기도 허용합니다.

결론

보시다시피, Unity가 개발되면서 비동기 작업을 구성하기 위한 도구가 점점 더 많아지고 있습니다.

단일성

코루틴

약속

.NET 작업

유니태스크

내장된 취소 토큰

대기 가능한 API

5.6





2017.1




2018.4



2022.2


2023.1


우리는 Unity에서 비동기 프로그래밍의 모든 주요 방법을 고려했습니다. 작업의 복잡성과 사용 중인 Unity 버전에 따라 코루틴 및 약속부터 작업 및 대기 테이블까지 다양한 기술을 사용하여 게임에서 원활하고 원활한 게임플레이를 보장할 수 있습니다. 읽어주셔서 감사합니다. 다음 걸작을 기다리겠습니다.