0

I am using Parallel.ForEach loop to download multiple files from a Web API in parallel, in a .NET 4.8 application.
The problem is that sometimes I get a ThreadAbortException / The Maximum Wait/Sleep/Join time has been exceeded (00:05:00).
I can't seem to find a way to extend the "Maximum Wait Timeout" for the treads inside the Parallel.ForEach loop.

Sample code causing the problem.

// create the httpclient
using (HttpClient httpClient = new HttpClient())
{
    // now try to get documents in a parallel threaded task.
    Parallel.ForEach(documentIds, 
        new ParallelOptions { MaxDegreeOfParallelism = 
            Convert.ToInt32(Math.Ceiling((Environment.ProcessorCount * 0.50) * 2.0))},
        (documentId) =>
    {
        try
        {
            // post the data to the service
            using (HttpResponseMessage response =
                httpClient.PostAsync(serviceURL, new StringContent(
                requestBody.ToString())).GetAwaiter().GetResult())
            {
                // show error if service failed
                if (response.IsSuccessStatusCode == false)
                {
                    throw new Exception("Error getting content. " +
                        response?.ReasonPhrase);
                }

                // get back content
                using (Stream responseStream = response.Content.ReadAsStreamAsync()
                    .GetAwaiter().GetResult())
                {
                    // write the file to the FileSystem
                }
            }
        }
        catch (Exception ex)
        {
            // eat the error
            
            // NOTE: This does catch the ThreadAbortException but it breaks out
            // of the Parallel.ForEach BUT I want it to continue with other documents
        }
    });
}

Update 1
I got it to work but the solution is not a good one.
Basically the HttpClient is the problem.
If I create a NEW HttpClient inside the Parallel.ForEach loop, then it will let me handle the ThreadAbortException without breaking out of the loop.
MS best practices says to not create multiple HttpClients else it can keep to many open sockets before cleanup.

goroth
  • 2,510
  • 5
  • 35
  • 66
  • @PanagiotisKanavos Am I missreading the MS article? In the link, it says "executes a operation with thread-local data ... in which iterations may run in parallel...". I do want to download different files in parallel not concurrent. https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.parallel.foreach?view=netframework-4.8 If not in a parallel foreach loop, then what should I be using to download files in parallel? – goroth Mar 20 '23 at 16:59
  • 1
    @TheodorZoulias This is in .NET 4.8 – goroth Mar 20 '23 at 17:00
  • 1
    @PanagiotisKanavos While not the most efficient way to do this, this does not have any spin waiting in it (there isn't even a loop to do it). I/O will block normally. – Matti Virkkunen Mar 20 '23 at 17:05
  • Could you include in the question the full stack trace of the error? Also please mention the `Environment.ProcessorCount` in your machine. Also could you try with `MaxDegreeOfParallelism = 2`, and see if the issue persists? – Theodor Zoulias Mar 20 '23 at 17:10
  • @TheodorZoulias The stack trace just says that line "Parallel.ForEach line number" has thrown "System.Threading.ThreadAbortException" with with message "Thread was being aborted. The Maximum Wait/Sleep/Join time has been exceeded (00:05:00). Thread is suspected to be deadlocked.". When downloading the files, we can get anywhere between 200 - 300 files before seing this error and we just want to continue the "other" downloads. We have code to sweep the missing files later. I have tried "MaxDegreeOfParallelism = 2" and it gets futrher but takes longer to download files. CPU 4 cores / 8 logical – goroth Mar 20 '23 at 17:33
  • Interesting. So you say that the exception is thrown by the `Parallel.ForEach` itself. It's not an exception thrown in the `body` delegate, by some `HttpClient` call, and propagated by the `Parallel.ForEach`, correct? – Theodor Zoulias Mar 20 '23 at 17:51
  • 1
    @TheodorZoulias That is somewhat correct. If I add a Try Catch around the actual HttpClient.PostAsync, as in the example I posted, it will catch the TheadAbortException BUT it will somehow re-throw the exception killing the ForEach loop. If I move the Try Catch outside the ForEach, the stack trace will show the line number as the ForEach line number. Sorry for the confusion. – goroth Mar 20 '23 at 18:11
  • If I was in your shoes I would check my project for the `Abort` keyword, to make sure that there is no custom code somewhere that aborts threads. Then I would open a [new issue](https://github.com/dotnet/runtime/issues/new/choose), or a [new discussion](https://github.com/dotnet/runtime/discussions/new/choose) on GitHub. I don't think that anyone here is able to answer your question. – Theodor Zoulias Mar 20 '23 at 18:55
  • Btw I have posted [here](https://stackoverflow.com/questions/11564506/nesting-await-in-parallel-foreach/65251949#65251949) a `Parallel_ForEachAsync` method that is compatible with the .NET Framework, and behaves similarly with the .NET 6 `Parallel.ForEachAsync` API. – Theodor Zoulias Mar 20 '23 at 19:01
  • The problem isn't HttpClient, it's using asynchronous code inside `Parallel.ForEach` and then blocking it. That's what causes the threading issues. To fix it you need to stop blocking, avoiding the exception in the first place. If you work on .NET Framework, you can use ActionBlock for this – Panagiotis Kanavos Mar 21 '23 at 08:12
  • I added an example of an ActionBlock that can be used to download N items concurrently – Panagiotis Kanavos Mar 21 '23 at 08:29

1 Answers1

3

That's a misuse of Parallel.ForEach. That method is simply not meant for concurrent operations, it's only meant for in-memory data parallelism. This code blocks all CPU cores spinwaiting while waiting for responses. That means it pegs the CPU at 100% for a while before the threads start getting evicted.

Use Parallel.ForEachAsync instead, or Dataflow ActionBlock, eg :

await Parallel.ForEachAsync(documentIds, async (documentId,ct)=>{
...
});

This will use roughly as many worker tasks as there cores to process the data. You can specify a different number (higher or lower) with the ParallelOptions parameter

await Parallel.ForEachAsync(documentIds, 
    new ParallelOptions { MaxDegreeOfParallelism = 4},
    async (documentId,ct)=>{
    ...
    });

You may want to specify a lower number if the files are large, flooding the network, or you can increase it if there are a lot of smaller files, or the response takes a lot time to start.

ActionBlock - .NET Framework 4.8

Another option, one that can scale to an entire processing pipeline, is to use a Dataflow ActionBlock to process data concurrently.

var dop=new ExecutionDataflowBlockOptions
         {
            MaxDegreeOfParallelism = 10,
            BoundedCapacity=10
         };
var httpClient = new HttpClient()
var downloader=new ActionBlock<string>(docId=>DownloadAsync(httpClient,docId),dop);

Once you have the downloader block you can start posting doc IDs to it. When done, you tell the block to complete and await for all pending messages to finish processing:

foreach(var docId in documentIds)
{
    await downloader.SendAsync(docId);
}
downloader.Complete();
await downloader.Completion;

The downloading method itself can be fully asynchronous:

async Task DownloadAsync(HttpClient client,string documentId)
{
    var url=...;
    var req=new StringContent(requestBody.ToString());
    var response=await client.PostAsync(serviceURL, req);
    ...
    using var dlStream=await response.Content.ReadAsStreamAsync();
    using var fileStream=File.Create(somePath);
    await dlStream.CopyTo(fileStream);
}

All Dataflow blocks have an input buffer to hold input messages. By default, it has no limit. Adding BoundedCapacity=10 will cause the posting code to pause if more than 10 messages are pending. This can be used to throttle publishing code that's faster than the processing code and avoid filling up memory

Panagiotis Kanavos
  • 120,703
  • 13
  • 188
  • 236
  • Sorry, I guess I should have showed all the code. I do indeed limit the the MaxDegreeOfParallelism to a calculation of 50% of the CPU cores. I will adjust the code to show that. – goroth Mar 20 '23 at 16:48
  • I would also like to mention that while running the code, we never see higher than 40% CPU usage. My best guess is that each thread expects to be finished within 5 minutes but some take longer than 5 minutes to download files but the Parallel.ForEach loop does not offer a way to extend the 5 minute timeout on the thread. – goroth Mar 20 '23 at 19:44
  • 40% for no CPU work is a lot. HttpClient isn't the problem either. I added an example using `ActionBlock` that allows fully asynchronous operation without blocking any threads – Panagiotis Kanavos Mar 21 '23 at 08:24