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

Async-streams: Disposal in async-iterator methods #31527

Merged
merged 9 commits into from
Dec 13, 2018

Conversation

jcouv
Copy link
Member

@jcouv jcouv commented Dec 4, 2018

Overview of the change:

  • moved async-iterator rewriter into its own class (AsyncIteratorMethodToStateMachineRewriter, instead of mixed in AsyncMethodToStateMachineRewriter)
  • implement VisitTryStatement, which includes adding jumps after finallys
    • to support that, updated AsyncExceptionHandlerRewriter to wrap extracted finally blocks with BoundExtractedFinallyBlock
  • adjust VisitYieldReturnStatement and VisitYieldBreakStatement with logic to jump to relevant finally
    • to support that, updated BoundTryStatement to record the label for extracted finally blocks
  • fixed the implementation of DisposeAsync() method
    • to support that, added implementation of IValueTaskSource to state machine (which previously implemented IValueTaskSource<T>

Please read the design doc (updated in this PR) for more details.

Fixes #30260

Note: I didn't test continue/break/goto yet (tracked in #24037)

Here's an example of lowering (input and pseudo-C# output):

        try
        {
            yield return 1;
            Write(""Try "");
            yield break;
        }
        finally
        {
            Write(""Finally "");
            await System.Threading.Tasks.Task.CompletedTask;
        }
{
    int temp1;
    temp1 = this.<>1__state;
    try
    {
        switch (temp1)
        {
            case 0:
                goto <tryDispatch-45>;
                break;
            case 1:
                goto <stateMachine-48>;
                break;
        }
        {
            {
                {
                    this.<>s__1 = default;
                    this.<>s__2 = default;
                    {
                        <tryDispatch-45>: ;
                        try
                        {
                            switch (temp1)
                            {
                                case 0:
                                    goto <stateMachine-43>;
                                    break;
                            }
                            {
                                {
                                    {
                                        this.<>2__current = 1;
                                        this.<>1__state = temp1 = 0;
                                        goto <yieldReturn-42>;
                                        <stateMachine-43>: ;
                                        this.<>1__state = temp1 = -1;
                                        {
                                            if (!this.<>w__disposeMode) goto <afterif-44>;
                                            goto <finallyLabel-37>;
                                            <afterif-44>: ;
                                        }
                                    }
                                    Write("Try ");
                                    {
                                        this.<>w__disposeMode = True;
                                        goto <finallyLabel-37>;
                                    }
                                }
                                goto <finallyLabel-37>;
                                {
                                }
                            }
                        }
                        catch (Object) 
                        {
                        }
                    }
                    {
                        {
                            <finallyLabel-37>: ;
                            {
                                Write("Finally ");
                                {
                                    System.Runtime.CompilerServices.TaskAwaiter temp2;
                                    {
                                        temp2 = Task.get_CompletedTask().GetAwaiter();
                                        {
                                            if (! BoolLogicalNegation temp2.get_IsCompleted()) goto <afterif-49>;
                                            {
                                                this.<>1__state = temp1 = 1;
                                                this.<>u__1 = temp2;
                                                { temp3 = this; this.<>t__builder.AwaitUnsafeOnCompleted(temp2, temp3) };
                                                goto <exitLabel-41>;
                                                <stateMachine-48>: ;
                                                temp2 = this.<>u__1;
                                                this.<>u__1 = default;
                                                this.<>1__state = temp1 = -1;
                                            }
                                            <afterif-49>: ;
                                        }
                                    }
                                    temp2.GetResult();
                                }
                            }
                            {
                                object temp4;
                                temp4 = this.<>s__1;
                                {
                                    if (!temp4 ObjectNotEqual null) goto <afterif-39>;
                                    {
                                        System.Exception temp5;
                                        temp5 = AsOperator
                                        ;
                                        {
                                            if (!temp5 ObjectEqual null) goto <afterif-38>;
                                            throw temp4;
                                            <afterif-38>: ;
                                        }
                                        Capture(temp5).Throw();
                                    }
                                    <afterif-39>: ;
                                }
                            }
                            this.<>s__2;
                        }
                        {
                            if (!this.<>w__disposeMode) goto <afterif-50>;
                            goto <exprReturn-40>;
                            <afterif-50>: ;
                        }
                    }
                }
                this.<>s__1 = null;
            }
        }
    }
    catch (Exception) 
    {
        this.<>1__state = -2;
        this.<>v__promiseOfValueOrEnd.SetException(temp6);
        goto <exitLabel-41>;
    }
    <exprReturn-40>: ;
    this.<>1__state = -2;
    {
        this.<>v__promiseOfValueOrEnd.SetResult(False);
        return;
        <yieldReturn-42>: ;
        this.<>v__promiseOfValueOrEnd.SetResult(True);
    }
    <exitLabel-41>: ;
    return;
}

Async-streams umbrella: #24037

@jcouv jcouv added this to the 16.0.P2 milestone Dec 4, 2018
@jcouv jcouv self-assigned this Dec 4, 2018
@jcouv jcouv requested a review from a team as a code owner December 4, 2018 21:51
@jcouv jcouv changed the title Disposal in async-iterator methods Async-streams: Disposal in async-iterator methods Dec 4, 2018
// if (State == StateMachineStates.FinishedStateMachine)
TypeSymbol returnType = IAsyncEnumerableOfElementType_MoveNextAsync.ReturnType.TypeSymbol;

GetPartsForStartingMachine(returnType,
Copy link
Member Author

@jcouv jcouv Dec 4, 2018

Choose a reason for hiding this comment

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

GetPartsForStartingMachine [](start = 16, length = 26)

📝 extracted a number of locals to this helper method, so that it could be re-used in implementation of DisposeAsync(). #Resolved

/// finally blocks in case the state machine is disposed. The Dispose method computes the new state
/// and then runs MoveNext. Not used if !this.useFinalizerBookkeeping.
/// </summary>
protected Dictionary<int, int> finalizerStateMap = new Dictionary<int, int>();
Copy link
Member Author

@jcouv jcouv Dec 5, 2018

Choose a reason for hiding this comment

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

finalizerStateMap [](start = 39, length = 17)

📝 removed as it was unused (only added to, but never read from) #Resolved

@jcouv
Copy link
Member Author

jcouv commented Dec 5, 2018

@agocke @cston @dotnet/roslyn-compiler for review. Thanks
I'm happy to walk you through the design. Let me know. #Resolved

@@ -56,7 +56,9 @@ async IAsyncEnumerable<int> GetValuesFromServer()
}
```

**open issue**: Design async LINQ
Just like in iterator methods, there are several restrictions on where a yield statement can appear in async-iterator methods:
- It is a compile-time error for a `yield` statement (of either form) to appear in the `finally` clause of a `try` statement.
Copy link
Member

@cston cston Dec 10, 2018

Choose a reason for hiding this comment

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

yield statement (of either form) [](start = 35, length = 34)

Minor point: perhaps just "... a yield return or yield break ..." #WontFix

Copy link
Member Author

Choose a reason for hiding this comment

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

Those two lines were copied from the existing C# spec. I'll keep as-is.


In reply to: 240409214 [](ancestors = 240409214)


In summary, disposal of an async-iterator works based on four design elements:
- `yield return` (jumps to finally when resuming in dispose mode)
- `yield break` (enters dispose mode and jumpps to finally)
Copy link
Member

@cston cston Dec 10, 2018

Choose a reason for hiding this comment

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

jumpps [](start = 41, length = 6)

Typo #Closed

The caller of an async-iterator method should only call `DisposeAsync()` when the method completed or was suspended by a `yield return`.
`DisposeAsync` sets a flag on the state machine ("dispose mode") and (if the method wasn't completed) resumes the execution from the current state.
The state machine can resume execution from a given state (even those located within a `try`).
When the execution is resumed in dispose mode, it jump straight to the relevant `finally`.
Copy link
Member

@cston cston Dec 10, 2018

Choose a reason for hiding this comment

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

jump [](start = 50, length = 4)

jumps? #Closed

`DisposeAsync` sets a flag on the state machine ("dispose mode") and (if the method wasn't completed) resumes the execution from the current state.
The state machine can resume execution from a given state (even those located within a `try`).
When the execution is resumed in dispose mode, it jump straight to the relevant `finally`.
`finally` blocks may involve pauses and resumes, but only for `await` expressions. As a result of the restrictions imposed on `yield return` (described above), dispose mode never runs into that statement.
Copy link
Member

@cston cston Dec 10, 2018

Choose a reason for hiding this comment

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

that statement [](start = 189, length = 14)

What does that statement refer to? #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

yield return
I'll make it explicit


In reply to: 240414615 [](ancestors = 240414615)

`finally` blocks may involve pauses and resumes, but only for `await` expressions. As a result of the restrictions imposed on `yield return` (described above), dispose mode never runs into that statement.
Once a `finally` block completes, the execution in dispose mode jumps to the next relevant `finally`, or the end of the method once we reach the top-level.

Reaching a `yield break` also sets the dispose mode flag and jumps to the next relevant `finally` (or end of the method). By the time we return control to the caller (completing the promise as `false` by reaching the end of the method) all disposal was completed, and the state machine is left in finished state. So `DisposeAsync()` has no work left to do.
Copy link
Member

@cston cston Dec 11, 2018

Choose a reason for hiding this comment

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

By [](start = 122, length = 2)

Consider breaking long line. #Closed

// resuming from state=<next_state> will dispatch execution to this label
<next_state_label>: ;
this.state = cachedState = NotStartedStateMachine;
if (disposeMode) /* jump to current finally or exit */
Copy link
Member

@cston cston Dec 11, 2018

Choose a reason for hiding this comment

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

current [](start = 28, length = 7)

enclosing? #Closed

// resuming from state=<next_state> will dispatch execution to this label
<next_state_label>: ;
this.state = cachedState = NotStartedStateMachine;
if (disposeMode) /* jump to current finally or exit */
Copy link
Member

@cston cston Dec 11, 2018

Choose a reason for hiding this comment

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

/* [](start = 17, length = 2)

Minor point: perhaps // #WontFix

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've used the following convention: /* ... */ as a placeholder for some omitted code, but use // ... to comment on code that is there.


In reply to: 240709778 [](ancestors = 240709778)

A `yield break` is lowered as:
```C#
disposeMode = true;
/* jump to current finally or exit */
Copy link
Member

@cston cston Dec 11, 2018

Choose a reason for hiding this comment

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

// jump to enclosing ...
Same comment for other case below. #Closed

@@ -1651,6 +1651,11 @@ protected virtual void VisitFinallyBlock(BoundStatement finallyBlock, ref TLocal
VisitStatement(finallyBlock); // this should generate no pending branches
}

public override BoundNode VisitExtractedFinallyBlock(BoundExtractedFinallyBlock node)
Copy link
Member

@cston cston Dec 11, 2018

Choose a reason for hiding this comment

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

BoundExtractedFinallyBlock [](start = 61, length = 26)

Doesn't this node only existing in lowering? If so, this visit method should throw ExceptionUtilities.Unreachable;. #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

Async rewriting uses data flow analysis (in StateMachineRewriter.Rewrite at line 106, we do IteratorAndAsyncCaptureWalker.Analyze on the incoming body). At that stage (after local rewriting and async exception handler rewriting), we have extracted finally blocks.


In reply to: 240715273 [](ancestors = 240715273)

/// We use _exprReturnLabel for normal end of method (ie. no more values).
/// We use _exprReturnLabelTrue for `yield return;`.
/// </summary>
private LabelSymbol _exprReturnLabelTrue;
Copy link
Member

@cston cston Dec 11, 2018

Choose a reason for hiding this comment

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

private [](start = 8, length = 7)

readonly #Closed

/// When we enter a `try` that has a `finally`, we'll use the label directly preceeding the `finally`.
/// When we enter a `try` that has an extracted `finally`, we will use the label preceeding the extracted `finally`.
/// </summary>
private LabelSymbol _currentFinallyOrExitLabel;
Copy link
Member

@cston cston Dec 11, 2018

Choose a reason for hiding this comment

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

current [](start = 29, length = 7)

Perhaps enclosing rather than current. #Closed

thenClause: F.Goto(_currentFinallyOrExitLabel));
}

BoundStatement AppendJumpToCurrentFinallyOrExit(BoundStatement node)
Copy link
Member

@cston cston Dec 11, 2018

Choose a reason for hiding this comment

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

BoundStatement [](start = 8, length = 14)

private #Closed

BoundStatement AppendJumpToCurrentFinallyOrExit(BoundStatement node)
{
// Append:
// if (disposeMode) /* jump to current finally or exit */
Copy link
Member

@cston cston Dec 11, 2018

Choose a reason for hiding this comment

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

/* jump to current finally or exit */ [](start = 33, length = 37)

goto finallyOrExitLabel;
Same comment for other instances. #Closed

@cston
Copy link
Member

cston commented Dec 11, 2018

                    F.Parameter(IValueTaskSourceOfBool_OnCompleted.Parameters[3]))),

Indent #Closed


Refers to: src/Compilers/CSharp/Portable/Lowering/AsyncRewriter/AsyncRewriter.AsyncIteratorRewriter.cs:441 in 766c1ca. [](commit_id = 766c1ca, deletion_comment = False)


/// <summary>
/// Initially, this is the method's return value label (<see cref="AsyncMethodToStateMachineRewriter._exprReturnLabel"/>).
/// When we enter a `try` that has a `finally`, we'll use the label directly preceeding the `finally`.
Copy link
Member

@agocke agocke Dec 11, 2018

Choose a reason for hiding this comment

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

The label "directly [preeceding] (sic) the finally" -- is that a label at the end of the try block? #Resolved

Copy link
Member Author

Choose a reason for hiding this comment

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

That depends:

  • If the finally was not extracted, then the label is the last thing in the try block. See VisitTryStatement (which adds this label)
  • If the finally was extracted, then the label is after the try statement and before the extracted block. (that label is added by AsyncExceptionHandlerRewriter and saved in BoundTryStatement.FinallyLabelOpt)

In reply to: 240822167 [](ancestors = 240822167)

Copy link
Member Author

Choose a reason for hiding this comment

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

Here's an example of lowered code for the latter case (extracted finally): #31526


In reply to: 240838373 [](ancestors = 240838373,240822167)

// Produce:
// disposeMode = true;
// if (state == StateMachineStates.FinishedStateMachine ||
// state == StateMachineStates.NotStartedStateMachine)
Copy link
Member

@agocke agocke Dec 11, 2018

Choose a reason for hiding this comment

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

Dispose is supposed to be idempotent. If someone calls it before starting enumeration, then starts enumeration, then disposes, does that still hold? Or is that considered implementation-defined behavior. #Resolved

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 think this implementation is idempotent already: you can safely call it twice.
I'll add tests for that in my next PR, which also related to DisposeAsync (adding guards).

But I think you should not enumerate after you disposed. I took a note to confirm that (in #24037)


In reply to: 240824375 [](ancestors = 240824375)

Copy link
Member Author

Choose a reason for hiding this comment

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

Found the answer: https://github.com/dotnet/csharplang/blob/master/spec/classes.md#the-dispose-method

if you dispose before enumerating, the state is changed to "after" (ie. end of the method). I'll tweak implementation to reflect that in next PR.


In reply to: 240839838 [](ancestors = 240839838,240824375)

}";
var comp = CreateCompilationWithAsyncIterator(source, options: TestOptions.DebugExe);
comp.VerifyDiagnostics();
CompileAndVerify(comp, expectedOutput: "B1::F;D::F;B1::F;");
Copy link
Member

@agocke agocke Dec 11, 2018

Choose a reason for hiding this comment

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

I think it would be good to have tests that uses goto to jump out of the try block. Especially one that re-enters, like

public class C {
    public IEnumerable M() {
        start:
        try{
            yield return 0;
            goto start;
        } 
        finally
        {
        }
    }
} #Resolved

Copy link
Member Author

Choose a reason for hiding this comment

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

Added. I also have a work item to do more exploratory testing on goto/continue/break.


In reply to: 240828761 [](ancestors = 240828761)


MethodSymbol IAsyncDisposable_DisposeAsync =
F.WellKnownMethod(WellKnownMember.System_IAsyncDisposable__DisposeAsync)
.AsMember(IAsyncDisposable);
Copy link
Member

@cston cston Dec 12, 2018

Choose a reason for hiding this comment

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

.AsMember(IAsyncDisposable) [](start = 20, length = 27)

Unnecessary. #Closed

.AsMember(IAsyncDisposable);
MethodSymbol IValueTaskSource_GetResult =
F.WellKnownMethod(WellKnownMember.System_Threading_Tasks_Sources_IValueTaskSource__GetResult)
.AsMember(IValueTaskSource);
Copy link
Member

@cston cston Dec 12, 2018

Choose a reason for hiding this comment

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

.AsMember(IValueTaskSource) [](start = 20, length = 27)

Unnecessary. #Closed

F.CloseMethod(F.Block(bodyBuilder.ToImmutableAndFree()));
MethodSymbol IValueTaskSource_OnCompleted =
F.WellKnownMethod(WellKnownMember.System_Threading_Tasks_Sources_IValueTaskSource__OnCompleted)
.AsMember(IValueTaskSource);
Copy link
Member

@cston cston Dec 12, 2018

Choose a reason for hiding this comment

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

.AsMember(IValueTaskSource) [](start = 20, length = 27)

Unnecessary. #Closed

@cston
Copy link
Member

cston commented Dec 12, 2018

    public void TryFinally_MultipleToplevelTryes(int iterations, string expectedOutput)

Perhaps Trys or TryBlocks.

And these look like nested blocks rather than top-level. #Closed


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncIteratorTests.cs:2710 in d17fca1. [](commit_id = d17fca1, deletion_comment = False)

@cston
Copy link
Member

cston commented Dec 12, 2018

    public void TryFinally_AwaitInCatch(int iterations, string expectedOutput)

Is this covered by previous test? #Closed


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncIteratorTests.cs:3220 in d17fca1. [](commit_id = d17fca1, deletion_comment = False)

@cston
Copy link
Member

cston commented Dec 12, 2018

        finally { }

Use catch rather than finally for one of these cases. #Resolved


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncIteratorTests.cs:3583 in d17fca1. [](commit_id = d17fca1, deletion_comment = False)

@cston
Copy link
Member

cston commented Dec 12, 2018

        finally

Test nested try/catch here as well. #Closed


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncIteratorTests.cs:3416 in d17fca1. [](commit_id = d17fca1, deletion_comment = False)

@cston
Copy link
Member

cston commented Dec 12, 2018

    private static CSharpTestSource Run(int iterations)

Why CSharpTestSource rather than string? #Closed


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncIteratorTests.cs:29 in d17fca1. [](commit_id = d17fca1, deletion_comment = False)

@jcouv
Copy link
Member Author

jcouv commented Dec 12, 2018

        finally { }

It's an error to have a yield return inside a try that has a catch.
See TryFinally_NoYieldReturnInTryCatch and TryFinally_NoYieldReturnInTryCatch_Nested.


In reply to: 446482362 [](ancestors = 446482362)


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncIteratorTests.cs:3583 in d17fca1. [](commit_id = d17fca1, deletion_comment = False)

@jcouv
Copy link
Member Author

jcouv commented Dec 12, 2018

    private static CSharpTestSource Run(int iterations)

Seems unnecessary. Thanks


In reply to: 446484490 [](ancestors = 446484490)


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncIteratorTests.cs:29 in d17fca1. [](commit_id = d17fca1, deletion_comment = False)

@jcouv
Copy link
Member Author

jcouv commented Dec 12, 2018

    public void TryFinally_AwaitInCatch(int iterations, string expectedOutput)

The name of the test isn't great, but I wanted to have just an await in the catch. No yield and no await in the try.


In reply to: 446480617 [](ancestors = 446480617)


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncIteratorTests.cs:3220 in d17fca1. [](commit_id = d17fca1, deletion_comment = False)

@cston
Copy link
Member

cston commented Dec 12, 2018

        finally { }

But shouldn't the error be ERR_BadYieldInFinally, even in the case of a try/catch nested in finally? At least that's the error non-async iterators.


In reply to: 446732459 [](ancestors = 446732459,446482362)


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncIteratorTests.cs:3583 in d17fca1. [](commit_id = d17fca1, deletion_comment = False)

@jcouv
Copy link
Member Author

jcouv commented Dec 12, 2018

        finally { }

Added a test below. We indeed report the problem of yield in finally rather than yield in try/catch in that scenario.
Let me know if that's not what you expected.


In reply to: 446749399 [](ancestors = 446749399,446732459,446482362)


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncIteratorTests.cs:3583 in d17fca1. [](commit_id = d17fca1, deletion_comment = False)

@jcouv
Copy link
Member Author

jcouv commented Dec 12, 2018

@agocke I addressed all the feedback so far. Please take another look. Thanks

Copy link
Member

@agocke agocke left a comment

Choose a reason for hiding this comment

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

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

DisposeAsync of async-iterator method should execute required finally blocks
3 participants