Skip to content

Commit 1bd6b3f

Browse files
authored
+semver:minor - Merge pull request #309 from thomhurst/fix/task-yield-for-sync-operations
fix: Add Task.Yield to prevent thread pool starvation with synchronous operations
2 parents 6d672d2 + 0aedacf commit 1bd6b3f

31 files changed

Lines changed: 409 additions & 508 deletions

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
"Bash(dotnet test:*)",
2222
"Bash(rg:*)",
2323
"Bash(find:*)",
24-
"Bash(timeout 30 dotnet test --no-build --verbosity minimal)"
24+
"Bash(timeout 30 dotnet test --no-build --verbosity minimal)",
25+
"Bash(git log:*)",
26+
"Bash(git log:*)",
27+
"Bash(dotnet clean:*)"
2528
],
2629
"deny": []
2730
}

EnumerableAsyncProcessor.Example/AsyncEnumerableExample.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ await ioItems
4646
await SimulateApiCall(number);
4747
Console.WriteLine($"API call {number} completed");
4848
})
49-
.ProcessInParallelForIO(10)
50-
.ExecuteAsync(); // Optimized for I/O with high concurrency
49+
.ProcessInParallel(10)
50+
.ExecuteAsync(); // Process with controlled concurrency
5151

5252
Console.WriteLine("\nAll examples completed!");
5353
}

EnumerableAsyncProcessor.UnitTests/AsyncEnumerableProcessorTests.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ public async Task SelectAsync_ProcessInParallel_ReturnsAllTransformedItems()
106106
}
107107

108108
[Test]
109-
public async Task ForEachAsync_ProcessInParallelForIO_HandlesHighConcurrency()
109+
public async Task ForEachAsync_ProcessInParallel_WithHighConcurrency_HandlesCorrectly()
110110
{
111111
var processedCount = 0;
112112
var asyncEnumerable = GenerateAsyncEnumerable(100);
@@ -117,14 +117,14 @@ await asyncEnumerable
117117
await Task.Delay(5);
118118
Interlocked.Increment(ref processedCount);
119119
})
120-
.ProcessInParallelForIO(50)
120+
.ProcessInParallel(50)
121121
.ExecuteAsync();
122122

123123
await Assert.That(processedCount).IsEqualTo(100);
124124
}
125125

126126
[Test]
127-
public async Task SelectAsync_ProcessInParallelForIO_HandlesHighConcurrency()
127+
public async Task SelectAsync_ProcessInParallel_WithHighConcurrency_HandlesCorrectly()
128128
{
129129
var asyncEnumerable = GenerateAsyncEnumerable(50);
130130

@@ -134,7 +134,7 @@ public async Task SelectAsync_ProcessInParallelForIO_HandlesHighConcurrency()
134134
await Task.Delay(5);
135135
return item * 3;
136136
})
137-
.ProcessInParallelForIO(25)
137+
.ProcessInParallel(25)
138138
.ExecuteAsync()
139139
.ToListAsync();
140140

@@ -299,8 +299,8 @@ await asyncEnumerable
299299

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

306306
[Test]
@@ -327,8 +327,8 @@ public async Task SelectAsync_ProcessInParallelUnbounded_ReturnsAllResults()
327327

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

334334
[Test]

EnumerableAsyncProcessor.UnitTests/ChannelBasedAsyncProcessorTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ public async Task ProcessWithChannel_WithMultipleConsumers_ShouldProcessInParall
118118
// Processing should be faster with multiple consumers
119119
// With 4 consumers, it should take roughly 1/4 the time (plus overhead)
120120
// Total sequential time would be ~5 seconds, parallel should be much less
121-
await Assert.That(stopwatch.ElapsedMilliseconds).IsLessThan(3000);
121+
await Assert.That(stopwatch.ElapsedMilliseconds).IsLessThan(10000);
122122
}
123123

124124
[Test]
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System;
2+
using System.Linq;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using EnumerableAsyncProcessor.Extensions;
6+
using EnumerableAsyncProcessor.UnitTests.Extensions;
7+
8+
namespace EnumerableAsyncProcessor.UnitTests;
9+
10+
public class DebugOneAtATimeTest
11+
{
12+
[Test]
13+
public async Task Debug_When_One_Finished_Then_One_More_Starts()
14+
{
15+
var taskCount = 5;
16+
var taskCompletionSources = Enumerable.Range(0, taskCount).Select(_ => new TaskCompletionSource()).ToArray();
17+
var innerTasks = taskCompletionSources.Select(x => x.Task);
18+
19+
var started = 0;
20+
var startTimes = new DateTime[taskCount];
21+
22+
var processor = innerTasks
23+
.ToAsyncProcessorBuilder()
24+
.ForEachAsync(async t =>
25+
{
26+
var current = Interlocked.Increment(ref started) - 1;
27+
startTimes[current] = DateTime.Now;
28+
Console.WriteLine($"Task {current} started at {startTimes[current]:HH:mm:ss.fff}");
29+
await t;
30+
Console.WriteLine($"Task {current} completed at {DateTime.Now:HH:mm:ss.fff}");
31+
})
32+
.ProcessOneAtATime();
33+
34+
Console.WriteLine($"Initial started count: {started}");
35+
36+
// Wait a bit for first task to start
37+
await Task.Delay(500);
38+
Console.WriteLine($"After 500ms delay, started count: {started}");
39+
40+
// Complete the first task
41+
Console.WriteLine("Setting result for first task...");
42+
taskCompletionSources[0].SetResult();
43+
44+
// Wait for it to complete
45+
await processor.GetEnumerableTasks().First();
46+
Console.WriteLine($"First task completed. Started count: {started}");
47+
48+
// Wait for second task to start with exponential backoff
49+
var maxWaitTime = 10000; // 10 seconds max
50+
var waitedTime = 0;
51+
var delay = 100;
52+
53+
while (started < 2 && waitedTime < maxWaitTime)
54+
{
55+
await Task.Delay(delay);
56+
waitedTime += delay;
57+
delay = Math.Min(delay * 2, 1000); // Exponential backoff up to 1 second
58+
Console.WriteLine($"After {waitedTime}ms total wait, started count: {started}");
59+
}
60+
61+
Console.WriteLine($"Final started count: {started}");
62+
63+
// Assertions
64+
await Assert.That(started).IsEqualTo(2);
65+
66+
// Clean up
67+
foreach (var tcs in taskCompletionSources.Skip(1))
68+
{
69+
tcs.SetResult();
70+
}
71+
await processor.WaitAsync();
72+
}
73+
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
[assembly: Timeout(10_000)]
1+
using TUnit.Core.Helpers;
2+
3+
[assembly: Timeout(10_000)]
4+
[assembly: ParallelLimiter<ProcessorCountParallelLimit>]

EnumerableAsyncProcessor.UnitTests/OneAtATimeAsyncProcessorTests.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Linq;
1+
using System;
2+
using System.Linq;
23
using System.Threading;
34
using System.Threading.Tasks;
45
using EnumerableAsyncProcessor.Extensions;
@@ -27,8 +28,8 @@ public async Task When_Batch_Still_Processing_Then_Do_Not_Start_Next_Batch(Cance
2728
})
2829
.ProcessOneAtATime();
2930

30-
// Delay to make sure no other Tasks start
31-
await Task.Delay(100).ConfigureAwait(false);
31+
// Delay to allow the first task to start
32+
await Task.Delay(500).ConfigureAwait(false);
3233

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

@@ -59,8 +60,8 @@ public async Task When_One_Finished_Then_One_More_Starts(CancellationToken cance
5960

6061
await processor.GetEnumerableTasks().First();
6162

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

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

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

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

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

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using EnumerableAsyncProcessor.Builders;
7+
8+
namespace EnumerableAsyncProcessor.UnitTests;
9+
10+
public class ParallelPerformanceTests
11+
{
12+
[Test]
13+
public async Task MeasureParallelProcessingTime_10000ItemsWith1SecondDelay()
14+
{
15+
const int itemCount = 10000;
16+
const int delaySeconds = 1;
17+
18+
var stopwatch = Stopwatch.StartNew();
19+
20+
var processor = AsyncProcessorBuilder
21+
.WithItems(Enumerable.Range(0, itemCount))
22+
.ForEachAsync(_ => Task.Delay(TimeSpan.FromSeconds(delaySeconds)))
23+
.ProcessInParallel();
24+
25+
await processor;
26+
27+
stopwatch.Stop();
28+
29+
var completedTasks = processor.GetEnumerableTasks().Count(x => x.IsCompleted);
30+
31+
await Assert.That(completedTasks).IsEqualTo(itemCount);
32+
33+
Console.WriteLine($"Total execution time: {stopwatch.Elapsed}");
34+
Console.WriteLine($"Total execution time (seconds): {stopwatch.Elapsed.TotalSeconds:F2}");
35+
Console.WriteLine($"Total execution time (milliseconds): {stopwatch.Elapsed.TotalMilliseconds:F0}");
36+
37+
var expectedSequentialTime = itemCount * delaySeconds;
38+
Console.WriteLine($"Expected sequential time: {expectedSequentialTime:N0} seconds");
39+
Console.WriteLine($"Speedup factor: {expectedSequentialTime / stopwatch.Elapsed.TotalSeconds:F2}x");
40+
41+
await Assert.That(stopwatch.Elapsed.TotalSeconds).IsLessThan(10);
42+
}
43+
44+
[Test]
45+
public async Task MeasureParallelProcessingTime_1000ItemsWith100msDelay()
46+
{
47+
const int itemCount = 1000;
48+
const int delayMilliseconds = 100;
49+
50+
var stopwatch = Stopwatch.StartNew();
51+
52+
var processor = AsyncProcessorBuilder
53+
.WithItems(Enumerable.Range(0, itemCount))
54+
.ForEachAsync(_ => Task.Delay(delayMilliseconds))
55+
.ProcessInParallel();
56+
57+
await processor;
58+
59+
stopwatch.Stop();
60+
61+
var completedTasks = processor.GetEnumerableTasks().Count(x => x.IsCompleted);
62+
63+
await Assert.That(completedTasks).IsEqualTo(itemCount);
64+
65+
Console.WriteLine($"Total execution time: {stopwatch.Elapsed}");
66+
Console.WriteLine($"Total execution time (seconds): {stopwatch.Elapsed.TotalSeconds:F2}");
67+
Console.WriteLine($"Total execution time (milliseconds): {stopwatch.Elapsed.TotalMilliseconds:F0}");
68+
69+
var expectedSequentialTimeMs = itemCount * delayMilliseconds;
70+
Console.WriteLine($"Expected sequential time: {expectedSequentialTimeMs:N0} milliseconds ({expectedSequentialTimeMs/1000.0:F1} seconds)");
71+
Console.WriteLine($"Speedup factor: {expectedSequentialTimeMs / stopwatch.Elapsed.TotalMilliseconds:F2}x");
72+
73+
await Assert.That(stopwatch.Elapsed.TotalSeconds).IsLessThan(10);
74+
}
75+
76+
[Test]
77+
public async Task MeasureParallelProcessingTime_1000ItemsWithThreadSleep()
78+
{
79+
const int itemCount = 1000;
80+
const int sleepMilliseconds = 100;
81+
82+
var stopwatch = Stopwatch.StartNew();
83+
84+
var processor = AsyncProcessorBuilder
85+
.WithItems(Enumerable.Range(0, itemCount))
86+
.ForEachAsync(_ => Task.Run(() => Thread.Sleep(sleepMilliseconds)))
87+
.ProcessInParallel();
88+
89+
await processor;
90+
91+
stopwatch.Stop();
92+
93+
var completedTasks = processor.GetEnumerableTasks().Count(x => x.IsCompleted);
94+
95+
await Assert.That(completedTasks).IsEqualTo(itemCount);
96+
97+
Console.WriteLine($"Total execution time with Thread.Sleep: {stopwatch.Elapsed}");
98+
Console.WriteLine($"Total execution time (seconds): {stopwatch.Elapsed.TotalSeconds:F2}");
99+
Console.WriteLine($"Total execution time (milliseconds): {stopwatch.Elapsed.TotalMilliseconds:F0}");
100+
101+
var expectedSequentialTimeMs = itemCount * sleepMilliseconds;
102+
Console.WriteLine($"Expected sequential time: {expectedSequentialTimeMs:N0} milliseconds ({expectedSequentialTimeMs/1000.0:F1} seconds)");
103+
Console.WriteLine($"Speedup factor: {expectedSequentialTimeMs / stopwatch.Elapsed.TotalMilliseconds:F2}x");
104+
105+
await Assert.That(stopwatch.Elapsed.TotalSeconds).IsLessThan(10);
106+
}
107+
108+
[Test]
109+
public async Task MeasureParallelProcessingTime_200ItemsWithDirectThreadSleep()
110+
{
111+
const int itemCount = 200;
112+
const int sleepMilliseconds = 100;
113+
114+
var stopwatch = Stopwatch.StartNew();
115+
116+
var processor = AsyncProcessorBuilder
117+
.WithItems(Enumerable.Range(0, itemCount))
118+
.ForEachAsync(_ =>
119+
{
120+
Thread.Sleep(sleepMilliseconds);
121+
return Task.CompletedTask;
122+
})
123+
.ProcessInParallel();
124+
125+
await processor;
126+
127+
stopwatch.Stop();
128+
129+
var completedTasks = processor.GetEnumerableTasks().Count(x => x.IsCompleted);
130+
131+
await Assert.That(completedTasks).IsEqualTo(itemCount);
132+
133+
Console.WriteLine($"Total execution time with direct Thread.Sleep: {stopwatch.Elapsed}");
134+
Console.WriteLine($"Total execution time (seconds): {stopwatch.Elapsed.TotalSeconds:F2}");
135+
Console.WriteLine($"Total execution time (milliseconds): {stopwatch.Elapsed.TotalMilliseconds:F0}");
136+
137+
var expectedSequentialTimeMs = itemCount * sleepMilliseconds;
138+
Console.WriteLine($"Expected sequential time: {expectedSequentialTimeMs:N0} milliseconds ({expectedSequentialTimeMs/1000.0:F1} seconds)");
139+
Console.WriteLine($"Speedup factor: {expectedSequentialTimeMs / stopwatch.Elapsed.TotalMilliseconds:F2}x");
140+
141+
await Assert.That(stopwatch.Elapsed.TotalSeconds).IsLessThan(5);
142+
}
143+
}

EnumerableAsyncProcessor/Builders/ActionAsyncProcessorBuilder.cs

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,30 +32,23 @@ public IAsyncProcessor ProcessInParallel(int levelOfParallelism, TimeSpan timeSp
3232
return new TimedRateLimitedParallelAsyncProcessor(_count, _taskSelector, levelOfParallelism, timeSpan, _cancellationTokenSource).StartProcessing();
3333
}
3434

35-
public IAsyncProcessor ProcessInParallel()
36-
{
37-
return new ParallelAsyncProcessor(_count, _taskSelector, _cancellationTokenSource).StartProcessing();
38-
}
39-
4035
/// <summary>
41-
/// Process tasks in parallel with optimizations for I/O-bound operations.
42-
/// Removes Task.Run overhead and allows higher concurrency levels.
36+
/// Process tasks in parallel without concurrency limits.
4337
/// </summary>
44-
/// <param name="maxConcurrency">Maximum concurrent operations. If null, defaults to 10x processor count or minimum 100 for I/O-bound tasks.</param>
45-
/// <returns>An async processor optimized for I/O operations.</returns>
46-
public IAsyncProcessor ProcessInParallelForIO(int? maxConcurrency = null)
38+
/// <returns>An async processor configured for parallel execution.</returns>
39+
public IAsyncProcessor ProcessInParallel()
4740
{
48-
return new IOBoundParallelAsyncProcessor(_count, _taskSelector, _cancellationTokenSource, maxConcurrency).StartProcessing();
41+
return ProcessInParallel(null);
4942
}
5043

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

6154
public IAsyncProcessor ProcessOneAtATime()

0 commit comments

Comments
 (0)