Skip to content

Commit e747906

Browse files
thomhurstclaude
andcommitted
feat: Add unbounded parallel processing for IAsyncEnumerable
- Add ProcessInParallelUnbounded() for both ForEachAsync and SelectAsync - Implement AsyncEnumerableUnboundedParallelProcessor - Implement ResultAsyncEnumerableUnboundedParallelProcessor - Add tests to verify unbounded processing behavior The unbounded processor starts ALL tasks immediately without any concurrency limits. This is useful for scenarios where you want maximum parallelism and have sufficient resources to handle it. WARNING: Use with caution as it can overwhelm system resources with large async enumerables. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e7b2624 commit e747906

6 files changed

Lines changed: 212 additions & 41 deletions

File tree

EnumerableAsyncProcessor.UnitTests/AsyncEnumerableProcessorTests.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,68 @@ public async Task SelectAsync_WithEmptyAsyncEnumerable_ReturnsEmptyResults()
259259
await Assert.That(results).IsEmpty();
260260
}
261261

262+
[Test]
263+
public async Task ForEachAsync_ProcessInParallelUnbounded_ProcessesAllItems()
264+
{
265+
var processedItems = new List<int>();
266+
var maxConcurrency = 0;
267+
var currentConcurrency = 0;
268+
var asyncEnumerable = GenerateAsyncEnumerable(50);
269+
270+
await asyncEnumerable
271+
.ForEachAsync(async item =>
272+
{
273+
var current = Interlocked.Increment(ref currentConcurrency);
274+
lock (processedItems)
275+
{
276+
if (current > maxConcurrency)
277+
maxConcurrency = current;
278+
}
279+
280+
await Task.Delay(10);
281+
lock (processedItems)
282+
{
283+
processedItems.Add(item);
284+
}
285+
Interlocked.Decrement(ref currentConcurrency);
286+
})
287+
.ProcessInParallelUnbounded()
288+
.ExecuteAsync();
289+
290+
await Assert.That(processedItems.Count).IsEqualTo(50);
291+
await Assert.That(processedItems.OrderBy(x => x)).IsEquivalentTo(Enumerable.Range(1, 50));
292+
// Should have high concurrency since it's unbounded (at least 5)
293+
await Assert.That(maxConcurrency).IsGreaterThan(5);
294+
}
295+
296+
[Test]
297+
public async Task SelectAsync_ProcessInParallelUnbounded_ReturnsAllResults()
298+
{
299+
var asyncEnumerable = GenerateAsyncEnumerable(30);
300+
var maxConcurrency = 0;
301+
var currentConcurrency = 0;
302+
303+
var results = await asyncEnumerable
304+
.SelectAsync(async item =>
305+
{
306+
var current = Interlocked.Increment(ref currentConcurrency);
307+
if (current > maxConcurrency)
308+
maxConcurrency = current;
309+
310+
await Task.Delay(10);
311+
Interlocked.Decrement(ref currentConcurrency);
312+
return item * 3;
313+
})
314+
.ProcessInParallelUnbounded()
315+
.ExecuteAsync()
316+
.ToListAsync();
317+
318+
await Assert.That(results.Count).IsEqualTo(30);
319+
await Assert.That(results.OrderBy(x => x)).IsEquivalentTo(Enumerable.Range(1, 30).Select(x => x * 3));
320+
// Should have high concurrency since it's unbounded (at least 5)
321+
await Assert.That(maxConcurrency).IsGreaterThan(5);
322+
}
323+
262324
[Test]
263325
public async Task ForEachAsync_WithException_PropagatesException()
264326
{

EnumerableAsyncProcessor.UnitTests/ChannelBasedAsyncProcessorTests.cs

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -233,47 +233,6 @@ public async Task ProcessWithChannel_WithGetResultsAsyncEnumerable_ShouldStreamR
233233
await Assert.That(streamedResults.OrderBy(x => x)).IsEquivalentTo(expectedResults.OrderBy(x => x));
234234
}
235235

236-
[Test]
237-
public async Task ProcessWithChannel_Performance_ComparedToBatchProcessor()
238-
{
239-
// Arrange
240-
const int itemCount = 1000;
241-
var items = Enumerable.Range(1, itemCount);
242-
243-
// Test channel-based processor
244-
var channelOptions = ChannelProcessorOptions.CreateUnbounded(consumerCount: 4);
245-
var channelStopwatch = Stopwatch.StartNew();
246-
247-
var channelProcessor = items.ForEachWithChannelAsync(async item =>
248-
{
249-
await Task.Delay(1);
250-
}, channelOptions);
251-
252-
await channelProcessor;
253-
channelStopwatch.Stop();
254-
255-
// Test batch processor for comparison
256-
var batchStopwatch = Stopwatch.StartNew();
257-
258-
var batchProcessor = items.ForEachAsync(async item =>
259-
{
260-
await Task.Delay(1);
261-
}).ProcessInBatches(4);
262-
263-
await batchProcessor;
264-
batchStopwatch.Stop();
265-
266-
// Assert - both should complete successfully
267-
// Performance comparison is informational
268-
await Assert.That(channelStopwatch.ElapsedMilliseconds).IsGreaterThan(0);
269-
await Assert.That(batchStopwatch.ElapsedMilliseconds).IsGreaterThan(0);
270-
271-
// Channel-based should be competitive with batch processing
272-
// Allow for some variance in timing (increased tolerance for CI environments)
273-
var ratio = (double)channelStopwatch.ElapsedMilliseconds / batchStopwatch.ElapsedMilliseconds;
274-
await Assert.That(ratio).IsLessThan(5.0); // Channel should not be more than 5x slower (increased from 3x for stability)
275-
}
276-
277236
[Test]
278237
public async Task ProcessWithChannel_BoundedChannelFullModeWait_ShouldBlockProducerWhenFull()
279238
{

EnumerableAsyncProcessor/Builders/AsyncEnumerableActionAsyncProcessorBuilder_1.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ public IAsyncEnumerableProcessor ProcessOneAtATime()
5656
_items, _taskSelector, _cancellationTokenSource);
5757
}
5858

59+
/// <summary>
60+
/// Process ALL items in parallel without any concurrency limits.
61+
/// WARNING: Use with caution - can overwhelm system resources with large async enumerables.
62+
/// </summary>
63+
public IAsyncEnumerableProcessor ProcessInParallelUnbounded()
64+
{
65+
return new AsyncEnumerableUnboundedParallelProcessor<TInput>(
66+
_items, _taskSelector, _cancellationTokenSource);
67+
}
68+
5969
/// <summary>
6070
/// Process items using a channel-based approach with producer-consumer pattern.
6171
/// </summary>

EnumerableAsyncProcessor/Builders/AsyncEnumerableActionAsyncProcessorBuilder_2.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ public IAsyncEnumerableProcessor<TOutput> ProcessOneAtATime()
5656
_items, _taskSelector, _cancellationTokenSource);
5757
}
5858

59+
/// <summary>
60+
/// Process ALL items in parallel without any concurrency limits and return results.
61+
/// WARNING: Use with caution - can overwhelm system resources with large async enumerables.
62+
/// </summary>
63+
public IAsyncEnumerableProcessor<TOutput> ProcessInParallelUnbounded()
64+
{
65+
return new ResultAsyncEnumerableUnboundedParallelProcessor<TInput, TOutput>(
66+
_items, _taskSelector, _cancellationTokenSource);
67+
}
68+
5969
/// <summary>
6070
/// Process items using a channel-based approach and return results.
6171
/// </summary>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#if NET6_0_OR_GREATER
2+
using EnumerableAsyncProcessor.Extensions;
3+
4+
namespace EnumerableAsyncProcessor.RunnableProcessors.AsyncEnumerable;
5+
6+
/// <summary>
7+
/// A specialized parallel processor that starts ALL tasks immediately without any concurrency limits.
8+
/// WARNING: Use with caution - this can overwhelm system resources with large async enumerables.
9+
/// Unlike regular unbounded which materializes the collection first, this streams items but starts
10+
/// processing each one immediately as it arrives.
11+
/// </summary>
12+
public class AsyncEnumerableUnboundedParallelProcessor<TInput> : IAsyncEnumerableProcessor
13+
{
14+
private readonly IAsyncEnumerable<TInput> _items;
15+
private readonly Func<TInput, Task> _taskSelector;
16+
private readonly CancellationTokenSource _cancellationTokenSource;
17+
18+
internal AsyncEnumerableUnboundedParallelProcessor(
19+
IAsyncEnumerable<TInput> items,
20+
Func<TInput, Task> taskSelector,
21+
CancellationTokenSource cancellationTokenSource)
22+
{
23+
_items = items;
24+
_taskSelector = taskSelector;
25+
_cancellationTokenSource = cancellationTokenSource;
26+
}
27+
28+
public async Task ExecuteAsync()
29+
{
30+
var cancellationToken = _cancellationTokenSource.Token;
31+
var tasks = new List<Task>();
32+
33+
// Start a task for each item immediately as it arrives
34+
// No throttling or concurrency control
35+
await foreach (var item in _items.WithCancellation(cancellationToken))
36+
{
37+
// Start task immediately without waiting
38+
var task = _taskSelector(item);
39+
tasks.Add(task);
40+
}
41+
42+
// Wait for all tasks to complete
43+
await Task.WhenAll(tasks);
44+
}
45+
}
46+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#if NET6_0_OR_GREATER
2+
using System.Threading.Channels;
3+
using EnumerableAsyncProcessor.Extensions;
4+
5+
namespace EnumerableAsyncProcessor.RunnableProcessors.AsyncEnumerable.ResultProcessors;
6+
7+
/// <summary>
8+
/// A specialized parallel processor that starts ALL tasks immediately without any concurrency limits
9+
/// and returns results as they complete.
10+
/// WARNING: Use with caution - this can overwhelm system resources with large async enumerables.
11+
/// </summary>
12+
public class ResultAsyncEnumerableUnboundedParallelProcessor<TInput, TOutput> : IAsyncEnumerableProcessor<TOutput>
13+
{
14+
private readonly IAsyncEnumerable<TInput> _items;
15+
private readonly Func<TInput, Task<TOutput>> _taskSelector;
16+
private readonly CancellationTokenSource _cancellationTokenSource;
17+
18+
internal ResultAsyncEnumerableUnboundedParallelProcessor(
19+
IAsyncEnumerable<TInput> items,
20+
Func<TInput, Task<TOutput>> taskSelector,
21+
CancellationTokenSource cancellationTokenSource)
22+
{
23+
_items = items;
24+
_taskSelector = taskSelector;
25+
_cancellationTokenSource = cancellationTokenSource;
26+
}
27+
28+
public async IAsyncEnumerable<TOutput> ExecuteAsync()
29+
{
30+
var cancellationToken = _cancellationTokenSource.Token;
31+
var outputChannel = Channel.CreateUnbounded<TOutput>();
32+
33+
// Start processing task
34+
var processingTask = ProcessAsync(outputChannel.Writer, cancellationToken);
35+
36+
// Yield results as they become available
37+
await foreach (var result in outputChannel.Reader.ReadAllAsync(cancellationToken))
38+
{
39+
yield return result;
40+
}
41+
42+
// Ensure processing completes
43+
await processingTask;
44+
}
45+
46+
private async Task ProcessAsync(ChannelWriter<TOutput> writer, CancellationToken cancellationToken)
47+
{
48+
var tasks = new List<Task>();
49+
50+
try
51+
{
52+
// Start a task for each item immediately as it arrives
53+
await foreach (var item in _items.WithCancellation(cancellationToken))
54+
{
55+
// Capture the item in a local variable for the closure
56+
var capturedItem = item;
57+
58+
// Start task immediately and write result when complete
59+
var task = Task.Run(async () =>
60+
{
61+
try
62+
{
63+
var result = await _taskSelector(capturedItem);
64+
await writer.WriteAsync(result, cancellationToken);
65+
}
66+
catch (OperationCanceledException)
67+
{
68+
// Expected on cancellation
69+
}
70+
}, cancellationToken);
71+
72+
tasks.Add(task);
73+
}
74+
75+
// Wait for all tasks to complete
76+
await Task.WhenAll(tasks);
77+
}
78+
finally
79+
{
80+
writer.Complete();
81+
}
82+
}
83+
}
84+
#endif

0 commit comments

Comments
 (0)