C# 效能優化小技巧:5 個順手減少 Allocation 的寫法

  • 17
  • 0

寫 C# 久了之後,會發現效能改善不一定都要做到很複雜
很多時候不是要換架構,也不是一定要上什麼很進階的技巧, 反而只是一些平常寫 Code 時的小習慣

例如少查一次 Dictionary、少產生一個 String、少配置一個 Array,單看一次幾乎沒有差
但如果這段程式每天跑幾十萬、幾百萬次,這些小地方累積起來其實就有差了..

這篇整理幾個我之前看到比較常用的 C# 小技巧, 都不是什麼很屌的技術只是一些小習慣的改變
也不一定每個地方都要硬套
但如果剛好是在大量迴圈、API、Background Service、Parser 或一些高頻處理的地方,就可以注意一下,畢竟量便會引發質變..

1. Dictionary 能用 TryGetValue,就不要多查一次

這種寫法應該很多人這樣寫,包括我

if (users.ContainsKey(userId))
{
    var user = users[userId];

    Console.WriteLine(user.Name);
}

邏輯沒有問題, 但這邊其實查了兩次 Dictionary。

第一次:

users.ContainsKey(userId)

第二次:

users[userId]

如果只是一般 CRUD,其實不用太在意。 但既然 .NET 已經有:

if (users.TryGetValue(userId, out var user))
{
    Console.WriteLine(user.Name);
}

那通常直接這樣寫就好。 一次 Lookup 就把 Key 是否存在跟 Value 一起拿回來
這種算是很好改,也不太會讓 Code 變難懂..


2. 大量更新 Dictionary,可以看看 CollectionsMarshal

如果只是單純讀取,TryGetValue() 已經很好用了, 但有些情境是會一直更新 Dictionary 裡面的值
例如統計次數:

Dictionary<string, int> counts = new();

foreach (var item in items)
{
    if (counts.TryGetValue(item, out var count))
    {
        counts[item] = count + 1;
    }
    else
    {
        counts[item] = 1;
    }
}

這段也沒有問題。 只是取得 Value 後,最後還是要再寫回 Dictionary

如果這段真的是很高頻的 Hot Path,可以用:

CollectionsMarshal.GetValueRefOrAddDefault()

直接拿 Dictionary 裡 Value 的 Reference

using System.Runtime.InteropServices;

Dictionary<string, int> counts = new();

foreach (var item in items)
{
    ref int count =
        ref CollectionsMarshal.GetValueRefOrAddDefault(
            counts,
            item,
            out bool exists);

    if (!exists)
    {
        count = 0;
    }

    count++;
}

這樣:

count++;

改到的就是 Dictionary 裡面的值。 不用再

counts[item] = count;

不過這個我不會建議到處用。 CollectionsMarshal 比較偏低階 API
一般程式碼還是先用 TryGetValue() 就好,真的確認是 Hot Path 再來看這個...


3. 字串忽略大小寫,不要先 ToLower

以前常用:

if (role.ToLower() == "admin")
{
}

或者

if (role.ToUpper() == "ADMIN")
{
}

看起來很直覺,好像也沒啥問題,但 ToLower()ToUpper() 都會產生新的 String
如果只是比較大小寫,其實不用先做一次字串轉換

可以直接:

if (string.Equals(
    role,
    "admin",
    StringComparison.OrdinalIgnoreCase))
{
}

如果是 Dictionary,也可以在建立時直接指定

var users = new Dictionary<string, User>(
    StringComparer.OrdinalIgnoreCase);

這個除了少掉 Allocation,還有 Culture 的問題。 如果是 Username、Role、HTTP Header、Cache Key、Protocol、Identifier
這些程式內部使用的字串, 通常用 OrdinalOrdinalIgnoreCase 會比較合理


4. 切字串很多次,可以開始看 Span

假設有一個字串:

string input = "ABC-123456";

以前可能直接:

string prefix = input.Substring(0, 3);
string number = input.Substring(4);

這樣很好懂,但 Substring() 會產生新的 String, 如果只是偶爾做一次完全沒差
但如果是在 Parser、Log 處理或大量資料裡面一直切,就會產生很多暫時物件

這時可以改用:

ReadOnlySpan<char> span = input.AsSpan();

ReadOnlySpan<char> prefix = span[..3];
ReadOnlySpan<char> number = span[4..];

Span 這邊不會另外建立新的 String,而是直接使用原本字串裡面的那一段資料
所以如果 Code 裡面有很多 Substring()String.Split()ToCharArray()
就可以看一下是不是真的需要另外建立新的資料, 如果只是拿其中一段來 Parse,Span<T> 就滿適合的..


5. 固定搜尋一組字元,可以看看 SearchValues

例如我要檢查檔名裡面有沒有非法字元:

char[] invalidChars =
{
    '<',
    '>',
    ':',
    '"',
    '/',
    '\\',
    '|',
    '?',
    '*'
};

很直覺的寫法:

foreach (char c in input)
{
    if (invalidChars.Contains(c))
    {
        return false;
    }
}

如果這組搜尋條件會一直重複使用,可以用:

SearchValues<char>

例如:

using System.Buffers;

private static readonly SearchValues<char> InvalidChars =
    SearchValues.Create("<>:\"/\\|?*");

接著:

bool containsInvalid =
    input.AsSpan().ContainsAny(InvalidChars);

全文長這樣:

using System.Buffers;

public static class FileNameValidator
{
    private static readonly SearchValues<char> InvalidChars =
        SearchValues.Create("<>:\"/\\|?*");

    public static bool IsValid(string fileName)
    {
        return !fileName
            .AsSpan()
            .ContainsAny(InvalidChars);
    }
}

這種比較適合 Parser、Validator、Tokenizer、大量文字掃描或 Protocol Processing 這類情境
尤其搜尋集合是固定的,又會一直重複使用時。 如果只是偶爾檢查一次,就沒差


這些東西當然不用全部套上去,像 TryGetValue()OrdinalIgnoreCase 這種本來就很好懂,我通常會直接用;CollectionsMarshalSpan<T>
就 真的看情況,確定是在 Hot Path 再處理就好

畢竟為了少一點 Allocation,把原本很單純的 Code 寫到半年後自己都看不懂,好像也沒有比較划算,而且現在都有 AI 了

真的哪天忘記自己在寫什麼,至少還可以先丟給 AI 問一下

這些技巧本來就只是少一次 Lookup、少一次 Allocation 或少一次 Copy,平常可能完全沒感覺,但同一段 Code 跑很多次還是會慢慢累積

不過說真的現在很多 Code 都直接叫 AI 生了,搞不好以後反而更需要偶爾看一下它到底幫你 new 了多少東西 .. :)

 

---

The bug existed in all possible states.
Until I ran the code.