我们正在使用通过http序列化为JSON的大型(GBs)网络流,使用Newtonsoft.Json nuget包将响应流反序列化为内存记录以进行进一步操作。
鉴于数据量过大,我们正在使用流式处理一次接收一大块响应,并希望在达到 CPU 限制时优化此过程。
优化的候选者之一似乎是JsonTextReader,它不断分配新对象,从而触发垃圾收集。
我们遵循了Newtonsoft Performance Tips的建议。
我创建了一个示例 .net 控制台应用,模拟在 JsonTextReader 读取响应流时分配新对象的行为,分配表示属性名称和值的字符串
问题: 我们还可以调整/覆盖其他内容以重用已分配的属性名称/值实例,因为在现实世界中,其中 95% 是重复的(在测试中它是相同的记录,因此 100% 重复)?
示例应用:
Install-Package Newtonsoft.Json -Version 12.0.2
Install-Package System.Buffers -Version 4.5.0
程序.cs
using System;
using System.Buffers;
using System.IO;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
namespace JsonNetTester
{
class Program
{
static void Main(string[] args)
{
using (var sr = new MockedStreamReader())
using (var jtr = new JsonTextReader(sr))
{
// does not seem to make any difference
//jtr.ArrayPool = JsonArrayPool.Instance;
// every read is allocating new objects
while (jtr.Read())
{
}
}
}
// simulating continuous stream of records serialised as json
public class MockedStreamReader : StreamReader
{
private bool initialProvided = false;
private byte[] initialBytes = Encoding.Default.GetBytes("[");
private static readonly byte[] recordBytes;
int nextStart = 0;
static MockedStreamReader()
{
var recordSb = new StringBuilder("{");
// generate [i] of { "Key[i]": "Value[i]" },
Enumerable.Range(0, 50).ToList().ForEach(i =>
{
if (i > 0)
{
recordSb.Append(",");
}
recordSb.Append($""Key{i}": "Value{i}"");
});
recordSb.Append("},");
recordBytes = Encoding.Default.GetBytes(recordSb.ToString());
}
public MockedStreamReader() : base(new MemoryStream())
{ }
public override int Read(char[] buffer, int index, int count)
{
// keep on reading the same record in loop
if (this.initialProvided)
{
var start = nextStart;
var length = Math.Min(recordBytes.Length - start, count);
var end = start + length;
nextStart = end >= recordBytes.Length ? 0 : end;
Array.Copy(recordBytes, start, buffer, index, length);
return length;
}
else
{
initialProvided = true;
Array.Copy(initialBytes, buffer, initialBytes.Length);
return initialBytes.Length;
}
}
}
// attempt to reuse data in serialisation
public class JsonArrayPool : IArrayPool<char>
{
public static readonly JsonArrayPool Instance = new JsonArrayPool();
public char[] Rent(int minimumLength)
{
return ArrayPool<char>.Shared.Rent(minimumLength);
}
public void Return(char[] array)
{
ArrayPool<char>.Shared.Return(array);
}
}
}
}
可以通过 Visual Studio 调试>性能探查器> .NET 对象分配跟踪或性能监视器 #Gen 0/1 集合来观察分配
部分回答:
-
像您已经做的那样设置
JsonTextReader.ArrayPool
(这也显示在DemoTests.ArrayPooling()
中)应该有助于最大限度地减少由于解析期间分配中间字符数组而导致的内存压力。 但是,由于字符串的分配,它不会减少内存使用,这似乎是您的抱怨。 -
从版本 12.0.1 开始,Json.NET 能够通过将
JsonTextReader.PropertyNameTable
设置为某个适当的JsonNameTable
子类来重用属性名称字符串的实例。此机制在反序列化期间通过
JsonSerializer.SetupReader()
用于在读取器上设置一个 name 表,该表返回协定解析器存储的属性名称,从而防止重复分配序列化程序预期的已知属性名称。但是,您没有使用序列化程序,而是直接读取,因此没有利用此机制。 若要启用它,可以创建自己的自定义
JsonNameTable
来缓存实际遇到的属性名称:public class AutomaticJsonNameTable : DefaultJsonNameTable { int nAutoAdded = 0; int maxToAutoAdd; public AutomaticJsonNameTable(int maxToAdd) { this.maxToAutoAdd = maxToAdd; } public override string Get(char[] key, int start, int length) { var s = base.Get(key, start, length); if (s == null && nAutoAdded < maxToAutoAdd) { s = new string(key, start, length); Add(s); nAutoAdded++; } return s; } }
然后按如下方式使用它:
const int MaxPropertyNamesToCache = 200; // Set through experiment. var nameTable = new AutomaticJsonNameTable(MaxPropertyNamesToCache); using (var sr = new MockedStreamReader()) using (var jtr = new JsonTextReader(sr) { PropertyNameTable = nameTable }) { // Process as before. }
这应该会大大减少由于属性名称而导致的内存压力。
请注意,
AutomaticJsonNameTable
只会自动缓存指定的有限数量的名称,以防止内存分配攻击。 您需要通过实验确定此最大数量。 还可以手动对添加的预期已知属性名称进行硬编码。另请注意,通过手动指定名称表,可以防止在反序列化期间使用序列化程序指定的名称表。 如果您的解析算法涉及通读文件以查找特定的嵌套对象,然后反序列化这些对象,则通过在反序列化之前暂时清空名称表(例如使用以下扩展方法)可能会获得更好的性能:
public static class JsonSerializerExtensions { public static T DeserializeWithDefaultNameTable<T>(this JsonSerializer serializer, JsonReader reader) { JsonNameTable old = null; var textReader = reader as JsonTextReader; if (textReader != null) { old = textReader.PropertyNameTable; textReader.PropertyNameTable = null; } try { return serializer.Deserialize<T>(reader); } finally { if (textReader != null) textReader.PropertyNameTable = old; } } }
需要通过实验来确定使用序列化程序的名称表是否比您自己的性能更好(在编写此答案时,我没有做任何此类实验)。
-
目前无法阻止
JsonTextReader
为属性值分配字符串,即使跳过或以其他方式忽略这些值也是如此。 请参阅请支持真正的跳过(没有属性/等的具体化)#1021 以获得类似的增强请求。您在这里的唯一选择似乎是分叉您自己的
JsonTextReader
版本并自己添加此功能。 您需要找到所有要SetToken(JsonToken.String, _stringReference.ToString(), ...)
的调用,并将对__stringReference.ToString()
的调用替换为不会无条件分配内存的内容。例如,如果您想跳过一大块 JSON,则可以向
JsonTextReader
添加一个string DummyValue
:public partial class MyJsonTextReader : JsonReader, IJsonLineInfo { public string DummyValue { get; set; }
然后在需要的地方添加以下逻辑(目前在两个地方):
string text = DummyValue ?? _stringReference.ToString(); SetToken(JsonToken.String, text, false);
或
SetToken(JsonToken.String, DummyValue ?? _stringReference.ToString(), false);
然后,当读取您知道可以跳过的值时,您将
MyJsonTextReader.DummyValue
设置为某个存根,例如"dummy value"
。或者,如果有许多可以提前预测的不可跳过的重复属性值,则可以创建第二个
JsonNameTable StringValueNameTable
,当非 null 时,尝试在其中查找StringReference
,如下所示:var text = StringValueNameTable?.Get(_stringReference.Chars, _stringReference.StartIndex, _stringReference.Length) ?? _stringReference.ToString();
不幸的是,分叉您自己的
JsonTextReader
可能需要大量的持续维护,因为您还需要分叉读者使用的任何和所有 Newtonsoft 实用程序(有很多),并将它们更新为原始库中的任何重大更改。您也可以对请求此功能的增强请求 #1021 进行投票或评论,或者自己添加类似的请求。