返回文章列表

游戏超高效网络/本地序列化,在C#/Unity中使用FlatBuffer,1万+小对象序列化仅140ms,0额外gc

笔者:心绪Source
未经许可,严禁转载

首先附上谷歌提供的序列化/反序列化对比图:

笔者在开始使用时踩到了不少坑,并对原项目增加了 Span 支持,以带来更高效的序列化。

下载并配置 flatc

首先我们要下载 flatc.exe,此程序用于将 .fbs 文件解析为供 C# 项目使用的 .cs 文件。

FlatBuffers GitHub 仓库

来到 FlatBuffers 在 GitHub 中的开源界面,点击右侧的 Releases,在分发界面中下载 25.2.10 版本:

flatc.exe 同级目录下写一个 .bat 命令:

Code
Windows Batch
@echo off
chcp 65001
rem chcp 936 > nul
set "rootPath=E:\替换为项目根目录\"
 
for /r "%rootPath%" %%i in (*.fbs) do (
    if not exist "%%~dpiFlat_CS_Auto\" mkdir "%%~dpiFlat_CS_Auto\"
    flatc --csharp --gen-onefile -I "%rootPath%" -I "%%~dpi" -o "%%~dpiFlat_CS_Auto\" "%%i"
    echo %%i 生成完成
)
pause
 
rem echo 完整路径: "%%i"
rem echo 驱动器: %%~di
rem echo 路径: %%~pi
rem echo 文件名(带扩展名): %%~nxi
rem echo 文件名(不带扩展名): %%~ni
rem echo 扩展名: %%~xi
rem echo 文件大小: %%~zi 字节
rem echo 最后修改时间: %%~ti

.bat 命令会从根目录开始查找所有 .fbs 文件,在每个文件的同级目录生成 Flat_CS_Auto 文件夹,并将生成的 .cs 文件放入其中。

  • --gen-onefile:如果一个 .fbs 定义了多个类型,只生成一个 .cs 文件,而不是每个类型生成一个。
  • -I:输入文件所在的文件夹。命令传入了项目根路径与 .fbs 所在路径,因此 .fbs 引用其他 .fbs 时,只需使用从根目录开始的相对路径。
  • -o:生成的 .cs 文件输出位置。
  • 最后的 %%i:当前 .fbs 文件名,程序会从 -I 指定的路径中依次寻找它所引用的文件。

.fbs 语法

Code
FlatBuffers Schema
// example IDL file
include "BasicData.fbs";
 
namespace MyGame;
 
enum Color : byte { Red = 1, Green, Blue }
 
struct Vec2 {
  x : float;
  y : float;
}
 
table Monster {
  pos : Vec2;
  pos_array : [Vec2];
  mana : short = 150;
  name : string;
  friendly : bool = false;
  inventory : [float];
  color : Color = Blue;
 
  dic_key : [string];
  dic_value : [int];
}
  1. 支持 // 代码注释。
  2. 除注释外不可使用中文。
  3. include 后面跟相对路径,相对于 .bat 命令中 -I 后面的路径。
  4. namespace 可以指定命名空间,生成的 C# 代码会自动放在此命名空间下。
  5. struct 是结构体。结构体中只能有基本类型,结构体可以相互嵌套;结构体数据会被直接嵌入父数据,解码比表更快。
  6. table 是表。表内可以有任意类型对象;表数据不会被嵌入父数据,而是单独存在,父数据仅持有表指针,所以解码时要先从父数据中获取指针,再查找表数据。
  7. 字段格式为 字段名 : 字段类型。字段名必须使用小写,单词之间可以使用 _ 分隔;[字段类型] 代表数组,= 后面跟默认值。
  8. 如果字段类型来自引用的其他文件,需要写成 命名空间.类型;同命名空间可以省略命名空间。

接下来调用 .bat 生成 .cs 文件并将其放入工程中。此时可能会报错,先不用处理:

可以看到生成了我们需要的三个类型,而 Monster 是表,所以还会额外生成检查器,用于检查一段字节是否为 Monster 类型。

引入 FlatBuffers C# 源码

接下来回到 GitHub 获取 C# 源代码:

下载与 flatc.exe 相同版本的源代码,解压后找到:

Code
目录路径
flatbuffers-master\net\FlatBuffers

将整个 FlatBuffers 文件夹作为代码的一部分放入项目中,之前的报错此时应该都已修复。

如果使用 Unity,还需要添加以下宏:

Project Settings → Player → Other Settings → Script Compilation 中添加:

Code
Unity Scripting Define Symbols
UNSAFE_BYTEBUFFER
BYTEBUFFER_NO_BOUNDS_CHECK
ENABLE_SPAN_T
  • UNSAFE_BYTEBUFFER:使用不安全代码操作底层字节数组,可以带来可观的性能提升。
  • BYTEBUFFER_NO_BOUNDS_CHECK:禁用字节数组的边界检查断言,在普通代码中可能带来较小的性能提升。
  • ENABLE_SPAN_T:启用使用 Span<T> 代替 T[] 读取和写入内存块。也可以通过提供 ByteBufferAllocator 的自定义实现,直接写入共享内存或其他类型的内存。ENABLE_SPAN_T 还需要定义 UNSAFE_BYTEBUFFER,或者使用 .NET Standard 2.1。

对于某些操作,同时使用 UNSAFE_BYTEBUFFERBYTEBUFFER_NO_BOUNDS_CHECK 可以带来约 15% 的性能提升,但这样做存在潜在风险,请自行评估。

扩展 FlatBufferBuilder

找到 FlatBufferBuilder 脚本,将类型声明改为:

Code
C#
public partial class FlatBufferBuilder

然后新建一个扩展脚本,将下面的代码复制进去:

Code
C#
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
 
namespace Google.FlatBuffers
{
    public partial class FlatBufferBuilder
    {
        /// <summary>
        /// 创建指针数组的指针
        /// </summary>
        /// <param name="offsets">全是指针的数组</param>
        /// <returns></returns>
        public VectorOffset CreateVector_Offset(Span<int> offsets)
        {
            NotNested();
            int oneObjectSize = sizeof(int);
            int allObjectSize = oneObjectSize * offsets.Length;
            StartVector(oneObjectSize, offsets.Length, oneObjectSize);
            Prep(oneObjectSize, allObjectSize);
            for (int i = 0; i < offsets.Length; i++)
            {
                int off = offsets[i];
                if (off != 0)
                    off = Offset - off + oneObjectSize;
                PutInt(off);
            }
            return EndVector();
        }
 
        /// <summary>
        /// 创建基本数据数组的指针
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="datas">全是基础类型的数组</param>
        /// <returns></returns>
        public unsafe VectorOffset CreateVector_Data<T>(Span<T> datas) where T : unmanaged
        {
            NotNested();
            int oneObjectSize = sizeof(T);
            int allObjectSize = oneObjectSize * datas.Length;
            StartVector(oneObjectSize, datas.Length, oneObjectSize);
            Prep(oneObjectSize, allObjectSize);
            Span<T> copyDatas = stackalloc T[datas.Length];
            fixed (T* src = datas, dest = copyDatas)
                Buffer.MemoryCopy(src, dest, allObjectSize, allObjectSize);
            copyDatas.Reverse();
            ToLittleEndian(copyDatas, oneObjectSize);
            _space -= allObjectSize;
            fixed (T* ptr = copyDatas)
            {
                Span<byte> datasByte = new Span<byte>(ptr, allObjectSize);
                datasByte.CopyTo(_bb.ToSpan(_space, allObjectSize));
            }
            return EndVector();
        }
 
        public VectorOffset CreateVector_Builder(FlatBufferBuilder datasForm) =>
            CreateVector_Data(datasForm.ToSpan());
 
        private static void ToLittleEndian<T>(Span<T> array, int oneObjectSize)
            where T : unmanaged
        {
            Span<byte> bytes = MemoryMarshal.AsBytes(array);
            // 如果当前系统是大端序,则反转字节
            if (oneObjectSize > 1 && !BitConverter.IsLittleEndian)
            {
                for (int i = 0; i < array.Length; i++)
                {
                    var elementSpan = bytes.Slice(i * oneObjectSize, oneObjectSize);
                    elementSpan.Reverse();
                }
            }
        }
 
        public override string ToString()
        {
            var span = ToSpan();
            StringBuilder sb = new StringBuilder(span.Length);
            for (int i = 0; i < span.Length; i++)
                sb.Append(span[i]);
            return sb.ToString();
        }
 
        private static readonly List<FlatBufferBuilder> pool =
            new List<FlatBufferBuilder>(26);
 
        public static FlatBufferBuilder TakeOut(int size)
        {
            if (size <= 0) size = 1024;
            int minGap = int.MaxValue;
            int minGapIndex = -1;
 
            int maxSize = size;
            int maxSizeIndex = -1;
            lock (pool)
            {
                for (int i = pool.Count - 1; i >= 0; i--)
                {
                    var builder = pool[i];
                    int gap = builder.DataBuffer.Length - size;
                    if (gap >= 0 && gap < minGap)
                    {
                        // 差距在一个 size 以内,直接返回
                        if (gap < size)
                        {
                            minGapIndex = i;
                            break;
                        }
                        minGap = gap;
                        minGapIndex = i;
                    }
                    if (builder.DataBuffer.Length > maxSize)
                    {
                        maxSize = builder.DataBuffer.Length;
                        maxSizeIndex = i;
                    }
                }
 
                FlatBufferBuilder ret;
                if (minGapIndex != -1)
                {
                    ret = pool[minGapIndex];
                    pool.RemoveAt(minGapIndex);
                }
                else if (maxSizeIndex != -1)
                {
                    ret = pool[maxSizeIndex];
                    pool.RemoveAt(maxSizeIndex);
                }
                else
                {
                    ret = new FlatBufferBuilder(size * 2);
                }
                return ret;
            }
        }
 
        public static void PutIn(FlatBufferBuilder builder)
        {
            builder.Clear();
            lock (pool)
            {
                pool.Add(builder);
            }
        }
 
        public Span<byte> ToSpan() =>
            DataBuffer.ToSpan(DataBuffer.Position, Offset);
    }
}

序列化与反序列化 Monster

接下来序列化一个 Monster 类型:

Code
C#
System.Diagnostics.Stopwatch stopwatch = new System.Diagnostics.Stopwatch();
stopwatch.Start();
 
// 生成序列化字节数组缓存,容量不够时大小会翻倍
FlatBufferBuilder builder = new FlatBufferBuilder(1024);
 
// 假设这些是我们的程序数据
List<Vector2> posArray = new List<Vector2>()
{
    new Vector2(1.3f, 4.2f),
    new Vector2(5.3f, 8.4f),
    new Vector2(10.11f, 31.24f)
};
string name = "心绪Source     请勿转载";
Span<float> inventory = new float[] { 3.14f, 12.009f, 2025 };
Dictionary<string, int> dic = new Dictionary<string, int>()
{
    { "你好", 1 },
    { "世界", 2 },
    { "!!", 3 }
};
 
// 在 FlatBuffers 中,非标量数据不能嵌套序列化,每一个都必须单独序列化后获取指针,
// 再将指针数组传递给需要存储的父对象。
// 标量类型数组区分大小端后直接复制到缓存;非标量数组存储时,需要遍历数组,
// 重新计算相对于数组头的偏移。
 
// 开始创建结构体数组
Monster.StartPosArrayVector(builder, posArray.Count);
// 创建时字节流正向添加,取出时字节流反向取出,所以取出时从高索引到低索引,
// 最后仍然是正向的。
for (int i = 0; i < posArray.Count; i++)
    Vec2.CreateVec2(builder, posArray[i].x, posArray[i].y);
VectorOffset posArrayOffset = builder.EndVector();
 
StringOffset nameOffset = builder.CreateString(name);
 
// 往数组中添加基本数据时使用 Put,而不是 Add。StartVector 已经将数据对齐,
// 每次调用 Add 都会尝试重新对齐。
Monster.StartInventoryVector(builder, inventory.Length);
for (int i = 0; i < inventory.Length; i++)
    builder.PutFloat(inventory[i]);
VectorOffset inventoryOffset = builder.EndVector();
 
// 字符串指针数组使用栈空间,避免在堆上开辟额外空间
Span<int> keyOffsetArray = stackalloc int[dic.Count];
// 同时记录 key 和 value,避免遍历字典两次
Span<int> valueArray = stackalloc int[dic.Count];
int index = 0;
foreach (var kv in dic)
{
    keyOffsetArray[index] = builder.CreateString(kv.Key).Value;
    valueArray[index] = kv.Value;
    index++;
}
 
// FlatBuffers 不能嵌套序列化非标量,无法在创建数组的同时创建 string,
// 因此使用扩展方法创建指针数组。
VectorOffset dicOffsetKey = builder.CreateVector_Offset(keyOffsetArray);
// 基本类型使用此方法,比上方 Inventory 数组的创建方法更快。
VectorOffset dicOffsetValue = builder.CreateVector_Data(valueArray);
 
// 开始生成 Monster 对象,完成之前不能再创建非标量
Monster.StartMonster(builder);
Monster.AddPos(builder, Vec2.CreateVec2(builder, 1.2f, -2.4f));
Monster.AddPosArray(builder, posArrayOffset);
Monster.AddMana(builder, 10);
Monster.AddName(builder, nameOffset);
Monster.AddFriendly(builder, true);
Monster.AddInventory(builder, inventoryOffset);
Monster.AddColor(builder, MyGame.Color.Red);
Monster.AddDicKey(builder, dicOffsetKey);
Monster.AddDicValue(builder, dicOffsetValue);
int endOffset = Monster.EndMonster(builder).Value;
 
// 将完成指针放入缓存,反序列化需要此指针
builder.Finish(endOffset);
 
Debug.Log("序列化一次时间" + stopwatch.ElapsedMilliseconds);
stopwatch.Restart();
 
using (var fileStream = new FileStream(@"D:\test.txt", FileMode.Create, FileAccess.Write))
{
    fileStream.Write(builder.DataBuffer.ToSpan(builder.DataBuffer.Position, builder.Offset));
}
 
byte[] buffer;
using (var fileStream = new FileStream(@"D:\test.txt", FileMode.Open, FileAccess.Read))
{
    buffer = ArrayPool<byte>.Shared.Rent((int)fileStream.Length);
    fileStream.Read(buffer);
    Debug.Log("长度" + buffer.Length);
}
 
Monster monster = Monster.GetRootAsMonster(new ByteBuffer(buffer));
StringBuilder sb = new StringBuilder();
sb.Append("Position: ").Append(monster.Pos.Value.X).Append(' ')
  .Append(monster.Pos.Value.Y).AppendLine();
sb.Append("Position Array:").AppendLine();
for (int i = monster.PosArrayLength - 1; i >= 0; i--)
{
    var pos = monster.PosArray(i);
    sb.Append("  ").Append(pos.Value.X).Append(' ')
      .Append(pos.Value.Y).AppendLine();
}
sb.Append("Mana: ").Append(monster.Mana).AppendLine();
sb.Append("Name: ").Append(monster.Name).AppendLine();
sb.Append("Friendly: ").Append(monster.Friendly).AppendLine();
 
sb.Append("Inventory:").AppendLine();
for (int i = monster.InventoryLength - 1; i >= 0; i--)
{
    sb.Append("  ").Append(monster.Inventory(i)).AppendLine();
}
 
sb.Append("Color: ").Append(monster.Color).AppendLine();
 
sb.Append("Dictionary:").AppendLine();
for (int i = monster.DicKeyLength - 1; i >= 0; i--)
{
    sb.Append("  Key: ").Append(monster.DicKey(i))
      .Append(", Value: ").Append(monster.DicValue(i)).AppendLine();
}
Debug.Log(sb.ToString());
Debug.Log("反序列化一次时间" + stopwatch.ElapsedMilliseconds);
stopwatch.Restart();

移除默认值判断

我们还应当在 FlatBufferBuilder.cs 文件中注释掉所有的:

Code
C#
if (ForceDefaults || x != d)
if (x != d)

这两个判断会在单独添加基础类型时检查值是否为默认值,如果是就不添加。但在工程中,如果有不想添加的值,我们根本不会调用添加方法;而且该判断还会比较两个浮点数是否相同,既不准确又浪费性能。

性能与复用

上面的单次测试中,序列化时间为 16 ms,反序列化时间为 15 ms,字节流大小为 256 字节。批量测试中,1 万+小对象的序列化耗时约为 140 ms,且没有产生额外 GC。

FlatBufferBuilder 对象可以复用。Clear 只会将指针置零,无需清理对象。初次创建 FlatBufferBuilder 时,应当指定合适的初始容量,防止扩容时丢弃旧数组。

结论

序列化与反序列化非常快,过程中基本没有额外堆空间分配。Protocol Buffers 的序列化与反序列化会生成辅助对象,而辅助对象都是引用类型;大量使用小 message 时会带来 GC 压力。

代价是产生的字节流完全没有压缩,比 Protocol Buffers 产生的字节流大约多一倍;API 也比大部分库复杂。与 JSON 相比,数据对人类完全不可读。序列化过程中非标量值不能更改,例如 string 必须先在缓存中创建并获取指针,再由父对象保存该指针;只能修改父对象的指向,缓存空间中原有的 string 无法删除,最终序列化的字节流可能产生额外垃圾数据。

总体来说,是以较小的代价换来了巨大的性能提升。

参考资料