Table of contents
🌐 Translate this post:

2025年的現在,對於Unity的開發者來說,在C#透過Async Task寫非同步執行的程式在已經是一個不得不學的技能了,Unity對於C# async task的支援甚至能夠回推到Unity 2017,但當時實際上使用的人還不像現在這麼多,這篇部落格是我透過看Stephen Toub的Deep .NET: Writing async/await from scratch in C# with Stephen Toub and Scott Hanselman以及同樣是Stepen Toub寫的部落格 - How Async/Await Really Works in C#,實作一個MyTask類別以及在Unity做了一些不同的實驗,來理解C#是如何透過async task來達成非同步執行的紀錄。

Warm Up


這部分會簡單複習一下同步/非同步執行程式以及Corutine與Async Task的寫法,如果你已經熟悉Coroutine與Async Task的區別,只是跟我一樣好奇C# Aync Task的實作原理,你可以跳過下面這部分。

同步執行 V.S 非同步執行

對於Unity的開發者來說,非同步執行不是什麼稀奇的事情,我們從很久以前就再用Unity專為非同步執行打造的類別 - Coroutine,來實作非同步執行與橫跨多個Frame的邏輯了。

簡單回顧一下同步與非同步在Unity中的差異,下面是一段讓物件的透明度從0變化成1的程式碼,沒有使用Coroutine、同步執行的版本:

[ContextMenu("Perform Fading Synchronously")]
public void PerformFade()
{
    Debug.Log("Start");
    Debug.Log("====Code execute synchronously , main thread will only execute other codes after Fade() complete====");
    
    Fade();
        
    Debug.Log("Exit");
}

public void Fade()
{
    Color c = _renderer.material.color;
    for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
    {
        c.a = alpha;
        _renderer.material.color = c;
    }
    Debug.Log("Fading Completed");
}

執行的結果如下圖 -

128BD5FD-9B94-444E-A30A-AF69C455C043.png

等Fade()全部跑完之後,Exit才會被印出,但同時,使用者也看不到透明度由1變成0中間變化的過程,原因是Unity的Main Thread也會等這段程式碼執行完成後,才執行畫面的渲染,同時,在這段程式執行完畢之前main thread都是無法操作的。

使用Unity的Coroutine的話,可以讓透明度的變化每個Frame都-0.1,也就是變化的過程分散到10個Frame中。

    [ContextMenu("Perform Fading With Coroutine")]
    public void PerformFadeCoroutine()
    {
        Debug.Log("Start");
        Debug.Log("====Code execution won't wait the coroutine complete====");
        
        StartCoroutine(FadeCoroutine());
        
        Debug.Log("Exit");
    }
    
    public IEnumerator FadeCoroutine()
    {
        Color c = _renderer.material.color;
        for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
        {
            c.a = alpha;
            _renderer.material.color = c;
            yield return new WaitForEndOfFrame();
        }
        Debug.Log("fading completed");
    }

Coroutine的執行結果 :

3EE4B961-8393-4FAC-9172-6E308C18686E.png

印出Log後則會看到PerformFadeCoroutine() function本身會先跑完,而Coroutine則是會繼續非同步執行,直到經過10個frame後Fading完成。

Unity Coroutine VS Async Task

既然Unity的Coroutine這麼好用,我們為什麼還需要Async Task呢?

我自己覺得有一個很大的原因是 - StartCoroutine本身是void的,沒有return value,我們很難很直覺的從code裡得到Coroutine執行的結果,最終我們會將很多作為Flag的變數放到程式內來代表各種不同的狀態,以及將callback當作參數傳入Coroutine做為接續不同狀態後續的邏輯,這直接導致了邏輯難以維護跟擴展。

下面我用一個實作彈出式視窗的程式碼為例,這一個彈出式視窗會表演一段動畫,並在動畫完成之後才能互動,使用者按下 確認取消 按鈕分別會有不同的行為 :

使用Coroutine的場合

    public void PerformAnimatedDialog()
    {
        StartCoroutine(MyAnimatedDialog(
            () =>
            {
                //something you want to do after dialog pop up.
            },
            () =>
            {
                //logic for click the confirm button
            },
            () =>
            {
                //logic for click the cancel button
            }));
            
            PlayDialogAudio();
    }
    
    public IEnumerator MyAnimatedDialog(Action OnAnimationCompleted, Action OnConfirm, Action OnCancel)
    {
        var dialog = Instantiate(_dialogPrefab).GetComponent<MyDialog>();
        var animator = dialog.GetComponent<Animator>();

        yield return new WaitUntil(() => animator.GetCurrentAnimatorStateInfo(0).IsName("Open") &&
                                         animator.GetCurrentAnimatorStateInfo(0).normalizedTime >= 1.0f);
        
        // make sure that only after the animation complete, player are able to click the button
        dialog.ConfirmButton.onClick.AddListener(()=>OnConfirm());
        dialog.ConfirmButton.onClick.AddListener(()=>OnCancel());
        
        OnAnimationCompleted?.Invoke();
    }

我們需要傳入各種callback來對應不同的狀態,為了避免不同callback內的邏輯互相干擾我們甚至可能還要再這段script內加入一些flag來做判斷。

💡 試想一下今天如果這一個彈出式視窗的按鈕可以再開啟其他視窗,或者要支援彈出到一半取消的動作,這段邏輯會變得複雜許多。

而下一個來寫這段程式碼的人就會在IDE灑滿breakpoint跟log來試圖整理出整個callback執行的順序。

Async Task的場合

    public async Task PerformAnimatedDialogAsync()
    {
        var onConfirmTcs = new TaskCompletionSource<bool>();
        var onCancelTcs = new TaskCompletionSource<bool>();

        var buttonTasks = new List<Task>();
        buttonTasks.Add(onConfirmTcs.Task);
        buttonTasks.Add(onCancelTcs.Task);
        
        var dialog = Instantiate(_dialogPrefab).GetComponent<MyDialog>();
        PlayDialogAudio();
       
        // waiting for the popup animation 
        await dialog.PopupAnimationTask();
        dialog.RegisterButtons(onConfirmTcs, onCancelTcs);
        
        // waiting until any button get clicked
        var waitButtonsTask = Task.WhenAny(buttonTasks);
        await waitButtonsTask;
        
        // unregister button events
        dialog.UnregisterButtons();
        
        if (waitButtonsTask.Result == onCancelTcs.Task)
        {
            // impl the following behaviour when user click the confirm button
        }
        else
        {
            // impl the following behaviour when user click the cancel button
        }
    }

上面這段程式碼中,我將彈出式視窗的動畫演出視為一個Task,並且不再將點擊各個button後的邏輯作為callback傳入,而是將每個button當作各別的獨立Task。

從上到下的程式碼執行順序為 :

  1. 等待PopupAnimationTask完成 →綁定button事件
  2. 等待任一Button的Task完成(waitButtonsTask) → 解綁定button事件
  3. 根據waitButtonsTask.Result來判斷是哪一個button被按下,執行後續的邏輯。

你甚至可以注意到這一整段method有一個Task的return value,如果這段彈出式視窗本身是由上層的其他UI呼叫出來的,則其他UI的程式碼中,在不依賴callback的情況下也也可以根據Task的狀態判斷這一個彈出式視窗是否完成所有動作。案

💡 如果想取消Task(或多個串連在一起的Task),我們可以透過CancellationTokenSource的CancellationToken來控制

C#中Aync Task的實作原理


這段實作會分成三個部分 - Thread Pool、Task、以及讓程式能非同步執行的Async/Await

Thread Pool

開始寫Task前,我們會用到一個自訂的Thread Pool - MyThreadPool

public static class MyThreadPool
{
  private static readonly BlockingCollection<(Action, ExecutionContext)> s_workItems = new();
  public static void QueueUserWorkItem(Action action) => s_workItems.Add((action, ExecutionContext.Capture()));

  static MyThreadPool()
  {
     for (int i = 0; i < Environment.ProcessorCount; i++)
     {
        new Thread(() =>
        {
           while (true)
           {
              (Action workItem,ExecutionContext? context) = s_workItems.Take();
              if (context is null)
              {
                 workItem();
              }
              else
              {
                 ExecutionContext.Run(context, state=> ((Action)state!).Invoke(), workItem);
              }
           }
        })
        { IsBackground = true }.Start();
     }
  }
}

這個類別會先建立N個Worker Thread,每個thread會去檢查s_workItems這一個BlockCollection是否否數量大於0,如果有數量不為0的話,就會有某個worker thread去執行該item(這些item就是我們在Task中enqueue的任何callback以及像是透過Task.Run執行的程式)。

💡 BlockCollection是一個Thread-Safe、支援從不同thread存取的collection容器,collection如果數量是0,則該thread的程式碼執行會等到Collection數量大於0的時候才執行。

實作Task

Task本身只是一個標記了一個工作完成與否,以及可以設定工作完成後,接續的callback的物件,一個最基本的Task會有如下的API :

public class MyTask
{
		private void Complete(Exception? exception);
		
		public bool IsCompleted();
		// mark the task complete immediately
		public void SetResult() => Complete(null);
		public void SetException(Exception exception) => Complete(exception);
		
		// block the code execution until the task comeplete
		public void Wait();
		// setup continuation callback that will be invoke once the task complete 
		public void ContinueWith(Action action);
	 
	 
		//member
	  private bool _completed;
		private Exception? _exception;
		
		// the continuation callback that will be invoke once the task complete 
		private Action? _continuation;
		private ExecutionContext? _context;
}

IsCompleted用來確認該Task是否已經完成。

SetResultSetException都是將該Task完成的API,差別只是SetExecption在完成Task的同時會拋出例外。

Wait的作用是在非同步是在該Task完成之前,讓目前的執行序暫停,要記得這個 Wait本身不是async task中非同步執行中的暫時中斷→再執行的主要角色,僅僅是負責在使用者想要在同步執行的程式碼中,直接獲得Result前的強制等待。 ContinueWith這個API讓使用者可以傳入在這個Task完成時需要被invoke的delegate。

💡 使用者傳給Task執行的callback,都會透過C#的ThreadPool來執行,實作 Complete以及 ContinueWith時會看到將callback enqueue至ThreadPool的程式碼。

接下來實作Task的 Complete以及 ContinueWith

Complete

// MyTask Class
private void Complete(Exception? exception)
{
   lock (this)
   {
      if (_completed)
         throw new InvalidOperationException("Tried to complete an already completed task!!");
      
      _completed = true;
      _exception = exception;
      if (_continuation is not null)
      {
         MyThreadPool.QueueUserWorkItem(() =>
         {
            if (_context is null)
            {
               _continuation();
            }
            else
            {
               ExecutionContext.Run(_context, state=> ((Action)state!).Invoke(), _continuation);
            }
         });
      }
   }
}

執行 Complete時(不論是 SetResult或者 SetException),我們會將Task設為完成,同時一個Task只能 完成 一次。如果嘗試對已經完成的Task呼叫 Complete則會拋出例外。

當一個Task完成之後會檢查是否有後續的_continuation,如果有的話會交給Thread Pool去執行這段callback 。

💡 為了確保不同這段邏輯在Thread之間不會互相競爭,這裡使用了lock(this),這是不理想的做法(這段code只是個簡單的範例),因為lock(this)實質上等於使用一個public的lock物件,因此外部的使用者是可以使用這個lock並造成deadlock的。

官方的C# Task內部都是lock-free的實作。

ContinueWith

public void ContinueWith(Action action)
{
   lock (this)
   {
      if (_completed)
      {
         MyThreadPool.QueueUserWorkItem(action);
      }
      else
      {
         _continuation = action;
         _context = ExecutionContext.Capture();
      }
   }
}

ContinueWith的主要流程很簡單 :

  • 如果Task已經完成 :

直接將該callback交給Thread Pool去執行

  • 如果Task尚未完成 :

將該callback存為_continuation成員變數,在Task的 Complete被呼叫時這個_continuation會自動被執行

Wait

public void Wait()
{
   ManualResetEventSlim? mres = null;
   lock (this)
   {
      if (!_completed)
      {
         mres = new ManualResetEventSlim();
         // The callback - mres.Set(), will be cached to our task inside ContinueWith
         // And then execute by Complete(), once the task is done.
         ContinueWith(mres.Set);
      }
   }
   mres?.Wait();
   if (_exception != null)
   {
      ExceptionDispatchInfo.Throw(_exception);  
   }
}

Wait的實作方式是先檢查當下Task是否已經完成,如果是尚未完成的Task則將呼叫ManualResetEventSlim.Set()的動作作為callback傳入至我們上方實作的 ContinueWith,這個thread的執行在直到這個ManualResetEventSlim.Set()被呼叫之前都會被block住。反之只要這個Task一完成thread就會繼續往下執行了。

💡 看到 Wait這段程式碼應該就會注意到,暫停當前的thread的操作在某些情況下 (Task也等待當前thread進行某些動作才能完成) 是會造成Dead Lock的,稍後會有範例。

ExecutionContext

💡 關於ExecutionContext,我們等實作完整個Async Task後再回過頭來看(因為要連同SynchronizationContext一起講),目前就先記得有這個東西就好。

Task.Delay

在上面我們已經實作了一個 MyTask類別,透過C#的 Timer我們可以實作一個 MyTask版本的Delay(sec)。

//建立一個MyTask,在經過指定的時間之後,這個MyTask會被設定為完成。
public static MyTask Delay(int delayTime)
{
    MyTask t = new();
    new Timer(_ => t.SetResult()).Change(delayTime, -1);
    return t;
}

測試Delay與Wait

[ContextMenu("Demo Delay With Synchronous Waiting")]
public void DelayWithTask()
{
   void DemoDelayTasks()
   {
      MyTask.Delay(1000).Wait();
      Debug.Log(LogWithTimestamp("Hello"));
   
      MyTask.Delay(1000).Wait();
      Debug.Log(LogWithTimestamp("World"));
   
      MyTask.Delay(1000).Wait();
      Debug.Log(LogWithTimestamp("How's your day?"));
   
      MyTask.Delay(1000).Wait();
      Debug.Log("======end======");
   }
   
   new Thread(() =>
   {
      Debug.Log("======start======");
      DemoDelayTasks();
      Debug.Log("======end======");
   }).Start();
}

執行**DelayWithTask()**的結果 :

FA5A76B4-5A32-4766-8C5F-C17FE6B28CFD.png

可以看到每印出一段字串後就會等待1秒,並且整個過程都是同步執行的

thread line 1 → ======start======

thread line 2→等待 DemoDelayTasks內所有Task依序完成

thread line 3→ ======end======

請注意,我們到目前為止都還沒實作真正的非同步執行,這裡的等待是在同一條thread內做同步的等待,也就是說在各個 MyTask.Delay從開始到完成的途中,當前thread是無法進行其他操作的。

💡 範例中的 **DelayWithTask**將後續的執行都夾在一個new Thread()之中,這只是為了避免呼叫 Wait後讓整個Unity的Main Thread凍結,導致無法觀測log的解法,實務上,99.9%的場合我們並不會直接使用 Wait

串聯多個Task - 需求

目前 MyTask.Wait只能對一個Task產生作用,如果想要將Task A、B、C互相依賴 - 當等待Task A時,也要一併等待B與C都完成,就要讓 ContinueWith的參數與自身都可以回傳Task而不是void -

串聯多個Task - 新增另一種ContinueWith

public MyTask ContinueWith(Func<MyTask> action)
{
    MyTask t = new();
    Action callback = () =>
    {
        try
        {
            MyTask next = action();
            
            next.ContinueWith(delegate
            {
                if (next._exception is not null)
                {
                    t.SetException(next._exception);
                }
                else
                {
                    t.SetResult();
                }
            }, ct);
        }
        catch (Exception e)
        {
            Debug.Log("catch exception in continue with : " + e);
            t.SetException(e);
        }
    };

    lock (this)
    {
        if (_completed)
        {
            MyThreadPool.QueueUserWorkItem(callback);
        }
        else
        {
            _continuation = callback;
            _context = ExecutionContext.Capture();
        }
    }
    return t;
}

串聯多個Task - 範例

public void DemoTaskChain()
{
   new Thread(() =>
   {
      Debug.Log("======start======");
      
      Debug.Log(LogWithTimestamp("Hello"));
      MyTask.Delay(1000).ContinueWith(delegate
      {
         Debug.Log(LogWithTimestamp("World"));
         return MyTask.Delay(1000).ContinueWith(delegate 
         { 
	         Debug.Log(LogWithTimestamp("How's your day?"));
         });
      }).Wait();

      Debug.Log("======end======");
   }).Start();
}

執行結果 :

FA5A76B4-5A32-4766-8C5F-C17FE6B28CFD.png

只要對最初的Task呼叫Wait,當前的thread在所有的Task完成前就會一直被暫停住不能往下執行。

Task.Run

接著我們實作一個常用的Task - Task.Run

public static MyTask Run(Action action)
{
    MyTask t = new();
    MyThreadPool.QueueUserWorkItem(() =>
    {
        try
        {
            action();
        }
        catch (Exception e)
        {
            t.SetException(e);
            return;
        }

        t.SetResult();
    });

    return t;
}

這Task讓我們傳入的delegate可以跑在 MyThreadPool中的thread上。

接著使用下面的程式來測試與觀察印出log的結果

[ContextMenu("Demo Task Run")]
public void DemoTaskRun()
{
   new Thread(() =>
   {
      PrintNumWithThreadPool();
      
   }).Start();

   void PrintNumWithThreadPool()
   {
      Debug.Log("======Start======");
      for (int i = 0; i < 10; i++)
      {
         int value = i;
         // 因為最後呼叫了Wait(),每個Task.Run完成前,
         // 當前thread都會同步(Synchronous)的等待,完成後才會進入下個loop
         MyTask.Run(() =>
         {
            Debug.Log(value);
         }).Wait();
      }
      Debug.Log("======end======");
   }
}

FBA7D090-C84A-4CD7-A9A8-87458509873B.png

可以觀察到數字是按照順序印出的,代表Loop中每個 Task.Run都是在前一個Task完成之後,才被enqueue進threadpool中執行。

Task.WhenAll

如果我不在意多個task的執行順序,希望可以同時fire出全部的 Task.Run,並且在所有Task完成之後獲得通知,我們需要實作 Task.WhenAll

public static MyTask WhenAll(List<MyTask> tasks)
{
    MyTask t = new();
    if (tasks.Count == 0)
    {
        t.SetResult();
    }
    else
    {
        int remaining = tasks.Count;

        Action continuation = () =>
        {
            // 只有這個tasks.Count的數字歸0時,才會將這個WhenAllG設為完成
            if (Interlocked.Decrement(ref remaining) == 0)
            {
                t.SetResult();
            }
        };

        foreach (var task in tasks)
        {
            task.ContinueWith(continuation);
        }
    }
    return t;
}

因為我們是在threadpool的thread上執行所有callback的,當在不同thread上操作/檢查同一個list的長度我們需要使用Interlocked,確保這個操作是thread-safe且不會有race condition。

有了 MyTask.WhenAll後將剛才的 DemoTaskRun改造 -

  [ContextMenu("Demo Task Run")]
  public void DemoTaskRun()
  {
     new Thread(() =>
     {
        PrintNumWithThreadPool();
       
     }).Start();

     void PrintNumWithThreadPool()
     {
        Debug.Log("======Start======");
        var allTasks = new List<MyTask>();
        // 一次fire全部的MyTask.Run,將每個Task存入List當中
        for (int i = 0; i < 10; i++)
        {
           int value = i;
           allTasks.Add(MyTask.Run(() =>
           {
              Debug.Log(value);
           }));
        }
        //等待allTasks中所有的task完成
        MyTask.WhenAll(allTasks).Wait();
        
        //    WhenAll其實類似於,但透過WhenAll我們實際上只等待了一個Task物件(WhenAll本身)。
        //    foreach (var t in allTasks)
        //    {
        //       t.Wait();
        //    }
        //
        
        Debug.Log("======end======");
     }
}

DE3946A5-A4CD-475C-AC15-D7E1BA7F86B4.png

可以觀察到,log印出的數字不再按照順序了,而是依照實際上threadpool中各個thread的執行順序,並且在全部的 MyTasl.Run都完成之後,印出最後的end。

Async/Await的原理


這篇筆記寫到這邊,我都還沒真正實做非同步執行的程式,Task的 Wait讓我們的thread強制等待Task的完成,在完成前該thread都會無法操作,這種流程是同步(Synchronous)的,而Unity的Coroutine雖然是在Main thread執行,但可不會讓Main thread整個卡住! C#的 await本身不會造成停止整個thread,是如何辦到的呢?

Iterator拯救世界

為了讓Task支援非同步執行,我們需要回頭複習一下C#的IEnumerator/IEnumerable -

public static IEnumerable<int> Fib()
{
    int prev = 0, next = 1;
    yield return prev;
    yield return next;

    while (true)
    {
        int sum = prev + next;
        yield return sum;
        prev = next;
        next = sum;
    }
}

public static PrintFibonaci()
{
	  using IEnumerator<int> e = Fib().GetEnumerator();
		while (e.MoveNext())
		{
	    int i = e.Current;
	    if (i > 100) break;
	    Console.Write($"{i} ");
		}
}

在每次呼叫IEnumrator的 MoveNext後, 程式會往下執行Fib()的每一行程式碼直到yield return出現, 將return value作為Current回傳,並且記錄下當前執行的狀態,在次呼叫 MoveNext的時候會由下一行程式碼開始執行, 最終這段程式會依序印出小於100的費波那契數 :

0 1 1 2 3 5 8 13 21 34 55 89

IEnumerator/IEnumerable這一個可以yield return value並且在下次呼叫 MoveNext後回到上次執行的程式碼的特性(compiler產的狀態機),就是實作 async/await的原理。

Iterator與Task.ContinueWith的組合

上一段的 PrintFibonaci中透過while迴圈手動呼叫 MoveNext,下面我們改為透過每個Task的 ContinueWith去呼叫 MoveNext,最終我們寫出下方的Helper Method :

static MyTask IterateAsync(IEnumerable<Task> tasks)
{
    var t = new MyTask();

    IEnumerator<MyTask> e = tasks.GetEnumerator();

    void Process()
    {
        try
        {
            if (e.MoveNext())
            {
                e.Current.ContinueWith(t => Process());
                return;
            }
        }
        catch (Exception e)
        {
            t.SetException(e);
            return;
        }
        t.SetResult();
    };
    
    Process();

    return t;
}

將一個包含多個Task的Enumerable作為參數傳入MyTask.IterateAsync後,每次透過呼叫MoveNext執行/得到新的Task時,會把再次呼叫 MoveNext的callback做為該Task的 ContinueWith的參數傳入 ,這樣在該Task完成時會自動呼叫該callback,並且嘗試對Enumerator中的下一個Task做一樣的動作,在這樣的流程下重複的呼叫 MoveNext到該Enumerator的盡頭時,這一個 IterateAsync就會完成。

下面我們試著使用 IterateAsync來修改稍早寫的 DelayWithTask


/* 稍早寫的同步執行版本的DelayWithTask
[ContextMenu("Demo Delay With Synchronous Waiting")]
public void DelayWithTask()
{
   void DemoDelayTasks()
   {
      MyTask.Delay(1000).Wait();
      Debug.Log(LogWithTimestamp("Hello"));
   
      MyTask.Delay(1000).Wait();
      Debug.Log(LogWithTimestamp("World"));
   
      MyTask.Delay(1000).Wait();
      Debug.Log(LogWithTimestamp("How's your day?"));
   
      MyTask.Delay(1000).Wait();
   }
   new Thread(() =>
   {
      Debug.Log("DelayWithTask start ");
      DemoDelayTasks();
      Debug.Log("DelayWithTask end");
   }).Start();
}
*/
[ContextMenu("Demo Delay Asynchronously")]
public void DelayWithTaskIterateAsync()
{ 
   Debug.Log("======start======");
   MyTask.IterateAsync(DemoDelayTasksIterate());
   Debug.Log("======end======");
}

// 注意我們這次改為用Enumerable
private IEnumerable<MyTask> DemoDelayTasksIterate()
{
   yield return MyTask.Delay(1000);
   Debug.Log(LogWithTimestamp("Hello"));
   
   yield return MyTask.Delay(1000);
   Debug.Log(LogWithTimestamp("World"));
   
   yield return MyTask.Delay(1000);
   Debug.Log(LogWithTimestamp("How's your day?"));
   
   yield return MyTask.Delay(1000);
}

DelayWithTask的執行結果 :

43F458AE-9984-40F0-BFA9-9A7FC8F95854.png

DemoDelayTasksIterate的執行結果 :

1661CA22-3D4F-4B41-A168-BC9FE43C709A.png

比較之後會發現

  • 原本的 DelayWithTask中對每個Task都呼叫 Wait,會強制當前thread做同步的等待,導致印出end log的動作也必須等待所有Task都完成才能執行。
  • 另一方面, DelayWithTaskIterateAsync中最後一行的end log印出來的時候,Enumerable中的Task一個都還沒完成,但最終會以非同步的方式執行完所有Task了。

到這邊我們其實已經成功的實作了一個支援非同步執行的程式了!

接著在比較一下相同邏輯,但使用C#官方的 async/await Task的程式碼

[ContextMenu("Demo Delay async await")]
public void DelayWithTaskAsync()
{ 
   Debug.Log("======start======");
   DemoDelayTasksAsync();
   Debug.Log("======end======");
}

private async void DemoDelayTasksAsync()
{
   await Task.Delay(1000);
   Debug.Log(LogWithTimestamp("Hello"));
   
   await Task.Delay(1000);
   Debug.Log(LogWithTimestamp("World"));
   
   await Task.Delay(1000);
   Debug.Log(LogWithTimestamp("How's your day?"));
   await Task.Delay(1000);
}

執行 DelayWithTaskAsync的結果會與上一段中自己實作的非同步程式結果一致。 其實官方的async/await就是在告訴compiler產生對應Iterator的狀態機以及實作一個類似於一個上方的 IterateAsync程式碼。

下方是將 DelayWithTaskAsync透過DotPeek decompile出的C# code :

[ContextMenu("Demo Delay async await")]
public void DelayWithTaskAsync()
{
  Debug.Log((object) "======start======");
  this.DemoDelayTasksAsync();
  Debug.Log((object) "======end======");
}

[AsyncStateMachine(typeof (MyTaskDemo.\u003CDemoDelayTasksAsync\u003Ed__8))]
[DebuggerStepThrough]
private void DemoDelayTasksAsync()
{
  MyTaskDemo.\u003CDemoDelayTasksAsync\u003Ed__8 stateMachine = new MyTaskDemo.\u003CDemoDelayTasksAsync\u003Ed__8();
  stateMachine.\u003C\u003Et__builder = AsyncVoidMethodBuilder.Create();
  stateMachine.\u003C\u003E4__this = this;
  stateMachine.\u003C\u003E1__state = -1;
  stateMachine.\u003C\u003Et__builder.Start<MyTaskDemo.\u003CDemoDelayTasksAsync\u003Ed__8>(ref stateMachine);
}

我自己到現在還是沒有看得很懂這一個state machine內部的程式碼,確定的是這個state machine的Start會呼叫Iterator的 MoveNext,而 MoveNext內部則是會包含一段將callback傳入 Task.ContinueWith的邏輯(類似於 IterateAsync中的 Process過程),最終這個state machine會在執行所有Task後結束。

同時,類似於修改後的 MyTask.ContinueWith還有 IterateAsync,這個builder本身會是一個Task類別,這也是為什麼你可以定義一個下方的Method,沒有任何return value卻不會有comipler error的關係 -

private async Task MyMethod()
{
}

// 實際上compile出的程式碼
/*
    [AsyncStateMachine(typeof (MyTaskDemo.\u003CMyMethod\u003Ed__8))]
    [DebuggerStepThrough]
    private Task MyMethod()
    {
      MyTaskDemo.\u003CMyMethod\u003Ed__8 stateMachine = new MyTaskDemo.\u003CMyMethod\u003Ed__8();
      stateMachine.\u003C\u003Et__builder = AsyncTaskMethodBuilder.Create();
      stateMachine.\u003C\u003E4__this = this;
      stateMachine.\u003C\u003E1__state = -1;
      stateMachine.\u003C\u003Et__builder.Start<MyTaskDemo.\u003CMyMethod\u003Ed__8>(ref stateMachine);
      return stateMachine.\u003C\u003Et__builder.Task;
    }
*/

當我們使用async Task語法之後compiler就會自動產出state machine與使用 AsynTaskMethodBuilder,並且將builder本身的Task回傳。

讓自定義的Task可以支援await與async語法

其實只需要加上一點點程式碼,我們就可以讓MyTask使用一般的async/await語法 -

支援await語法

只要實作了awaiter pattern,該類別就能被await, 在原本的 MyTask 類別內新增一個 Awaiter struct,該struct必須實作INotifyCompletion 介面

public class MyTask
{
    private bool _completed;
    private bool _canceled;
    private Exception? _exception;
    private Action? _continuation;
    private ExecutionContext? _context;

    public struct Awaiter : INotifyCompletion
    {
        private MyTask task;

        public Awaiter(MyTask t)
        {
            task = t;
        }
        
        public bool IsCompleted => task.IsCompleted;

        public void OnCompleted(Action continuation)
        {
            task.ContinueWith(continuation);
        }

        public void GetResult() => task.Wait();
    }

    public Awaiter GetAwaiter() => new(this);
}

同時在MyTask中補上一個 GetAwaiter的Getter,這樣就可以在一般的async function中使用await語法來等待自定義的Task物件。

[ContextMenu("Print Num With custom Awaiter")]
private async Task PrintNumbersWithCustomAwater()
{
   Debug.Log("start of print number task");
   for (int i = 0; i < 5; i++)
   {
      await MyTask.Delay(1000);
      Debug.Log(string.Format("Print Num: {0}, Local Time: {1:HH:mm:ss}", i, DateTime.Now.ToString("ss")));
   }
   Debug.Log("=======end======");
}

支援async語法

注意到上面的funtion最終仍然是回傳Task類別,而不是自定義的MyTask,在MyTask類別上方補上AsyncMethodBuilder attribute就可以了

[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]
public class MyTask
{
......
}


//回傳型別可以改為MyTask
[ContextMenu("Print Num With custom Awaiter")]
private async MyTask PrintNumbersWithCustomAwater()
{
   Debug.Log("start of print number task");
   for (int i = 0; i < 5; i++)
   {
      await MyTask.Delay(1000);
      Debug.Log(string.Format("Print Num: {0}, Local Time: {1:HH:mm:ss}", i, DateTime.Now.ToString("ss")));
   }
   Debug.Log("=======end======");
}

執行結果

A31CBFCF-8D14-43BE-8A67-506BA5F9EC85.png

常見問題


ExecutionContext跟SynchronizationContext的作用是什麼?

ExecutionContext

Execution Context官方文件的定義是一個容器,包含了當前Thread的各種Context與狀態,比如 security context, call context, synchronization context. 還有記錄了像是 AsyncLocal這種可以確保在同一個async function內流竄,但有可能會在不同thread上操作的資料的一致性的類別。

我們可以用下面這段程式碼來稍微體會一下Execution Context的作用

  static AsyncLocal<int> _asyncLocalInt= new AsyncLocal<int>();

  static async MyTask AsyncMethodA()
  {
     _asyncLocalString.Value = 10;
     var t1 = AsyncMethodB(10);
     
     _asyncLocalString.Value = 60;
     var t2 = AsyncMethodC(60);
     
     await t1;
     await t2;
  }

  static async MyTask AsyncMethodB(int expectedValue)
  {
     Debug.Log(string.Format("Entering AsyncMethod B, Expected {0}, AsyncLocal value is {1} ", 
        expectedValue, _asyncLocalInt.Value));
     
     await MyTask.Delay(100);
     
     Debug.Log(string.Format("Exiting AsyncMethod B, Expected {0}, AsyncLocal value is '{1}'", 
        expectedValue, ""+_asyncLocalInt.Value));
  }
  
  
  static async MyTask AsyncMethodC(int expectedValue)
  {
     Debug.Log(string.Format("Entering AsyncMethod C, Expected {0}, AsyncLocal value is {1} ", 
        expectedValue, _asyncLocalInt.Value));
     
     await MyTask.Delay(100);
     
     Debug.Log(string.Format("Exiting AsyncMethod C, Expected {0}, AsyncLocal value is '{1}'", 
        expectedValue, ""+_asyncLocalInt.Value));
  }

  [ContextMenu("Test AsyncLocal Variable")]
  public  async void TestAsyncVariable()
  {
     await AsyncMethodA();
  }

這是AsyncMethodA 執行的過程:

  1. _asyncLocalInt被設為10
  2. 非同步執行→AsyncMethodB
  3. _asyncLocalInt被設為60 (AsyncMethodB 尚未完成)
  4. 非同步執行→AsyncMethodC

到第4步的時候method B 與 C 均尚未完成,也都還沒印出最後的log

對於Method B來說,預期的結果是等待一秒後印出10

對於Method C來說,預期的結果是等待一秒後印出60

如果我現在回頭把 MyTask.Complete改成不使用 ExecutionContext

private void Complete(Exception? exception)
{
    lock (this)
    {
        if (_completed)
            throw new InvalidOperationException("Tried to complete an already completed task!!");

        if (exception != null && exception is OperationCanceledException)
            _canceled = true;

        _completed = true;
        _exception = exception;
        if (_continuation is not null)
        {
            MyThreadPool.QueueUserWorkItem(() =>
            {
                
                /*if (_context is null)
                {
                    _continuation();
                }
                else
                {
                    ExecutionContext.Run(_context, state => ((Action)state!).Invoke(), _continuation);
                }*/
                
                //忽略ExecutionContext,直接讓threadpool的thread去執行continuation
                _continuation();
            });
        }
    }
}

我們會得到以下的執行結果

2DB0C075-9BAA-4754-B356-0438D53E6416.png

因為沒有透過ExecutionContext來儲存當前thread的狀態以及AsyncLocal變數,在 await 一個Task之後再次讀_asyncLocalInt的值時,我們就無法得到正確的值。

await的原理是透過Task的 _continuation 再次呼叫Iterator的 MoveNext 後,回到上一次程式執行 yield return 的地方,而_continuation 本身是透過threadpool的thread去跑的,所以每次await完之後其實跑的thread環境是有可能不同的,因此我們需要ExecutionContext才能保證資料的正確性。

SynchronizationContext

SynchronizationContext負責讓程式碼能在指定的時機、環境下執行,這個類別本身是抽象的,需要使用者端自行去實作,比如Unity就有UnitySynchronizationContext,Unity的UnitySynchronizationContext確保任何非同步的task執行完畢之後,接續的程式碼都會回到Mainthread上。

根據我目前的理解, ExecutionContext 就像是包含各種狀態的copy,呼叫ExecutionContext.Capture() 變會複製當下的各種變數與資料的數值,所以當非同步的程式在不同thread間切換的時候,透過ExecutionContext 還是能保持資料的正確性,而 SynchronizationContext則是指定了程式執行的時機與環境(比如該在哪條Thread上跑),透過 SynchronizationContext.Post()執行的程式就能夠在指定的時機與thread上執行。

await之後,接續執行的程式碼在哪個Thread上執行?

答案是 - 要看情況,往上看我們MyTask裡面 Complete 的實作,你會注意到一個Task完成之後呼叫的_continuation 都是enqueue進threadPool去執行的,而await的原理是透過Task的 _continuation 再次呼叫Iterator的 MoveNext 後,回到上一次程式執行 yield return 的地方…..所以說會變成在threadpool的thread上囉? 不完全對,因為大部分的app都有自己的SynchronizationContext ,C#在await後會使用當前的SynchronizationContext 來讓後續執行的程式回到指定的環境下執行,Unity的話就是會回到Mainthread。

我們可以用下面這段程式在Unity中做測試

  private LogWithThreadID(string log)
  {
     return string.Format("{0}, Thread ID: {1}", log, Thread.CurrentThread.ManagedThreadId);
  }
  
  [ContextMenu("Thread Id Test")]
  public async Task TestThreadID()
  {
     //SynchronizationContext.SetSynchronizationContext(null);
     
     Debug.Log(LogWithThreadID("before await, "));
     
     await Task.Delay(1000);
     Debug.Log(LogWithThreadID("after await 1, "));
     
     await Task.Delay(1000);
     Debug.Log(LogWithThreadID("after await 2, "));
     
     await Task.Delay(1000);
     Debug.Log(LogWithThreadID("after await 3, "));
  }

上面這段程式執行的結果是 :

03FEAB37-D2E9-45CA-B89A-A401719F3640.png

每次 await之後,我們的程式碼都回到main thread上印log。

現在拿掉SynchronizationContext.SetSynchronizationContext(null); 的註解,在執行一次程式 :

086B3260-84CC-4D1D-BA44-4AAC03A9CC5C.png

你沒看錯, await完成之後會留在原本的ThreadPool中的worker thread上,這時候執行任何Unity相關API都是會噴error的,這就是SynchronizationContext 扮演重要角色的地方!

async task是多執行緒的嗎?

我覺得.…不算是,async本身是一個狀態機與iterator的組合,透過串聯Task的ContinueWith與Iterator的MoveNext以及yield return來中斷執行、與回到上次執行的程式碼位置,這操作本身並沒有牽扯到多執行緒。

而Task只是一個紀錄工作是否完成的物件(C#則是把 SetResultSetException交給TaskCompletionSource這個物件),使用async Task不等於執行多執行緒,但是有例外像是Task.Run這類Task會強制在其他thread上執行。

而await語法本身也是一個Task物件,但 await完成之後的continuation是有可能在worker thread上執行的(視SynchronizationContext而定 ),也因為有SynchronizationContext的關係,因此對於一般的Unity使用者來說await的Task完成之後都會在Mainthread上接續執行的。

async void的問題是什麼?

async void的method,也就是Fire And Forget型的async method,會有這種沒有回傳值得task是因為像是UI、按鈕之類觸發的非同步程式,有回傳值好像也不太合理。但async void 有許多問題存在

  1. 不向有回傳Task值的Method,我們無法透過回傳的Task檢查完成的狀態。
  2. 當出現錯誤的時候, async Task function的Exception會放在Task物件上,async void 的Exception則是直接在SynchronizationContext上被raise,也就是說,你catch不住這個Exception,這最終有可能導致

以下面這段程式測試

private async void ThrowExceptionAsync()
{
   await Task.Run(() => throw new InvalidOperationException());
}

[ContextMenu("Test Async Void Trap")]
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
   try
   {
      //death
      ThrowExceptionAsync();
      Thread.Sleep(100);
      GC.Collect();
   }
   catch (Exception e)
   {
      // The exception is never caught here!
      Debug.Log("I caught the exception : "+e);
   }
}

執行上面這段程式的結果

F3AA2787-EAC2-4B95-BE80-7503D0911FA4.png

我們無法catch內部的Exception,如果今天是很嚴重的Exception那可能會導致程式崩潰。

我們可以透過新增HelperMethod來處理這種情況


public static class TaskEx
{
  public static void FireAndForget(this Task task)
  {
     task.ContinueWith(x =>
     {
        Debug.Log("TaskUnhandled : " + x.Exception);
     }, TaskContinuationOptions.OnlyOnFaulted);
  }
}

~~//private async void ThrowExceptionAsync()~~
private async Task ThrowExceptionAsync()
{
   await Task.Run(() => throw new InvalidOperationException());
}

[ContextMenu("Test Async Void Trap")]
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
   try
   {
      //death
      ThrowExceptionAsync().FireAndForget();
      Thread.Sleep(100);
      GC.Collect();
   }
   catch (Exception e)
   {
      // The exception is never caught here!
      Debug.Log("I caught the exception : "+e);
   }
}

8BF9C3DE-272C-4E10-A0A4-96DB8DF133D3.png

另外有個寫法滿危險的,就是呼叫一個 async Task function但是不做任何等待,這樣出事的時候不會跳出任何error,最終有可能導致UnobservedTaskException,這可能比用async void還要有風險。

private async Task ThrowExceptionAsync()
{
   await Task.Run(() => throw new InvalidOperationException());
}

[ContextMenu("Test Async Void Trap")]
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
   try
   {
      ThrowExceptionAsync();
      Thread.Sleep(100);
      GC.Collect();
   }
   catch (Exception e)
   {
      // The exception is never caught here!
      Debug.Log("I caught the exception : "+e);
   }
}

執行結果 :

86FDA938-1E05-410F-9312-2C97E4D0B6FF.png

…..非常可怕,總之如果要使用fire and forget的情境,一定要好好注意是否有正確的處理Exception。 同時確保自己使用async void的場合僅僅是在Event上,而不是僅僅為了方便。

在Unity該用Coroutine還是async task好?

Unity Coroutine的特性 :

  • 每次yield return至少會等待一個game frame (yield return null)
  • 與MonoBehaviour生命週期綁定,Game Object被銷毀,Coroutine也會被中斷
  • 一律都在MainThread上
  • 沒有return value
  • 有針對Unity Game Logic的helper,比如 WaitForSecondsWaitForEndOfFrame …etc

相比之下,C# asyn Task的特性:

  • await時間不與game frame綁定
  • 沒有針對Unity Game Logic的helper
  • 不與MonoBehaviour生命週期綁定
  • 不一定在MainThread執行
  • 有return value
  • fire and forget的狀況需要留意

對我來說async Task的最大優勢是處理複雜的User-IO流程,也可以用非常容易讀且易於擴充的程式碼來實作,能透過return的Task來檢查單一或多個Task的完成狀態並且控制整個程式的流程實在是太好用了。

而Unity Coroutine本身綁定Game Object的特性也是有它方便的地方,比如說當今天想要根據遊戲內Animation的狀態來非同步執行不同的邏輯,用Coroutine可以很直覺的實作,因為Coroutine每次 yield return就是一個frame,或者說要執行一些fire and forget的邏輯,但其實我們並不是很在乎結果(比如在某個frame後播放音效),coroutine就很方便,因為生命週期綁再一起的關係播放音效的game object中途被銷毀的話這音效也不會播放了。

UniTask這個Library就算是為了Unity打造的async Task,有許多例外處理,能夠與Game-frame、Game Obejct生命週期綁定的Task,能夠等待Coroutine的helper,可以說是非常方便。

現在在日本,Unity相關職缺上UniTask幾乎是必備技能了。