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的修改全都放在这里了。