首頁 > 軟體

.NET效能優化ValueStringBuilder拼接字串使用範例

2022-06-11 22:00:56

前言

這一次要和大家分享的一個Tips是在字串拼接場景使用的,我們經常會遇到有很多短小的字串需要拼接的場景,在這種場景下及其的不推薦使用String.Concat也就是使用+=運運算元。
目前來說官方最推薦的方案就是使用StringBuilder來構建這些字串,那麼有什麼更快記憶體佔用更低的方式嗎?那就是今天要和大家介紹的ValueStringBuilder

ValueStringBuilder

ValueStringBuilder不是一個公開的API,但是它被大量用於.NET的基礎類庫中,由於它是值型別的,所以它本身不會在堆上分配,不會有GC的壓力。
微軟提供的ValueStringBuilder有兩種使用方式,一種是自己已經有了一塊記憶體空間可供字串構建使用。這意味著你可以使用棧空間,也可以使用堆空間甚至非託管堆的空間,這對於GC來說是非常友好的,在高並行情況下能大大降低GC壓力。

// 建構函式:傳入一個Span的Buffer陣列
public ValueStringBuilder(Span<char> initialBuffer);
// 使用方式:
// 棧空間
var vsb = new ValueStringBuilder(stackalloc char[512]);
// 普通數租
var vsb = new ValueStringBuilder(new char[512]);
// 使用非託管堆
var length = 512;
var ptr = NativeMemory.Alloc((nuint)(512 * Unsafe.SizeOf<char>()));
var span = new Span<char>(ptr, length);
var vsb = new ValueStringBuilder(span);
.....
NativeMemory.Free(ptr); // 非託管堆用完一定要Free

另外一種方式是指定一個容量,它會從預設的ArrayPoolchar物件池中獲取緩衝空間,因為使用的是物件池,所以對於GC來說也是比較友好的,千萬需要注意,池中的物件一定要記得歸還。

// 傳入預計的容量
public ValueStringBuilder(int initialCapacity)  
{  
    // 從物件池中獲取緩衝區
    _arrayToReturnToPool = ArrayPool<char>.Shared.Rent(initialCapacity);  
    ......
}

那麼我們就來比較一下使用+=StringBuilderValueStringBuilder這幾種方式的效能吧。

// 一個簡單的類
public class SomeClass  
{  
    public int Value1; public int Value2; public float Value3;  
    public double Value4; public string? Value5; public decimal Value6;  
    public DateTime Value7; public TimeOnly Value8; public DateOnly Value9;  
    public int[]? Value10;  
}
// Benchmark類
[MemoryDiagnoser]  
[HtmlExporter]  
[Orderer(SummaryOrderPolicy.FastestToSlowest)]  
public class StringBuilderBenchmark  
{  
    private static readonly SomeClass Data;  
    static StringBuilderBenchmark()  
    {  
        var baseTime = DateTime.Now;  
        Data = new SomeClass  
        {  
            Value1 = 100, Value2 = 200, Value3 = 333,  
            Value4 = 400, Value5 = string.Join('-', Enumerable.Range(0, 10000).Select(i => i.ToString())),  
            Value6 = 655, Value7 = baseTime.AddHours(12),  
            Value8 = TimeOnly.MinValue, Value9 = DateOnly.MaxValue,  
            Value10 = Enumerable.Range(0, 5).ToArray()  
        };  
    }
    // 使用我們熟悉的StringBuilder
    [Benchmark(Baseline = true)]  
    public string StringBuilder()  
    {  
        var data = Data;  
        var sb = new StringBuilder();  
        sb.Append("Value1:"); sb.Append(data.Value1);  
        if (data.Value2 > 10)  
        {  
            sb.Append(" ,Value2:"); sb.Append(data.Value2);  
        }  
        sb.Append(" ,Value3:"); sb.Append(data.Value3);  
        sb.Append(" ,Value4:"); sb.Append(data.Value4);  
        sb.Append(" ,Value5:"); sb.Append(data.Value5);  
        if (data.Value6 > 20)  
        {  
            sb.Append(" ,Value6:"); sb.AppendFormat("{0:F2}", data.Value6);  
        }  
        sb.Append(" ,Value7:"); sb.AppendFormat("{0:yyyy-MM-dd HH:mm:ss}", data.Value7);  
        sb.Append(" ,Value8:"); sb.AppendFormat("{0:HH:mm:ss}", data.Value8);  
        sb.Append(" ,Value9:"); sb.AppendFormat("{0:yyyy-MM-dd}", data.Value9);  
        sb.Append(" ,Value10:");  
        if (data.Value10 is null or {Length: 0}) return sb.ToString();  
        for (int i = 0; i < data.Value10.Length; i++)  
        {  
            sb.Append(data.Value10[i]);  
        }  
        return sb.ToString();  
    }
    // StringBuilder使用Capacity
    [Benchmark]  
    public string StringBuilderCapacity()  
    {  
        var data = Data;  
        var sb = new StringBuilder(20480);  
        sb.Append("Value1:"); sb.Append(data.Value1);  
        if (data.Value2 > 10)  
        {  
            sb.Append(" ,Value2:"); sb.Append(data.Value2);  
        }  
        sb.Append(" ,Value3:"); sb.Append(data.Value3);  
        sb.Append(" ,Value4:"); sb.Append(data.Value4);  
        sb.Append(" ,Value5:"); sb.Append(data.Value5);  
        if (data.Value6 > 20)  
        {  
            sb.Append(" ,Value6:"); sb.AppendFormat("{0:F2}", data.Value6);  
        }  
        sb.Append(" ,Value7:"); sb.AppendFormat("{0:yyyy-MM-dd HH:mm:ss}", data.Value7);  
        sb.Append(" ,Value8:"); sb.AppendFormat("{0:HH:mm:ss}", data.Value8);  
        sb.Append(" ,Value9:"); sb.AppendFormat("{0:yyyy-MM-dd}", data.Value9);  
        sb.Append(" ,Value10:");  
        if (data.Value10 is null or {Length: 0}) return sb.ToString();  
        for (int i = 0; i < data.Value10.Length; i++)  
        {  
            sb.Append(data.Value10[i]);  
        }  
        return sb.ToString();  
    }  
    // 直接使用+=拼接字串
    [Benchmark]  
    public string StringConcat()  
    {  
        var str = "";  
        var data = Data;  
        str += ("Value1:"); str += (data.Value1);  
        if (data.Value2 > 10)  
        {  
            str += " ,Value2:"; str += data.Value2;  
        }  
        str += " ,Value3:"; str += (data.Value3);  
        str += " ,Value4:"; str += (data.Value4);  
        str += " ,Value5:"; str += (data.Value5);  
        if (data.Value6 > 20)  
        {  
            str += " ,Value6:"; str += data.Value6.ToString("F2");  
        }  
        str += " ,Value7:"; str += data.Value7.ToString("yyyy-MM-dd HH:mm:ss");  
        str += " ,Value8:"; str += data.Value8.ToString("HH:mm:ss");  
        str += " ,Value9:"; str += data.Value9.ToString("yyyy-MM-dd");  
        str += " ,Value10:";  
        if (data.Value10 is not null && data.Value10.Length > 0)  
        {  
            for (int i = 0; i < data.Value10.Length; i++)  
            {  
                str += (data.Value10[i]);  
            }     
        }  
        return str;  
    }  
    // 使用棧上分配的ValueStringBuilder
    [Benchmark]  
    public string ValueStringBuilderOnStack()  
    {  
        var data = Data;  
        Span<char> buffer = stackalloc char[20480];  
        var sb = new ValueStringBuilder(buffer);  
        sb.Append("Value1:"); sb.AppendSpanFormattable(data.Value1);  
        if (data.Value2 > 10)  
        {  
            sb.Append(" ,Value2:"); sb.AppendSpanFormattable(data.Value2);  
        }  
        sb.Append(" ,Value3:"); sb.AppendSpanFormattable(data.Value3);  
        sb.Append(" ,Value4:"); sb.AppendSpanFormattable(data.Value4);  
        sb.Append(" ,Value5:"); sb.Append(data.Value5);  
        if (data.Value6 > 20)  
        {  
            sb.Append(" ,Value6:"); sb.AppendSpanFormattable(data.Value6, "F2");  
        }  
        sb.Append(" ,Value7:"); sb.AppendSpanFormattable(data.Value7, "yyyy-MM-dd HH:mm:ss");  
        sb.Append(" ,Value8:"); sb.AppendSpanFormattable(data.Value8, "HH:mm:ss");  
        sb.Append(" ,Value9:"); sb.AppendSpanFormattable(data.Value9, "yyyy-MM-dd");  
        sb.Append(" ,Value10:");  
        if (data.Value10 is not null && data.Value10.Length > 0)  
        {  
            for (int i = 0; i < data.Value10.Length; i++)  
            {  
                sb.AppendSpanFormattable(data.Value10[i]);  
            }     
        }  
        return sb.ToString();  
    }
    // 使用ArrayPool 堆上分配的StringBuilder
    [Benchmark]  
    public string ValueStringBuilderOnHeap()  
    {  
        var data = Data;  
        var sb = new ValueStringBuilder(20480);  
        sb.Append("Value1:"); sb.AppendSpanFormattable(data.Value1);  
        if (data.Value2 > 10)  
        {  
            sb.Append(" ,Value2:"); sb.AppendSpanFormattable(data.Value2);  
        }  
        sb.Append(" ,Value3:"); sb.AppendSpanFormattable(data.Value3);  
        sb.Append(" ,Value4:"); sb.AppendSpanFormattable(data.Value4);  
        sb.Append(" ,Value5:"); sb.Append(data.Value5);  
        if (data.Value6 > 20)  
        {  
            sb.Append(" ,Value6:"); sb.AppendSpanFormattable(data.Value6, "F2");  
        }  
        sb.Append(" ,Value7:"); sb.AppendSpanFormattable(data.Value7, "yyyy-MM-dd HH:mm:ss");  
        sb.Append(" ,Value8:"); sb.AppendSpanFormattable(data.Value8, "HH:mm:ss");  
        sb.Append(" ,Value9:"); sb.AppendSpanFormattable(data.Value9, "yyyy-MM-dd");  
        sb.Append(" ,Value10:");  
        if (data.Value10 is not null && data.Value10.Length > 0)  
        {  
            for (int i = 0; i < data.Value10.Length; i++)  
            {  
                sb.AppendSpanFormattable(data.Value10[i]);  
            }     
        }
        return sb.ToString();  
    }
}

結果如下所示。

從上圖的結果中,我們可以得出如下的結論。

  • 使用StringConcat是最慢的,這種方式是無論如何都不推薦的。
  • 使用StringBuilder要比使用StringConcat快6.5倍,這是推薦的方法。
  • 設定了初始容量的StringBuilder要比直接使用StringBuilder快25%,正如我在你應該為集合型別設定初始大小一樣,設定初始大小絕對是相當推薦的做法。
  • 棧上分配的ValueStringBuilderStringBuilder要快50%,比設定了初始容量的StringBuilder還快25%,另外它的GC次數是最低的。
  • 堆上分配的ValueStringBuilderStringBuilder要快55%,他的GC次數稍高與棧上分配。
    從上面的結論中,我們可以發現ValueStringBuilder的效能非常好,就算是在棧上分配緩衝區,效能也比StringBuilder快25%。

原始碼解析

ValueStringBuilder的原始碼不長,我們挑幾個重要的方法給大家分享一下,部分原始碼如下。

// 使用 ref struct 該物件只能在棧上分配
public ref struct ValueStringBuilder
{
    // 如果從ArrayPool裡分配buffer 那麼需要儲存一下
    // 以便在Dispose時歸還
    private char[]? _arrayToReturnToPool;
    // 暫存外部傳入的buffer
    private Span<char> _chars;
    // 當前字串長度
    private int _pos;
    // 外部傳入buffer
    public ValueStringBuilder(Span<char> initialBuffer)
    {
        // 使用外部傳入的buffer就不使用從pool裡面讀取的了
        _arrayToReturnToPool = null;
        _chars = initialBuffer;
        _pos = 0;
    }
    public ValueStringBuilder(int initialCapacity)
    {
        // 如果外部傳入了capacity 那麼從ArrayPool裡面獲取
        _arrayToReturnToPool = ArrayPool<char>.Shared.Rent(initialCapacity);
        _chars = _arrayToReturnToPool;
        _pos = 0;
    }
    // 返回字串的Length 由於Length可讀可寫
    // 所以重複使用ValueStringBuilder只需將Length設定為0
    public int Length
    {
        get => _pos;
        set
        {
            Debug.Assert(value >= 0);
            Debug.Assert(value <= _chars.Length);
            _pos = value;
        }
    }
    ......
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Append(char c)
    {
        // 新增字元非常高效 直接設定到對應Span位置即可
        int pos = _pos;
        if ((uint) pos < (uint) _chars.Length)
        {
            _chars[pos] = c;
            _pos = pos + 1;
        }
        else
        {
            // 如果buffer空間不足,那麼會走
            GrowAndAppend(c);
        }
    }
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Append(string? s)
    {
        if (s == null)
        {
            return;
        }
        // 追加字串也是一樣的高效
        int pos = _pos;
        // 如果字串長度為1 那麼可以直接像追加字元一樣
        if (s.Length == 1 && (uint) pos < (uint) _chars .Length)
        {
            _chars[pos] = s[0];
            _pos = pos + 1;
        }
        else
        {
            // 如果是多個字元 那麼使用較慢的方法
            AppendSlow(s);
        }
    }
    private void AppendSlow(string s)
    {
        // 追加字串 空間不夠先擴容
        // 然後使用Span複製 相當高效
        int pos = _pos;
        if (pos > _chars.Length - s.Length)
        {
            Grow(s.Length);
        }
        s
#if !NETCOREAPP
                .AsSpan()
#endif
            .CopyTo(_chars.Slice(pos));
        _pos += s.Length;
    }
    // 對於需要格式化的物件特殊處理
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void AppendSpanFormattable<T>(T value, string? format = null, IFormatProvider? provider = null)
        where T : ISpanFormattable
    {
        // ISpanFormattable非常高效
        if (value.TryFormat(_chars.Slice(_pos), out int charsWritten, format, provider))
        {
            _pos += charsWritten;
        }
        else
        {
            Append(value.ToString(format, provider));
        }
    }
    [MethodImpl(MethodImplOptions.NoInlining)]
    private void GrowAndAppend(char c)
    {
        // 單個字元擴容在新增
        Grow(1);
        Append(c);
    }
    // 擴容方法
    [MethodImpl(MethodImplOptions.NoInlining)]
    private void Grow(int additionalCapacityBeyondPos)
    {
        Debug.Assert(additionalCapacityBeyondPos > 0);
        Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos,
            "Grow called incorrectly, no resize is needed.");
        // 同樣也是2倍擴容,預設從物件池中獲取buffer
        char[] poolArray = ArrayPool<char>.Shared.Rent((int) Math.Max((uint) (_pos + additionalCapacityBeyondPos),
            (uint) _chars.Length * 2));
        _chars.Slice(0, _pos).CopyTo(poolArray);
        char[]? toReturn = _arrayToReturnToPool;
        _chars = _arrayToReturnToPool = poolArray;
        if (toReturn != null)
        {
            // 如果原本就是使用的物件池 那麼必須歸還
            ArrayPool<char>.Shared.Return(toReturn);
        }
    }
    // 
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Dispose()
    {
        char[]? toReturn = _arrayToReturnToPool;
        this = default; // 為了安全,在釋放時置空當前物件
        if (toReturn != null)
        {
            // 一定要記得歸還物件池
            ArrayPool<char>.Shared.Return(toReturn);
        }
    }
}

從上面的原始碼我們可以總結出ValueStringBuilder的幾個特徵:

  • 比起StringBuilder來說,實現方式非常簡單。
  • 一切都是為了高效能,比如各種Span的用法,各種內聯引數,以及使用物件池等等。
  • 記憶體佔用非常低,它本身就是結構體型別,另外它是ref struct,意味著不會被裝箱,不會在堆上分配。

適用場景

ValueStringBuilder是一種高效能的字串建立方式,針對於不同的場景,可以有不同的使用方式。

1.非常高頻次的字串拼接的場景,並且字串長度較小,此時可以使用棧上分配的ValueStringBuilder

大家都知道現在ASP.NET Core效能非常好,在其依賴的內部庫UrlBuilder中,就使用棧上分配,因為棧上分配在當前方法結束後記憶體就會回收,所以不會造成任何GC壓力。

2.非常高頻次的字串拼接場景,但是字串長度不可控,此時使用ArrayPool指定容量的ValueStringBuilder。比如在.NET BCL庫中有很多場景使用,比如動態方法的ToString實現。從池中分配雖然沒有棧上分配那麼高效,但是一樣的能降低記憶體佔用和GC壓力。

3. 非常高頻次的字串拼接場景,但是字串長度可控,此時可以棧上分配和ArrayPool分配聯合使用,比如正規表示式解析類中,如果字串長度較小那麼使用棧空間,較大那麼使用ArrayPool。

需要注意的場景

1.在asyncawait中無法使用ValueStringBuilder。原因大家也都知道,因為ValueStringBuilderref struct,它只能在棧上分配,asyncawait會編譯成狀態機拆分await前後的方法,所以ValueStringBuilder不好在方法內傳遞,不過編譯器也會警告。

2.無法將ValueStringBuilder作為返回值返回,因為在當前棧上分配,方法結束後它會被釋放,返回它將指向未知的地址。這個編譯器也會警告。

3.如果要將ValueStringBuilder傳遞給其它方法,那麼必須使用ref傳遞,否則發生值拷貝會存在多個範例。這個編譯器不會警告,但是你必須非常注意。

4. 如果使用棧上分配,那麼Buffer大小控制在5KB內比較穩妥,至於為什麼需要這樣,後面有機會在講一講。

總結

今天和大家分享了一下高效能幾乎無記憶體佔用的字串拼接結構體ValueStringBuilder,在大多數的場景還是推薦大家使用。但是要非常注意上面提到的的幾個場景,如果不符合條件,那麼大家還是可以使用高效的StringBuilder來進行字串拼接。

原始碼連結點選

以上就是.NET效能優化ValueStringBuilder拼接字串使用範例的詳細內容,更多關於.NET ValueStringBuilder拼接字串的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com