.NET效能最佳化-使用ValueStringBuilder串接字串

.NET效能最佳化-使用ValueStringBuilder串接字串

這一次要和大家分享的一個Tips是在字串串接場景使用的

最後更新 2022/5/11 上午7:13
InCerry
預計閱讀 13 分鐘
分類
.NET
標籤
.NET C# 效能最佳化

前言

這一次要和大家分享的一個 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 堆上配置的 ValueStringBuilder
    [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 壓力。

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

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

需要注意的場景

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

  1. 無法將 ValueStringBuilder 作為回傳值回傳,因為在目前堆疊上配置,方法結束後它會被釋放,回傳它將指向未知的位址。這個編譯器也會警告。

  1. 如果要將 ValueStringBuilder 傳遞給其他方法,那麼必須使用 ref 傳遞,否則發生值複製會存在多個執行個體。這個編譯器不會警告,但是你必須非常注意。

  1. 如果使用堆疊上配置,那麼 Buffer 大小控制在 5KB 內比較妥當,至於為什麼需要這樣,後面有機會再講一講。

總結

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

本文原始碼連結: https://github.com/InCerryGit/BlogCode-Use-ValueStringBuilder

繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2026/2/7

AOT使用經驗總結

從專案建立伊始,就應養成良好的習慣,即只要添加了新功能或使用了較新的語法,就及時進行 AOT 發布測試。

繼續閱讀