-
Notifications
You must be signed in to change notification settings - Fork 10.6k
Dylan/resettable tcs #12453
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
Dylan/resettable tcs #12453
Conversation
|
|
||
| // enqueue request with a tcs | ||
| var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); | ||
| var tcs = Interlocked.Exchange(ref _cachedResettableTCS, null) ?? new ResettableBooleanCompletionSource(this); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't need Interlocked as its in a lock?
| var tcs = Interlocked.Exchange(ref _cachedResettableTCS, null) ?? new ResettableBooleanCompletionSource(this); | |
| var tcs = _cachedResettableTCS ??= new ResettableBooleanCompletionSource(this); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The pooled TCSs are being placed back in that field by a Volatile.Write that's not in any lock, and so this change introduces a race condition and breaks unit tests.
I will go run benchmarks on several combinations of locks, and see what's best. Thinking
- Current strategy
- Interlocked for the write, this change for the read
- Locking on the same lock for the write (probably slowest)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah didn't see was different class returning it. Probably good as now.
Does mean only one gets reused rather than a pool of them?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does mean only one gets reused rather than a pool of them?
Correct. Generally the call to GetResult() happens inline and returns the ResettableBooleanCompletionSource to the field before the next attempt to retrieve/alloc another.
It's important that we await something like ThreadPoolAwaitable before calling _next(context) in ConcurrencyLimiterMiddleware so we do not:
A) Run app code (i.e. _next(context)) with the LIFOQueuePolicy _bufferLock acquired.
B) Stack dive by calling repeatedly _next(context) inline inside LIFOQueuePolicy.OnExit() which could ultimately lead to a stack overflow.
It would be safer to dispatch at the LIFOQueuePolicy layer instead of the ConcurrencyLimiterMiddleware, since the inline-scheduled ValueTask is exposed to end users if they resolve the LIFOQueuePolicy as an IQueuePolicy from DI.
The problem is without inline scheduling, you lose the nice behavior where GetResult() happens inline and returns the ResettableBooleanCompletionSource to the field before the next attempt to retrieve/alloc another.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The way the pooling works, is that the queue is full of these awaitables. When one gets awaited on, and has .GetResult called, it resets itself and puts itself back into the _cache field. So there's a pool, containing: the awaitables in active use + one additional cached awaitable.
If the queue goes back down, from say 1000 items to 0, then 999 of those thousand will get GCd and 1 will be kept. What this optimizes for is the case of heavy load / ddos, where the queue is stuck at full and requests keep coming in; it avoids allocating extra completion sources in this state.
So I found why the unit tests broke, and it was because the queue policy needs to reset the cache to null to prevent to awaitable from getting regrabbed before it's ready.
Ended up testing five different scenarios, by far the best performance was the simple approach of:
// no locking or checking within the CompletionSource reset
_mrvts.Reset();
_queue._cachedResettableTCS = this;
// no additional locking within the queue policy either, setting to null after
var tcs = _cachedResettableTCS ??= new ResettableBooleanCompletionSource(this);
_cachedResettableTCS = null;
This feels sloppy but safe. The policy can never double grab from the cache, because it's stuck in a lock, and if the reset fails to go through (which happens <1% on my benchmarks) worst case is allocating a couple hundred bytes.
Net perf gains are (in terms of strict overhead from my middleware):
- empty queue 14% faster (466 -> 399ns)
- full queue being emptied 24% faster (554 -> 422 ns)
- full queue actively rejecting 8% faster (732 -> 670ns)
Good catch!
src/Middleware/ConcurrencyLimiter/perf/Microbenchmarks/QueueFullOverhead.cs
Outdated
Show resolved
Hide resolved
src/Middleware/ConcurrencyLimiter/test/PolicyTests/LIFOQueueTests.cs
Outdated
Show resolved
Hide resolved
src/Middleware/ConcurrencyLimiter/test/PolicyTests/ResettableBooleanCompletionSourceTests.cs
Outdated
Show resolved
Hide resolved
I don't think so. I generally don't like decoupling until there are at least 3 unique consumers. The extra abstraction can make it harder to reason about what's really happening, and without multiple consumers you cannot really be sure if it's a good abstraction to begin with.
@davidfowl would warn you agains trusting me to name things, but I think QueuePolicy and StackPolicy is likely fine. |
src/Middleware/ConcurrencyLimiter/src/QueuePolicies/ResettableBooleanCompletionSource.cs
Outdated
Show resolved
Hide resolved
src/Middleware/ConcurrencyLimiter/src/ConcurrencyLimiterMiddleware.cs
Outdated
Show resolved
Hide resolved
|
@DAllanCarr The "using" statement lets the event sources track the duration spent in queue. It starts a new stopwatch, and reads the value when disposing. If it wasn't a using statement, that pattern would look like: vs |
| } | ||
|
|
||
| if (waitInQueueTask.Result) | ||
| if (result) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would any unit tests now fail if you were to check waitInQueueTask.Result here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bit confused here. I thought the point of this change was to avoid calling GetResult twice. Wouldn't checking waitInQueueTask.Result here run into the same issue?
| private static async Task RunsContinuationsAsynchronouslyInternally() | ||
| { | ||
| var tcs = new ResettableBooleanCompletionSource(_testQueue); | ||
| var mre = new ManualResetEventSlim(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: can this test work with a TaskCompletionSource instead of MRE.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could use a TCS, but you'd have to call .Wait() or something on it. It probably is a good idea to wrap the test in a try/finally, and call .Set() on the MRE in the finally so this test doesn't end up blocking a thread indefinitely if it fails.
src/Middleware/ConcurrencyLimiter/perf/Microbenchmarks/QueueFullOverhead.cs
Outdated
Show resolved
Hide resolved
src/Middleware/ConcurrencyLimiter/perf/Microbenchmarks/QueueFullOverhead.cs
Show resolved
Hide resolved
src/Middleware/ConcurrencyLimiter/src/ConcurrencyLimiterMiddleware.cs
Outdated
Show resolved
Hide resolved
src/Middleware/ConcurrencyLimiter/src/QueuePolicies/ResettableBooleanCompletionSource.cs
Show resolved
Hide resolved
|
|
||
| namespace Microsoft.AspNetCore.ConcurrencyLimiter | ||
| { | ||
| internal class ResettableBooleanCompletionSource : IValueTaskSource<bool> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Add a comment with a general description for this class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// Custom awaiter to allow the StackPolicy to reduce allocations.
/// When this completion source has its result checked, it resets itself and returns itself to the cache of its parent StackPolicy.
/// Then when the StackPolicy needs a new completion source, it tries to get one from its cache, otherwise it allocates.
This seem good?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems good to me
src/Middleware/ConcurrencyLimiter/src/QueuePolicies/ResettableBooleanCompletionSource.cs
Outdated
Show resolved
Hide resolved
| { | ||
| public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddFIFOQueue(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action<Microsoft.AspNetCore.ConcurrencyLimiter.QueuePolicyOptions> configure) { throw null; } | ||
| public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddLIFOQueue(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action<Microsoft.AspNetCore.ConcurrencyLimiter.QueuePolicyOptions> configure) { throw null; } | ||
| public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddQueuePolicy(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action<Microsoft.AspNetCore.ConcurrencyLimiter.QueuePolicyOptions> configure) { throw null; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be next to other stuff unrelated to to the ConcurrencyLimiter like collection.AddMvc(). I think this will make it confusing to most developers what this is adding a QueuePolicy to. Maybe this should be AddConcurrencyLimiterQueuePolicy() or AddConcurrencyLimiterStackPolicy().
I don't want to hold up the PR on this though. We can have a bigger discussion over naming and do a follow-up PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Follow up PR sounds good
Added
ResettableBooleanCompletionSource, used it in theLIFOQueuePolicyto avoid allocating unnecessarily.Renamed various components for clarity and consistency.
Big Concern:
.Resulton the resettable Task twice, causing a hidden error. I tried a couple things, but can't find a way to write a good regression test.Two Questions:
ResettableBooleanCompletionSourceholds a reference to aLifoQueuePolicy(to return itself for pooling on .GetResult()). This is moderate coupling. Should I remove the coupling (likely passing in an OnGetResult callback), move the awaitable to be part of theLIFOQueuePolicy.csfile, or is it good as is?LIFOQueuePolicyvsLifoQueuePolicyvsSomeOtherNamePotentially. What's a good mix of clarity vs avoiding weird looking capital letters?