Skip to content

Commit 96512f5

Browse files
committed
perf: pool async-enumerable workers
Bounded async streams now use P persistent channel workers and capped ordered-result queues, keeping source read-ahead and coordination proportional to concurrency. Refs #337
1 parent 1f41055 commit 96512f5

5 files changed

Lines changed: 349 additions & 200 deletions

File tree

EnumerableAsyncProcessor.UnitTests/AsyncEnumerableProcessorTests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,71 @@ public async Task SelectAsync_ProcessInParallel_ReturnsAllTransformedItems()
104104
await Assert.That(results.OrderBy(x => x)).IsEquivalentTo(Enumerable.Range(1, 10).Select(x => x * 2));
105105
}
106106

107+
[Test]
108+
public async Task SelectAsync_BoundedParallelism_PreservesInputOrder()
109+
{
110+
var results = await GenerateAsyncEnumerable(20)
111+
.SelectAsync(async item =>
112+
{
113+
await Task.Delay((21 - item) * 2);
114+
return item;
115+
})
116+
.ProcessInParallel(4)
117+
.ExecuteAsync()
118+
.ToListAsync();
119+
120+
await Assert.That(results.SequenceEqual(Enumerable.Range(1, 20))).IsTrue();
121+
}
122+
123+
[Test, Timeout(10_000)]
124+
public async Task BoundedParallelism_AppliesBackpressureToSource(CancellationToken cancellationToken)
125+
{
126+
const int maxConcurrency = 2;
127+
128+
var producedCount = 0;
129+
var startedCount = 0;
130+
var workersStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
131+
var releaseWorkers = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
132+
133+
async IAsyncEnumerable<int> Source([EnumeratorCancellation] CancellationToken token = default)
134+
{
135+
for (var i = 0; i < 100; i++)
136+
{
137+
token.ThrowIfCancellationRequested();
138+
Interlocked.Increment(ref producedCount);
139+
yield return i;
140+
await Task.Yield();
141+
}
142+
}
143+
144+
var processingTask = Source(cancellationToken)
145+
.ForEachAsync(async _ =>
146+
{
147+
if (Interlocked.Increment(ref startedCount) == maxConcurrency)
148+
{
149+
workersStarted.TrySetResult();
150+
}
151+
152+
await releaseWorkers.Task;
153+
}, cancellationToken)
154+
.ProcessInParallel(maxConcurrency)
155+
.ExecuteAsync();
156+
157+
try
158+
{
159+
await workersStarted.Task.WaitAsync(TimeSpan.FromSeconds(3), cancellationToken);
160+
await Task.Delay(100, cancellationToken);
161+
162+
await Assert.That(producedCount).IsLessThanOrEqualTo((maxConcurrency * 2) + 1);
163+
}
164+
finally
165+
{
166+
releaseWorkers.TrySetResult();
167+
}
168+
169+
await processingTask;
170+
}
171+
107172
[Test]
108173
public async Task ForEachAsync_ProcessInParallel_WithHighConcurrency_HandlesCorrectly()
109174
{
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
using System.Collections.Concurrent;
2+
using System.Runtime.CompilerServices;
3+
using System.Runtime.ExceptionServices;
4+
using System.Threading.Channels;
5+
6+
namespace EnumerableAsyncProcessor;
7+
8+
/// <summary>
9+
/// Processes asynchronous sources with a bounded channel and a fixed set of workers.
10+
/// Source read-ahead and queued results stay proportional to worker count.
11+
/// </summary>
12+
internal static class AsyncEnumerableWorkerPool
13+
{
14+
internal static async Task ProcessAsync<TInput>(
15+
IAsyncEnumerable<TInput> items,
16+
Func<TInput, Task> taskSelector,
17+
int workerCount,
18+
CancellationToken cancellationToken)
19+
{
20+
using var pipelineCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
21+
var pipelineToken = pipelineCancellation.Token;
22+
var channel = CreateChannel<TInput>(workerCount);
23+
var exceptions = new ConcurrentQueue<Exception>();
24+
var wasCanceled = 0;
25+
var workers = StartWorkers(channel.Reader, taskSelector, workerCount, exceptions, () => Interlocked.Exchange(ref wasCanceled, 1), pipelineToken);
26+
27+
try
28+
{
29+
try
30+
{
31+
await foreach (var item in items.WithCancellation(pipelineToken).ConfigureAwait(false))
32+
{
33+
await channel.Writer.WriteAsync(item, pipelineToken).ConfigureAwait(false);
34+
}
35+
}
36+
catch (OperationCanceledException)
37+
{
38+
Interlocked.Exchange(ref wasCanceled, 1);
39+
}
40+
catch (Exception exception)
41+
{
42+
exceptions.Enqueue(exception);
43+
}
44+
finally
45+
{
46+
channel.Writer.TryComplete();
47+
}
48+
49+
await Task.WhenAll(workers).ConfigureAwait(false);
50+
51+
ThrowIfFailed(exceptions, wasCanceled, cancellationToken);
52+
}
53+
finally
54+
{
55+
pipelineCancellation.Cancel();
56+
channel.Writer.TryComplete();
57+
}
58+
}
59+
60+
internal static async IAsyncEnumerable<TOutput> ProcessResultsAsync<TInput, TOutput>(
61+
IAsyncEnumerable<TInput> items,
62+
Func<TInput, Task<TOutput>> taskSelector,
63+
int workerCount,
64+
[EnumeratorCancellation] CancellationToken cancellationToken)
65+
{
66+
using var pipelineCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
67+
var pipelineToken = pipelineCancellation.Token;
68+
var channel = CreateChannel<ResultWorkItem<TInput, TOutput>>(workerCount);
69+
var workers = StartResultWorkers(channel.Reader, taskSelector, workerCount, pipelineToken);
70+
var pendingResults = new Queue<Task<TOutput>>(workerCount);
71+
72+
try
73+
{
74+
await foreach (var item in items.WithCancellation(pipelineToken).ConfigureAwait(false))
75+
{
76+
var completionSource = new TaskCompletionSource<TOutput>(TaskCreationOptions.RunContinuationsAsynchronously);
77+
await channel.Writer.WriteAsync(new ResultWorkItem<TInput, TOutput>(item, completionSource), pipelineToken).ConfigureAwait(false);
78+
pendingResults.Enqueue(completionSource.Task);
79+
80+
if (pendingResults.Count == workerCount)
81+
{
82+
yield return await pendingResults.Dequeue().ConfigureAwait(false);
83+
}
84+
}
85+
86+
channel.Writer.TryComplete();
87+
88+
while (pendingResults.TryDequeue(out var resultTask))
89+
{
90+
yield return await resultTask.ConfigureAwait(false);
91+
}
92+
93+
await Task.WhenAll(workers).ConfigureAwait(false);
94+
}
95+
finally
96+
{
97+
pipelineCancellation.Cancel();
98+
channel.Writer.TryComplete();
99+
100+
try
101+
{
102+
await Task.WhenAll(workers).ConfigureAwait(false);
103+
}
104+
catch (OperationCanceledException) when (pipelineToken.IsCancellationRequested)
105+
{
106+
// Expected when enumeration is canceled or the consumer stops early.
107+
}
108+
}
109+
}
110+
111+
private static Channel<T> CreateChannel<T>(int capacity)
112+
{
113+
return Channel.CreateBounded<T>(new BoundedChannelOptions(capacity)
114+
{
115+
SingleWriter = true,
116+
SingleReader = false,
117+
FullMode = BoundedChannelFullMode.Wait,
118+
AllowSynchronousContinuations = false
119+
});
120+
}
121+
122+
private static Task[] StartWorkers<TInput>(
123+
ChannelReader<TInput> reader,
124+
Func<TInput, Task> taskSelector,
125+
int workerCount,
126+
ConcurrentQueue<Exception> exceptions,
127+
Action recordCancellation,
128+
CancellationToken cancellationToken)
129+
{
130+
var workers = new Task[workerCount];
131+
132+
for (var i = 0; i < workerCount; i++)
133+
{
134+
workers[i] = Task.Run(async () =>
135+
{
136+
await foreach (var item in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
137+
{
138+
Task? task = null;
139+
140+
try
141+
{
142+
task = taskSelector(item);
143+
await task.ConfigureAwait(false);
144+
}
145+
catch (OperationCanceledException)
146+
{
147+
recordCancellation();
148+
}
149+
catch (Exception exception)
150+
{
151+
EnqueueExceptions(exceptions, task, exception);
152+
}
153+
}
154+
}, cancellationToken);
155+
}
156+
157+
return workers;
158+
}
159+
160+
private static Task[] StartResultWorkers<TInput, TOutput>(
161+
ChannelReader<ResultWorkItem<TInput, TOutput>> reader,
162+
Func<TInput, Task<TOutput>> taskSelector,
163+
int workerCount,
164+
CancellationToken cancellationToken)
165+
{
166+
var workers = new Task[workerCount];
167+
168+
for (var i = 0; i < workerCount; i++)
169+
{
170+
workers[i] = Task.Run(async () =>
171+
{
172+
await foreach (var workItem in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
173+
{
174+
Task<TOutput>? task = null;
175+
176+
try
177+
{
178+
task = taskSelector(workItem.Input);
179+
workItem.CompletionSource.TrySetResult(await task.ConfigureAwait(false));
180+
}
181+
catch (Exception exception)
182+
{
183+
workItem.CompletionSource.TrySetFromFault(task, exception, cancellationToken);
184+
}
185+
}
186+
}, cancellationToken);
187+
}
188+
189+
return workers;
190+
}
191+
192+
private static void EnqueueExceptions(
193+
ConcurrentQueue<Exception> exceptions,
194+
Task? task,
195+
Exception exception)
196+
{
197+
if (task is { IsFaulted: true })
198+
{
199+
foreach (var innerException in task.Exception!.InnerExceptions)
200+
{
201+
exceptions.Enqueue(innerException);
202+
}
203+
204+
return;
205+
}
206+
207+
exceptions.Enqueue(exception);
208+
}
209+
210+
private static void ThrowIfFailed(
211+
ConcurrentQueue<Exception> exceptions,
212+
int wasCanceled,
213+
CancellationToken cancellationToken)
214+
{
215+
if (exceptions.TryDequeue(out var firstException))
216+
{
217+
if (exceptions.IsEmpty)
218+
{
219+
ExceptionDispatchInfo.Capture(firstException).Throw();
220+
}
221+
222+
var allExceptions = new List<Exception> { firstException };
223+
allExceptions.AddRange(exceptions);
224+
throw new AggregateException(allExceptions);
225+
}
226+
227+
if (wasCanceled != 0)
228+
{
229+
throw new OperationCanceledException(cancellationToken);
230+
}
231+
}
232+
233+
private readonly record struct ResultWorkItem<TInput, TOutput>(
234+
TInput Input,
235+
TaskCompletionSource<TOutput> CompletionSource);
236+
}

0 commit comments

Comments
 (0)