ET框架6.0分析二、非同步程式設計

2023-05-15 12:04:02

概述

ET框架很多地方都用到了非同步,例如資源載入、AI、Actor模型等等。ET框架對C#的非同步操作進行了一定程度的封裝和改造,有一些特點:

  • 顯式的或者說強調了使用C#非同步實現協程機制(其實C#的非同步程式設計天生就能實現這種用法)
  • 強制單執行緒非同步
  • 沒有使用C#庫的Task,自己實現了ETTask等類
  • 實現了協程鎖

為了更好的理解下面的內容,推薦先看一下之前寫的這兩篇文章:

ETTask

C# 的非同步函數有三個返回值(現在好像.NET7又多了一個ValueTask):Task,Task<T>,void,對應的,ET框架也一樣對應實現了:ETTask,ETTask/,ETVoid,其實現相比C#簡化了一些邏輯,並新增一些新的特性以適應ET框架,其實使用起來是差不多的。為了實現ETTask,也實現了對應AsyncTaskCompletedMethodBuilder的AsyncETTaskCompletedMethodBuilder等類(其實還C#原來的邏輯差不太多,有興趣可以看下上述C# 非同步程式設計的連結)。

ETTask新增了一些特性:

  • 支援物件池
  • 顯式強調協程
[DebuggerHidden]
private async ETVoid InnerCoroutine()
{
    await this;
}

[DebuggerHidden]
public void Coroutine()
{
    InnerCoroutine().Coroutine();
}

可以看到這裡的所謂協程Coroutine,其實等效於 await task,只是平平無奇的非同步呼叫罷了

  • 異常訊息列印

同步上線文 SynchronizationContext

C#非同步程式設計在大多數情況下會使用多執行緒,ET的非同步操作例如定時器等,使用多執行緒的開銷相比較大,且ET框架是多程序,效能是分攤到多個程序中。所以ET使用了單執行緒的非同步。

ThreadSynchronizationContext繼承自SynchronizationContext,在構造初始化是會把自身設為當前SynchronizationContext.Current,重寫了Post(非同步訊息分派到同步上下文)方法,來改寫非同步訊息的分派到當前執行緒(就是進入佇列)。

而非同步函數在執行時,會獲取當前上下文(__builder.AwaitUnsafeOnCompleted方法會呼叫GetCompletionAction,內部呼叫ExecutionContext.FastCapture(),這個方法內部捕獲SynchronizationContext,感興趣可以關鍵詞搜尋下)

public class ThreadSynchronizationContext : SynchronizationContext
{
    // 執行緒同步佇列,傳送接收socket回撥都放到該佇列,由poll執行緒統一執行
    private readonly ConcurrentQueue<Action> queue = new ConcurrentQueue<Action>();

    private Action a;

    public void Update()
    {
        while (true)
        {
            if (!this.queue.TryDequeue(out a))
            {
                return;
            }

            try
            {
                a();
            }
            catch (Exception e)
            {
                Log.Error(e);
            }
        }
    }

    public override void Post(SendOrPostCallback callback, object state)
    {
        this.Post(() => callback(state));
    }
    
    public void Post(Action action)
    {
        this.queue.Enqueue(action);
    }
}

public class MainThreadSynchronizationContext: Singleton<MainThreadSynchronizationContext>, ISingletonUpdate
{
    private readonly ThreadSynchronizationContext threadSynchronizationContext = new ThreadSynchronizationContext();

    public MainThreadSynchronizationContext()
    {
        SynchronizationContext.SetSynchronizationContext(this.threadSynchronizationContext);
    }
    
    public void Update()
    {
        this.threadSynchronizationContext.Update();
    }
    
    public void Post(SendOrPostCallback callback, object state)
    {
        this.Post(() => callback(state));
    }
    
    public void Post(Action action)
    {
        this.threadSynchronizationContext.Post(action);
    }
}

// MainThreadSynchronizationContext.Instance.Update()
Game.Update();

ThreadSynchronizationContex由包裹的MainThreadSynchronizationContext驅動更新,MainThreadSynchronizationContext是個單件,由外面驅動。更新Update方法會把佇列裡的委託取出執行。

SynchronizationContext

假設有兩個執行緒,一個UI執行緒,一個後臺執行緒,一個業務先在後臺執行緒計算資料,然後在UI執行緒中重新整理顯示資料,顯然不同的執行緒其上下文環境是不同的,兩個執行緒的通訊可以使用SynchronizationContext完成。
SynchronizationContext官方檔案 https://learn.microsoft.com/zh-CN/dotnet/api/system.threading.synchronizationcontext?view=netcore-3.0

協程鎖

多執行緒程式設計,對公共資源的存取要加鎖,以保證資料存取的安全。類似的,在ET的非同步程式設計中,從雖然上文中可以瞭解到ET的非同步其實是單執行緒的,從程式碼執行的層面其實是一個執行緒以某種順序處理一個個的任務,但是這種「順序」並不可控。ET這裡的協程鎖其實就是使用某個key,對所有用這個key包裹的程式碼段推入一個佇列,只有前面的程式碼段執行結束才能執行後面的程式碼。

這看起來和C#平時用的lock(object),其實只是用法上比較像,其實在實現細節是有根本的差距的:簡單來說。ET實現的協程鎖是一種使用者態的鎖,不會造成核心態/使用者態的切換。而lock是一種C#語法糖,在編譯時其實是通過Monitor監視器實現的,會涉及到核心轉換。一個執行緒上可能會執行成百上千個協程,如果這個執行緒被掛起,那麼有可能造成很多協程Delay,可能造成災難性的後果。

結構類圖:

時序圖:

結合ET工程官方的一個用法:

public static async ETTask<T> Query<T>(this DBComponent self, long id, string collection = null) where T : Entity
{
    using (await CoroutineLockComponent.Instance.Wait(CoroutineLockType.DB, id % DBComponent.TaskCount))
    {
        IAsyncCursor<T> cursor = await self.GetCollection<T>(collection).FindAsync(d => d.Id == id);

        return await cursor.FirstOrDefaultAsync();
    }
}

可以看到協程鎖是被using包裹的,即{}包裹的程式碼塊執行結束,協程鎖會被dispose。
先來看當第一次呼叫Wait時會直接返回,當第一次的鎖沒有被dispose時,後面獲取鎖時會進入佇列。當前面的鎖被dispose時,會通知佇列中後面一個鎖在下一次Update時被Notify,SetResult獲取到鎖,其所屬的程式碼段得以執行。