使用 InterpolatedString 减少字符串拼接的 GC

原视频链接

考虑到 Unity 准备在 2024 年前后,推出基于 dotnet Runtime 的版本,本篇文章也标记为 Unity 分类,等后面 Unity 准备好之后,再对新版的客户端进行改造

在日常开发过程中,字符串的拼接通常会占用大量的 GC,通常拼接字符串我们会使用如下几种方法

1. 1 + "/" + 2 + "/" + 3
2. StringBuilder
3. string.Format("{0}/{1}/{2}", 1, 2, 3)
4. "{1}/{2}/{3}" // 美元符号因为博客解析问题,此处省略

无论上述的哪一种方法,在 dotnet 5.0 的环境下,都无法做到 0 GC,但是在 dotnet 6.0 的版本,微软推出了一套基于 InterpolatedString 的解决方案,做到了即使是 值类型 字符串的拼接,也没有装箱/拆箱,以及 GC 问题

性能问题

很多时候开发者图方便,直接使用第一种方式,对字符串进行拼接,但实际生成的代码性能非常非常糟糕

// 原始代码
public string Concat()
{
    return 1 + "/" + 2 + "/" + 3;
}

// 实际生成的代码
public string Concat()
{
    string[] array = new string[5];
    array[0] = 1.ToString();
    array[1] = "/";
    array[2] = 2.ToString();
    array[3] = "/";
    array[4] = 3.ToString();
    return string.Concat(array);
}

同样的,如果我们使用 string.Format 函数对字符串进行拼接时,由于参数是 object,对于值类型一样会有装箱和拆箱的问题

InterpolatedString 实现

正常服务器项目的日志打印,都会定制一份自己的实现,一般用于控制日志等级,搜集日志等等。由于有这层封装,我们对于日志 GC 的整体改造会简单许多,这里以我们项目实际接入为例

注意此处需要 dotnet 6.0 以及 C#10,低版本无法使用

此时如果我们使用 $ 对字符串进行拼接时,生成的代码就完全不一样了

// 生成前
public string Dotnet5Interpolate()
{
    return "{1}/{2}/{3}"; // 省略了美元符号
}
    
// 生成后
public string Dotnet5Interpolate()
{
    return string.Format("{0}/{1}/{2}", 1, 2, 3);
}

// 生成前
public string Dotnet6Interpolate()
{
    return "{1}/{2}/{3}"; // 省略了美元符号
}

// 生成后
public string Dotnet6Interpolate()
{
    DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(2, 3);
    defaultInterpolatedStringHandler.AppendFormatted(1);
    defaultInterpolatedStringHandler.AppendLiteral("/");
    defaultInterpolatedStringHandler.AppendFormatted(2);
    defaultInterpolatedStringHandler.AppendLiteral("/");
    defaultInterpolatedStringHandler.AppendFormatted(3);
    return defaultInterpolatedStringHandler.ToStringAndClear();
}

这里的 DefaultInterpolatedStringHandler 是一个 ref struct,所以所有操作均在栈上执行,而微软为了解决 值类型 的装箱拆箱,将 AppendFormatted 方法定义为了泛型方法

public void AppendFormatted<T>(T value);

至此我们还需要对现有的日志接口进行改造,改造过程也非常简单,我们先定义 LogInterpolatedStringHandler 结构体

[InterpolatedStringHandler]
public ref struct LogInterpolatedStringHandler
{
    private DefaultInterpolatedStringHandler _inner_handler;

    public LogInterpolatedStringHandler(int literal_length, int formatted_count)
    {
        _inner_handler = new DefaultInterpolatedStringHandler(literal_length, formatted_count);
    }

    public override string ToString() => _inner_handler.ToString();

    public void AppendLiteral(string literal) => _inner_handler.AppendLiteral(literal);

    public void AppendFormatted<T>(T value) => _inner_handler.AppendFormatted(value);

    public string ToStringAndClear() => _inner_handler.ToStringAndClear();
}

假设日志有 Debug 接口,那么此处的写法如下

public static class Log
{
    public static void Debug(ref LogInterpolatedStringHandler msg)
    {
        // 注意这里记得使用 ToStringAndClear 方法
        // 底层使用了 ArrayPool,此时需要将 char[] 还给池子
        xxx.Log(msg.ToStringAndClear());
    }
}

// 后续全部省略了美元符号
// 输入我是日志,注意此处如果没有写美元符号,无法通过编译
Log.Debug("我是日志")
// 输出 我是日志 1/2/3
Log.Debug("我是日志 {1}/{2}/{3}");
int value = 123;
// 输出 123
Log.Debug("{value}");

这样我们就完成了 0GC 的字符串拼接,对于上层的业务逻辑来说,打印日志仅允许使用 $ 符号对字符串进行拼接,从而限制了一些程序喜欢使用 + 来拼接字符串的写法,而且即使是外面想使用 string.Format 传入字符串,也同样无法通过编译