Newtonsoft json.net JsonTextReader 垃圾收集器强化

use*_*154 3 .net performance garbage-collection json.net

我们正在使用通过 http 序列化为 JSON 的大型 (GB) 网络流,使用 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
Run Code Online (Sandbox Code Playgroud)

程序.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);
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

可以通过 Visual Studio 调试 > 性能分析器 > .NET 对象分配跟踪或性能监视器 #Gen 0/1 集合来观察分配情况

dbc*_*dbc 5

分部分回答:

  1. 按照您已经做的设置JsonTextReader.ArrayPool(也显示在 中DemoTests.ArrayPooling())应该有助于最大限度地减少由于解析期间分配中间字符数组而产生的内存压力。但是,它不会因为strings的分配而减少内存使用,这似乎是您的抱怨。

  2. 从版本 12.0.1开始,Json.NET 能够通过设置为某些适当的子类来重用属性名称字符串的实例。JsonTextReader.PropertyNameTableJsonNameTable

    在反序列化过程中,使用此机制JsonSerializer.SetupReader()在读取器上设置名称表,该名称表返回合约解析器存储的属性名称,从而防止重复分配序列化器期望的已知属性名称。

    但是,您没有使用序列化器,而是直接读取,因此没有利用此机制。要启用它,您可以创建自己的自定义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;
        }
    }
    
    Run Code Online (Sandbox Code Playgroud)

    然后使用它如下:

    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.
    }
    
    Run Code Online (Sandbox Code Playgroud)

    这应该会大大减少属性名称造成的内存压力。

    请注意,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;
            }
        }
    }
    
    Run Code Online (Sandbox Code Playgroud)

    需要通过实验来确定使用序列化器的名称表是否比您自己的名称表提供更好的性能(并且我在编写此答案时没有进行任何此类实验)。

  3. 当前无法阻止JsonTextReader为属性值分配字符串,即使跳过或以其他方式忽略这些值也是如此。对于类似的增强请求,请参阅应该支持真正的跳过(没有属性/等的具体化)#1021 。

    您在这里唯一的选择似乎是分叉您自己的版本JsonTextReader并自己添加此功能。您需要找到所有调用SetToken(JsonToken.String, _stringReference.ToString(), ...),并将调用替换__stringReference.ToString()为不会无条件分配内存的调用。

    例如,如果您想跳过一大块 JSON,则可以添加一个string DummyValueto JsonTextReader

    public partial class MyJsonTextReader : JsonReader, IJsonLineInfo
    {
        public string DummyValue { get; set; }
    
    Run Code Online (Sandbox Code Playgroud)

    然后在需要的地方添加以下逻辑(目前在两个地方):

    string text = DummyValue ?? _stringReference.ToString();
    SetToken(JsonToken.String, text, false);
    
    Run Code Online (Sandbox Code Playgroud)

    或者

    SetToken(JsonToken.String,  DummyValue ?? _stringReference.ToString(), false); 
    
    Run Code Online (Sandbox Code Playgroud)

    然后,当读取您知道可以跳过的值时,您将设置MyJsonTextReader.DummyValue为某个存根,例如"dummy value"

    或者,如果您有许多可以提前预测的不可跳过的重复属性值,则可以创建第二个JsonNameTable StringValueNameTable,当非空时,尝试StringReference在其中查找,如下所示:

    var text = StringValueNameTable?.Get(_stringReference.Chars, _stringReference.StartIndex, _stringReference.Length) ?? _stringReference.ToString();
    
    Run Code Online (Sandbox Code Playgroud)

    不幸的是,分叉您自己的JsonTextReader可能需要大量的持续维护,因为您还需要分叉读者使用的任何和所有 Newtonsoft 实用程序(有很多)并将它们更新为原始库中的任何重大更改。

    您还可以对请求此功能的增强请求 #1021进行投票或发表评论,或者自己添加类似的请求。

  • 很好的答案,谢谢,比较各种选项,看看什么最有效 (3认同)