Parallel.ForEach MaxDegreeOfParallelism Strange Behavior with Increasing "Chunking"

Viewed 364

I'm not sure if the title makes sense, it was the best I could come up with, so here's my scenario.

I have an ASP.NET Core app that I'm using more as a shell and for DI configuration. In Startup it adds a bunch of IHostedServices as singletons, along with their dependencies, also as singletons, with minor exceptions for SqlConnection and DbContext which we'll get to later. The hosted services are groups of similar services that:

  1. Listen for incoming reports from GPS devices and put into a listening buffer.
  2. Parse items out of the listening buffer and put into a parsed buffer.

Eventually there's a single service that reads the parsed buffer and actually processes the parsed reports. It does this by passing the report it took out of the buffer to a handler and awaits for it to complete to move to the next. This has worked well for the past year, but it appears we're running into a scalability issue now because its processing one report at a time and the average time to process is 62ms on the server which includes the Dapper trip to the database to get the data needed and the EF Core trip to save changes.

If however the handler decides that a report's information requires triggering background jobs, then I suspect it takes 100ms or more to complete. Over time, the buffer fills up faster than the handler can process to the point of holding 10s if not 100s of thousands of reports until they can be processed. This is an issue because notifications are delayed and because it has the potential for data loss if the buffer is still full by the time the server restarts at midnight.

All that being said, I'm trying to figure out how to make the processing parallel. After lots of experimentation yesterday, I settled on using Parallel.ForEach over the buffer using GetConsumingEnumerable(). This works well, except for a weird behavior I don't know what to do about or even call. As the buffer is filled and the ForEach is iterating over it it will begin to "chunk" the processing into ever increasing multiples of two. The size of the chunking is affected by the MaxDegreeOfParallelism setting. For example (N# = Next # of reports in buffer):

MDP = 1

  • N3 = 1 at a time
  • N6 = 2 at a time
  • N12 = 4 at a time
  • ...

MDP = 2

  • N6 = 1 at a time
  • N12 = 2 at a time
  • N24 = 4 at a time
  • ...

MDP = 4

  • N12 = 1 at a time
  • N24 = 2 at a time
  • N48 = 4 at a time
  • ...

MDP = 8 (my CPU core count)

  • N24 = 1 at a time
  • N48 = 2 at a time
  • N96 = 4 at a time
  • ...

This is arguably worse than the serial execution I have now because by the end of the day it will buffer and wait for, say, half a million reports before actually processing them.

Is there a way to fix this? I'm not very experienced with Parallel.ForEach so from my point of view this is strange behavior. Ultimately I'm looking for a way to parallel process the reports as soon as they are in the buffer, so if there's other ways to accomplish this I'm all ears. This is roughly what I have for the code. The handler that processes the reports does use IServiceProvider to create a scope and get an instance of SqlConnection and DbContext. Thanks in advance for any suggestions!

public sealed class GpsReportService :
    IHostedService {
    private readonly GpsReportBuffer _buffer;
    private readonly Config _config;
    private readonly GpsReportHandler _handler;
    private readonly ILogger _logger;

    public GpsReportService(
        GpsReportBuffer buffer,
        Config config,
        GpsReportHandler handler,
        ILogger<GpsReportService> logger) {
        _buffer = buffer;
        _config = config;
        _handler = handler;
        _logger = logger;
    }

    public Task StartAsync(
        CancellationToken cancellationToken) {
        _logger.LogInformation("GPS Report Service => starting");

        Task.Run(Process, cancellationToken).ConfigureAwait(false);//   Is ConfigureAwait here correct usage?

        _logger.LogInformation("GPS Report Service => started");

        return Task.CompletedTask;
    }

    public Task StopAsync(
        CancellationToken cancellationToken) {
        _logger.LogInformation("GPS Parsing Service => stopping");

        _buffer.CompleteAdding();

        _logger.LogInformation("GPS Parsing Service => stopped");

        return Task.CompletedTask;
    }

    //  ========================================================================
    //  Utilities
    //  ========================================================================

    private void Process() {
        var options = new ParallelOptions {
            MaxDegreeOfParallelism = 8,
            CancellationToken = CancellationToken.None
        };

        Parallel.ForEach(_buffer.GetConsumingEnumerable(), options, async report => {
            try {
                await _handler.ProcessAsync(report).ConfigureAwait(false);
            } catch (Exception e) {
                if (_config.IsDevelopment) {
                    throw;
                }

                _logger.LogError(e, "GPS Report Service");
            }
        });
    }

    private async Task ProcessAsync() {
        while (!_buffer.IsCompleted) {
            try {
                var took = _buffer.TryTake(out var report, 10);

                if (!took) {
                    continue;
                }

                await _handler.ProcessAsync(report!).ConfigureAwait(false);
            } catch (Exception e) {
                if (_config.IsDevelopment) {
                    throw;
                }

                _logger.LogError(e, "GPS Report Service");
            }
        }
    }
}

public sealed class GpsReportBuffer :
    BlockingCollection<GpsReport> {
}
3 Answers

You can't use Parallel methods with async delegates - at least, not yet.

Since you already have a "pipeline" style of architecture, I recommend looking into TPL Dataflow. A single ActionBlock may be all that you need, and once you have that working, other blocks in TPL Dataflow may replace other parts of your pipeline.

If you prefer to stick with your existing buffer, then you should use asynchronous concurrency instead of Parallel:

private void Process() {
  var throttler = new SemaphoreSlim(8);
  var tasks = _buffer.GetConsumingEnumerable()
      .Select(async report =>
      {
        await throttler.WaitAsync();
        try {
          await _handler.ProcessAsync(report).ConfigureAwait(false);
        } catch (Exception e) {
          if (_config.IsDevelopment) {
            throw;
          }

          _logger.LogError(e, "GPS Report Service");
        }
        finally {
          throttler.Release();
        }
      })
      .ToList();
  await Task.WhenAll(tasks);
}

You have an event stream processing/dataflow problem, not a parallelism problem. If you use the appropriate classes, like the Dataflow blocks, Channels, or Reactive Extensions the problem is simplified a lot.

Even if you want to use a single buffer and a fat worker method though, the appropriate buffer class is the asynchronous Channel, not BlockingCollection. The code could become as simple as:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    await foreach(GpsMessage msg in _reader.ReadAllAsync(stopppingToken))
    {
       await _handler.ProcessAsync(msg);
    }
}

The first option shows how to use a Dataflow to create a pipeline. The second, how to use Channel instead of BlockingCollection to process multiple queued items concurrently

A pipeline with Dataflow

Once you break the process into independent methods, it's easy to create a pipeline of processing steps using any library.

Task<IEnumerable<GpsMessage>> Poller(DateTime time,IList<Device> devices,CancellationToken token=default)
{
    foreach(var device in devices)
    {
        if(token.IsCancellationRequested)
        {
            break;
        }
        var msg=await device.ReadMessage();
        yield return msg;
    }
}

GpsReport Parser(GpsMessage msg)
{
    //Do some parsing magic. 
    return report;
}

async Task<GpsReport> Enrich(GpsReport report,string connectionString,CancellationToken token=default)
{
    //Depend on connection pooling to eliminate the cost of connections
    //We may have to use a pool of opened connections otherwise
    using var con=new SqlConnection(connectionString);
    var extraData=await con.QueryAsync<Extra>(sql,new {deviceId=report.DeviceId},token);
    report.Extra=extraData;
    return report;
}

async Task BulkImport(SqlReport[] reports,CancellationToken token=default)
{
    using var bcp=new SqlBulkCopy(...);
    using var reader=ObjectReader.Create(reports);
    ...
    await bcp.WriteToServerAsync(reader,token);
}

In the BulkImport method I use FasMember's ObjectReader to create an IDataReader wrapper over the reports so I can use them with SqlBulkCopy. Another option would be to convert them to a DataTable, but that would create an extra copy of the data in memory.

Combining all these with Dataflow is relatively easy.

var execOptions=new ExecutionDataflowBlockOptions
{
    MaxDegreeOfParallelism = 10
}

_poller      = new TransformManyBlock<DateTime,GpsBuffer>(time=>Poller(time,devices));
_parser      = new TransformBlock<GpsBuffer,GpsReport>(b=>Parser(b),execOptions);
var enricher = new TransformBlock<GpsReport,GpsReport>(rpt=>Enrich(rpt,connStr),execOptions);
_batch       = new BatchBlock<GpsReport>(50);
_bcpBlock    = new ActionBlock<GpsReport[]>(reports=>BulkImport(reports));

Each block has an input and output buffer (except ActionBlock). Each block takes care of processing the messages in its input buffer and processes it. By default, each block uses only one worker task, but that can be changed. The message order is maintained, so if we use eg 10 worker tasks for the parser block, the messages will still be emitted in the order they were received.

Next comes linking the blocks.

var linkOptions=new DataflowLinkOptions {PropagateCompletion=true};

_poller.LinkTo(_parser,options);
_parser.LinkTo(_enricher,options);
_enricher.LinkTo(_batch,options);
_batch.LinkTo(_bcpBlock,options);

After that, a timer can be used to "ping" the head block, the poller, whenever we want:


private void Ping(object state)
{
    _poller.Post(DateTime.Now);
}

public Task StartAsync(CancellationToken stoppingToken)
{
    _logger.LogInformation("Timed Hosted Service running.");

    _timer = new Timer(Ping, null, TimeSpan.Zero, 
        TimeSpan.FromSeconds(5));

    return Task.CompletedTask;
}

To stop the pipeline gracefully, we call Complete() on the head block and await the Completion task on the last block. Assuming the hosted service is similar to the timed background service example:

public Task StopAsync(CancellationToken cancellationToken) 
{

    ....
    _timer?.Change(Timeout.Infinite, 0);
    _poller.Complete();
    await _bcpBlock.Completion;
    ...
}

Using Channel as an Async queue

A Channel is a far better alternative for asynchronous publisher/subscriber scenarios than BlockingCollection. Roughly, it's an asynchronous queue that goes to extremes to prevent the publisher from reading, or the subscriber from writing, by forcing callers to use the ChannelWriter and ChannelReader classes. In fact, it's quite common to only pass those classes around, never the Channel instance itself.

In your publishing code, you can create a Channel<T> and pass its Reader to the GpsReportService service. Let's assume the publisher is another service that implements an IGpsPublisher interface :

public interface IGpsPublisher
{
    ChannelReader<GspMessage> Reader{get;}
}

and the implementation


Channel<GpsMessage> _channel=Channel.CreateUnbounded<GpsMessage>();

public ChannelReader<GspMessage> Reader=>_channel;

private async void Ping(object state)
{
    foreach(var device in devices)
    {
        if(token.IsCancellationRequested)
        {
            break;
        }
        var msg=await device.ReadMessage();
        await _channel.Writer.WriteAsync(msg);
    }
}

public Task StartAsync(CancellationToken stoppingToken)
{

    _timer = new Timer(Ping, null, TimeSpan.Zero, 
        TimeSpan.FromSeconds(5));

    return Task.CompletedTask;
}

public Task StopAsync(CancellationToken cancellationToken) 
{
    _timer?.Change(Timeout.Infinite, 0);
    _channel.Writer.Complete();
}

This can be passed to GpsReportService as a dependency that will be resolved by the DI container:

public sealed class GpsReportService : BackgroundService
{
    private readonly ChannelReader<GpsMessage> _reader;


    public GpsReportService(
        IGpsPublisher publisher,
        ...) 
    {
        _reader = publisher.Reader;
        ...
    }

And used

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    await foreach(GpsMessage msg in _reader.ReadAllAsync(stopppingToken))
    {
       await _handler.ProcessAsync(msg);
    }
}

Once the publisher completes, the subscriber loop will also complete once all messages are processed.

To process in parallel, you can start multiple loops concurrently:

async Task Process(ChannelReader<GgpsMessage> reader,CancellationToken token)
{
    await foreach(GpsMessage msg in reader.ReadAllAsync(token))
    {
       await _handler.ProcessAsync(msg);
    }
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    var tasks=Enumerable.Range(0,10)
                 .Select(_=>ProcessReader(_reader,stoppingToken))
                 .ToArray();
    await Task.WhenAll(tasks);
}

Explaining the pipeline

I have a similar situation: every 15 minutes I request air ticket sales reports from airlines (actually GDSs), parse them to extract data and ticket numbers, download the ticket record for each ticket to get some extra data and save everything to the database. I have to do that for 20+ cities (ticket reports are per city) with each report having from 10 to over 100K tickets.

This almost begs for a pipeline. Using your example, you can create a pipeline with the following steps/blocks:

  1. Listen for GPS messages and emit the unparsed message.
  2. Parse the message and emit the parsed message
  3. Load any extra data needed per message and emit a combined record
  4. Handle the combined record and emit the result
  5. (Optional) batch results
  6. Save the results to the database

All three options (Dataflow, Channels, Rx) take care of buffering between the steps. Dataflow is a some-assembly-required library for pipelines processing independent events, Rx is ready-made to analyze streams of events where time is important (eg to calculate average speed in a sliding window), Channels is Lego bricks that can do anything but need to be put together.

Why not Parallel.ForEach

Parallel.ForEach is meant for data parallelism, not async operations. It's meant to process large chunks of in-memory data, independent of each other. Amdah's Law explains that parallelization benefits are limited by the synchronous part of an operation, so all data parallelism libraries try to reduce that by partitioning, and using one core/machine/node to process each partition.

Parallel.ForEach also works by partitioning the data and using roughly one worker task per CPU core, to reduce synchronization between cores. It will even use the current thread which leads to the mistaken assumption it's blocking. When all cores are busy, why not use the thread? It won't be able to run anyway.

The Parallel.ForEach employs chunk partitioning by default, which is intended for reducing the synchronization overhead in CPU-intensive applications, but can result to problematic behavior in some usage scenarios. The chunk partitioning can be disabled by passing as argument a Partitioner<T> instead of an IEnumerable<T>:

Parallel.ForEach(Partitioner.Create(_buffer.GetConsumingEnumerable(),
    EnumerablePartitionerOptions.NoBuffering), options, ...

You can also find a custom partitioner, tailored specifically for BlockingCollection<T>s, in this article: ParallelExtensionsExtras Tour – #4 – BlockingCollectionExtensions

That said, the Parallel.ForEach is not async-friendly, meaning that it doesn't understand async delegates. The lambda passed is async void, which is something to avoid. So I would recommend using an ActionBlock<T> instead.

Related