Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use global caching in JsonSerializerOptions #64646

Merged
merged 10 commits into from
Feb 14, 2022
3 changes: 3 additions & 0 deletions src/libraries/System.Text.Json/src/System.Text.Json.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ System.Text.Json.Nodes.JsonValue</PackageDescription>
<Compile Include="System\Text\Json\Serialization\JsonSerializer.Write.Element.cs" />
<Compile Include="System\Text\Json\Serialization\JsonSerializer.Write.Node.cs" />
<Compile Include="System\Text\Json\Serialization\JsonSerializerContext.cs" />
<Compile Include="System\Text\Json\Serialization\JsonSerializerOptions.Caching.cs" />
<Compile Include="System\Text\Json\Serialization\ReferenceEqualsWrapper.cs" />
<Compile Include="System\Text\Json\Serialization\ConverterStrategy.cs" />
<Compile Include="System\Text\Json\Serialization\ConverterList.cs" />
Expand Down Expand Up @@ -247,6 +248,8 @@ System.Text.Json.Nodes.JsonValue</PackageDescription>
<Compile Include="System\Text\Json\Serialization\Metadata\MemberAccessor.cs" />
<Compile Include="System\Text\Json\Serialization\Metadata\ParameterRef.cs" />
<Compile Include="System\Text\Json\Serialization\Metadata\PropertyRef.cs" />
<Compile Include="System\Text\Json\Serialization\Metadata\ReflectionEmitCachingMemberAccessor.Cache.cs" />
<Compile Include="System\Text\Json\Serialization\Metadata\ReflectionEmitCachingMemberAccessor.cs" />
<Compile Include="System\Text\Json\Serialization\Metadata\ReflectionEmitMemberAccessor.cs" />
<Compile Include="System\Text\Json\Serialization\Metadata\ReflectionMemberAccessor.cs" />
<Compile Include="System\Text\Json\Serialization\MetadataPropertyName.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ private static JsonTypeInfo GetTypeInfo(JsonSerializerOptions? options, Type run
Debug.Assert(runtimeType != null);

options ??= JsonSerializerOptions.Default;
if (!options.IsInitializedForReflectionSerializer)
if (!JsonSerializerOptions.IsInitializedForReflectionSerializer)
{
options.InitializeForReflectionSerializer();
JsonSerializerOptions.InitializeForReflectionSerializer();
}

return options.GetOrAddClassForRootType(runtimeType);
return options.GetOrAddJsonTypeInfoForRootType(runtimeType);
}

private static JsonTypeInfo GetTypeInfo(JsonSerializerContext context, Type type)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,9 @@ public static partial class JsonSerializer
CancellationToken cancellationToken = default)
{
options ??= JsonSerializerOptions.Default;
if (!options.IsInitializedForReflectionSerializer)
if (!JsonSerializerOptions.IsInitializedForReflectionSerializer)
{
options.InitializeForReflectionSerializer();
JsonSerializerOptions.InitializeForReflectionSerializer();
}

return CreateAsyncEnumerableDeserializer(utf8Json, options, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace System.Text.Json
{
public sealed partial class JsonSerializerOptions
{
/// <summary>
/// Encapsulates all cached metadata referenced by the current <see cref="JsonSerializerOptions" /> instance.
/// Context can be shared across multiple equivalent options instances.
/// </summary>
private CachingContext? _cachingContext;

// Simple LRU cache for the public (de)serialize entry points that avoid some lookups in _cachingContext.
// Although this may be written by multiple threads, 'volatile' was not added since any local affinity is fine.
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
private JsonTypeInfo? _lastTypeInfo;

internal JsonTypeInfo GetOrAddJsonTypeInfo(Type type)
{
if (_cachingContext == null)
{
InitializeCachingContext();
Debug.Assert(_cachingContext != null);
}

return _cachingContext.GetOrAddJsonTypeInfo(type);
}

internal bool TryGetJsonTypeInfo(Type type, [NotNullWhen(true)] out JsonTypeInfo? typeInfo)
{
if (_cachingContext == null)
{
typeInfo = null;
return false;
}

return _cachingContext.TryGetJsonTypeInfo(type, out typeInfo);
}

internal bool IsJsonTypeInfoCached(Type type) => _cachingContext?.IsJsonTypeInfoCached(type) == true;

/// <summary>
/// Return the TypeInfo for root API calls.
/// This has an LRU cache that is intended only for public API calls that specify the root type.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
internal JsonTypeInfo GetOrAddJsonTypeInfoForRootType(Type type)
{
JsonTypeInfo? jsonTypeInfo = _lastTypeInfo;

if (jsonTypeInfo?.Type != type)
{
jsonTypeInfo = GetOrAddJsonTypeInfo(type);
_lastTypeInfo = jsonTypeInfo;
}

return jsonTypeInfo;
}

internal void ClearCaches()
{
_cachingContext?.Clear();
_lastTypeInfo = null;
}

private void InitializeCachingContext()
{
_cachingContext = TrackedCachingContexts.GetOrCreate(this);
}

/// <summary>
/// Stores and manages all reflection caches for one or more <see cref="JsonSerializerOptions"/> instances.
/// NB the type encapsulates the original options instance and only consults that one when building new types;
/// this is to prevent multiple options instances from leaking into the object graphs of converters which
/// could break user invariants.
/// </summary>
private sealed class CachingContext
{
private readonly ConcurrentDictionary<Type, JsonConverter> _converterCache = new();
private readonly ConcurrentDictionary<Type, JsonTypeInfo> _jsonTypeInfoCache = new();

public CachingContext(JsonSerializerOptions options)
{
Options = options;
_ = Count;
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
}

public JsonSerializerOptions Options { get; }
public int Count => _converterCache.Count + _jsonTypeInfoCache.Count;
public JsonConverter GetOrAddConverter(Type type) => _converterCache.GetOrAdd(type, Options.GetConverterFromType);
public JsonTypeInfo GetOrAddJsonTypeInfo(Type type) => _jsonTypeInfoCache.GetOrAdd(type, Options.GetJsonTypeInfoFromContextOrCreate);
public bool TryGetJsonTypeInfo(Type type, [NotNullWhen(true)] out JsonTypeInfo? typeInfo) => _jsonTypeInfoCache.TryGetValue(type, out typeInfo);
public bool IsJsonTypeInfoCached(Type type) => _jsonTypeInfoCache.ContainsKey(type);

public void Clear()
{
_converterCache.Clear();
_jsonTypeInfoCache.Clear();
}
}

/// <summary>
/// Defines a cache of CachingContexts; instead of using a ConditionalWeakTable which can be slow to traverse
/// this approach uses a regular dictionary pointing to weak references of <see cref="CachingContext"/>.
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
/// Relevant caching contexts are looked up using the equality comparison defined by <see cref="EqualityComparer"/>.
/// </summary>
private static class TrackedCachingContexts
{
private const int MaxTrackedContexts = 64;
private static readonly ConcurrentDictionary<JsonSerializerOptions, WeakReference<CachingContext>> s_cache =
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
new(concurrencyLevel: 1, capacity: MaxTrackedContexts, new EqualityComparer());

private const int EvictionCountHistory = 10;
private static Queue<int> s_recentEvictionCounts = new(EvictionCountHistory);
private static int s_evictionRunsToSkip;

public static CachingContext GetOrCreate(JsonSerializerOptions options)
{
if (s_cache.TryGetValue(options, out WeakReference<CachingContext>? wr) && wr.TryGetTarget(out CachingContext? ctx))
{
return ctx;
}

lock (s_cache)
{
if (s_cache.TryGetValue(options, out wr))
{
if (!wr.TryGetTarget(out ctx))
{
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
// Found a dangling weak reference; replenish with a fresh instance.
ctx = new CachingContext(options);
wr.SetTarget(ctx);
}

return ctx;
}

if (s_cache.Count == MaxTrackedContexts)
{
if (!TryEvictDanglingEntries())
{
// Cache is full; return a fresh instance.
return new CachingContext(options);
}
}

Debug.Assert(s_cache.Count < MaxTrackedContexts);

// Use a defensive copy of the options instance as key to
// avoid capturing references to any caching contexts.
var key = new JsonSerializerOptions(options) { _context = options._context };
Debug.Assert(key._cachingContext == null);

ctx = new CachingContext(options);
bool success = s_cache.TryAdd(key, new(ctx));
Debug.Assert(success);

return ctx;
}
}

private static bool TryEvictDanglingEntries()
{
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
// Worst case scenario, the cache has been saturated with permanent entries.
// We want to avoid iterating needlessly through the entire cache every time
// a new options instance is created. For this reason we implement a backoff
// strategy to amortize the cost of eviction across multiple cache initializations.
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
// The backoff count is determined by the eviction rates of the most recent eviction runs.

if (s_evictionRunsToSkip > 0)
{
--s_evictionRunsToSkip;
return false;
}

int currentEvictions = 0;
foreach (KeyValuePair<JsonSerializerOptions, WeakReference<CachingContext>> kvp in s_cache)
{
if (!kvp.Value.TryGetTarget(out _))
{
bool result = s_cache.TryRemove(kvp.Key, out _);
Debug.Assert(result);
currentEvictions++;
}
}

s_evictionRunsToSkip = EstimateEvictionRunsToSkip(currentEvictions);
return currentEvictions > 0;

// Estimate the number of eviction runs to skip based on recent eviction rates.
static int EstimateEvictionRunsToSkip(int latestEvictionCount)
{
if (s_recentEvictionCounts.Count < EvictionCountHistory)
{
// Insufficient data to determine a skip count.
s_recentEvictionCounts.Enqueue(latestEvictionCount);
return 0;
}
else
{
s_recentEvictionCounts.Dequeue();
s_recentEvictionCounts.Enqueue(latestEvictionCount);

// Calculate the average evictions per run.
double avgEvictionsPerRun = 0;

foreach (int rate in s_recentEvictionCounts)
{
avgEvictionsPerRun += rate;
}

avgEvictionsPerRun /= EvictionCountHistory;

// - If avgEvictionsPerRun >= 1 we skip no subsequent eviction calls.
// - If avgEvictionsPerRun < 1 we skip ~ `1 / avgEvictionsPerRun` calls.
int runsPerEviction = (int)Math.Round(1 / Math.Min(Math.Max(avgEvictionsPerRun, 0.1), 1));
Debug.Assert(runsPerEviction >= 1 && runsPerEviction <= 10);
return runsPerEviction - 1;
}
}
}
}

/// <summary>
/// Provides a conservative equality comparison for JsonSerializerOptions instances.
/// If two instances are equivalent, they should generate identical metadata caches;
/// the converse however does not necessarily hold.
/// </summary>
private class EqualityComparer : IEqualityComparer<JsonSerializerOptions>
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
{
public bool Equals(JsonSerializerOptions? left, JsonSerializerOptions? right)
{
Debug.Assert(left != null && right != null);
return
left._dictionaryKeyPolicy == right._dictionaryKeyPolicy &&
left._jsonPropertyNamingPolicy == right._jsonPropertyNamingPolicy &&
left._readCommentHandling == right._readCommentHandling &&
left._referenceHandler == right._referenceHandler &&
left._encoder == right._encoder &&
left._defaultIgnoreCondition == right._defaultIgnoreCondition &&
left._numberHandling == right._numberHandling &&
left._unknownTypeHandling == right._unknownTypeHandling &&
left._defaultBufferSize == right._defaultBufferSize &&
left._maxDepth == right._maxDepth &&
left._allowTrailingCommas == right._allowTrailingCommas &&
left._ignoreNullValues == right._ignoreNullValues &&
left._ignoreReadOnlyProperties == right._ignoreReadOnlyProperties &&
left._ignoreReadonlyFields == right._ignoreReadonlyFields &&
left._includeFields == right._includeFields &&
left._propertyNameCaseInsensitive == right._propertyNameCaseInsensitive &&
left._writeIndented == right._writeIndented &&
left._context == right._context &&
CompareConverters(left.Converters, right.Converters);

static bool CompareConverters(IList<JsonConverter> left, IList<JsonConverter> right)
{
int n;
if ((n = left.Count) != right.Count)
{
return false;
}

for (int i = 0; i < n; i++)
{
if (left[i] != right[i])
{
return false;
}
}

return true;
}
}

public int GetHashCode(JsonSerializerOptions options)
{
HashCode hc = default;

hc.Add(options._dictionaryKeyPolicy);
hc.Add(options._jsonPropertyNamingPolicy);
hc.Add(options._readCommentHandling);
hc.Add(options._referenceHandler);
hc.Add(options._encoder);
hc.Add(options._defaultIgnoreCondition);
hc.Add(options._numberHandling);
hc.Add(options._unknownTypeHandling);
hc.Add(options._defaultBufferSize);
hc.Add(options._maxDepth);
hc.Add(options._allowTrailingCommas);
hc.Add(options._ignoreNullValues);
hc.Add(options._ignoreReadOnlyProperties);
hc.Add(options._ignoreReadonlyFields);
hc.Add(options._includeFields);
hc.Add(options._propertyNameCaseInsensitive);
hc.Add(options._writeIndented);
hc.Add(options._context);

foreach (JsonConverter converter in options.Converters)
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
{
hc.Add(converter);
}

return hc.ToHashCode();
}

#if !NETCOREAPP
/// <summary>
/// Polyfill for System.HashCode.
/// </summary>
private struct HashCode
{
private int _hashCode;
public void Add<T>(T? value) => _hashCode = (_hashCode, value).GetHashCode();
public int ToHashCode() => _hashCode;
}
#endif
}
}
}
Loading