记一次C#数据包GC优化

[TOC]

几个月前接手了项目客户端网络层模块,要求分析数据包GC大小并优化。基于不可抗拒的原因,这个项目没有使用流行的通信协议,如google protobuffer和风云大大的sproto等,而是自研的key-value协议。经过一系列优化,最终将代码的GC减少了约50%,并且优化以来运行良好。由于不能将项目的具体代码逻辑贴出来,同时为了更好地理解本文主旨,下文使用伪代码来表达主体逻辑,请放心这不会影响你对要点的理解。

也许阅读的过程你会嘲笑和惊讶部分代码为何“如些地烂”。遗憾的是已无法对这些“上古的遗迹”细细地追溯过去,只能战战兢兢地修改下去罢了~~!!

修改前的代码

主要有两个对象NetPackage和NetWork

NetPackage 数据包实例。负责储存着字段列表和序列化工作 NetWork 负责网络收发

// c#伪代码
class NetPackage
{
    // 使用字典结构保存字段
    Dictionary<string, object> dicFields = new Dictionary<string, object>();

    // 新增/修改字段key-value
    public void SetField(string key, object value)
    {
        // do someting here

        dicFields[key] = value;
    }

    // 获取字段
    public void GetField<T>(string key)
    {
        return (T)dicFields[key];
    }

    // 将字段列表序列化为字节流
    public byte[] ToBytes()
    {
        // 创建序列化字节流(假设最大长度为256)
        byte[] buf = new byte[256];

        // 遍历key-value,逐一转换为字节流
        foreach(k, v in dicFields)
        {
            Type type = v.GetType();
            byte[] data = null;

            if (type == typeof(int))
            {
                data = BitConverter.GetBytes(value);
            }
            else if(...)
            {
                ...
            }

            // 保证空间足够
            EnsureCapacity(buf, data, len);
            Buffer.BlockCopy(buf, i, data, 0, len);
            // do something here
        }


        return buf;
    }

    // 从字节流反序列为具体字段
    public void FromBytes(byte[] buf)
    {
        while(next)
        {
            switch(fieldType)
            {
                case FIELD_TYPE_INT:
                    SetField(key, value)
                    break;
                // tod something here
            }
        }
    }
}
// c#伪代码
class NetWork
{
    public void Send(NetPackage pkg)
    {
        byte[] buf = pkg.ToBytes();
        socket.send(buf, 0, buf.Lenght);

        // do something here
    }

    public void Recv(byte[] buf)
    {
        while (能组合一个完整包)
        {
            NetPackage pkg = new NetPackage();
            pkg.FromBytes(buf, start, len);
        }
        // do something here
    }

    // 读取TCP网络流线程
    private void ReadThread()
    {
        MemoryStream memoryStream;
        NetworkStream stream = networkStream;
        byte[] arrByte;
        memoryStream = new MemoryStream(READ_BUFFER_SIZE);
        arrByte = new byte[READ_BUFFER_SIZE];

        while(true)
        {
            size = stream.Read(arrByte, 0, READ_BUFFER_SIZE);
            memoryStream.Write(arrByte, 0, size);

            // do somthing here

            if(memoryStream.Length > 0)
            {
                Recv(memoryStream.ToArray());
		memoryStream.Position = 0;
		memoryStream.SetLength(0);
            }
        }
        memoryStream.Close();
    }

    // do something here
}
// c#伪代码
class Example
{
    void Main()
    {
        int protoId = 1234
        NetPackage pkg = new NetPackage(protoId);
        pkg.SetField("key", 5678);
        pkg.SetField("key1", 9.10);
        pkg.SetField("key2", true);

        NetWork.Send(pkg);
    }
}

不知道各位能看出多少GC个问题。

这些问题不是我瞎编!

性能问题

1. 值类型装拆箱GC

  • SetField第二个参数声明类型是System.object,但当值类型传入时会进行装箱
  • Dictionary<T1, T2>的T2类型也是System.object,GetField在返回值类型时将进行拆箱

解决办法是增加多个SetField/GetField的重载方法,同时设计新的实例PackageField令所有value类型都引用类型,那么装/拆箱问题就不存在了。以整型为例的重载方法会有void SetField(string key, int value)SetInt(string key, int value)int GetInt(string key)

2. 在socket发送数据包前,NetPackage.ToBytes内部调用new byte[]

基于TCP socket内部会拷贝传入的byte[],同时NetWork不会在多个线程调用的情况。所以解决办法完成可以预分配一个空间较大byte[],所有数据包序列化时都使用它。

3. 带GC的API 面对.Net Framework内部的GC,不要放弃优化,细细地看源代码(.Net Framework线上源码),了解API的逻辑很可能找到优化的方法。比如有些接口同时提供了带GC和无GC的版本,如Encoding.GetBytes(string), 如下第一个重载带GC,第二个不带GC

GetBytes(String) When overridden in a derived class, encodes all the characters in the specified string into a sequence of bytes.
GetBytes(String, Int32, Int32, Byte[], Int32) When overridden in a derived class, encodes a set of characters from the specified string into the specified byte array.

尽管带GC的API用起来非常方便,但频繁调用的逻辑,一定要用无GC的API

然而面对没有无GC重载方法的接口,如BitConvert.GetBytes,见以下BinConvert.GetBytes对整型的重载方法为例

// Converts an int into an array of bytes with length 
// four.
[System.Security.SecuritySafeCritical]  // auto-generated
public unsafe static byte[] GetBytes(int value)
{
	Contract.Ensures(Contract.Result<byte[]>() != null);
	Contract.Ensures(Contract.Result<byte[]>().Length == 4);
 
	byte[] bytes = new byte[4];
	fixed(byte* b = bytes)
	    *((int*)b) = value;
        return bytes;
}

这里数据包序列化的逻辑简单,其实不是必要用到BitConvert,完全可以参照它的实现来满足我们的要求并且记录好当前写入的位置即可。见下代码

public byte[] ToBytes(byte[] buf)
{
    foreach(k, v in dicFields)
    {
        PackageField field = v.GetType();

        if (field.type == EPACKAGE_FIELD_INT)
        {
            // 不使用BitConvert.GetBytes,下标i记录写入位置
	    fixed(byte* b = buf[i])
	        *((int*)b) = field.value;
	    i += 4;
        }
        ...
    }

    return buf;
}

4. Network读取字节流及组包的GC

ReadThread为了处理TCP粘包问题,使用了MemoryStream缓存读取到的所有字节。不过MemoryStream.ToArray()内部分实现会产生GC,而且没有无GC重载方法。

public virtual byte[] ToArray() 
{
	BCLDebug.Perf(_exposable, "MemoryStream::GetBuffer will let you avoid a copy.");
	byte[] copy = new byte[_length - _origin];
	Buffer.InternalBlockCopy(_buffer, _origin, copy, 0, _length - _origin);
	return copy;
}

解决办法抛弃MemoryStream,预分配一个较大的byte[],记录当前有效的字节长度,新读取到的字节往后追加。

size = stream.Read(arrByte, 0, READ_BUFFER_SIZE);

修改后将新到的字节,追加到后面

int recLen = networkStream.Read(copyArray, start, maxSize - start);

PS: 有关foreach的GC,Unity 5.5版本已经修复

优化后的代码

// c#伪代码
class NetPackage
{
    Dictionary<string, PackageField> dicFields = new Dictionary<string, PackageField>();

    public void Release()
    {
        foreach(var itr in dicFields)
        {
            itr.Value.Relase();
        }
    }

    public void SetField(string key, int value)
    {
        // do someting here
        SetInt(key, value);
    }

    public void SetInt(string key, int value)
    {
        // do someting here
        PackageField field = ObjectPool<PackageFieldInt>.Get();
        dicFields[key] = field;
    }

    // 获取字段
    public int GetInt(string key)
    {
        PackageField field = dicFields[key] = field;
        return field.value;
    }

    // 此处省略各种类型重载方法

    // 序列化的buffer由调用端传入
    public byte[] ToBytes(byte[] buf)
    {
        foreach(k, v in dicFields)
        {
            PackageField field = v.GetType();

            if (field.type == EPACKAGE_FIELD_INT)
            {
                // 不使用BitConvert.GetBytes
                buf[i++] = (byte) field.value);
                buf[i++] = (byte) (field.value >> 8));
                buf[i++] = (byte) (field.value >> 16));
                buf[i++] =(byte) (field.value >> 24));
            }
            else if(...)
            {
                ...
            }
            // do something here
        }


        return buf;
    }

    // 因收包重用buffer,需要start和len定义有效的字节范围
    public void FromBytes(byte[] buf, int start, int len)
    {
        while(next)
        {
            switch(fieldType)
            {
                case FIELD_TYPE_INT:
                    SetField(key, value)
                    break;
                // tod something here
            }
        }
    }
}
// c#伪代码
class NetWork
{
    byte[] buf = new byte[256]
    public void Send(NetPackage pkg)
    {
        pkg.ToBytes(buf);
        socket.send(buf, 0, buf.Lenght);

        // do something here
    }

    public int Recv(byte[] buf, int start, int len)
    {
        while (能组合一个完整包)
        {
            NetPackage pkg = new NetPackage();
            pkg.FromBytes(buf, start, len);
        }
        // do something here
    }

     // 读取TCP网络流线程
    private void ReadThread()
    {
        NetworkStream stream = networkStream;
        byte[] arrByte = new byte[READ_BUFFER_SIZE];
        int start = 0;
        int maxSize = arrByte.Length;

        while(true)
        {
            int recLen = networkStream.Read(copyArray, start, maxSize - start);
            start += recLen;
            
            // 数据包超出预定长度,重建buffer
            if (start >= maxSize)
            {
                int newSize = maxSize * 2;
                byte[] newBuffer = new byte[newSize];
                Buffer.BlockCopy(arrByte, 0, newBuffer, 0, maxSize);
                maxSize = newSize;
                arrByte = newBuffer;

            }

            // do somthing here

            if (start > 0)
            {
                int dirtySize = Recv(arrByte, 0, start);

                // dirtySize成功组包后,耗掉的字节长度
                if (dirtySize > 0)
                {
                    // 将后面粘包的字节,移到前面组包后的位置
                    Buffer.BlockCopy(arrByte, dirtySize, arrByte, 0, start - dirtySize);
                    start -= dirtySize;
                }
            }
        }
    }
}

性能测试

数据包周边的一些逻辑本文并没有提及,比如收发字节流实际并不是直接使用byte[]序列化,为了调用方便而是封装到了NetPackageWriter和NetPackageReader中,如些这些并不影响对本文的理解。我写了一段测试代码,观察优化后的性能。结果显示耗时多了一丢丢(封装PackageFields和使用缓存池的原因),但GC下降了将近一半,总体性能提高十分理想。

// 伪代码
NetPackage pkg = GetSamplePkg();
int TEST_TIME = 99;

for (int i = 0; i < TEST_TIME; ++i)
{
    pkgWriter.Reset();
    pkgWriter.Write(pkg);
    byte[] buffer = pkgWriter.GetBuffer();
    int size = pkgWriter.Position;

    pkgReader.Reset();
    NetPacket pkg2 = pkgReader.Read(buffer, 0, size);
}

99次循环-纯值类型字段(boo、short、ushort、int、uint、long、ulong、float、double) ||gc|执行耗时(ms)| |—|—|—| |修改前|63.8kb|10.98| |修改后|38.4kb|10.71|

99次循环-所有字段类型(除了以上,还包括:字符串、Pacakge、Pacakge数组和嵌套Pacakge) ||gc|执行耗时(ms)| |—|—|—| |修改前|1.2mb|26.34| |修改后|0.7mb|30.12|



原文:
https://lizijie.github.io/2020/03/10/%E8%AE%B0%E4%B8%80%E6%AC%A1C-%E6%95%B0%E6%8D%AE%E5%8C%85GC%E4%BC%98%E5%8C%96.html
作者github:
https://github.com/lizijie

PREVIOUS玩塞尔达荒野之息的特殊感觉
NEXT使用swig导出c# wraper并调用native dll