深入解析 C# 的 String.Create 方法

作者:Casey McQuillan

譯者:精緻碼農

原文:http://dwz.win/YVW

說明:原文比較長,翻譯時精簡了很多內容,對於不重要的細枝末節只用了一句話概括,但不併影響閱讀。

你還記得上一次一個無足輕重的細節點燃你思考火花的時刻嗎?作爲一個軟件工程師,我習慣於專注於一個從未見過的微小細節。那一時刻,我大腦的齒輪會開始轉動,我喜歡這樣的時刻

最近,我在逛 Twitter 時發生了一件事。我看到了 David Fowler 和 Damian Edwards 之間的這段交流,他們討論了 .NET 的 Span<T> API。我以前使用過 Span<T> API,但我在推文中發現了一些不一樣的新東西。

上面使用的 String.Create 方法是我從未見過的用法。我決定要揭開 String.Create 的神祕面紗。此時我在問自己一個問題:

爲什麼用這個方法創建字符串而不用其它的?

我便開始探索,它把我帶到了一些有趣的地方,我想和你分享。在本文中,我們將深入探討幾個話題:

  • String.Create 與其它 API 有什麼不同?
  • String.Create 做得更好的是什麼,它如何讓我的 C# 代碼更快?
  • String.Create 的性能能提高多少?

爲了書寫方便,我將用下面的詞來指代 .NET 中的幾個 API:

  • Create — 指代 String.Create()
  • Concat — 指代 String.Concat()+操作符
  • StringBuilder — 指代StringBuilder構造字符串或使用其流式 API。

它是如何工作的

.NET Core 代碼庫是在 GitHub 開源的,這提供了一個很好的機會來深入分析微軟自己的實踐。他們提供了 Create API,所以看看他們如何使用它,應該能找到有價值的發現。讓我們從深入瞭解 String 對象及其相關 API 開始。

要想從原始字符數據中構造一個 string,你需要使用構造函數,它需要一個指向 char 數組的指針。如果直接使用這個 API,則需要將單個字符放入特定的數組位置。下面是使用這個構造函數分配一個字符串的代碼。創建字符串的方法還有很多,但這是我認爲與 Create 方法最相近的。

string Ctor(char[]? value)
{
    if (value == null || value.Length == 0)
        return Empty;

    string result = FastAllocateString(value.Length);

    Buffer.Memmove(
        elementCount: (uint)result.Length, // derefing Length now allows JIT to prove 'result' not null below
        destination: ref result._firstChar,
        source: ref MemoryMarshal.GetArrayDataReference(value));

    return result;
}

這裏的兩個重要步驟是:

  • 根據數組長度使用 FastAllocateString 分配內存。FastAllocateString 是在 .NET Runtime 中實現的,它幾乎是所有字符串分配內存的基礎。
  • 調用 Buffer.Memmove,它將原來數組中的所有字節複製到新分配的字符串中。

要使用這個構造函數,我們需要向它提供一個 char 數組。在它的工作完成後,我們最終會得到一個(當前不必要的)char 數組和一個字符串,數組有與字符串相同的數據。如果我們要修改原來的數組,字符串是不會被修改的,因爲它是一個獨立的、不同的數據副本。在高性能的 .NET 環境中,節省對象和數組的內存分配是非常有價值的,因爲它減少了 .NET 垃圾回收器每次運行時需要做的工作。每一個留在內存中的額外對象都會增加收集的頻率,並損耗總性能。

爲了與構造函數形成對比,並消除這種不必要的內存分配,我們來看一下 Create 方法的代碼。

public static string Create<TState>(int length, TState state, SpanAction<char, TState> action)
{
    if (action == null)
        throw new ArgumentNullException(nameof(action));

    if (length <= 0)
    {
        if (length == 0)
            return Empty;
        throw new ArgumentOutOfRangeException(nameof(length));
    }

    string result = FastAllocateString(length);
    action(new Span<char>(ref result.GetRawStringData(), length), state);

    return result;
}

步驟相似,但有一個關鍵的區別:

  1. FastAllocateString 根據 length 參數分配內存。
  2. 將新分配的 string 轉換爲 Span<char>
  3. 調用 action,並將 Span<char> 實例與 state 作爲參數。

這種方法避免了多餘的內存分配,因爲它允許我們傳入 SpanAction,這是一組有關如何創建字符串的方法,而不是要求我們將需要放入字符串中的所有字節進行二次複製。

對比上面兩張圖,圖二的 Create 比圖一構造函數少了一塊內存分配。

String.Create 好在哪

此時,你可能會對Create方法感到好奇,但你不一定知道爲什麼它比你之前使用過的方法更好。Create API 的用處是因地制宜的,但在適當的情況下,它可以發揮極大的威力。

  • 它會預先分配一塊內存空間,然後給你一個接口來安全地填充這個空間。其他創建字符串的方法可能需要編寫不安全代碼或管理緩衝池。
  • 它避免了對數據進行額外的複製操作,這通常使內存的分配更少。這也減少了來自垃圾收集器的壓力,可以加快程序的整體效率。
  • 它允許你將高性能代碼集中在應用程序的業務需求上,而不是將你的字符串構建代碼與複雜的內存管理交織在一起。

ID生成器示例

只有當你已經知道最終字符串的長度時,你才能使用Create方法。然而,你可以創造性地使用這個約束,並發現幾種利用Create的方法。我在 dotnet/aspnetcoredotnet/runtime 的代碼庫中進行了搜索,看看微軟團隊在哪些地方用了這個API。

下面這個類來自 ASP.NET Core 倉庫,用來爲每個Web請求生成相關ID。這些ID的格式由數字(0-9)和大寫字母(A-V)組成。

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Threading;

namespace Microsoft.AspNetCore.Connections
{
    internal static class CorrelationIdGenerator
    {
        // Base32 encoding - in ascii sort order for easy text based sorting
        private static readonly char[] s_encode32Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUV".ToCharArray();

        // Seed the _lastConnectionId for this application instance with
        // the number of 100-nanosecond intervals that have elapsed since 12:00:00 midnight, January 1, 0001
        // for a roughly increasing _lastId over restarts
        private static long _lastId = DateTime.UtcNow.Ticks;

        public static string GetNextId() => GenerateId(Interlocked.Increment(ref _lastId));

        private static string GenerateId(long id)
        {
            return string.Create(13, id, (buffer, value) =>
            {
                char[] encode32Chars = s_encode32Chars;

                buffer[12] = encode32Chars[value & 31];
                buffer[11] = encode32Chars[(value >> 5) & 31];
                buffer[10] = encode32Chars[(value >> 10) & 31];
                buffer[9] = encode32Chars[(value >> 15) & 31];
                buffer[8] = encode32Chars[(value >> 20) & 31];
                buffer[7] = encode32Chars[(value >> 25) & 31];
                buffer[6] = encode32Chars[(value >> 30) & 31];
                buffer[5] = encode32Chars[(value >> 35) & 31];
                buffer[4] = encode32Chars[(value >> 40) & 31];
                buffer[3] = encode32Chars[(value >> 45) & 31];
                buffer[2] = encode32Chars[(value >> 50) & 31];
                buffer[1] = encode32Chars[(value >> 55) & 31];
                buffer[0] = encode32Chars[(value >> 60) & 31];
            });
        }
    }
}

算法很簡單:

  • 使用UTC的最新Tick計數作爲ID的起始值,Tick計數數是一個64位的整數。
  • 在每次請求新的ID時以一遞增。
  • 將值右移5(character_index * 5)位,獲取最右邊的5位(shifted_value & 31),並根據預先確定的字符表(encode32Chars)選擇一個字符,從後向前填充到buffer

譯者注:64位的整數,每5位一劃分可劃爲13段,前十二段爲5位,最後一段爲4位。之所以5位一劃分是因爲 2^5-1=31,可以確保字符表(encode32Chars)的每個字符都可以被索引到(encode32Chars[31] V)。若以4位劃分,則最大的索引是15,字符表就有一半的字符輪空。

我們用 StringBuilder 作爲我們比較對象。我之所以選擇StringBuilder,是因爲它通常被推薦爲常規字符串拼接性能較好的API。我寫了額外的實現,嘗試使用StringBuilder(有容量)、StringBuilder(無容量)和簡單拼接。

運行性能 Benchmarks:

內存分配 Benchmarks:

String.Create() 方法在性能(16.58納秒)和內存分配(只有48 bytes)方面表現得最好。

字符串拼接優化示例

C# Roslyn 編譯器在優化字符串拼接時非常聰明。編譯器會傾向於將多次使用加號 + 運算符轉換爲對 Concat 的單次調用,並且很可能有許多我不知道的額外技巧。由於這些原因,拼接通常是一個快速的操作,但在簡單場景下,它仍然可以用 Create 替代。

用 Create 方法演示拼接的示例代碼:

public static class ConcatenationStringCreate
{
    public static string Concat(string first, string second)
    {
        first ??= string.Empty;
        second ??= String.Empty;
        bool addSpace = second.Length > 0;

        int length = first.Length + (addSpace ? 1 : 0) + second.Length;
        return string.Create(length, (first, second, addSpace),
        (dst, v) =>
        {
            ReadOnlySpan<char> prefix = v.first;
            prefix.CopyTo(dst);

            if (v.addSpace)
            {
                dst[prefix.Length] = ' ';

                ReadOnlySpan<char> detail = v.second;
                detail.CopyTo(dst.Slice(prefix.Length + 1, detail.Length));
            }
        });
    }
}

我在 .NET Core 源代碼中只找到一個真正的例子後,就寫了這個特殊的示例。這像是一個可以合理抽象的示例,並且可以在重度使用加號 + 操作符或 String.Concat 的代碼庫中使用。

下面是運行性能和內存分配的 Benchmarks:

Create 要比 Concat (加號 + 操作符或 String.Concat)快那麼幾個百分點。對於大部分場景,Concat 拼接的性能還是可以的,不需要封裝 Create 方法做優化。但如果你是以每秒幾百萬的速度拼接字符串(比如一個高流量的Web應用),性能提高几個百分點也是值得的。

用與不用

String.Create 雖然有較好的性能,但一般只在性能要求較高場景下使用。一個良好的系統取決於很多指標,作爲軟件工程師,我們不能只追求性能指標,而忽略了大局。一般來說,我認爲簡潔可維護的代碼應該優於夢幻般的性能。

本文性能測試的有關代碼都放在了 GitHub:

https://github.com/cmcquillan/StringCreateBenchmarks
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章