Getting Started with TPL
The Task Parallel Library (TPL) consists of types added to the System.Threading and System.Threading.Tasks namespaces in .NET Framework 4.0. TPL simplifies parallel and concurrent programming for .NET developers significantly. Rather than manually managing ThreadPool tasks, developers can leverage TPL's automatic thread management that dynamically adjusts active thread counts based on processor capabilities and workload.
When introducing parallelism or concurrency to improve performance, TPL serves as the primary choice. However, TPL isn't suitable for every scenario. Understanding when to use TPL and which TPL construct fits specific situations requires careful consideration.
I/O-Bound Operations
For I/O-bound operations such as file operations, database queries, or web service calls, using Task objects with C# async/await patterns provides the best approach. When services require iterating through large collections and making service calls for each object, refactoring to return data in a single service call minimizes network overhead. This enables client code to make a single asynchronous call while keeping the main thread available for other work.
I/O-bound operations typically don't benefit from parallel processing, though exceptions exist. When iterating through folder hierarchies in a file system, parallel loops work well. However, ensure no iterations attempt to access the same file simultaneously to prevent locking issues.
CPU-Bound Operations
CPU-bound operations don't depend on extrenal resources like filesystems, networks, or internet connections. These operations process in-memory data within the application process. Data transformation operations fall into this category, including serialization/deserialization, file format conversions, and image or binary data processing.
These operations benefit significantly from data parallelism and parallel loops, with some exceptions. First, if each iteration doesn't consume substantial CPU resources, TPL overhead outweighs the benefits. Second, when the process is CPU-intensive but iterates over few objects, consider using Parallel.Invoke instead of parallel loops. Using parallel constructs for lightly CPU-bound operations typically degrades performance. Performance analysis tools in Visual Studio help identify bottlenecks in parallel and concurrent code.
Parallel Loops in .NET
This section explores data parallelism examples in .NET projects. Parallel versions of C# for and foreach loops—Parallel.For and Parallel.ForEach—reside in the System.Threading.Tasks.Parallel namespace. Using these parallel loops resembles their standard counterparts with some key differences.
A critical distinction involves how loop bodies execute as lambda expressions. Stopping or breaking parallel loops differs from traditional loops. Instead of using continue, return statements exit the current iteration without terminating the entire loop. Breaking out of parallel loops uses Stop() or Break() methods.
Basic Parallel.For Loop
Create a WinForms application that allows users to select a folder and retrieve file information. The FileProcessor class iterates through files to aggregate sizes and find the most recently written file:
- Create a new .NET 6 WinForms project in Visual Studio
- Add a new class named FileData to contain processed information:
public class FileData
{
public List<FileInfo> Files { get; set; } = new();
public long TotalSize { get; set; }
public string MostRecentFile { get; set; } = string.Empty;
public DateTime MostRecentTimestamp { get; set; }
}
This class returns the list of FileInfo objects, total size, most recently written filename, and timestamp.
- Create a class named FileProcessor
- Add a static method named GatherFileInformation:
public static FileData GatherFileInformation(string[] files)
{
var results = new FileData();
var fileInfos = new List<FileInfo>();
long totalSize = 0;
DateTime latestWriteTime = DateTime.MinValue;
string latestFileName = string.Empty;
object syncObject = new object();
Parallel.For(0, files.Length,
index => {
FileInfo fi = new(files[index]);
long fileSize = fi.Length;
DateTime lastWrite = fi.LastWriteTimeUtc;
lock (syncObject)
{
if (latestWriteTime < lastWrite)
{
latestWriteTime = lastWrite;
latestFileName = fi.Name;
}
}
Interlocked.Add(ref totalSize, fileSize);
fileInfos.Add(fi);
});
results.Files = fileInfos;
results.TotalSize = totalSize;
results.MostRecentTimestamp = latestWriteTime;
results.MostRecentFile = latestFileName;
return results;
}
Several aspects require attention in this Parallel.For implementation. First, the index parameter allows the lambda body to access the current file from the array. Second, Interlocked.Add safely increments totalSize in parallel code. Third, updating the lastWriteTime DateTime requires a lock since no Interlocked equivalent exists for DateTime types. The lock block with syncObject ensures thread-safe reads and writes to the method-level variables.
- Open Form1.cs designer and add these controls:
private GroupBox FileProcessorGroup;
private Button BrowseButton;
private Button ProcessButton;
private TextBox FolderPathTextBox;
private Label SourceLabel;
private TextBox ResultsTextBox;
private Label ResultsLabel;
private FolderBrowserDialog folderDialog;
- Double-click the browse button to generate the Click event handler:
private void BrowseButton_Click(object sender, EventArgs e)
{
var result = folderDialog.ShowDialog();
if (result == DialogResult.OK)
{
FolderPathTextBox.Text = folderDialog.SelectedPath;
}
}
The selected folder path appears in FolderPathTextBox. Users can also manually type or paste paths. Setting FolderPathTextBox.ReadOnly to true prevents manual input.
- Double-click the process button to generate the Click event handler:
private void ProcessButton_Click(object sender, EventArgs e)
{
if (!string.IsNullOrWhiteSpace(FolderPathTextBox.Text) &&
Directory.Exists(FolderPathTextBox.Text))
{
string[] files = Directory.GetFiles(FolderPathTextBox.Text);
FileData? data = FileProcessor.GatherFileInformation(files);
if (data == null)
{
ResultsTextBox.Text = string.Empty;
return;
}
var output = new StringBuilder();
output.Append($"Total files: {data.Files.Count}; ");
output.AppendLine($"Total size: {data.TotalSize} bytes");
output.Append($"Most recent: {data.MostRecentFile} ");
output.Append($"at {data.MostRecentTimestamp}");
ResultsTextBox.Text = output.ToString();
}
}
This code calls the static GatherFileInformation method which returns a FileData instance containing file information. StringBuilder creates the output displayed in ResultsTextBox.
- Run the application, select a folder, and observe the results
For a more advanced exercise, modify the project to process files in all subfolders. Alternatively, reduce lock contention by implementing thread-local variables.
Parallel Loops with Thread-Local Variables
Parallel.For includes an overload that maintains running subtotals for each thread participating in the loop. This reduces Interlocked.Add calls to once per thread instead of once per iteration. Thread-local variables store discrete subtotals per thread—five calls to Interlocked.Add instead of two hundred for a loop with 200 iterations across 5 threads:
public static FileData GatherFileInformationLocal(string[] files)
{
var results = new FileData();
var fileInfos = new List<FileInfo>();
long totalSize = 0;
DateTime latestWriteTime = DateTime.MinValue;
string latestFileName = string.Empty;
object syncObject = new object();
Parallel.For<long>(0, files.Length, () => 0,
(index, loop, subtotal) => {
FileInfo fi = new(files[index]);
long fileSize = fi.Length;
DateTime lastWrite = fi.LastWriteTimeUtc;
lock (syncObject)
{
if (latestWriteTime < lastWrite)
{
latestWriteTime = lastWrite;
latestFileName = fi.Name;
}
}
subtotal += fileSize;
fileInfos.Add(fi);
return subtotal;
},
(threadTotal) => Interlocked.Add(ref totalSize, threadTotal)
);
results.Files = fileInfos;
results.TotalSize = totalSize;
results.MostRecentTimestamp = latestWriteTime;
results.MostRecentFile = latestFileName;
return results;
}
Changes include using Parallel.For<long> generic method specifying long for the subtotal type instead of default int. The first lambda expression adds sizes to subtotal without locks. Returning subtotal enables subsequent iterations to access the accumulated data. The final lambda expression adds each thread's runningTotal to totalSize via Interlocked.Add.
Updating the button click handler to call GatherFileInformationLocal produces identical output but improved performance. The improvement magnitude depends on file count in the selected folder.
Simple Parallel.ForEach Loop
Parallel.ForEach resembles Parallel.For in usage compared to its sequential counterpart. Use Parallel.ForEach when processing IEnumerable collections. This example creates a method accepting a List<string> of image files to convert to Bitmap objects:
- Create a private static method named LoadImageInFile in the FileProcessor class. This method opens each image file and returns a Bitmap containing image data:
private static Bitmap LoadImageInFile(string filePath)
{
Bitmap bitmap;
using (Stream stream = File.Open(filePath, FileMode.Open))
{
Image image = Image.FromStream(stream);
bitmap = new Bitmap(image);
}
return bitmap;
}
- Create a public static method named TransformImageFiles in the same class:
public static List<Bitmap> TransformImageFiles(List<string> files)
{
var output = new List<Bitmap>();
Parallel.ForEach(files, filePath => {
FileInfo fi = new(filePath);
string extension = fi.Extension.ToLower();
if (extension == ".jpg" || extension == ".jpeg")
{
output.Add(LoadImageInFile(filePath));
}
});
return output;
}
This method accepts a List<string> containing files from the selected folder. The Parallel.ForEach loop checks for .jpg or .jpeg extensions, converts matching files to Bitmaps, and adds them to the result collection.
- Add a new button to Form1.cs with Name property "ConvertImagesButton" and Text property "Convert Images"
- Double-click the button to create the event handler:
private void ConvertImagesButton_Click(object sender, EventArgs e)
{
if (!string.IsNullOrWhiteSpace(FolderPathTextBox.Text) &&
Directory.Exists(FolderPathTextBox.Text))
{
List<string> files = Directory.GetFiles(FolderPathTextBox.Text).ToList();
List<Bitmap> bitmaps = FileProcessor.TransformImageFiles(files);
var output = new StringBuilder();
foreach (var bmp in bitmaps)
{
output.AppendLine($"Bitmap height: {bmp.Height}");
}
ResultsTextBox.Text = output.ToString();
}
}
- Run the project, select a folder with JPG files, and click Convert Images to see height information for each converted image
This demonstrates the basic Parallel.ForEach loop usage. For cancelling long-running parallel loops, implement Parallel.ForEachAsync.
Cancelling Parallel.ForEachAsync Loops
Parallel.ForEachAsync is new in .NET 6. It's an awaitable version of Parallel.ForEach using async lambda expressions as the body. Update the example to use this new parallel method with cancellation support:
- Create an async version of TransformImageFiles named TransformImagesAsync. Key differences highlighted below:
public static async Task<List<Bitmap>> TransformImagesAsync(
List<string> files,
CancellationTokenSource tokenSource)
{
ParallelOptions options = new()
{
CancellationToken = tokenSource.Token,
MaxDegreeOfParallelism =
Environment.ProcessorCount == 1 ? 1 : Environment.ProcessorCount - 1
};
var output = new List<Bitmap>();
try
{
await Parallel.ForEachAsync(files, options, async (filePath, cancelToken) => {
FileInfo fi = new(filePath);
string extension = fi.Extension.ToLower();
if (extension == ".jpg" || extension == ".jpeg")
{
output.Add(LoadImageInFile(filePath));
await Task.Delay(2000, cancelToken);
}
});
}
catch (OperationCanceledException ex)
{
MessageBox.Show(ex.Message);
}
finally
{
tokenSource.Dispose();
}
return output;
}
This async method returns Task<List<Bitmap>>, accepts CancellationTokenSource, and uses it when creating ParallelOptions passed to Parallel.ForEachAsync. The awaitable Parallel.ForEachAsync with async lambda allows awaiting Task.Delay, providing enough time to click the cancel button before loop completion.
Wrapping Parallel.ForEachAsync in a try/catch block handling OperationCanceledException catches cancellation requests. After handling cancellation, display a message to the user.
The code also sets ProcessorCount options—setting to 1 when only one CPU core is available, otherwise using available cores minus one. The .NET runtime manages this value well, so only change it when testing confirms performance improvement.
- Add a private CancellationTokenSource variable in Form1.cs:
private CancellationTokenSource _cancellationSource;
- Update the event handler to be async, instantiate CancellationTokenSource, pass it to TransformImagesAsync, and await the call:
private async void ConvertImagesButton_Click(object sender, EventArgs e)
{
if (!string.IsNullOrWhiteSpace(FolderPathTextBox.Text) &&
Directory.Exists(FolderPathText.Text))
{
_cancellationSource = new CancellationTokenSource();
List<string> files = Directory.GetFiles(FolderPathText.Text).ToList();
List<Bitmap> bitmaps = await FileProcessor.TransformImagesAsync(files, _cancellationSource);
var output = new StringBuilder();
foreach (var bmp in bitmaps)
{
output.AppendLine($"Bitmap height: {bmp.Height}");
}
ResultsTextBox.Text = output.ToString();
}
}
- Add a new button named CancelButton with text "Cancel"
- Double-click CancelButton and add the event handler:
private void CancelButton_Click(object sender, EventArgs e)
{
if (_cancellationSource != null)
{
_cancellationSource.Cancel();
}
}
- Run the application, select a folder with JPG files, click Convert Images, then immediately click Cancel. A message indicates cancellation, and processing stops.
Understanding Parallel Task Relationships
Previous chapters covered async/await for parallel work execution and ContinueWith for task flow management.
Behind Parallel.Invoke
Chapter 2 introduced Parallel.Invoke for parallel task execution. Revisit what happens behind the scenes when calling two methods:
Parallel.Invoke(MethodA, MethodB);
This translates to:
var tasks = new List<Task>();
tasks.Add(Task.Run(MethodA));
tasks.Add(Task.Run(MethodB));
Task.WaitAll(tasks.ToArray());
Two tasks queue to the thread pool. With available system resources, both tasks run in parallel. The calling method blocks, waiting for parallel tasks to complete. Blocking duration equals the longest-running task.
If blocking the calling thread is acceptable, Parallel.Invoke provides cleaner, more readable code. For non-blocking behavior, options exist. First, modify the example to use await:
var tasks = new List<Task>();
tasks.Add(Task.Run(MethodA));
tasks.Add(Task.Run(MethodB));
await Task.WhenAll(tasks.ToArray());
<p>Awaiting Task.WhenAll instead of Task.WaitAll allows the current thread to perform other work while waiting for both subtasks. To achieve similar results with Parallel.Invoke, wrap it in Task:</p>
await Task.Run(() => Parallel.Invoke(MethodA, MethodB));
<p>Apply the same technique to Parallel.For to avoid blocking during loop completion. Parallel.ForEach doesn't require this—instead of replacing with Parallel.ForEachAsync, wrap Parallel.ForEach in Task. As .NET 6 added Parallel.ForEachAsync returning an awaitable Task.</p>
<h3>Understanding Parallel Subtasks</h3>
<p>When executing nested tasks, parent tasks don't wait for children by default unless using Wait() or await. Task.Factory.StartNew offers options controlling this default behavior. Create a new example project to demonstrate available options:</p>
<ol>
<li>Create a C# console application named TaskRelationshipsDemo</li>
<li>Add a class named Worker to the project containing parent and child methods</li>
<li>Add these three methods to the Worker class as child methods. Each writes console output at start and completion. Thread.SpinWait injects delays:</li>
</ol>
public void ExecuteFirstWorkItem()
{
Console.WriteLine("Starting ExecuteFirstWorkItem");
Thread.SpinWait(1000000);
Console.WriteLine("Finishing ExecuteFirstWorkItem");
}
public void ExecuteSecondWorkItem()
{
Console.WriteLine("Starting ExecuteSecondWorkItem");
Thread.SpinWait(1000000);
Console.WriteLine("Finishing ExecuteSecondWorkItem");
}
public void ExecuteThirdWorkItem()
{
Console.WriteLine("Starting ExecuteThirdWorkItem");
Thread.SpinWait(1000000);
Console.WriteLine("Finishing ExecuteThirdWorkItem");
}
<ol>
<li>Add a method named ProcessAllWork creating a parent task that invokes the three child methods without waiting for completion:</li>
</ol>
public void ProcessAllWork()
{
Console.WriteLine("Starting ProcessAllWork");
Task parent = Task.Factory.StartNew(() =>
{
var child1 = Task.Factory.StartNew(ExecuteFirstWorkItem);
var child2 = Task.Factory.StartNew(ExecuteSecondWorkItem);
var child3 = Task.Factory.StartNew(ExecuteThirdWorkItem);
});
parent.Wait();
Console.WriteLine("Finishing ProcessAllWork");
}
<ol>
<li>Add code in Program.cs to execute ProcessAllWork:</li>
</ol>
using TaskRelationshipsDemo;
var worker = new Worker();
worker.ProcessAllWork();
Console.ReadKey();
<ol>
<li>Run the program and observe output. The parent completes before its children as expected</li>
</ol>
<ol>
<li>Create a method named ProcessAllWorkAttached running the same three child tasks with TaskCreationOptions.AttachedToParent:</li>
</ol>
public void ProcessAllWorkAttached()
{
Console.WriteLine("Starting ProcessAllWorkAttached");
Task parent = Task.Factory.StartNew(() =>
{
var child1 = Task.Factory.StartNew(ExecuteFirstWorkItem,
TaskCreationOptions.AttachedToParent);
var child2 = Task.Factory.StartNew(ExecuteSecondWorkItem,
TaskCreationOptions.AttachedToParent);
var child3 = Task.Factory.StartNew(ExecuteThirdWorkItem,
TaskCreationOptions.AttachedToParent);
});
parent.Wait();
Console.WriteLine("Finishing ProcessAllWorkAttached");
}
<ol>
<li>Update Program.cs to call ProcessAllWorkAttached and run again</li>
</ol>
<p>The parent won't complete before its children even without explicit waiting.</p>
<p>For scenarios where the parent shouldn't wait for children regardless of AttachedToParent usage, create a method handling this:</p>
<ol>
<li>Create a method named ProcessAllWorkDenyAttach:</li>
</ol>
public void ProcessAllWorkDenyAttach()
{
Console.WriteLine("Starting ProcessAllWorkDenyAttach");
Task parent = Task.Factory.StartNew(() =>
{
var child1 = Task.Factory.StartNew(ExecuteFirstWorkItem,
TaskCreationOptions.AttachedToParent);
var child2 = Task.Factory.StartNew(ExecuteSecondWorkItem,
TaskCreationOptions.AttachedToParent);
var child3 = Task.Factory.StartNew(ExecuteThirdWorkItem,
TaskCreationOptions.AttachedToParent);
},
TaskCreationOptions.DenyChildAttach);
parent.Wait();
Console.WriteLine("Finishing ProcessAllWorkDenyAttach");
}
<p>Child tasks still use AttachedToParent, but the parent sets DenyChildAttach, overriding the child attachment request.</p>
<ol>
<li>Update Program.cs to call ProcessAllWorkDenyAttach and run again</li>
</ol>
<p>The DenyChildAttach overrides AttachedToParent on each child task. The parent completes without waiting for children, similar to ProcessAllWork.</p>
<p>Note: Use Task.Factory.StartNew instead of Task.Run even when TaskCreationOptions aren't required. Task.Run prevents child task attachment to parent. Using Task.Run in ProcessAllWorkAttached causes the parent to complete first.</p>
<h2>Common Parallelism Pitfalls</h2>
<p>When using TPL, avoid certain practices to ensure optimal application results. Improper parallelism sometimes causes performance degradation, errors, or data corruption.</p>
<h3>Parallelism Not Guaranteed</h3>
<p>When using parallel loops or Parallel.Invoke, iterations may run in parallel but not guaranteed. Code within these parallel delegates must succeed in either scenario.</p>
<h3>Parallel Loops Aren't Always Faster</h3>
<p>As discussed earlier, parallel versions of for and foreach loops don't always outperform sequential versions. When each iteration runs quickly, parallelism overhead slows the application.</p>
<p>Remember this when introducing any threading. Test code before and after introducing concurrency or parallelism to verify performance gains justify thread overhead.</p>
<h3>Beware of Blocking the UI Thread</h3>
<p>Parallel.For and Parallel.ForEach are blocking calls. Using them on the UI thread blocks during execution. Blocking duration equals at least the longest-running iteration.</p>
<p>As discussed, wrap parallel code in Task.Run calls to move execution from the UI thread to background threads on the thread pool.</p>
<h3>Thread Safety</h3>
<p>Don't call non-thread-safe .NET methods within parallel loops. Microsoft Docs documents thread safety for each .NET type. Use the .NET API browser for specific API information.</p>
<p>Limit static .NET method usage in parallel loops even when marked thread-safe. They won't cause data consistency issues but negatively impact loop performance. Even Console.WriteLine calls serve only testing or demonstration purposes—avoid in production code.</p>
<h3>UI Controls</h3>
<p>In Windows client applications, avoid accessing UI controls within parallel loops. WinForms and WPF controls can only be accessed from their creating threads. Use Dispatcher.Invoke for cross-thread operations, though this impacts performance. Update UI after parallel loop completion.</p>
<h3>Thread-Local Data</h3>
<p>Leverage ThreadLocal variables in parallel loops. The earlier section demonstrated this approach using C# and .NET.</p>