Skip to content

Commit c24bf80

Browse files
authored
Pool the underlying list and dictionary in scopes. (#50463)
- This change pools a set of scopes assuming they are short lived. - One breaking change is that after disposal, pooled scopes will throw if services are accessed afterwards on the scope. - Modified test to throw after dispose
1 parent e203644 commit c24bf80

File tree

7 files changed

+181
-42
lines changed

7 files changed

+181
-42
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Collections.Generic;
7+
using System.Diagnostics;
8+
using System.Threading;
9+
using Microsoft.Extensions.DependencyInjection.ServiceLookup;
10+
11+
namespace Microsoft.Extensions.DependencyInjection
12+
{
13+
internal class ScopePool
14+
{
15+
// Modest number to re-use. We only really care about reuse for short lived scopes
16+
private const int MaxQueueSize = 128;
17+
18+
private int _count;
19+
private readonly ConcurrentQueue<State> _queue = new();
20+
21+
public State Rent()
22+
{
23+
if (_queue.TryDequeue(out State state))
24+
{
25+
Interlocked.Decrement(ref _count);
26+
return state;
27+
}
28+
return new State(this);
29+
}
30+
31+
public bool Return(State state)
32+
{
33+
if (Interlocked.Increment(ref _count) > MaxQueueSize)
34+
{
35+
Interlocked.Decrement(ref _count);
36+
return false;
37+
}
38+
39+
state.Clear();
40+
_queue.Enqueue(state);
41+
return true;
42+
}
43+
44+
public class State
45+
{
46+
private readonly ScopePool _pool;
47+
48+
public IDictionary<ServiceCacheKey, object> ResolvedServices { get; }
49+
public List<object> Disposables { get; set; }
50+
51+
public State(ScopePool pool = null)
52+
{
53+
_pool = pool;
54+
// When pool is null, we're in the global scope which doesn't need pooling.
55+
// To reduce lock contention for singletons upon resolve we use a concurrent dictionary.
56+
ResolvedServices = pool == null ? new ConcurrentDictionary<ServiceCacheKey, object>() : new Dictionary<ServiceCacheKey, object>();
57+
}
58+
59+
internal void Clear()
60+
{
61+
// This should only get called from the pool
62+
Debug.Assert(_pool != null);
63+
// REVIEW: Should we trim excess here as well?
64+
ResolvedServices.Clear();
65+
Disposables?.Clear();
66+
}
67+
68+
public bool Return()
69+
{
70+
return _pool?.Return(this) ?? false;
71+
}
72+
}
73+
}
74+
}

src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/CallSiteRuntimeResolver.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,15 @@ protected override object VisitScopeCache(ServiceCallSite callSite, RuntimeResol
9090
private object VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
9191
{
9292
bool lockTaken = false;
93-
IDictionary<ServiceCacheKey, object> resolvedServices = serviceProviderEngine.ResolvedServices;
93+
object sync = serviceProviderEngine.Sync;
9494

9595
// Taking locks only once allows us to fork resolution process
9696
// on another thread without causing the deadlock because we
9797
// always know that we are going to wait the other thread to finish before
9898
// releasing the lock
9999
if ((context.AcquiredLocks & lockType) == 0)
100100
{
101-
Monitor.Enter(resolvedServices, ref lockTaken);
101+
Monitor.Enter(sync, ref lockTaken);
102102
}
103103

104104
try
@@ -109,7 +109,7 @@ private object VisitCache(ServiceCallSite callSite, RuntimeResolverContext conte
109109
{
110110
if (lockTaken)
111111
{
112-
Monitor.Exit(resolvedServices);
112+
Monitor.Exit(sync);
113113
}
114114
}
115115
}
@@ -123,7 +123,7 @@ private object ResolveService(ServiceCallSite callSite, RuntimeResolverContext c
123123
// For scoped: takes a dictionary as both a resolution lock and a dictionary access lock.
124124
Debug.Assert(
125125
(lockType == RuntimeResolverLock.Root && resolvedServices is ConcurrentDictionary<ServiceCacheKey, object>) ||
126-
(lockType == RuntimeResolverLock.Scope && Monitor.IsEntered(resolvedServices)));
126+
(lockType == RuntimeResolverLock.Scope && Monitor.IsEntered(serviceProviderEngine.Sync)));
127127

128128
if (resolvedServices.TryGetValue(callSite.Cache.Key, out object resolved))
129129
{

src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/Expressions/ExpressionResolverBuilder.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,19 @@ internal sealed class ExpressionResolverBuilder : CallSiteVisitor<object, Expres
2626
private static readonly ParameterExpression ScopeParameter = Expression.Parameter(typeof(ServiceProviderEngineScope));
2727

2828
private static readonly ParameterExpression ResolvedServices = Expression.Variable(typeof(IDictionary<ServiceCacheKey, object>), ScopeParameter.Name + "resolvedServices");
29+
private static readonly ParameterExpression Sync = Expression.Variable(typeof(object), ScopeParameter.Name + "sync");
2930
private static readonly BinaryExpression ResolvedServicesVariableAssignment =
3031
Expression.Assign(ResolvedServices,
3132
Expression.Property(
3233
ScopeParameter,
3334
typeof(ServiceProviderEngineScope).GetProperty(nameof(ServiceProviderEngineScope.ResolvedServices), BindingFlags.Instance | BindingFlags.NonPublic)));
3435

36+
private static readonly BinaryExpression SyncVariableAssignment =
37+
Expression.Assign(Sync,
38+
Expression.Property(
39+
ScopeParameter,
40+
typeof(ServiceProviderEngineScope).GetProperty(nameof(ServiceProviderEngineScope.Sync), BindingFlags.Instance | BindingFlags.NonPublic)));
41+
3542
private static readonly ParameterExpression CaptureDisposableParameter = Expression.Parameter(typeof(object));
3643
private static readonly LambdaExpression CaptureDisposable = Expression.Lambda(
3744
Expression.Call(ScopeParameter, CaptureDisposableMethodInfo, CaptureDisposableParameter),
@@ -96,8 +103,9 @@ private Expression<Func<ServiceProviderEngineScope, object>> BuildExpression(Ser
96103
{
97104
return Expression.Lambda<Func<ServiceProviderEngineScope, object>>(
98105
Expression.Block(
99-
new[] { ResolvedServices },
106+
new[] { ResolvedServices, Sync },
100107
ResolvedServicesVariableAssignment,
108+
SyncVariableAssignment,
101109
BuildScopedExpression(callSite)),
102110
ScopeParameter);
103111
}
@@ -213,7 +221,6 @@ protected override Expression VisitScopeCache(ServiceCallSite callSite, object c
213221
// Move off the main stack
214222
private Expression BuildScopedExpression(ServiceCallSite callSite)
215223
{
216-
217224
ConstantExpression keyExpression = Expression.Constant(
218225
callSite.Cache.Key,
219226
typeof(ServiceCacheKey));
@@ -257,9 +264,10 @@ private Expression BuildScopedExpression(ServiceCallSite callSite)
257264
// The C# compiler would copy the lock object to guard against mutation.
258265
// We don't, since we know the lock object is readonly.
259266
ParameterExpression lockWasTaken = Expression.Variable(typeof(bool), "lockWasTaken");
267+
ParameterExpression sync = Sync;
260268

261-
MethodCallExpression monitorEnter = Expression.Call(MonitorEnterMethodInfo, resolvedServices, lockWasTaken);
262-
MethodCallExpression monitorExit = Expression.Call(MonitorExitMethodInfo, resolvedServices);
269+
MethodCallExpression monitorEnter = Expression.Call(MonitorEnterMethodInfo, sync, lockWasTaken);
270+
MethodCallExpression monitorExit = Expression.Call(MonitorExitMethodInfo, sync);
263271

264272
BlockExpression tryBody = Expression.Block(monitorEnter, blockExpression);
265273
ConditionalExpression finallyBody = Expression.IfThen(lockWasTaken, monitorExit);

src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/ILEmit/ILEmitResolverBuilder.cs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ internal sealed class ILEmitResolverBuilder : CallSiteVisitor<ILEmitResolverBuil
1414
private static readonly MethodInfo ResolvedServicesGetter = typeof(ServiceProviderEngineScope).GetProperty(
1515
nameof(ServiceProviderEngineScope.ResolvedServices), BindingFlags.Instance | BindingFlags.NonPublic).GetMethod;
1616

17+
private static readonly MethodInfo ScopeLockGetter = typeof(ServiceProviderEngineScope).GetProperty(
18+
nameof(ServiceProviderEngineScope.Sync), BindingFlags.Instance | BindingFlags.NonPublic).GetMethod;
19+
1720
private static readonly FieldInfo FactoriesField = typeof(ILEmitResolverBuilderRuntimeContext).GetField(nameof(ILEmitResolverBuilderRuntimeContext.Factories));
1821
private static readonly FieldInfo ConstantsField = typeof(ILEmitResolverBuilderRuntimeContext).GetField(nameof(ILEmitResolverBuilderRuntimeContext.Constants));
1922
private static readonly MethodInfo GetTypeFromHandleMethod = typeof(Type).GetMethod(nameof(Type.GetTypeFromHandle));
@@ -319,6 +322,7 @@ private ILEmitResolverBuilderRuntimeContext GenerateMethodBody(ServiceCallSite c
319322
{
320323
LocalBuilder cacheKeyLocal = context.Generator.DeclareLocal(typeof(ServiceCacheKey));
321324
LocalBuilder resolvedServicesLocal = context.Generator.DeclareLocal(typeof(IDictionary<ServiceCacheKey, object>));
325+
LocalBuilder syncLocal = context.Generator.DeclareLocal(typeof(object));
322326
LocalBuilder lockTakenLocal = context.Generator.DeclareLocal(typeof(bool));
323327
LocalBuilder resultLocal = context.Generator.DeclareLocal(typeof(object));
324328

@@ -339,8 +343,15 @@ private ILEmitResolverBuilderRuntimeContext GenerateMethodBody(ServiceCallSite c
339343
// Store resolved services
340344
Stloc(context.Generator, resolvedServicesLocal.LocalIndex);
341345

342-
// Load resolvedServices
343-
Ldloc(context.Generator, resolvedServicesLocal.LocalIndex);
346+
// scope
347+
context.Generator.Emit(OpCodes.Ldarg_1);
348+
// .Sync
349+
context.Generator.Emit(OpCodes.Callvirt, ScopeLockGetter);
350+
// Store syncLocal
351+
Stloc(context.Generator, syncLocal.LocalIndex);
352+
353+
// Load syncLocal
354+
Ldloc(context.Generator, syncLocal.LocalIndex);
344355
// Load address of lockTaken
345356
context.Generator.Emit(OpCodes.Ldloca_S, lockTakenLocal.LocalIndex);
346357
// Monitor.Enter
@@ -388,8 +399,8 @@ private ILEmitResolverBuilderRuntimeContext GenerateMethodBody(ServiceCallSite c
388399
Ldloc(context.Generator, lockTakenLocal.LocalIndex);
389400
// return if not
390401
context.Generator.Emit(OpCodes.Brfalse, returnLabel);
391-
// Load resolvedServices
392-
Ldloc(context.Generator, resolvedServicesLocal.LocalIndex);
402+
// Load syncLocal
403+
Ldloc(context.Generator, syncLocal.LocalIndex);
393404
// Monitor.Exit
394405
context.Generator.Emit(OpCodes.Call, ExpressionResolverBuilder.MonitorExitMethodInfo);
395406

src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/ServiceProviderEngine.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@ protected ServiceProviderEngine(IEnumerable<ServiceDescriptor> serviceDescriptor
2525
CallSiteFactory.Add(typeof(IServiceProvider), new ServiceProviderCallSite());
2626
CallSiteFactory.Add(typeof(IServiceScopeFactory), new ServiceScopeFactoryCallSite());
2727
RealizedServices = new ConcurrentDictionary<Type, Func<ServiceProviderEngineScope, object>>();
28+
ScopePool = new ScopePool();
2829
}
2930

31+
internal ScopePool ScopePool { get; }
32+
3033
internal ConcurrentDictionary<Type, Func<ServiceProviderEngineScope, object>> RealizedServices { get; }
3134

3235
internal CallSiteFactory CallSiteFactory { get; }

src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/ServiceProviderEngineScope.cs

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5-
using System.Collections.Concurrent;
65
using System.Collections.Generic;
7-
using System.Diagnostics;
86
using System.Threading.Tasks;
97
using Microsoft.Extensions.Internal;
108

@@ -15,20 +13,23 @@ internal sealed class ServiceProviderEngineScope : IServiceScope, IServiceProvid
1513
// For testing only
1614
internal Action<object> _captureDisposableCallback;
1715

18-
private List<object> _disposables;
19-
2016
private bool _disposed;
21-
private readonly object _disposelock = new object();
17+
private ScopePool.State _state;
18+
19+
// This lock protects state on the scope, in particular, for the root scope, it protects
20+
// the list of disposable entries only, since ResolvedServices is a concurrent dictionary.
21+
// For other scopes, it protects ResolvedServices and the list of disposables
22+
private readonly object _scopeLock = new object();
2223

2324
public ServiceProviderEngineScope(ServiceProviderEngine engine, bool isRoot = false)
2425
{
2526
Engine = engine;
26-
27-
// To reduce lock contention for singletons upon resolve we use a concurrent dictionary.
28-
ResolvedServices = isRoot ? new ConcurrentDictionary<ServiceCacheKey, object>() : new Dictionary<ServiceCacheKey, object>();
27+
_state = isRoot ? new ScopePool.State() : engine.ScopePool.Rent();
2928
}
3029

31-
internal IDictionary<ServiceCacheKey, object> ResolvedServices { get; }
30+
internal IDictionary<ServiceCacheKey, object> ResolvedServices => _state?.ResolvedServices ?? ScopeDisposed();
31+
32+
internal object Sync => _scopeLock;
3233

3334
public ServiceProviderEngine Engine { get; }
3435

@@ -53,7 +54,7 @@ internal object CaptureDisposable(object service)
5354
return service;
5455
}
5556

56-
lock (_disposelock)
57+
lock (_scopeLock)
5758
{
5859
if (_disposed)
5960
{
@@ -66,16 +67,15 @@ internal object CaptureDisposable(object service)
6667
// sync over async, for the rare case that an object only implements IAsyncDisposable and may end up starving the thread pool.
6768
Task.Run(() => ((IAsyncDisposable)service).DisposeAsync().AsTask()).GetAwaiter().GetResult();
6869
}
70+
6971
ThrowHelper.ThrowObjectDisposedException();
7072
}
7173

72-
if (_disposables == null)
73-
{
74-
_disposables = new List<object>();
75-
}
74+
_state.Disposables ??= new List<object>();
7675

77-
_disposables.Add(service);
76+
_state.Disposables.Add(service);
7877
}
78+
7979
return service;
8080
}
8181

@@ -97,6 +97,8 @@ public void Dispose()
9797
}
9898
}
9999
}
100+
101+
ClearState();
100102
}
101103

102104
public ValueTask DisposeAsync()
@@ -115,7 +117,7 @@ public ValueTask DisposeAsync()
115117
ValueTask vt = asyncDisposable.DisposeAsync();
116118
if (!vt.IsCompletedSuccessfully)
117119
{
118-
return Await(i, vt, toDispose);
120+
return Await(this, i, vt, toDispose);
119121
}
120122

121123
// If its a IValueTaskSource backed ValueTask,
@@ -134,9 +136,11 @@ public ValueTask DisposeAsync()
134136
}
135137
}
136138

139+
ClearState();
140+
137141
return default;
138142

139-
static async ValueTask Await(int i, ValueTask vt, List<object> toDispose)
143+
static async ValueTask Await(ServiceProviderEngineScope scope, int i, ValueTask vt, List<object> toDispose)
140144
{
141145
await vt.ConfigureAwait(false);
142146
// vt is acting on the disposable at index i,
@@ -155,30 +159,52 @@ static async ValueTask Await(int i, ValueTask vt, List<object> toDispose)
155159
((IDisposable)disposable).Dispose();
156160
}
157161
}
162+
163+
scope.ClearState();
164+
}
165+
}
166+
167+
private IDictionary<ServiceCacheKey, object> ScopeDisposed()
168+
{
169+
ThrowHelper.ThrowObjectDisposedException();
170+
return null;
171+
}
172+
173+
private void ClearState()
174+
{
175+
// We lock here since ResolvedServices is always accessed in the scope lock, this means we'll never
176+
// try to return to the pool while somebody is trying to access ResolvedServices.
177+
lock (_scopeLock)
178+
{
179+
// ResolvedServices is never cleared for singletons because there might be a compilation running in background
180+
// trying to get a cached singleton service. If it doesn't find it
181+
// it will try to create a new one which will result in an ObjectDisposedException.
182+
183+
// Dispose the state, which will end up attempting to return the state pool.
184+
// This will return false if the pool is full or if this state object is the root scope
185+
if (_state.Return())
186+
{
187+
_state = null;
188+
}
158189
}
159190
}
160191

161192
private List<object> BeginDispose()
162193
{
163-
List<object> toDispose;
164-
lock (_disposelock)
194+
lock (_scopeLock)
165195
{
166196
if (_disposed)
167197
{
168198
return null;
169199
}
170200

201+
// We've transitioned to the disposed state, so future calls to
202+
// CaptureDisposable will immediately dispose the object.
203+
// No further changes to _state.Disposables, are allowed.
171204
_disposed = true;
172-
toDispose = _disposables;
173-
_disposables = null;
174205

175-
// Not clearing ResolvedServices here because there might be a compilation running in background
176-
// trying to get a cached singleton service instance and if it won't find
177-
// it it will try to create a new one tripping the Debug.Assert in CaptureDisposable
178-
// and leaking a Disposable object in Release mode
206+
return _state.Disposables;
179207
}
180-
181-
return toDispose;
182208
}
183209
}
184210
}

0 commit comments

Comments
 (0)