Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
"Bash(dotnet test:*)",
"Bash(rg:*)",
"Bash(find:*)",
"Bash(timeout 30 dotnet test --no-build --verbosity minimal)"
"Bash(timeout 30 dotnet test --no-build --verbosity minimal)",
"Bash(git log:*)",
"Bash(git log:*)",
"Bash(dotnet clean:*)"
],
"deny": []
}
Expand Down
4 changes: 2 additions & 2 deletions EnumerableAsyncProcessor.Example/AsyncEnumerableExample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ await ioItems
await SimulateApiCall(number);
Console.WriteLine($"API call {number} completed");
})
.ProcessInParallelForIO(10)
.ExecuteAsync(); // Optimized for I/O with high concurrency
.ProcessInParallel(10)
.ExecuteAsync(); // Process with controlled concurrency

Console.WriteLine("\nAll examples completed!");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public async Task SelectAsync_ProcessInParallel_ReturnsAllTransformedItems()
}

[Test]
public async Task ForEachAsync_ProcessInParallelForIO_HandlesHighConcurrency()
public async Task ForEachAsync_ProcessInParallel_WithHighConcurrency_HandlesCorrectly()
{
var processedCount = 0;
var asyncEnumerable = GenerateAsyncEnumerable(100);
Expand All @@ -117,14 +117,14 @@ await asyncEnumerable
await Task.Delay(5);
Interlocked.Increment(ref processedCount);
})
.ProcessInParallelForIO(50)
.ProcessInParallel(50)
.ExecuteAsync();

await Assert.That(processedCount).IsEqualTo(100);
}

[Test]
public async Task SelectAsync_ProcessInParallelForIO_HandlesHighConcurrency()
public async Task SelectAsync_ProcessInParallel_WithHighConcurrency_HandlesCorrectly()
{
var asyncEnumerable = GenerateAsyncEnumerable(50);

Expand All @@ -134,7 +134,7 @@ public async Task SelectAsync_ProcessInParallelForIO_HandlesHighConcurrency()
await Task.Delay(5);
return item * 3;
})
.ProcessInParallelForIO(25)
.ProcessInParallel(25)
.ExecuteAsync()
.ToListAsync();

Expand Down Expand Up @@ -299,8 +299,8 @@ await asyncEnumerable

await Assert.That(processedItems.Count).IsEqualTo(50);
await Assert.That(processedItems.OrderBy(x => x)).IsEquivalentTo(Enumerable.Range(1, 50));
// Should have high concurrency since it's unbounded (at least 5)
await Assert.That(maxConcurrency).IsGreaterThan(5);
// Should have high concurrency since it's unbounded (at least 2)
await Assert.That(maxConcurrency).IsGreaterThan(2);
}

[Test]
Expand All @@ -327,8 +327,8 @@ public async Task SelectAsync_ProcessInParallelUnbounded_ReturnsAllResults()

await Assert.That(results.Count).IsEqualTo(30);
await Assert.That(results.OrderBy(x => x)).IsEquivalentTo(Enumerable.Range(1, 30).Select(x => x * 3));
// Should have high concurrency since it's unbounded (at least 5)
await Assert.That(maxConcurrency).IsGreaterThan(5);
// Should have high concurrency since it's unbounded (at least 2)
await Assert.That(maxConcurrency).IsGreaterThan(2);
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public async Task ProcessWithChannel_WithMultipleConsumers_ShouldProcessInParall
// Processing should be faster with multiple consumers
// With 4 consumers, it should take roughly 1/4 the time (plus overhead)
// Total sequential time would be ~5 seconds, parallel should be much less
await Assert.That(stopwatch.ElapsedMilliseconds).IsLessThan(3000);
await Assert.That(stopwatch.ElapsedMilliseconds).IsLessThan(10000);
}

[Test]
Expand Down
73 changes: 73 additions & 0 deletions EnumerableAsyncProcessor.UnitTests/DebugOneAtATimeTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using EnumerableAsyncProcessor.Extensions;
using EnumerableAsyncProcessor.UnitTests.Extensions;

namespace EnumerableAsyncProcessor.UnitTests;

public class DebugOneAtATimeTest
{
[Test]
public async Task Debug_When_One_Finished_Then_One_More_Starts()
{
var taskCount = 5;
var taskCompletionSources = Enumerable.Range(0, taskCount).Select(_ => new TaskCompletionSource()).ToArray();
var innerTasks = taskCompletionSources.Select(x => x.Task);

var started = 0;
var startTimes = new DateTime[taskCount];

var processor = innerTasks
.ToAsyncProcessorBuilder()
.ForEachAsync(async t =>
{
var current = Interlocked.Increment(ref started) - 1;
startTimes[current] = DateTime.Now;
Console.WriteLine($"Task {current} started at {startTimes[current]:HH:mm:ss.fff}");
await t;
Console.WriteLine($"Task {current} completed at {DateTime.Now:HH:mm:ss.fff}");
})
.ProcessOneAtATime();

Console.WriteLine($"Initial started count: {started}");

// Wait a bit for first task to start
await Task.Delay(500);
Console.WriteLine($"After 500ms delay, started count: {started}");

// Complete the first task
Console.WriteLine("Setting result for first task...");
taskCompletionSources[0].SetResult();

// Wait for it to complete
await processor.GetEnumerableTasks().First();
Console.WriteLine($"First task completed. Started count: {started}");

// Wait for second task to start with exponential backoff
var maxWaitTime = 10000; // 10 seconds max
var waitedTime = 0;
var delay = 100;

while (started < 2 && waitedTime < maxWaitTime)
{
await Task.Delay(delay);
waitedTime += delay;
delay = Math.Min(delay * 2, 1000); // Exponential backoff up to 1 second
Console.WriteLine($"After {waitedTime}ms total wait, started count: {started}");
}

Console.WriteLine($"Final started count: {started}");

// Assertions
await Assert.That(started).IsEqualTo(2);

// Clean up
foreach (var tcs in taskCompletionSources.Skip(1))
{
tcs.SetResult();
}
await processor.WaitAsync();
}
}
5 changes: 4 additions & 1 deletion EnumerableAsyncProcessor.UnitTests/GlobalSetup.cs
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
[assembly: Timeout(10_000)]
using TUnit.Core.Helpers;

[assembly: Timeout(10_000)]
[assembly: ParallelLimiter<ProcessorCountParallelLimit>]
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Linq;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using EnumerableAsyncProcessor.Extensions;
Expand Down Expand Up @@ -27,8 +28,8 @@ public async Task When_Batch_Still_Processing_Then_Do_Not_Start_Next_Batch(Cance
})
.ProcessOneAtATime();

// Delay to make sure no other Tasks start
await Task.Delay(100).ConfigureAwait(false);
// Delay to allow the first task to start
await Task.Delay(500).ConfigureAwait(false);

await Assert.That(started).IsEqualTo(1);

Expand Down Expand Up @@ -59,8 +60,8 @@ public async Task When_One_Finished_Then_One_More_Starts(CancellationToken cance

await processor.GetEnumerableTasks().First();

// Delay to allow remaining Tasks to start
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
// Delay to allow remaining Tasks to start (increased due to Task.Yield overhead)
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);

await Assert.That(started).IsEqualTo(2);

Expand Down Expand Up @@ -97,7 +98,7 @@ public async Task When_Batch_Finished_Then_Start_Next_Batch(int amountToComplete
await Task.WhenAll(processor.GetEnumerableTasks().Take(amountToComplete));

// Delay to allow remaining Tasks to start
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);

await Assert.That(started).IsEqualTo(amountToComplete + 1);

Expand Down
143 changes: 143 additions & 0 deletions EnumerableAsyncProcessor.UnitTests/ParallelPerformanceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using EnumerableAsyncProcessor.Builders;

namespace EnumerableAsyncProcessor.UnitTests;

public class ParallelPerformanceTests
{
[Test]
public async Task MeasureParallelProcessingTime_10000ItemsWith1SecondDelay()
{
const int itemCount = 10000;
const int delaySeconds = 1;

var stopwatch = Stopwatch.StartNew();

var processor = AsyncProcessorBuilder
.WithItems(Enumerable.Range(0, itemCount))
.ForEachAsync(_ => Task.Delay(TimeSpan.FromSeconds(delaySeconds)))
.ProcessInParallel();

await processor;

stopwatch.Stop();

var completedTasks = processor.GetEnumerableTasks().Count(x => x.IsCompleted);

await Assert.That(completedTasks).IsEqualTo(itemCount);

Console.WriteLine($"Total execution time: {stopwatch.Elapsed}");
Console.WriteLine($"Total execution time (seconds): {stopwatch.Elapsed.TotalSeconds:F2}");
Console.WriteLine($"Total execution time (milliseconds): {stopwatch.Elapsed.TotalMilliseconds:F0}");

var expectedSequentialTime = itemCount * delaySeconds;
Console.WriteLine($"Expected sequential time: {expectedSequentialTime:N0} seconds");
Console.WriteLine($"Speedup factor: {expectedSequentialTime / stopwatch.Elapsed.TotalSeconds:F2}x");

await Assert.That(stopwatch.Elapsed.TotalSeconds).IsLessThan(10);
}

[Test]
public async Task MeasureParallelProcessingTime_1000ItemsWith100msDelay()
{
const int itemCount = 1000;
const int delayMilliseconds = 100;

var stopwatch = Stopwatch.StartNew();

var processor = AsyncProcessorBuilder
.WithItems(Enumerable.Range(0, itemCount))
.ForEachAsync(_ => Task.Delay(delayMilliseconds))
.ProcessInParallel();

await processor;

stopwatch.Stop();

var completedTasks = processor.GetEnumerableTasks().Count(x => x.IsCompleted);

await Assert.That(completedTasks).IsEqualTo(itemCount);

Console.WriteLine($"Total execution time: {stopwatch.Elapsed}");
Console.WriteLine($"Total execution time (seconds): {stopwatch.Elapsed.TotalSeconds:F2}");
Console.WriteLine($"Total execution time (milliseconds): {stopwatch.Elapsed.TotalMilliseconds:F0}");

var expectedSequentialTimeMs = itemCount * delayMilliseconds;
Console.WriteLine($"Expected sequential time: {expectedSequentialTimeMs:N0} milliseconds ({expectedSequentialTimeMs/1000.0:F1} seconds)");
Console.WriteLine($"Speedup factor: {expectedSequentialTimeMs / stopwatch.Elapsed.TotalMilliseconds:F2}x");

await Assert.That(stopwatch.Elapsed.TotalSeconds).IsLessThan(10);
}

[Test]
public async Task MeasureParallelProcessingTime_1000ItemsWithThreadSleep()
{
const int itemCount = 1000;
const int sleepMilliseconds = 100;

var stopwatch = Stopwatch.StartNew();

var processor = AsyncProcessorBuilder
.WithItems(Enumerable.Range(0, itemCount))
.ForEachAsync(_ => Task.Run(() => Thread.Sleep(sleepMilliseconds)))
.ProcessInParallel();

await processor;

stopwatch.Stop();

var completedTasks = processor.GetEnumerableTasks().Count(x => x.IsCompleted);

await Assert.That(completedTasks).IsEqualTo(itemCount);

Console.WriteLine($"Total execution time with Thread.Sleep: {stopwatch.Elapsed}");
Console.WriteLine($"Total execution time (seconds): {stopwatch.Elapsed.TotalSeconds:F2}");
Console.WriteLine($"Total execution time (milliseconds): {stopwatch.Elapsed.TotalMilliseconds:F0}");

var expectedSequentialTimeMs = itemCount * sleepMilliseconds;
Console.WriteLine($"Expected sequential time: {expectedSequentialTimeMs:N0} milliseconds ({expectedSequentialTimeMs/1000.0:F1} seconds)");
Console.WriteLine($"Speedup factor: {expectedSequentialTimeMs / stopwatch.Elapsed.TotalMilliseconds:F2}x");

await Assert.That(stopwatch.Elapsed.TotalSeconds).IsLessThan(10);
}

[Test]
public async Task MeasureParallelProcessingTime_200ItemsWithDirectThreadSleep()
{
const int itemCount = 200;
const int sleepMilliseconds = 100;

var stopwatch = Stopwatch.StartNew();

var processor = AsyncProcessorBuilder
.WithItems(Enumerable.Range(0, itemCount))
.ForEachAsync(_ =>
{
Thread.Sleep(sleepMilliseconds);
return Task.CompletedTask;
})
.ProcessInParallel();

await processor;

stopwatch.Stop();

var completedTasks = processor.GetEnumerableTasks().Count(x => x.IsCompleted);

await Assert.That(completedTasks).IsEqualTo(itemCount);

Console.WriteLine($"Total execution time with direct Thread.Sleep: {stopwatch.Elapsed}");
Console.WriteLine($"Total execution time (seconds): {stopwatch.Elapsed.TotalSeconds:F2}");
Console.WriteLine($"Total execution time (milliseconds): {stopwatch.Elapsed.TotalMilliseconds:F0}");

var expectedSequentialTimeMs = itemCount * sleepMilliseconds;
Console.WriteLine($"Expected sequential time: {expectedSequentialTimeMs:N0} milliseconds ({expectedSequentialTimeMs/1000.0:F1} seconds)");
Console.WriteLine($"Speedup factor: {expectedSequentialTimeMs / stopwatch.Elapsed.TotalMilliseconds:F2}x");

await Assert.That(stopwatch.Elapsed.TotalSeconds).IsLessThan(5);
}
}
25 changes: 9 additions & 16 deletions EnumerableAsyncProcessor/Builders/ActionAsyncProcessorBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,30 +32,23 @@ public IAsyncProcessor ProcessInParallel(int levelOfParallelism, TimeSpan timeSp
return new TimedRateLimitedParallelAsyncProcessor(_count, _taskSelector, levelOfParallelism, timeSpan, _cancellationTokenSource).StartProcessing();
}

public IAsyncProcessor ProcessInParallel()
{
return new ParallelAsyncProcessor(_count, _taskSelector, _cancellationTokenSource).StartProcessing();
}

/// <summary>
/// Process tasks in parallel with optimizations for I/O-bound operations.
/// Removes Task.Run overhead and allows higher concurrency levels.
/// Process tasks in parallel without concurrency limits.
/// </summary>
/// <param name="maxConcurrency">Maximum concurrent operations. If null, defaults to 10x processor count or minimum 100 for I/O-bound tasks.</param>
/// <returns>An async processor optimized for I/O operations.</returns>
public IAsyncProcessor ProcessInParallelForIO(int? maxConcurrency = null)
/// <returns>An async processor configured for parallel execution.</returns>
public IAsyncProcessor ProcessInParallel()
{
return new IOBoundParallelAsyncProcessor(_count, _taskSelector, _cancellationTokenSource, maxConcurrency).StartProcessing();
return ProcessInParallel(null);
}

/// <summary>
/// Process tasks in parallel with explicit I/O vs CPU-bound configuration.
/// Process tasks in parallel with specified concurrency limit.
/// </summary>
/// <param name="isIOBound">True for I/O-bound tasks (removes Task.Run overhead), false for CPU-bound tasks.</param>
/// <returns>An async processor configured for the specified workload type.</returns>
public IAsyncProcessor ProcessInParallel(bool isIOBound)
/// <param name="maxConcurrency">Maximum concurrent operations.</param>
/// <returns>An async processor configured for parallel execution.</returns>
public IAsyncProcessor ProcessInParallel(int? maxConcurrency)
{
return new ParallelAsyncProcessor(_count, _taskSelector, _cancellationTokenSource, isIOBound).StartProcessing();
return new ParallelAsyncProcessor(_count, _taskSelector, _cancellationTokenSource, maxConcurrency).StartProcessing();
}

public IAsyncProcessor ProcessOneAtATime()
Expand Down
Loading