Skip to content

Commit 8542bed

Browse files
author
Julien Couvreur
committed
Emit async-iterators with runtime-async when possible
1 parent ec4f06e commit 8542bed

File tree

37 files changed

+6409
-540
lines changed

37 files changed

+6409
-540
lines changed
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
# Runtime Async-Streams Design
2+
3+
## Overview
4+
5+
Async methods that return `IAsyncEnumerable<T>` or `IAsyncEnumerator<T>` are transformed by the compiler into state machines.
6+
States are created for each `await` and `yield`.
7+
Runtime-async support was added in .NET 10 as a preview feature and reduces the overhead of async methods by letting the runtime handling `await` suspensions.
8+
The following design describes how the compiler generates code for async-stream methods when targeting a runtime that supports runtime async.
9+
In short, the compiler generates a state machine similar to async-streams, that implements `IAsyncEnumerable<T>` and `IAsyncEnumerator<T>`.
10+
The states corresponding to `yield` suspensions match those of existing async-streams.
11+
No state is created for `await` expressions, which are lowered to a runtime call instead.
12+
13+
See `docs/features/async-streams.md` and `Runtime Async Design.md` for more background information.
14+
15+
## Structure
16+
17+
For an async-stream method, the compiler generates the following members:
18+
- kickoff method
19+
- state machine class
20+
- fields
21+
- constructor
22+
- `GetAsyncEnumerator` method
23+
- `Current` property
24+
- `DisposeAsync` method
25+
- `MoveNextAsync` method
26+
27+
Considering a simple async-iterator method:
28+
```csharp
29+
class C
30+
{
31+
public static async System.Collections.Generic.IAsyncEnumerable<int> M()
32+
{
33+
Write("1");
34+
await System.Threading.Tasks.Task.Yield();
35+
Write("2");
36+
yield return 3;
37+
Write("4");
38+
}
39+
}
40+
```
41+
42+
The following pseudo-code illustrates the intermediate implementation the compiler generates.
43+
Note that async methods `MoveNextAsync` and `DisposeAsync` will be further lowered following runtime-async design.
44+
```csharp
45+
class C
46+
{
47+
public static IAsyncEnumerable<int> M()
48+
{
49+
return new M_d__0(-2);
50+
}
51+
52+
[CompilerGenerated]
53+
private sealed class M_d__0 : IAsyncEnumerable<int>, IAsyncEnumerator<int>, IAsyncDisposable
54+
{
55+
public int 1__state;
56+
private int 2__current;
57+
private bool w__disposeMode;
58+
private int l__initialThreadId;
59+
60+
[DebuggerHidden]
61+
public M_d__0(int state)
62+
{
63+
1__state = state;
64+
l__initialThreadId = Environment.CurrentManagedThreadId;
65+
}
66+
67+
[DebuggerHidden]
68+
IAsyncEnumerator<int> IAsyncEnumerable<int>.GetAsyncEnumerator(CancellationToken cancellationToken = default)
69+
{
70+
M_d__0 result;
71+
72+
if (1__state == -2 && l__initialThreadId == Environment.CurrentManagedThreadId)
73+
{
74+
1__state = -3;
75+
w__disposeMode = false;
76+
result = this;
77+
}
78+
else
79+
{
80+
result = new <M>d__0(-3);
81+
}
82+
83+
return result;
84+
}
85+
86+
ValueTask<bool> IAsyncEnumerator<int>.MoveNextAsync()
87+
{
88+
int temp1 = 1__state;
89+
try
90+
{
91+
switch (temp1)
92+
{
93+
case -4:
94+
goto <stateMachine-7>;
95+
}
96+
97+
if (w__disposeMode)
98+
goto <topLevelDisposeLabel-5>;
99+
100+
1__state = temp1 = -1;
101+
Write("1");
102+
runtime-await Task.Yield(); // `runtime-await` will be lowered to a call to runtime helper method
103+
Write("2");
104+
105+
{
106+
// suspension for `yield return 3;`
107+
2__current = 3;
108+
1__state = temp1 = -4;
109+
return true;
110+
111+
<stateMachine-7>:;
112+
1__state = temp1 = -1;
113+
114+
if (w__disposeMode)
115+
goto <topLevelDisposeLabel-5>;
116+
}
117+
118+
Write("4");
119+
120+
w__disposeMode = true;
121+
goto <topLevelDisposeLabel-5>;
122+
}
123+
catch (Exception)
124+
{
125+
1__state = -2;
126+
2__current = default;
127+
throw;
128+
}
129+
130+
<topLevelDisposeLabel-5>: ;
131+
1__state = -2;
132+
2__current = default;
133+
return false;
134+
}
135+
136+
[DebuggerHidden]
137+
int IAsyncEnumerator<int>.Current
138+
{
139+
get => 2__current;
140+
}
141+
142+
[DebuggerHidden]
143+
async ValueTask IAsyncDisposable.DisposeAsync()
144+
{
145+
if (<>1__state >= -1)
146+
throw new NotSupportedException();
147+
148+
if (<>1__state == -2)
149+
return;
150+
151+
w__disposeMode = true;
152+
runtime-await MoveNextAsync(); // `runtime-await` will be lowered to a runtime call
153+
}
154+
}
155+
}
156+
```
157+
158+
## Lowering details
159+
160+
The overall lowering strategy is similar to existing async-streams lowering,
161+
except for simplifications since `await` expressions are left to the runtime to handle.
162+
PROTOTYPE overall lifecycle diagram
163+
164+
### Kickoff method, fields and constructor
165+
166+
The state machine class contains fields for:
167+
- the state (an `int`),
168+
- the current value (of the yield type of the async iterator),
169+
- the dispose mode (a `bool`),
170+
- the initial thread ID (an `int`),
171+
- the combined cancellation token (a `CancellationTokenSource`) when the `[EnumeratorCancellation]` attribute is applied,
172+
- hoisted variables (parameters and locals) as needed.
173+
- parameter proxies (serve to initialize hoisted parameters when producing an enumerator when the method is declared as enumerable)
174+
175+
The constructor of the state machine class has the signature `.ctor(int state)`.
176+
Its body is:
177+
```
178+
{
179+
this.state = state;
180+
this.initialThreadId = {managedThreadId};
181+
this.instanceId = LocalStoreTracker.GetNewStateMachineInstanceId(); // when local state tracking is enabled
182+
}
183+
```
184+
185+
The kickoff method has the signature of the user's method. It simply creates and returns a new instance of the state machine class, capturing the necessary context.
186+
187+
### GetAsyncEnumerator
188+
189+
The signature of this method is `IAsyncEnumerator<Y> IAsyncEnumerable<Y>.GetAsyncEnumerator(CancellationToken cancellationToken = default)`
190+
where `Y` is the yield type of the async iterator.
191+
192+
The `GetAsyncEnumerator` method either returns the current instance if it can be reused,
193+
or creates a new instance of the state machine class.
194+
195+
Assuming that the unspeakble state machine class is named `Unspeakable`, `GetAsyncEnumerator` is emitted as:
196+
```
197+
{
198+
Unspeakable result;
199+
if (__state == FinishedState && __initialThreadId == Environment.CurrentManagedThreadId)
200+
{
201+
__state = InitialState;
202+
result = this;
203+
__disposeMode = false;
204+
}
205+
else
206+
{
207+
result = new Unspeakable(InitialState);
208+
}
209+
return result;
210+
}
211+
```
212+
213+
### Current property
214+
215+
The signature of the property is `Y IAsyncEnumerator<Y>.Current { get; }`
216+
where `Y` is the yield type of the async iterator.
217+
The getter simply returns the field holding the current value.
218+
219+
### DisposeAsync
220+
221+
The signature of this method is `ValueTask IAsyncDisposable.DisposeAsync()`.
222+
This method is emitted with the `async` runtime modifier, so it need only `return;`.
223+
224+
Its body is:
225+
```
226+
{
227+
if (__state >= NotStartedStateMachine)
228+
{
229+
// running
230+
throw new NotSupportedException();
231+
}
232+
233+
if (__state == FinishedState)
234+
{
235+
// already disposed
236+
return;
237+
}
238+
239+
__disposeMode = true;
240+
runtime-await MoveNextAsync(); // `runtime-await` will be lowered to a call to runtime helper method
241+
return;
242+
}
243+
```
244+
245+
PROTOTYPE different ways to reach disposal
246+
247+
### MoveNextAsync
248+
249+
The signature of this method is `ValueTask<bool> IAsyncEnumerator<Y>.MoveNextAsync()`
250+
where `Y` is the yield type of the async iterator.
251+
This method is emitted with the `async` runtime modifier, so it need only `return` with a `bool`.
252+
253+
A number of techniques from existing async-streams lowering are reused here (PROTOTYPE provide more details on these):
254+
- replacement of generic type parameters
255+
- dispatching based on state
256+
- extraction of exception handlers
257+
- dispatching out of try/catch
258+
- replacing cancellation token parameter with one from combined tokens when `[EnumeratorCancellation]` is used
259+
260+
PROTOTYPE do we still need spilling for `await` expressions?
261+
262+
#### Lowering of `yield return`
263+
264+
`yield return` is disallowed in finally, in try with catch and in catch.
265+
`yield return` is lowered as a suspension of the state machine (essentially `__current = ...; return true;` with a way of resuming execution after the return):
266+
267+
```
268+
// a `yield return 42;` in user code becomes:
269+
__state = stateForThisYieldReturn;
270+
__current = 42;
271+
return true; // in an ValueTask<bool>-returning runtime-async method, we need only return a boolean
272+
273+
labelForThisYieldReturn:
274+
__state = RunningState;
275+
if (__disposeMode) /* jump to enclosing finally or exit */
276+
```
277+
278+
#### Lowering of `yield break`
279+
280+
`yield break` is disallowed in finally.
281+
When a `yield break;` is reached, the relevant `finally` blocks should get executed immediately.
282+
283+
```
284+
// a `yield break;` in user code becomes:
285+
disposeMode = true;
286+
/* jump to enclosing finally or exit */
287+
```
288+
289+
Note that in this case, the caller will not get a result from `MoveNextAsync()`
290+
until we've reached the end of the method (**finished** state) and so `DisposeAsync()` will have no work left to do.
291+
292+
#### Lowering of `await`
293+
294+
`await` is disallowed in lock bodies, and in catch filters.
295+
`await` expressions are lowered to runtime calls (instead of being transformed into state machine logic for regular async-streams),
296+
following the runtime-async design.
297+
298+
#### Overall method structure
299+
300+
A catch-all `try` wraps the entire body of the method:
301+
302+
```csharp
303+
cachedState = __state;
304+
cachedThis = __capturedThis; // if needed
305+
306+
try
307+
{
308+
... dispatch based on cachedState ...
309+
310+
initialStateResumeLabel:
311+
if (__disposeMode) { goto topLevelDisposeLabel; }
312+
313+
__state = RunningState;
314+
315+
... method body with lowered `await`, `yield return` and `yield break` ...
316+
317+
__disposeMode = true;
318+
goto topLevelDisposeLabel;
319+
}
320+
catch (Exception)
321+
{
322+
__state = FinishedState;
323+
... clear locals ...
324+
if (__combinedTokens != null) { __combinedTokens.Dispose(); __combinedTokens = null; }
325+
__current = default;
326+
throw;
327+
}
328+
329+
topLevelDisposeLabel:
330+
__state = FinishedState;
331+
... clear locals ...
332+
if (__combinedTokens != null) { __combinedTokens.Dispose(); __combinedTokens = null; }
333+
__current = default;
334+
return false;
335+
```
336+
337+
## Open issues
338+
339+
Question: AsyncIteratorStateMachineAttribute, or IteratorStateMachineAttribute, or other attribute on kickoff method?

0 commit comments

Comments
 (0)