使用Protobuf-net序列化分块字节数组的内存使用情况

在我们的应用程序中,我们有一些数据结构,其中包含一个分块的字节列表(当前公开为List )。 我们将字节大块化,因为如果我们允许将字节数组放在大对象堆上,那么随着时间的推移,我们会遇到内存碎片。

我们还开始使用Protobuf-net来序列化这些结构,使用我们自己生成的序列化DLL。

但是我们注意到Protobuf-net在序列化时会创建非常大的内存缓冲区。 浏览源代码时,似乎它可能无法刷新其内部缓冲区,直到整个List结构被写入,因为它需要在缓冲区前面写入总长度。

不幸的是,这首先解决了我们的工作,首先将字节分块,最终由于内存碎片而给我们OutOfMemoryExceptions(exception发生在Protobuf-net尝试将缓冲区扩展到84k以上时,这显然是在LOH,我们的整体进程内存使用率相当低。

如果我对Protobuf-net工作原理的分析是正确的,那么有没有解决这个问题的方法呢?


更新

根据Marc的回答,这是我尝试过的:

 [ProtoContract] [ProtoInclude(1, typeof(A), DataFormat = DataFormat.Group)] public class ABase { } [ProtoContract] public class A : ABase { [ProtoMember(1, DataFormat = DataFormat.Group)] public BB { get; set; } } [ProtoContract] public class B { [ProtoMember(1, DataFormat = DataFormat.Group)] public List Data { get; set; } } 

然后序列化它:

 var a = new A(); var b = new B(); aB = b; b.Data = new List { Enumerable.Range(0, 1999).Select(v => (byte)v).ToArray(), Enumerable.Range(2000, 3999).Select(v => (byte)v).ToArray(), }; var stream = new MemoryStream(); Serializer.Serialize(stream, a); 

但是,如果我在ProtoWriter.WriteBytes()中使用断点来调用DemandSpace()到方法的底部并进入DemandSpace() ,我可以看到缓冲区没有被刷新,因为writer.flushLock等于1

如果我像这样为ABase创建另一个基类:

 [ProtoContract] [ProtoInclude(1, typeof(ABase), DataFormat = DataFormat.Group)] public class ABaseBase { } [ProtoContract] [ProtoInclude(1, typeof(A), DataFormat = DataFormat.Group)] public class ABase : ABaseBase { } 

然后,在DemandSpace() writer.flushLock等于2

我猜我有一个明显的步骤,我错过了派生类型吗?

我将在这里阅读一些行…因为List (在protobuf用语中映射为repeated )没有整体长度前缀,而byte[] (映射为bytes )具有一个微不足道的长度 -不应导致额外缓冲的前缀。 所以我猜你实际拥有的更像是:

 [ProtoContract] public class A { [ProtoMember(1)] public B Foo {get;set;} } [ProtoContract] public class B { [ProtoMember(1)] public List Bar {get;set;} } 

这里,缓冲长度前缀的需要实际上是在编写A.Foo ,基本上是为了声明 “以下复杂数据是A.Foo的值”)。 幸运的是有一个简单的解决方法:

 [ProtoMember(1, DataFormat=DataFormat.Group)] public B Foo {get;set;} 

这在protobuf中的两种包装技术之间发生了变化:

  • 默认(谷歌声明的偏好)是长度前缀,这意味着你得到一个标记,指示要遵循的消息的长度,然后是子消息有效负载
  • 但是也可以选择使用开始标记,子消息有效负载和结束标记

当使用第二种技术时, 它不需要缓冲 ,因此:它不需要缓冲 。 这意味着它将为相同的数据写出略微不同的字节,但是protobuf-net非常宽容,并且很乐意在这里对这两种格式的数据进行反序列化。 含义:如果进行此更改,您仍然可以读取现有数据,但新数据将使用开始/结束标记技术。

这就提出了一个问题:谷歌为什么更喜欢长度前缀方法? 可能这是因为当使用长度前缀方法读取跳过字段(通过原始读取器API或作为不需要的/意外的数据)时,它更有效,因为您可以只读取长度前缀,然后只是进行流[n]字节; 相反,要使用开始/结束标记跳过数据,您仍需要爬过有效负载,单独跳过子字段。 当然,如果您期望数据并希望将其读入您的对象,那么读取性能的这种理论差异就不适用了,您几乎肯定会这样做。 此外,在谷歌protobuf实现中,因为它不使用常规POCO模型,有效载荷的大小已经知道,所以他们在写作时并没有真正看到相同的问题。

额外的你的编辑; [ProtoInclude(..., DataFormat=...)]看起来好像没有被处理。 我在当前的本地版本中添加了一个测试,现在它通过了:

 [Test] public void Execute() { var a = new A(); var b = new B(); aB = b; b.Data = new List { Enumerable.Range(0, 1999).Select(v => (byte)v).ToArray(), Enumerable.Range(2000, 3999).Select(v => (byte)v).ToArray(), }; var stream = new MemoryStream(); var model = TypeModel.Create(); model.AutoCompile = false; #if DEBUG // this is only available in debug builds; if set, an exception is // thrown if the stream tries to buffer model.ForwardsOnly = true; #endif CheckClone(model, a); model.CompileInPlace(); CheckClone(model, a); CheckClone(model.Compile(), a); } void CheckClone(TypeModel model, A original) { int sum = original.B.Data.Sum(x => x.Sum(b => (int)b)); var clone = (A)model.DeepClone(original); Assert.IsInstanceOfType(typeof(A), clone); Assert.IsInstanceOfType(typeof(B), clone.B); Assert.AreEqual(sum, clone.B.Data.Sum(x => x.Sum(b => (int)b))); } 

此提交与其他一些不相关的重构(WinRT / IKVM的一些返工)相关联,但应该尽快提交。