Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docfx/analyzers/VSTHRD202.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# VSTHRD202 Remove unnecessary async state machine

This informational rule identifies an `async` method whose only `await` is the final operation and whose awaited expression produces exactly the same `Task` or `Task<T>` type as the method returns. Returning that task directly avoids allocating and running an async state machine.

## Examples of patterns that are flagged by this analyzer

```csharp
async Task<string> DoSomethingAsync()
{
return await SomethingElseAsync();
}
```

The analyzer also recognizes `.ConfigureAwait(bool)` and `.ConfigureAwaitRunInline()` on the final expression:

```csharp
async Task DoSomethingAsync()
{
await SomethingElseAsync().ConfigureAwait(false);
}
```

It does not report methods with multiple awaits, a non-terminal await, an await inside a `try` statement, a `using` declaration, a different returned task type, or a return type other than `Task` or `Task<T>`.

## Code fixes

The minimal code fix removes `async`, `await`, and a supported task configuration call such as `.ConfigureAwait(bool)` or `.ConfigureAwaitRunInline()`:

```csharp
Task<string> DoSomethingAsync()
{
return SomethingElseAsync();
}
```

This changes how a synchronous exception from `SomethingElseAsync()` or from earlier code in the method is observed: it is thrown directly instead of being stored in the returned task. A second code fix wraps such exceptions in the returned task by adding a `try`/`catch`:

```csharp
Task<string> DoSomethingAsync()
{
try
{
return SomethingElseAsync();
}
catch (OperationCanceledException ex)
{
CancellationToken cancellationToken = ex.CancellationToken.IsCancellationRequested
? ex.CancellationToken
: new CancellationToken(canceled: true);
return Task.FromCanceled<string>(cancellationToken);
}
catch (Exception ex)
{
return Task.FromException<string>(ex);
}
}
```

## Caveats

Removing the state machine is a performance optimization, but the two forms are not identical:

* While debugging a continuation in `SomethingElseAsync`, `DoSomethingAsync` no longer appears as an async frame in the call stack.
* Exception stack traces can change. The minimal fix also changes synchronous exceptions from a faulted or canceled returned task into exceptions thrown directly to the caller. The try/catch fix keeps ordinary exceptions in a faulted task and `OperationCanceledException` in a canceled task.
* The returned task is the original task instead of a task created by the wrapper method, which can make task identity observable.
* Compiler warning CS4014 for unawaited calls is only produced within an `async` method. Removing `async` may therefore stop the compiler from warning about other unawaited calls in the method. Consider enabling [VSTHRD110](VSTHRD110.md) before applying this optimization broadly.

The try/catch fix is only offered when the target framework provides `Task.FromException` and `Task.FromCanceled`.

These tradeoffs are why this diagnostic has an **Informational** default severity. Suppress or disable it when preserving the debugging experience or the original method boundary is more important than avoiding the state machine.
1 change: 1 addition & 0 deletions docfx/analyzers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ ID | Title | Severity | Supports | Default diagnostic severity
[VSTHRD116](VSTHRD116.md) | Use `ThreadStaticAttribute` only with static fields | Advisory | | Warning
[VSTHRD117](VSTHRD117.md) | Avoid initialization of `ThreadStatic` fields in a type initializer | Advisory | | Warning
[VSTHRD200](VSTHRD200.md) | Use `Async` naming convention | Guideline | [VSTHRD103](VSTHRD103.md) | Warning
[VSTHRD202](VSTHRD202.md) | Remove unnecessary async state machine | Guideline | | Info

## Severity descriptions

Expand Down
1 change: 1 addition & 0 deletions docfx/analyzers/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ items:
- href: VSTHRD116.md
- href: VSTHRD117.md
- href: VSTHRD200.md
- href: VSTHRD202.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

namespace Microsoft.VisualStudio.Threading.Analyzers;

/// <summary>
/// Identifies methods where an unnecessary <see langword="async"/> state machine can be removed.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class VSTHRD202RemoveUnnecessaryAsyncAnalyzer : DiagnosticAnalyzer
{
/// <summary>
/// The diagnostic ID.
/// </summary>
public const string Id = "VSTHRD202";

/// <summary>
/// The descriptor for this diagnostic.
/// </summary>
internal static readonly DiagnosticDescriptor Descriptor = new(
id: Id,
title: new LocalizableResourceString(nameof(Strings.VSTHRD202_Title), Strings.ResourceManager, typeof(Strings)),
messageFormat: new LocalizableResourceString(nameof(Strings.VSTHRD202_MessageFormat), Strings.ResourceManager, typeof(Strings)),
helpLinkUri: Utils.GetHelpLink(Id),
category: "Style",
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true);

/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Descriptor);

/// <inheritdoc />
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.RegisterSyntaxNodeAction(Utils.DebuggableWrapper(AnalyzeMethod), SyntaxKind.MethodDeclaration);
}

private static bool ShouldDescendInto(SyntaxNode node)
=> node is not AnonymousFunctionExpressionSyntax and not LocalFunctionStatementSyntax;

private static void AnalyzeMethod(SyntaxNodeAnalysisContext context)
{
var method = (MethodDeclarationSyntax)context.Node;
SyntaxToken asyncKeyword = method.Modifiers.FirstOrDefault(modifier => modifier.IsKind(SyntaxKind.AsyncKeyword));
ImmutableArray<SyntaxNode> descendants = method.DescendantNodes(ShouldDescendInto).ToImmutableArray();
ImmutableArray<AwaitExpressionSyntax> awaitExpressions = descendants.OfType<AwaitExpressionSyntax>().ToImmutableArray();
if (asyncKeyword.RawKind == 0
|| awaitExpressions is not [AwaitExpressionSyntax awaitExpression]
|| context.SemanticModel.GetDeclaredSymbol(method, context.CancellationToken) is not IMethodSymbol { IsAsync: true } methodSymbol
|| !Utils.IsTask(methodSymbol.ReturnType)
|| !IsTerminalAwait(method, awaitExpression)
|| descendants.OfType<LocalDeclarationStatementSyntax>().Any(local => local.UsingKeyword.RawKind != 0)
|| descendants.OfType<UsingStatementSyntax>().Any(usingStatement => usingStatement.AwaitKeyword.RawKind != 0)
|| descendants.OfType<CommonForEachStatementSyntax>().Any(forEachStatement => forEachStatement.AwaitKeyword.RawKind != 0))
{
return;
}

if (context.SemanticModel.GetOperation(awaitExpression, context.CancellationToken) is not IAwaitOperation awaitOperation)
{
return;
}

IOperation returnedTask = UnwrapConfigureAwait(awaitOperation, context.SemanticModel, context.CancellationToken);
if (SymbolEqualityComparer.Default.Equals(returnedTask.Type, methodSymbol.ReturnType))
{
context.ReportDiagnostic(Diagnostic.Create(Descriptor, asyncKeyword.GetLocation()));
}
}

private static bool IsTerminalAwait(MethodDeclarationSyntax method, AwaitExpressionSyntax awaitExpression)
{
SyntaxNode expression = awaitExpression;
while (expression.Parent is ParenthesizedExpressionSyntax parenthesizedExpression)
{
expression = parenthesizedExpression;
}

if (method.ExpressionBody?.Expression == expression)
{
return true;
}

StatementSyntax? statement = expression.Parent switch
{
ReturnStatementSyntax returnStatement when returnStatement.Expression == expression => returnStatement,
ExpressionStatementSyntax expressionStatement when expressionStatement.Expression == expression => expressionStatement,
_ => null,
};

return statement is object
&& method.Body is BlockSyntax body
&& statement.Parent == body
&& body.Statements.LastOrDefault() == statement;
}

private static IOperation UnwrapConfigureAwait(IAwaitOperation awaitOperation, SemanticModel semanticModel, CancellationToken cancellationToken)
{
IOperation operation = awaitOperation.Operation;
while (operation is IParenthesizedOperation parenthesizedOperation)
{
operation = parenthesizedOperation.Operand;
}

if (operation is IInvocationOperation invocation && IsTaskConfigureAwait(invocation))
{
if (invocation.Instance is IOperation instance)
{
return instance;
}

ExpressionSyntax expression = ((AwaitExpressionSyntax)awaitOperation.Syntax).Expression;
while (expression is ParenthesizedExpressionSyntax parenthesizedExpression)
{
expression = parenthesizedExpression.Expression;
}

if (expression is InvocationExpressionSyntax invocationSyntax
&& invocationSyntax.Expression is MemberAccessExpressionSyntax memberAccess
&& semanticModel.GetOperation(memberAccess.Expression, cancellationToken) is IOperation receiver)
{
return receiver;
}
}

return operation;
}

private static bool IsTaskConfigureAwait(IInvocationOperation invocation)
=> CommonInterest.TaskConfigureAwait.Any(configureAwait => configureAwait.IsMatch(invocation.TargetMethod));
}
Loading
Loading