Skip to content

Commit 7697f70

Browse files
authored
Implement async functions and generators (.NET) (#2046)
* Implement async support in coroutine handling * Add async generator support * Create async coroutine cold * Simplify and clean up * Add more async tests * Add async generator tests (.NET) * Update after review
1 parent e9315f2 commit 7697f70

19 files changed

Lines changed: 1016 additions & 6 deletions

eng/net10.0.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<Features>$(Features);FEATURE_METADATA_READER</Features>
2424
<Features>$(Features);FEATURE_MMAP</Features>
2525
<Features>$(Features);FEATURE_NATIVE</Features>
26+
<Features>$(Features);FEATURE_NET_ASYNC</Features>
2627
<Features>$(Features);FEATURE_OSPLATFORMATTRIBUTE</Features>
2728
<Features>$(Features);FEATURE_PIPES</Features>
2829
<Features>$(Features);FEATURE_PROCESS</Features>

eng/net8.0.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<Features>$(Features);FEATURE_METADATA_READER</Features>
2424
<Features>$(Features);FEATURE_MMAP</Features>
2525
<Features>$(Features);FEATURE_NATIVE</Features>
26+
<Features>$(Features);FEATURE_NET_ASYNC</Features>
2627
<Features>$(Features);FEATURE_OSPLATFORMATTRIBUTE</Features>
2728
<Features>$(Features);FEATURE_PIPES</Features>
2829
<Features>$(Features);FEATURE_PROCESS</Features>

eng/net9.0.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<Features>$(Features);FEATURE_METADATA_READER</Features>
2424
<Features>$(Features);FEATURE_MMAP</Features>
2525
<Features>$(Features);FEATURE_NATIVE</Features>
26+
<Features>$(Features);FEATURE_NET_ASYNC</Features>
2627
<Features>$(Features);FEATURE_OSPLATFORMATTRIBUTE</Features>
2728
<Features>$(Features);FEATURE_PIPES</Features>
2829
<Features>$(Features);FEATURE_PROCESS</Features>

src/core/IronPython/Compiler/Ast/AstMethods.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ internal static class AstMethods {
8080
public static readonly MethodInfo FormatString = GetMethod((Func<CodeContext, string, object, string>)PythonOps.FormatString);
8181
public static readonly MethodInfo GeneratorCheckThrowableAndReturnSendValue = GetMethod((Func<object, object>)PythonOps.GeneratorCheckThrowableAndReturnSendValue);
8282
public static readonly MethodInfo MakeCoroutine = GetMethod((Func<PythonFunction, MutableTuple, object, PythonCoroutine>)PythonOps.MakeCoroutine);
83+
#if FEATURE_NET_ASYNC
84+
public static readonly MethodInfo AsTaskForAwait = GetMethod((Func<object, System.Threading.Tasks.Task<object>>)PythonOps.AsTaskForAwait);
85+
public static readonly MethodInfo MakeAsyncCoroutine = GetMethod((Func<PythonFunction, Func<System.Threading.Tasks.Task<object>>, System.Threading.CancellationTokenSource, System.Runtime.CompilerServices.StrongBox<System.Exception>, PythonCoroutine>)PythonOps.MakeAsyncCoroutine);
86+
public static readonly MethodInfo MakeAsyncGenerator = GetMethod((Func<PythonFunction, System.Collections.Generic.IAsyncEnumerable<object>, System.Runtime.CompilerServices.StrongBox<object>, System.Runtime.CompilerServices.StrongBox<System.Exception>, System.Threading.CancellationTokenSource, PythonAsyncGenerator>)PythonOps.MakeAsyncGenerator);
87+
#endif
8388

8489
// builtins
8590
public static readonly MethodInfo Format = GetMethod((Func<CodeContext, object, string, string>)PythonOps.Format);

src/core/IronPython/Compiler/Ast/AwaitExpression.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,41 @@
66

77
using MSAst = System.Linq.Expressions;
88

9+
using IronPython.Runtime.Operations;
10+
911
using AstUtils = Microsoft.Scripting.Ast.Utils;
1012

1113
namespace IronPython.Compiler.Ast {
1214
using Ast = MSAst.Expression;
1315

1416
/// <summary>
15-
/// Represents an await expression. Implemented as yield from expr.__await__().
17+
/// Represents <c>await expr</c>. Under <c>FEATURE_NET_ASYNC</c> this compiles directly to a DLR async suspension point.
18+
/// Otherwise it is desugared into <c>yield from expr.__await__()</c> against the enclosing generator-shaped coroutine state machine.
1619
/// </summary>
1720
public class AwaitExpression : Expression {
21+
#if FEATURE_NET_ASYNC
22+
public AwaitExpression(Expression expression) {
23+
Expression = expression;
24+
}
25+
26+
public Expression Expression { get; }
27+
28+
public override MSAst.Expression Reduce() {
29+
// await x -> AsyncHelpers-driven suspension on the Task produced by
30+
// PythonOps.AsTaskForAwait(x).
31+
return AstUtils.Await(
32+
Ast.Call(
33+
AstMethods.AsTaskForAwait,
34+
AstUtils.Convert(Expression, typeof(object))));
35+
}
36+
37+
public override void Walk(PythonWalker walker) {
38+
if (walker.Walk(this)) {
39+
Expression?.Walk(walker);
40+
}
41+
walker.PostWalk(this);
42+
}
43+
#else
1844
private readonly Statement _statement;
1945
private readonly NameExpression _result;
2046

@@ -60,6 +86,7 @@ public override void Walk(PythonWalker walker) {
6086
}
6187
walker.PostWalk(this);
6288
}
89+
#endif
6390

6491
public override string NodeName => "await expression";
6592
}

src/core/IronPython/Compiler/Ast/FunctionDefinition.cs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Runtime.CompilerServices;
99
using System.Text;
1010
using System.Threading;
11+
using System.Threading.Tasks;
1112

1213
using IronPython.Runtime;
1314
using IronPython.Runtime.Operations;
@@ -117,7 +118,25 @@ internal override int KwOnlyArgCount {
117118

118119
public Expression ReturnAnnotation { get; internal set; }
119120

121+
#if FEATURE_NET_ASYNC
122+
// Under .NET-async, async functions are compiled directly to a Task<object?> via the DLR's AsyncExpression
123+
// rather than reused through the generator state machine, so IsAsync does not imply generator-shaped emission.
124+
internal override bool IsGeneratorMethod => IsGenerator;
125+
126+
// Async-generator (PEP 525) channels. The StrongBox *values* are per-async-generator instance,
127+
// allocated per stack frame at runtime in the function body.
128+
// Declared/assigned in the body, captured by the generator (its yields read them through Parent),
129+
// and handed to the PythonAsyncGenerator wrapper, which writes them before each resume:
130+
// AsyncSendSlot — the value of `x = yield z` (asend(v); None for __anext__/async for).
131+
// AsyncThrowSlot — an exception to rethrow at the yield resume point (athrow/aclose).
132+
private readonly MSAst.ParameterExpression _asyncSendSlot = MSAst.Expression.Variable(typeof(StrongBox<object>), "$asyncSend");
133+
private readonly MSAst.ParameterExpression _asyncThrowSlot = MSAst.Expression.Variable(typeof(StrongBox<Exception>), "$asyncThrow");
134+
135+
internal MSAst.ParameterExpression AsyncSendSlot => _asyncSendSlot;
136+
internal MSAst.ParameterExpression AsyncThrowSlot => _asyncThrowSlot;
137+
#else
120138
internal override bool IsGeneratorMethod => IsGenerator || IsAsync;
139+
#endif
121140

122141
/// <summary>
123142
/// The function is a generator
@@ -182,9 +201,15 @@ internal override FunctionAttributes Flags {
182201
fa |= FunctionAttributes.ContainsTryFinally;
183202
}
184203

204+
#if FEATURE_NET_ASYNC
205+
if (IsGenerator) {
206+
fa |= FunctionAttributes.Generator;
207+
}
208+
#else
185209
if (IsGenerator || IsAsync) {
186210
fa |= FunctionAttributes.Generator;
187211
}
212+
#endif
188213

189214
if (IsAsync) {
190215
fa |= FunctionAttributes.Coroutine;
@@ -357,7 +382,13 @@ internal MSAst.Expression MakeFunctionExpression() {
357382
annotations
358383
)
359384
),
385+
#if FEATURE_NET_ASYNC
386+
// Async generators are lowered via AsyncEnumerableExpression in the body,
387+
// so they must not be wrapped as a PythonGenerator here — only plain (non-async) generators are.
388+
(IsGenerator && !IsAsync) ?
389+
#else
360390
(IsGenerator || IsAsync) ?
391+
#endif
361392
(MSAst.Expression)new PythonGeneratorExpression(code, GlobalParent.PyContext.Options.CompilationThreshold, IsAsync) :
362393
(MSAst.Expression)code
363394
);
@@ -659,7 +690,13 @@ private LightLambdaExpression CreateFunctionLambda() {
659690
// For generators/coroutines, we need to do a check before the first statement for Generator.Throw() / Generator.Close().
660691
// The exception traceback needs to come from the generator's method body, and so we must do the check and throw
661692
// from inside the generator.
693+
#if FEATURE_NET_ASYNC
694+
// Async generators have no backing PythonGenerator (they lower to IAsyncEnumerable via AsyncEnumerableExpression),
695+
// so skip the $generator.CheckThrowable() prologue for them.
696+
if (IsGenerator && !IsAsync) {
697+
#else
662698
if (IsGenerator || IsAsync) {
699+
#endif
663700
MSAst.Expression s1 = YieldExpression.CreateCheckThrowExpression(SourceSpan.None);
664701
statements.Add(s1);
665702
}
@@ -681,6 +718,59 @@ private LightLambdaExpression CreateFunctionLambda() {
681718
body = Ast.Block(body, AstUtils.Empty());
682719
body = AddReturnTarget(body);
683720

721+
#if FEATURE_NET_ASYNC
722+
// Under .NET-async, an `async def` body returns a PythonCoroutine wrapping a Task<object?>.
723+
// We pre-allocate a CancellationTokenSource and a StrongBox<Exception?> here
724+
// so the same instances are shared with both AsyncExpression, which threads them into AsyncHelpers.DriveAsync
725+
// and PythonCoroutine, which uses them to implement coro.throw(exc) on a running coroutine:
726+
// write the exception to the box, cancel the CTS, and DriveAsync surfaces that exception in place of OperationCanceledException.
727+
if (IsAsync) {
728+
var cts = MSAst.Expression.Variable(typeof(CancellationTokenSource), "$cts");
729+
var excBox = MSAst.Expression.Variable(typeof(StrongBox<Exception>), "$cancelExc");
730+
var ctToken = MSAst.Expression.Property(cts, nameof(CancellationTokenSource.Token));
731+
if (IsGenerator) {
732+
// Async generator: the body has both `await` and `yield`. Lower it to an
733+
// IAsyncEnumerable<object?> via AsyncEnumerableExpression, sharing the generator label so
734+
// the body's yields and the rewritten awaits land in one generator, then wrap it in a
735+
// PythonAsyncGenerator. The send/throw slots are per-generator StrongBoxes captured by the
736+
// generator (the body's yields read them) AND handed to the wrapper, which writes them
737+
// before each resume — see AsyncSendSlot / AsyncThrowSlot.
738+
var sendSlot = AsyncSendSlot;
739+
var throwSlot = AsyncThrowSlot;
740+
body = MSAst.Expression.Block(
741+
[cts, excBox, sendSlot, throwSlot],
742+
MSAst.Expression.Assign(cts, MSAst.Expression.New(typeof(CancellationTokenSource))),
743+
MSAst.Expression.Assign(excBox, MSAst.Expression.New(typeof(StrongBox<Exception>))),
744+
MSAst.Expression.Assign(sendSlot, MSAst.Expression.New(typeof(StrongBox<object>))),
745+
MSAst.Expression.Assign(throwSlot, MSAst.Expression.New(typeof(StrongBox<Exception>))),
746+
Ast.Call(
747+
AstMethods.MakeAsyncGenerator,
748+
_functionParam,
749+
AstUtils.AsyncEnumerable(Name, body, GeneratorLabel, ctToken, excBox),
750+
sendSlot,
751+
throwSlot,
752+
cts));
753+
} else {
754+
// Plain async def: the body returns a PythonCoroutine wrapping a Task<object?>.
755+
// Lazy start: hand MakeAsyncCoroutine a thunk (Func<Task<object?>>) instead of an already-running Task,
756+
// so the body doesn't execute until the coroutine is first driven (send/AsTask).
757+
// This makes calling an async def side-effect-free (PEP 492) and lets the body's first await capture the driver's SynchronizationContext
758+
// rather than whatever context happened to be current at construction.
759+
body = MSAst.Expression.Block(
760+
[cts, excBox],
761+
MSAst.Expression.Assign(cts, MSAst.Expression.New(typeof(CancellationTokenSource))),
762+
MSAst.Expression.Assign(excBox, MSAst.Expression.New(typeof(StrongBox<Exception>))),
763+
Ast.Call(
764+
AstMethods.MakeAsyncCoroutine,
765+
_functionParam,
766+
MSAst.Expression.Lambda<Func<Task<object>>>(
767+
AstUtils.Async(Name, body, ctToken, excBox)),
768+
cts,
769+
excBox));
770+
}
771+
}
772+
#endif
773+
684774
MSAst.Expression bodyStmt = body;
685775
if (localContext != null) {
686776
var createLocal = CreateLocalContext(_parentContext);

src/core/IronPython/Compiler/Ast/ReturnStatement.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information.
44

5+
using System.Diagnostics;
6+
57
using MSAst = System.Linq.Expressions;
68

79
namespace IronPython.Compiler.Ast {
@@ -17,6 +19,16 @@ public ReturnStatement(Expression expression) {
1719

1820
public override MSAst.Expression Reduce() {
1921
if (Parent.IsGeneratorMethod) {
22+
#if FEATURE_NET_ASYNC
23+
// An async generator (`async def` with `yield`) lowers through the DLR generator via AsyncEnumerableExpression,
24+
// which doesn't understand IronPython's -2 "generator-return" marker.
25+
// `return` there is always bare (`return value` is a SyntaxError in async generators)
26+
// and simply ends the async iteration — map it to a YieldBreak without a value.
27+
if (Parent is FunctionDefinition { IsAsync: true }) {
28+
Debug.Assert(Expression == null, "async generators should not have a return value");
29+
return GlobalParent.AddDebugInfo(AstUtils.YieldBreak(GeneratorLabel), Span);
30+
}
31+
#endif
2032
// Reduce to a yield return with a marker of -2, this will be interpreted as a yield break with a return value
2133
return GlobalParent.AddDebugInfo(AstUtils.YieldReturn(GeneratorLabel, TransformOrConstantNull(Expression, typeof(object)), -2), Span);
2234
}

src/core/IronPython/Compiler/Ast/YieldExpression.cs

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
using MSAst = System.Linq.Expressions;
88

99
using System;
10+
using System.Runtime.CompilerServices;
11+
using System.Runtime.ExceptionServices;
1012
using System.Diagnostics;
1113
using Microsoft.Scripting;
1214
using Microsoft.Scripting.Runtime;
@@ -19,6 +21,13 @@ namespace IronPython.Compiler.Ast {
1921
// x = yield z
2022
// The return value (x) is provided by calling Generator.Send()
2123
public class YieldExpression : Expression {
24+
#if FEATURE_NET_ASYNC
25+
private static readonly System.Reflection.MethodInfo s_captureMethod
26+
= typeof(ExceptionDispatchInfo).GetMethod(nameof(ExceptionDispatchInfo.Capture))!;
27+
private static readonly System.Reflection.MethodInfo s_throwMethod
28+
= typeof(ExceptionDispatchInfo).GetMethod(nameof(ExceptionDispatchInfo.Throw), Type.EmptyTypes)!;
29+
#endif
30+
2231
public YieldExpression(Expression? expression) {
2332
Expression = expression;
2433
}
@@ -43,16 +52,40 @@ internal static MSAst.Expression CreateCheckThrowExpression(SourceSpan span) {
4352
}
4453

4554
public override MSAst.Expression Reduce() {
55+
MSAst.Expression yieldValue = Expression == null ? AstUtils.Constant(null) : AstUtils.Convert(Expression, typeof(object));
56+
57+
#if FEATURE_NET_ASYNC
58+
// An async generator (`async def` with `yield`) is lowered via AsyncEnumerableExpression
59+
// and has no backing PythonGenerator, so there is no `$generator` to call CheckThrowable() on.
60+
// Instead the resume reads two per-generator cells that PythonAsyncGenerator writes before advancing:
61+
// AsyncThrowSlot — if set (athrow/aclose), rethrow it here (preserving stack);
62+
// cleared first so a body that catches it and yields again doesn't re-throw on the next resume.
63+
// AsyncSendSlot — the value of the yield expression: the asend(v) value, or None.
64+
if (Parent is FunctionDefinition { IsAsync: true } fd) {
65+
MSAst.ParameterExpression sendSlot = fd.AsyncSendSlot;
66+
MSAst.ParameterExpression throwSlot = fd.AsyncThrowSlot;
67+
MSAst.ParameterExpression pending = Ast.Variable(typeof(Exception), "$athrow");
68+
return Ast.Block(
69+
typeof(object),
70+
[pending],
71+
AstUtils.YieldReturn(GeneratorLabel, yieldValue),
72+
Ast.Assign(pending, Ast.Field(throwSlot, nameof(StrongBox<Exception>.Value))),
73+
Ast.Assign(Ast.Field(throwSlot, nameof(StrongBox<Exception>.Value)), Ast.Constant(null, typeof(Exception))),
74+
Ast.IfThen(
75+
Ast.ReferenceNotEqual(pending, Ast.Constant(null, typeof(Exception))),
76+
Ast.Call(Ast.Call(s_captureMethod, pending), s_throwMethod)),
77+
Ast.Field(sendSlot, nameof(StrongBox<object>.Value))
78+
);
79+
}
80+
#endif
81+
4682
// (yield z) becomes:
4783
// .comma (1) {
4884
// .void ( .yield_statement (_expression) ),
49-
// $gen.CheckThrowable() // <-- has return result from send
85+
// $gen.CheckThrowable() // <-- has return result from send
5086
// }
5187
return Ast.Block(
52-
AstUtils.YieldReturn(
53-
GeneratorLabel,
54-
Expression == null ? AstUtils.Constant(null) : AstUtils.Convert(Expression, typeof(object))
55-
),
88+
AstUtils.YieldReturn(GeneratorLabel, yieldValue),
5689
CreateCheckThrowExpression(Span) // emits ($gen.CheckThrowable())
5790
);
5891
}

src/core/IronPython/Compiler/Parser.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1999,10 +1999,16 @@ private Expression ParseAtomExpr() {
19991999
if (current is null || !current.IsAsync) {
20002000
ReportSyntaxError("'await' outside async function");
20012001
}
2002+
#if !FEATURE_NET_ASYNC
2003+
// Under the generator-based async path, `await` desugars to `yield from`,
2004+
// so the enclosing function must be marked a generator.
2005+
// Under FEATURE_NET_ASYNC, the body is lowered through AsyncExpression and is *not* a Python generator
2006+
// so this mark assignment is being skipped here.
20022007
if (current is not null) {
20032008
current.IsGenerator = true;
20042009
current.GeneratorStop = GeneratorStop;
20052010
}
2011+
#endif
20062012
}
20072013

20082014
Expression ret = ParseAtom();

0 commit comments

Comments
 (0)