C#:終於有人把 ValueTask、IValueTaskSource、ManualResetValueTaskSourceCore 說清楚了!

痴者工良發表於2020-12-03


最近 NCC 群裡在討論 ValueTask/ValueTask<TResult>,大帥(Natasha主要開發者)最近執著於搞演算法和高效能運算,他這麼關注這個東西,說明有搞頭,揹著他偷偷學一下,免得沒話題?。

ValueTask/ValueTask<TResult> 出現時間其實比較早的了,之前一直沒有深入,藉此機會好好學習一番。

文章中說 ValueTask 時,為了減少文字數量,一般包括其泛型版本 ValueTask<TRsult>;提到 Task,也包括其泛型版本;

1,可用版本與參考資料

根據 Microsoft 官網的參考資料,以下版本的 .NET 程式(集)可以使用 ValueTask/ValueTask<TResult>

版本類別 版本要求
.NET 5.0
.NET Core 2.1、3.0、3.1
.NET Standard 2.1

以下是筆者閱讀時的參考資料連結地址:

【1】 https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.tasks.valuetask?view=net-5.0

【2]】 https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.tasks.valuetask-1?view=net-5.0

【3】 https://www.infoworld.com/article/3565433/how-to-use-valuetask-in-csharp.html

【4】 https://tooslowexception.com/implementing-custom-ivaluetasksource-async-without-allocations/

【5】 https://blog.marcgravell.com/2019/08/prefer-valuetask-to-task-always-and.html

【6】 https://qiita.com/skitoy4321/items/31a97e03665bd7bcc8ca

【7】 https://neuecc.medium.com/valuetasksupplement-an-extensions-to-valuetask-4c247bc613ea

【8】https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Threading/Tasks/Sources/ManualResetValueTaskSourceCore.cs

2,ValueTask<TResult> 和 Task

ValueTask<TResult> 存在於 System.Threading.Tasks 名稱空間下,ValueTask<TResult> 的定義如下:

public struct ValueTask<TResult> : IEquatable<ValueTask<TResult>>
筆者注:IEquatable<T> 介面定義 Equals 方法,用於確定兩個例項是否相等。

而 Task 的定義如下:

public class Task : IAsyncResult, IDisposable

從其繼承的介面和官方文件來看,ValueTask<TResult> 複雜度應該不高。

根據文件表面理解,這個型別,應該是 Task 的簡化版本,Task 是引用型別,因此從非同步方法返回 Task 物件或者每次呼叫非同步方法時,都會在託管堆中分配該物件。

根據比較,我們應當知道:

  • Task 是引用型別,會在託管堆中分配記憶體;ValueTask 是值型別;

目前就只有這一點需要記住,下面我們繼續比較兩者的異同點。

這裡我們嘗試一下使用這個型別對比 Task ,看看程式碼如何。

        public static async ValueTask<int> GetValueTaskAsync()
        {
            await Task.CompletedTask;	// 這裡別誤會,這是隨便找個地方 await 一下
            return 666;
        }

        public static async Task<int> GetTaskAsync()
        {
            await Task.CompletedTask;
            return 666;
        }

從程式碼上看,兩者在簡單程式碼上使用的方法一致(CURD基本就是這樣)。

3,編譯器如何編譯

Task 在編譯時,由編譯器生成狀態機,會為每個方法生成一個繼承 IAsyncStateMachine 的類,並且出現大量的程式碼包裝。

據筆者測試,ValueTask 也是生成類似的程式碼。

如圖:

編譯後的Task

訪問 https://sharplab.io/#gist:ddf2a5e535a34883733196c7bf4c55b2 可線上閱讀以上程式碼(Task)。

訪問 https://sharplab.io/#gist:7129478fc630a87c08ced38e7fd14cc0 線上閱讀 ValueTask 示例程式碼。

你分別訪問這裡 URL,對比差異。

筆者將有差異的部分取出來了,讀者可以認真看一下:

Task

    [AsyncStateMachine(typeof(<GetTaskAsync>d__0))]
    [DebuggerStepThrough]
    public static Task<int> GetTaskAsync()
    {
        <GetTaskAsync>d__0 stateMachine = new <GetTaskAsync>d__0();
        stateMachine.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
        stateMachine.<>1__state = -1;
        AsyncTaskMethodBuilder<int> <>t__builder = stateMachine.<>t__builder;
        <>t__builder.Start(ref stateMachine);
        return stateMachine.<>t__builder.Task;
    }

ValueTask

    [AsyncStateMachine(typeof(<GetValueTaskAsync>d__0))]
    [DebuggerStepThrough]
    public static ValueTask<int> GetValueTaskAsync()
    {
        <GetValueTaskAsync>d__0 stateMachine = new <GetValueTaskAsync>d__0();
        stateMachine.<>t__builder = AsyncValueTaskMethodBuilder<int>.Create();
        stateMachine.<>1__state = -1;
        AsyncValueTaskMethodBuilder<int> <>t__builder = stateMachine.<>t__builder;
        <>t__builder.Start(ref stateMachine);
        return stateMachine.<>t__builder.Task;
    }

我是沒看出有啥區別。。。

不過這裡要提到第二點:

  • 如果這個方法的處理速度很快,或者你的程式碼執行後立即可用等,使用非同步並不會比同步快,反而有可能多消耗一下效能資源。

4,ValueTask 有什麼優勢

從前面的內容可知,ValueTask 跟 Task 編譯後生成的狀態機程式碼一致,那麼真正有區別的地方,就是 ValueTask 是值型別,Task 是引用型別。

從功能上看,ValueTask 是簡單的非同步表示,而 Task 具有很多強大的方法,有各種各樣的騷操作。

ValueTask 因為不需要堆分配記憶體而提高了效能,這是 ValueTask 對 Task 有優勢的地方。

要避免記憶體分配開銷,我們可以使用 ValueTask 包裝需要返回的結果。

        public static ValueTask<int> GetValueTask()
        {
            return new ValueTask<int>(666);
        }

        public static async ValueTask<int> StartAsync()
        {
            return await GetValueTask();
        }

但是目前,我們還沒有進行任何效能測試,不足以說明 ValueTask 對提高效能的優勢,筆者繼續講解一些基礎知識,待時機成熟後,會進行一些測試並放出示例程式碼。

5,ValueTask 建立非同步任務

我們看一下 ValueTaskValueTask<TResult> 的建構函式定義。

// ValueTask
        public ValueTask(Task task);
        public ValueTask(IValueTaskSource source, short token);

// ValueTask<TResult>
        public ValueTask(Task<TResult> task);
        public ValueTask(TResult result);
        public ValueTask(IValueTaskSource<TResult> source, short token);

如果通過 Task 建立任務,可以使用 new Task()Task.Run() 等方式建立一個任務,然後就可以使用 async/await 關鍵字 定義非同步方法,開啟非同步任務。那麼如果使用 ValueTask 呢?

第四小節我們已經有了示例,使用了 ValueTask(TResult result) 建構函式,可以自己 new ValueTask ,然後就可以使用 await 關鍵字。

另外, ValueTask 的建構函式有多個,我們可以繼續挖掘一下。

通過 Task 轉換為 ValueTask

        public static async ValueTask<int> StartAsync()
        {
            Task<int> task = Task.Run<int>(() => 666);
            return await new ValueTask<int>(task);
        }

剩下一個 IValueTaskSource 引數型別做建構函式的方法,我們放到第 6 小節講。

ValueTask 例項僅可等待一次!必須記住這一點!

6,IValueTaskSource 和自定義包裝 ValueTask

關於 IValueTaskSource

IValueTaskSource 在 System.Threading.Tasks.Sources 名稱空間中,其定義如下:

    public interface IValueTaskSource
    {
        void GetResult(short token);

        ValueTaskSourceStatus GetStatus(short token);

        void OnCompleted(
            Action<object?> continuation, 
            object? state, 
            short token, 
            ValueTaskSourceOnCompletedFlags flags);
    }
方法名稱 作用
GetResult(Int16) 獲取 IValueTaskSource 的結果,僅在非同步狀態機需要獲取操作結果時呼叫一次
GetStatus(Int16) 獲取當前操作的狀態,由非同步狀態機呼叫以檢查操作狀態
OnCompleted(Action, Object, Int16, ValueTaskSourceOnCompletedFlags) 為此 IValueTaskSource 計劃延續操作,開發者自己呼叫

在這個名稱空間中,還有一些跟 ValueTask 相關的型別,可參考 微軟文件

在上述三個方法中,OnCompleted 用於延續任務,這個方法熟悉 Task 的讀者應該都清楚,這裡就不再贅述。

前面我們有一個示例:

        public static ValueTask<int> GetValueTask()
        {
            return new ValueTask<int>(666);
        }

        public static async ValueTask<int> StartAsync()
        {
            return await GetValueTask();
        }

編譯器轉換後的簡化程式碼:

        public static int _StartAsync()
        {
            var awaiter = GetValueTask().GetAwaiter();
            if (!awaiter.IsCompleted)
            {
                // 一些莫名其妙的操作程式碼
            }

            return awaiter.GetResult();
        }

基於這個程式碼,我們發現 ValueTask 可以有狀態感知,那麼如何表達任務已經完成?裡面又有啥實現原理?

什麼是 IValueTaskSource

IValueTaskSource 是一種抽象,通過這種抽象我們可以將 任務/操作 的邏輯行為和結果本身分開表示(狀態機)。

簡化示例:

IValueTaskSource<int> someSource = // ...
short token =                      // ...令牌
var vt = new ValueTask<int>(someSource, token);  // 建立任務
int value = await vt;						     // 等待任務完成

但從這段程式碼來看,我們無法看到 如何實現 IValueTaskSource,ValueTask 內部又是如何使用 IValueTaskSource 的。在深入其原理之前,筆者從其它部落格、文件等地方查閱到,為了降低 Task(C#5.0引入) 的效能開銷,C# 7.0 出現了 ValueTask。ValueTask 的出現是為了包裝返回結果,避免使用堆分配。

所以,需要使用 Task 轉換為 ValueTask:

public ValueTask(Task task);		// ValueTask 建構函式

ValueTask 只是包裝 Task 的返回結果。

後來,為了更高的效能,引入了 IValueTaskCource,ValueTask 便多增加了一個建構函式。

可以通過實現 IValueTaskSource:

public ValueTask(IValueTaskSource source, short token);    // ValueTask 建構函式

這樣,可以進一步消除 ValueTask 跟 Task 轉換的效能開銷。ValueTask 便擁有狀態“管理”能力,不再依賴 Task 。

再說 ValueTask 優勢

2019-8-22 的 coreclr 草案中,有個主題 “Make "async ValueTask/ValueTask" methods ammortized allocation-free”,深入探討了 ValueTask 的效能影響以及後續改造計劃。

Issue 地址:https://github.com/dotnet/coreclr/pull/26310

裡面有各種各樣的效能指標比較,筆者十分推薦有興趣深入研究的讀者看一下這個 Issue。

不要自己全部實現 IValueTaskSource

大多數人無法完成這個介面,我個人看來很多次也沒有看懂,翻了很久,沒有找到合適的程式碼示例。根據官方的文件,我發現了 ManualResetValueTaskSourceCore,這個型別實現了 IValueTaskSource 介面,並且進行了封裝,因此我們可以使用 ManualResetValueTaskSourceCore 對自己的程式碼進行包裝,更加輕鬆地實現 IValueTaskSource。

關於 ManualResetValueTaskSourceCore ,文章後面再給出使用方法和程式碼示例。

ValueTaskSourceOnCompletedFlags

ValueTaskSourceOnCompletedFlags 是一個列舉,用於表示延續的行為,其列舉說明如下:

列舉 說明
FlowExecutionContext 2 OnCompleted 應捕獲當前 ExecutionContext 並用它來執行延續。
None 0 對延續的呼叫方式內有任何要求。
UseSchedulingContext 1 OnCompleted 應該捕獲當前排程上下文(SynchronizationContext),並在將延續加入執行佇列時使用。 如果未設定此標誌,實現可以選擇執行任意位置的延續。

ValueTaskSourceStatus

ValueTaskSourceStatus 列舉用於指示 指示 IValueTaskSource 或 IValueTaskSource 的狀態,其列舉說明如下:

列舉 說明
Canceled 3 操作因取消操作而完成。
Faulted 2 操作已完成但有錯誤。
Pending 0 操作尚未完成。
Succeeded 1 操作已成功完成。

7,編寫 IValueTaskSource 例項

完整程式碼:https://github.com/whuanle/RedisClientLearn/issues/1

假如我們要設計一個 Redis 客戶端,並且實現非同步,如果你有 Socket 開發經驗,會了解 Socket 並不是 一發一收的。C# 中的 Socket 中也沒有直接的非同步介面。

所以這裡我們要實現一個非同步的 Redis 客戶端。

使用 IValueTaskSource 編寫狀態機:

    // 一個可以將同步任務、不同執行緒同步操作,通過狀態機構建非同步方法
    public class MyValueTaskSource<TRusult> : IValueTaskSource<TRusult>
    {
        // 儲存返回結果
        private TRusult _result;
        private ValueTaskSourceStatus status = ValueTaskSourceStatus.Pending;

        // 此任務有異常
        private Exception exception;

        #region 實現介面,告訴呼叫者,任務是否已經完成,以及是否有結果,是否有異常等
        // 獲取結果
        public TRusult GetResult(short token)
        {
            // 如果此任務有異常,那麼獲取結果時,重新彈出
            if (status == ValueTaskSourceStatus.Faulted)
                throw exception;
            // 如果任務被取消,也彈出一個異常
            else if (status == ValueTaskSourceStatus.Canceled)
                throw new TaskCanceledException("此任務已經被取消");

            return _result;
        }

        // 獲取狀態,這個示例中,用不到令牌 token
        public ValueTaskSourceStatus GetStatus(short token)
        {
            return status;
        }

        // 實現延續
        public void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags)
        {
            // 不需要延續,不實現此介面
        }

        #endregion

        #region 實現狀態機,能夠控制此任務是否已經完成,以及是否有異常

        // 以及完成任務,並給出結果
        public void SetResult(TRusult result)
        {
            status = ValueTaskSourceStatus.Succeeded;  // 此任務已經完成
            _result = result;
        }

        // 取消任務
        public void Cancel()
        {
            status = ValueTaskSourceStatus.Canceled;
        }

        // 要執行的任務出現異常
        public void SetException(Exception exception)
        {
            this.exception = exception;
            status = ValueTaskSourceStatus.Faulted;
        }

        #endregion

    }

假的 Socket:

    public class 假的Socket
    {
        private bool IsHaveSend = false;

        // 模擬 Socket 向伺服器傳送資料
        public void Send(byte[] data)
        {
            new Thread(() =>
            {
                Thread.Sleep(100);
                IsHaveSend = true;
            }).Start();
        }

        // 同步阻塞等待伺服器的響應
        public byte[] Receive()
        {
            // 模擬網路傳輸的資料
            byte[] data = new byte[100];

            while (!IsHaveSend)
            {
                // 伺服器沒有傳送資料到客戶端時,一直空等待
            }

            // 模擬網路接收資料耗時
            Thread.Sleep(new Random().Next(0, 100));
            new Random().NextBytes(data);
            IsHaveSend = false;
            return data;
        }
    }

實現 Redis 客戶端,並且實現

    // Redis 客戶端
    public class RedisClient
    {
        // 佇列
        private readonly Queue<MyValueTaskSource<string>> queue = new Queue<MyValueTaskSource<string>>();

        private readonly 假的Socket _socket = new 假的Socket();  // 一個 socket 客戶端

        public RedisClient(string connectStr)
        {
            new Thread(() =>
            {
                while (true)
                {
                    byte[] data = _socket.Receive();
                    // 從佇列中拿出一個狀態機
                    if (queue.TryDequeue(out MyValueTaskSource<string> source))
                    {
                        // 設定此狀態機的結果
                        source.SetResult(Encoding.UTF8.GetString(data));
                    }
                }
            }).Start();
        }

        private void SendCommand(string command)
        {
            Console.WriteLine("客戶端傳送了一個命令:" + command);
            _socket.Send(Encoding.UTF8.GetBytes(command));
        }

        public async ValueTask<string> GetStringAsync(string key)
        {
            // 自定義狀態機
            MyValueTaskSource<string> source = new MyValueTaskSource<string>();
            // 建立非同步任務
            ValueTask<string> task = new ValueTask<string>(source, 0);

            // 加入佇列中
            queue.Enqueue(source);

            // 傳送獲取值的命令
            SendCommand($"GET {key}");

            // 直接使用 await ,只會檢查移除狀態!一層必須在檢查之前完成任務,然後 await 後會陷入無限等待中!
            // return await task;

            // 要想真正實現這種非同步,必須使用 SynchronizationContext 等複雜的結構邏輯!
            // 為了避免過多程式碼,我們可以使用下面這種 無限 while 的方法!
            var awaiter = task.GetAwaiter();
            while (!awaiter.IsCompleted) { }

            // 返回結果
            return await task;
        }
    }

大概思路就是這樣。但是最後是無法像 Task 那樣直接 await 的!ValueTask 只能 await 一次,並且 await 只能是最後的結果檢查!

如果我們使用 TaskCompletionSource 寫 Task 狀態機,是可以直接 await 的。

如果你要真正實現可以 await 的 ValueTask,那麼編寫 IValueTasksource 時,必須實現 SynchronizationContextTaskScheduler 等。

實現這些程式碼,比較複雜,怎麼辦?微軟官方給出了一個ManualResetValueTaskSourceCore<TResult>,有了它,我們可以省去很多複雜的程式碼!

ValueTask 是不可被取消的!

8,使用 ManualResetValueTaskSourceCore

接下來,我們通過 ManualResetValueTaskSourceCore 改造以往的程式碼,這樣我們可以直觀的感受到這個型別是用來幹嘛的!

改造 MyValueTaskSource 如下:

    // 一個可以將同步任務、不同執行緒同步操作,通過狀態機構建非同步方法
    public class MyValueTaskSource<TRusult> : IValueTaskSource<TRusult>
    {
        private ManualResetValueTaskSourceCore<TRusult> _source = new ManualResetValueTaskSourceCore<TRusult>();

        #region 實現介面,告訴呼叫者,任務是否已經完成,以及是否有結果,是否有異常等
        // 獲取結果
        public TRusult GetResult(short token)
        {
            return _source.GetResult(token);
        }

        // 獲取狀態,這個示例中,用不到令牌 token
        public ValueTaskSourceStatus GetStatus(short token)
        {
            return _source.GetStatus(token); ;
        }

        // 實現延續
        public void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags)
        {
            _source.OnCompleted(continuation, state, token, flags);
        }

        #endregion

        #region 實現狀態機,能夠控制此任務是否已經完成,以及是否有異常

        // 以及完成任務,並給出結果
        public void SetResult(TRusult result)
        {
            _source.SetResult(result);
        }

        // 要執行的任務出現異常
        public void SetException(Exception exception)
        {
            _source.SetException(exception);
        }

        #endregion
    }

之後,我們可以直接在 GetStringAsync 使用 await 了!

        public async ValueTask<string> GetStringAsync(string key)
        {
            // 自定義狀態機
            MyValueTaskSource<string> source = new MyValueTaskSource<string>();
            // 建立非同步任務
            ValueTask<string> task = new ValueTask<string>(source, 0);

            // 加入佇列中
            queue.Enqueue(source);

            // 傳送獲取值的命令
            SendCommand($"GET {key}");

            return await task;
        }

到此為止,ValueTask、IValueTaskSource、ManualResetValueTaskSourceCore,你搞明白了沒有!

有人給 ValueTask 實現了大量擴充,使得 ValueTask 擁有跟 Task 一樣多工併發能力,例如 WhenAll、WhenAny、Factory等,擴充庫地址:https://github.com/Cysharp/ValueTaskSupplement

時間原因(筆者一般11點就睡),本文筆者就不給出併發以及其它情況下的 GC 和效能比較了,大家學會使用後,可以自行測試。
可關注 NCC 公眾號,瞭解更多效能知識!

相關文章