UnityGC优化 - Protobuf 3的优化

 

9 | Protobuf 3的优化

Protobuf是比较常用的消息协议,它利用变长整数和默认值的方法压缩消息,提高了消息传输的效率,又用生成代码提高序列化/反序列化速度。但是在使用过程中,还是存在比较大的坑,如果使用不当,会造成很大的GC压力。

这里说一下如何用对象池来优化Protobuf 3的GC。



9.1 字节流的缓存


首先,当反序列化字节流的时候,一般不要使用下面的代码:

 MemoryStream stream;
    ...
    message.MergeFrom(stream);

传入参数如果是stream,会新建一个CodedInputStream,并新建一个byte[]数组,相当于把字节流又复制了一份,如果改成这样:

    byte[] cachedBytes;
    ...
    message.MergeFrom(cachedBytes);

这样会省掉复制字节流的开销。

也可以用对象池缓存CodedInputStream,类似如下代码:

  public class NetMsgInStream
    {
        public CodedInputStream codedStream { get; private set; }
        public MemoryStream memoryStream { get; } = new MemoryStream();
        public NetMsgInStream()
        {
            this.codedStream = new CodedInputStream(memoryStream);
        }
    
        public static ObjectPool<NetMsgInStream> pool { get; }
            = new ObjectPool<NetMsgInStream>(actionOnRelease: (s) => s.memoryStream.SetLength(0));
    }

    var stream = NetMsgInStream.pool.Get();
    message.MergeFrom(stream.codedStream);
    NetMsgInStream.pool.Release(stream);

事实上,Protobuf的所有扩展方法都不建议滥用。



9.2 消息类的缓存


频繁生成的消息类,如战斗时的移动同步消息,如果每次接受到消息都新建一个对象,一场战斗下来会有很大的GC Alloc。

所以,最好也要用对象池来缓存。

但是,仅缓存是不够的。如前文所说,Protobuf为了节省传输压力,对默认值进行了优化,所以,并不是所有字段都会在反序列化的时候赋值,这时如果服务器生成的消息某个字段是默认值,就不会放入字节流里传给前端。

如果消息在放回缓冲池时没有把所有字段恢复到默认值,下一次反序列化重用到这个对象时,字段有可能还是上一次的值。所以,在使用protoc生成消息类时,最好也为消息生成一个Clear方法,清空所有字段。

plugin在Protobuf 3中已经不能用了,所以比较方便的做法是直接修改protoc,在csharp_message.cc的MessageGenerator::Generate中添加:

   printer->Print("public void Clear()\n{\n");
    for (int i = 0; i < descriptor_->field_count(); i++)
    {
        const FieldDescriptor* fieldDescriptor = descriptor_->field(i);
        if (fieldDescriptor->is_repeated() || fieldDescriptor->type() == FieldDescriptor::Type::TYPE_MESSAGE || fieldDescriptor->type() == FieldDescriptor::Type::TYPE_BYTES)
        {
            printer->Print(" if($field_name$_ != null)$field_name$_.Clear();\n", "field_name", fieldDescriptor->name());
        }
        else if (fieldDescriptor->type() == FieldDescriptor::Type::TYPE_ENUM)
        {
            printer->Print(
                " $field_name$_ = $field_type$.$default_value$;\n", "field_type", GetClassName(fieldDescriptor->enum_type()), "field_name", fieldDescriptor->name(), "default_value", fieldDescriptor->default_value_enum()->name());
        }
        else
        {
            printer->Print(
                " $field_name$_ = $default_value$;\n", "field_name", fieldDescriptor->name(),"default_value", fieldDescriptor->GetDefaultValue());
        }
      }

例如一个位置同步消息MoveSync的简化版的结构大概如下图,包含在某一帧的所有玩家的位置信息(MoveInfo):

   message MoveInfo
    {
        int64 id = 1;
        Vector3Msg position = 3;
    }
    message SCP_MoveSync {
      float timeStamp = 1;
      repeated MoveInfo moveInfo = 2;
    }

MoveInfo生成的Clear方法:

 public void Clear()
    {
        id_ = 0;
        if(position_ != null) position_.Clear();
    }

MoveSync生成的Clear方法:

   public void Clear()
    {
        timeStamp_ = 0;
        if(moveInfo_ != null)moveInfo_.Clear();
    }

这样缓存后,还是存在一个问题。如下图:

这是项目最初在UWA上的Mono测试的截图,我们缓存了MoveSync消息,但是可以看到,消息线程中还是有43%的GC Alloc都是位置同步消息,整个测试期间产生了10.46MB的GC Alloc。

这是因为,MoveSync中的MoveInfo是RepeatedField<MoveInfo>这种形式,即里面是一个消息容器,每次反序列化时,不能调用外部逻辑层的对象池,所以还是会创建新的message,截图中,10MB中有6MB创建MoveInfo对象,3MB是创建MoveInfo中的其它消息类(如: Vector3Msg)。

解决方法是在Protobuf的C#代码中留出一个接口,可以设置外部的对象池,当反序列化MoveSync时,Protobuf可以使用对象池中的MoveInfo,这里可以在MessageParser.cs中添加:

   public static ObjectPool<T> pool;
    
    internal new T CreateTemplate()
    {
        if (pool != null)
            return pool.Get();
        else
            return factory();
    }

做完上面的工作,就可以针对MoveSync和MoveInfo使用对象池了,对象池类似这样:

   static ObjectPool<MoveInfo> moveInfoPool = new ObjectPool<MoveInfo>(
        null,
        msg=>msg.Clear();
    )
    static ObjectPool<MoveSync> moveSyncPool = new ObjectPool<MoveSync>(
        null,
        msg=>
        {
            foreach(var item in msg.moveInfo)
            {
                moveInfoPool.Release(msg.moveInfo);
            }
            msg.Clear();
        }
    )

当MoveSync返回对象池的时候,需要遍历将所有MoveInfo返回到对象池。

然后,将对象池添加到Protobuf中:

  MessageParser<MoveInfo>.pool = moveInfoPool;

这样就可以使用对象池来创建MoveSync实例了。

优化后的MoveSync:

可以看到,优化后只剩下RepeatedField扩容造成的开销了,这里可以通过设置初始容量来优化。

关于Protobuf的修改全都放在这里了。

 
 

猜你喜欢

转载自www.cnblogs.com/chenggg/p/12533142.html