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

9
在我们的应用程序中,我们有一些数据结构,其中包含一组分块的字节(目前暴露为List<byte[]>)。我们将字节分块是因为如果允许将字节数组放在大对象堆上,则随着时间的推移,我们会遭受内存碎片化的影响。
我们还开始使用Protobuf-net来序列化这些结构,使用我们自己生成的序列化DLL。
然而,我们注意到Protobuf-net在序列化时创建了非常大的内存缓冲区。浏览源代码,似乎它不能刷新其内部缓冲区,直到整个List<byte[]>结构已被写入,因为它需要在缓冲区前面写入总长度。
不幸的是,这样做破坏了我们对字节进行分块的工作,并最终由于内存碎片而导致OutOfMemoryExceptions(异常发生在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 B B
    {
        get;
        set;
    }
}

[ProtoContract]
public class B
{
    [ProtoMember(1, DataFormat = DataFormat.Group)]
    public List<byte[]> Data
    {
        get;
        set;
    }
}

然后进行序列化:
var a = new A();
var b = new B();
a.B = b;
b.Data = new List<byte[]>
{
    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

我猜这里可能有一个明显的步骤与派生类型有关?

2个回答

6

我在这里要读懂一些内容...因为 List<T>(在 protobuf 中被映射为 repeated)没有整体长度前缀,而 byte[](被映射为 bytes)有一个微不足道的长度前缀,不应引起额外的缓冲。 所以我猜想你实际拥有的更像是:

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

在写入 A.Foo 时,需要缓冲长度前缀,基本上是为了声明 "以下复杂数据是 A.Foo 的值"。幸运的是,这个问题有一个简单的解决方法:
[ProtoMember(1, DataFormat=DataFormat.Group)]
public B Foo {get;set;}

这是protobuf中2种打包技术之间的变化:

  • 默认方式(Google首选)是长度前缀,意味着您会得到一个标记,指示后面消息的长度,然后是子消息负载
  • 但也有一个使用起始标记、子消息负载和结束标记的选项

当使用第二种技术时,它不需要缓冲,所以它就没有。这意味着它会为相同的数据写入稍微不同的字节,但protobuf-net非常宽容,并且可以愉快地反序列化来自任何一种格式的数据。这意味着:如果您进行此更改,则仍然可以读取现有数据,但新数据将使用起始/结束标记技术。

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


谢谢您的快速回复。你对我们数据结构的猜测是正确的。我是否可以说,我们需要将任何包含指向A的属性的DataFormat更改为Group,并一直更改到对象图的根部?这个更改也需要在相关的ProtoInclude属性上进行吗? - James Thurley
@James 基本上是这样。嗯...也许我应该为此添加一个模型级别的默认值! - Marc Gravell
我已经更新了我的问题,并尝试使用DataFormat.Group来解决问题,但我仍然无法让缓冲区刷新。如果我很蠢的话,请原谅。 - James Thurley

3

关于您的编辑,[ProtoInclude(..., DataFormat=...)] 看起来似乎没有被处理。我在我的本地构建中添加了一个测试,现在测试通过:

[Test]
public void Execute()
{

    var a = new A();
    var b = new B();
    a.B = b;

    b.Data = new List<byte[]>
    {
        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的重新工作),但应尽快提交。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接