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

Avoid cancellation exceptions in async queue implementation #68412

Merged
merged 4 commits into from
Jul 19, 2023

Conversation

sharwell
Copy link
Member

@sharwell sharwell commented Jun 1, 2023

Addresses 59+% of the exceptions (220K out of 375K total) observed in CABs associated with AB#1827592.

@sharwell sharwell requested a review from a team as a code owner June 1, 2023 15:31
@dotnet-issue-labeler dotnet-issue-labeler bot added Area-IDE untriaged Issues and PRs which have not yet been triaged by a lead labels Jun 1, 2023
@CyrusNajmabadi
Copy link
Member

Can we instead wait for @jcouv 's work to be done. This code is pretty confusing to understand.

@sharwell
Copy link
Member Author

sharwell commented Jun 1, 2023

Can we instead wait for @jcouv 's work to be done. This code is pretty confusing to understand.

That work will make 780403b unnecessary, but it will not address c933b80. We also don't have an ETA for this and it seems questionable as a short term target.

// If any of the requests was high priority, then compute at that speed.
var highPriority = changes.Contains(true);
await RecomputeTagsAsync(highPriority, cancellationToken).ConfigureAwait(false);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 This await resulted in 47916 exceptions in the trace I'm reviewing. Following this change, there are no exceptions thrown from this location.

// tagging operation. Otherwise, it is impossible to know which changes occurred prior to the
// request to tag, and which ones occurred during the tagging itself.
this.AccumulatedTextChanges = null;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not getting the logic here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is a separate commit. It might help to review it that way.

The underlying problem is we claimed data from the type and reset that data at the point of acquisition. If cancellation occurs after acquisition but before processing completes, the accumulated text changes for that batch processing operation were simply discarded previously. The new code will re-process the accumulated text changes if cancellation interrupts processing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ I retained this change as it is a correctness fix

}
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very worried about the difficulty validating that logic like this is correct. I would prefer we just wait on the compiler doing this work for us so we can get the battle testing of that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be happy to have @stephentoub review this. I would consider it a candidate for inclusion in vs-threading along with the NoThrowAwaitable method they already have there for Task<TResult>.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'm not a fan of changing the await to return a tuple of the status, exception, and result and would hope we wouldn't promote that. We're adding support for no-throw awaiting in .NET 8 and, after many go-arounds on design and discussing doing this exact thing and various variations, are very explicitly only doing so for Task and not ValueTask. If you really need to do this with ValueTask, you have various options, like

vt = vt.Preserve();
await vt.NoThrow(); // your simple awaiter that just nops GetResult
... examine vt

and it doesn't require inventing a complicated thing or using an unfamiliar pattern.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⏱️ Works for me. I'll rewrite to this pattern.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ This has now been rewritten.

Copy link
Member Author

@sharwell sharwell Jun 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ This code will be rewritten to use microsoft/vs-threading#1193 after 17.7 Preview 3 ships.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this done?

Copy link
Member Author

@sharwell sharwell Jul 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ No, I checked CI this morning and the images are still on 17.7 Preview 2.

Copy link
Member

@CyrusNajmabadi CyrusNajmabadi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently blocking. but open to discussion on this. but it should be combined with the convo on compiler async/await emit.

/// </summary>
public (TaskStatus Status, ExceptionDispatchInfo? Exception, TResult? Result) GetResult()
{
var preserved = _task.Preserve();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this VT is backed by an IValueTaskSource and it's faulted or canceled, this is going to result in throwing and catching its exception. My understanding is that's the cost this whole thing is trying to prevent.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also on very shaky ground. It may work most of the time, but again with an IVTS, this is calling Preserve after it's already hooked up a continuation to the IVTS. That's not something that's technically supported, and could possibly fail with some implementations. I think it should work with ours. But, shaky ground.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ This has been rewritten to call Preserve() at the point the awaitable is initialized.

My understanding is that's the cost this whole thing is trying to prevent.

This isn't the specific situation impacting us, so it's acceptable that there is no clear resolution for it.

Comment on lines -177 to -180
// no point preceding if we're already disposed. We check this on the UI thread so that we will know
// about any prior disposal on the UI thread.
if (cancellationToken.IsCancellationRequested)
return;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 I retained this change as it served no purpose (SwitchToMainThreadAsync checks the cancellation token immediately before this).

Comment on lines 229 to 230
if (cancellationToken.IsCancellationRequested)
return;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 I retained these changes as they represent a trivial consistency update to this method, and do not overlap with the planned compiler changes.

// tagging operation. Otherwise, it is impossible to know which changes occurred prior to the
// request to tag, and which ones occurred during the tagging itself.
this.AccumulatedTextChanges = null;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ I retained this change as it is a correctness fix

@sharwell sharwell dismissed CyrusNajmabadi’s stale review June 2, 2023 13:24

Changes which overlap the compiler work have been removed from this pull request

Comment on lines +233 to +234
var batchResultTask = _processBatchAsync(nextBatch, batchCancellationToken).Preserve();
await batchResultTask.NoThrowAwaitableInternal(false);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 This change is retained because it does not overlap with compiler work. The no-throw logic in async state machines does not apply to await statements within a try block that contains one or more catch clauses, so avoiding an exception here requires updating to a no-throw form of awaiter.

@@ -170,32 +170,31 @@ private void OnEventSourceChanged(object? _1, TaggerEventArgs _2)
private void EnqueueWork(bool highPriority)
=> _eventChangeQueue.AddWork(highPriority, _dataSource.CancelOnNewWork);

private async ValueTask ProcessEventChangeAsync(ImmutableSegmentedList<bool> changes, CancellationToken cancellationToken)
private ValueTask<VoidResult> ProcessEventChangeAsync(ImmutableSegmentedList<bool> changes, CancellationToken cancellationToken)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the urpse of hte 'Voidresult' in all of this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Required as part of the move to AsyncBatchingWorkQueue<bool, VoidResult>. The helper type AsyncBatchingWorkQueue<bool> is not suitable for this scenario due to performance impact in an edge case. This restriction is documented and localized to private implementation code.

else
{
// Access the result to force the exception to be thrown.
_ = batchResultTask.Result;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this throw cancellation in the event of cancellatino? or does it throw an aggregate exception? should this be GetAwaiter().GetResult() instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ This is a ValueTask, so there is no need to use GetAwaiter().GetResult() (exceptions are not wrapped in AggregateException)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you comment this. needing to know the subtle differences between the task systems is non trivial mental overhead for validating this stuff.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Now uses helper method VerifyCompleted

public NoThrowValueTaskAwaitable(ValueTask task, bool captureContext)
{
Contract.ThrowIfNull(task, nameof(task));
_task = task.Preserve();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Rewrote this to use a direct copy of the code from vs-threading (only changes were renames to avoid code style warnings):
https://github.com/microsoft/vs-threading/blob/main/src/Microsoft.VisualStudio.Threading/TplExtensions.cs

public NoThrowValueTaskAwaitable(ValueTask<TResult> task, bool captureContext)
{
Contract.ThrowIfNull(task, nameof(task));
_task = task.Preserve();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Rewrote this to use a direct copy of the code from vs-threading (only changes were renames to avoid code style warnings):
https://github.com/microsoft/vs-threading/blob/main/src/Microsoft.VisualStudio.Threading/TplExtensions.cs

// tagging operation. Otherwise, it is impossible to know which changes occurred prior to the
// request to tag, and which ones occurred during the tagging itself.
this.AccumulatedTextChanges = null;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok. this change seems very safe to me. That said, i would prefer the comment on it (or some documentation somewhere) be expanded a bit. namely, stating that it's ok to not clear this out, as the only impact is slightly worse performance for some taggers by indicating potentially more of the document was changed than actually changed.

Basically, i'd like docs somewhere that this is more a conservative estimate of what changed, and it is ok to be more (but not less) than what actually did change. Thanks!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Documentation updated

// Don't bubble up cancellation to the queue for the nested batch cancellation. Just because we decided
// to cancel this batch isn't something that should stop processing further batches.
return default;
var batchResultTask = _processBatchAsync(nextBatch, batchCancellationToken).Preserve();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok. just to check. we need to all .Preserve because not only are we awaiting this value-task, but we're also doing things like directly checking .Result (and other members) off of it. And ValueTasks otherwise do not support this after an await because they may be put back in a pool?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Correct

return task.GetAwaiter().GetResult();
// Propagate any exceptions that may have been thrown. Relies on ValueTask.Result behaving like
// Task.GetAwaiter().GetResult() in the presence of exceptions.
return task.Result;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't get it. why was the old code wrong?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ The old code had the same behavior for the case where the ValueTask<T> is already completed, but @stephentoub has suggested on some other cases where I used .GetAwaiter().GetResult() that it was unnecessary and we can just use .Result for ValueTask<T>.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ I removed this change from the pull request.

Avoids an intermediate async state machine that throws a frequent
cancellation exception.
internal static class ValueTaskExtensions
{
// Following code is copied from Microsoft.VisualStudio.Threading.TplExtensions (renamed to avoid ambiguity)
// https://github.com/microsoft/vs-threading/blob/main/src/Microsoft.VisualStudio.Threading/TplExtensions.cs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this file up to date with the code in vs-threading? (i just want to make sure if we have a copy, we're reasonably close to it).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Yes, the only changes are renamed private fields to have _ prefix, and removal of this. qualification.

@sharwell sharwell enabled auto-merge July 19, 2023 16:26
@sharwell sharwell merged commit d1f6077 into dotnet:main Jul 19, 2023
24 checks passed
@sharwell sharwell deleted the no-throw branch August 1, 2023 16:01
@sharwell sharwell added this to the 17.8 P1 milestone Aug 2, 2023
sharwell added a commit to sharwell/roslyn that referenced this pull request Sep 13, 2023
* Avoid throwing cancellation exceptions (follow-up to dotnet#68412)
* Use provided NoThrowAwaitable extension method
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-IDE untriaged Issues and PRs which have not yet been triaged by a lead
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants