Skip to content

Chapter 13: The Scatter-Gather Pattern - Querying Multiple Models Simultaneously (Task.WhenAll)

Theoretical Foundations

The Scatter-Gather pattern is a fundamental architectural paradigm in distributed systems and asynchronous programming that addresses the challenge of reducing latency when coordinating multiple independent operations. In the context of AI application development, this pattern becomes indispensable when building complex workflows that require inputs from multiple specialized models. For instance, a content moderation system might need to simultaneously query a toxicity detection model, a fact-checking model, and a sentiment analysis model to generate a comprehensive safety assessment. Executing these queries sequentially would result in cumulative latency equal to the sum of individual response times, whereas executing them concurrently allows the total latency to approach the duration of the slowest single operation.

At its core, the Scatter-Gather pattern operates on a simple principle: scatter independent tasks across available computational resources, then gather and aggregate their results once they have all completed. This pattern is particularly relevant in AI applications because modern AI systems are increasingly composed of multiple specialized models rather than monolithic, all-encompassing entities. A single AI-powered application might leverage OpenAI's GPT-4 for creative text generation, a local Llama model for structured data extraction, and a specialized vision model for image analysis—all within the same user request pipeline.

The Task.WhenAll method in C# serves as the primary mechanism for implementing this pattern. It represents a sophisticated synchronization primitive that operates at the intersection of the Task Parallel Library (TPL) and the async/await language feature introduced in C# 5.0. Unlike traditional thread-based parallelism, which requires manual thread management and synchronization, Task.WhenAll leverages the runtime's thread pool and continuation-based asynchronous execution to achieve optimal resource utilization without blocking threads unnecessarily.

To understand why this matters for AI applications, consider the architectural constraints of modern AI services. Most cloud-based AI endpoints (OpenAI, Azure Cognitive Services, AWS Bedrock) have response times that vary significantly based on request complexity, model size, and current load. A typical request might take anywhere from 200ms to 5 seconds. When building a multi-model AI pipeline, if we execute these requests sequentially, a three-model workflow could easily take 15 seconds. With Task.WhenAll, the same workflow might complete in 5 seconds or less, dramatically improving user experience and system throughput.

The Scatter-Gather Pattern in AI Contexts

The Scatter-Gather pattern manifests in several common AI application scenarios:

  1. Multi-Model Validation: When processing critical content, multiple models might be consulted for different aspects of validation. For example, a financial document processing system might simultaneously query a legal compliance model, a numerical accuracy model, and a semantic consistency model. Each model provides independent validation, and the system aggregates their outputs to produce a final confidence score.

  2. Ensemble Learning Inference: While traditional ensemble learning typically combines model outputs during training, modern AI systems often perform "inference-time ensembling" by querying multiple models and combining their predictions. This is particularly common in scenarios where different models have complementary strengths—for instance, combining a large, general-purpose language model with a smaller, domain-specific model.

  3. Redundancy and Fallback Strategies: In production AI systems, model availability can be inconsistent. The Scatter-Gather pattern enables implementing sophisticated fallback strategies where multiple models are queried simultaneously, and the system uses the first successful response while gracefully handling failures in others.

  4. Multi-Modal Processing: Modern AI applications increasingly need to process multiple types of data simultaneously—text, images, audio, and structured data. Each modality might require a specialized model, and the Scatter-Gather pattern allows these parallel processing pipelines to execute concurrently.

The Role of Task.WhenAll in C

The Task.WhenAll method is the cornerstone of implementing the Scatter-Gather pattern in C#. It accepts a collection of tasks and returns a task that completes when all input tasks have completed. The method is highly optimized and handles several complex scenarios:

Heterogeneous Task Collections: In AI applications, we often need to await tasks that return different types. Task.WhenAll can handle collections of Task<TResult> where TResult varies. For example, we might have one task returning a string (text generation), another returning a float[] (embedding vector), and a third returning a bool (classification result). The method provides overloads that return Task<object[]> or can be used with value tuples to maintain type safety.

Exception Aggregation: When multiple tasks execute concurrently, several might fail. Task.WhenAll aggregates all exceptions from faulted tasks into an AggregateException. This is crucial for AI applications where partial failures are common—a model might timeout, return an error, or produce invalid output. The pattern allows the application to decide whether to fail fast, retry individual tasks, or proceed with partial results.

Cancellation Propagation: AI model queries often need to respect cancellation tokens, especially in interactive applications. Task.WhenAll respects cancellation and propagates cancellation tokens to all constituent tasks. If cancellation is requested, all tasks that haven't completed will attempt to cancel, and the returned task will be marked as canceled.

Performance Characteristics: Unlike Task.WaitAll, which blocks the calling thread, Task.WhenAll is fully asynchronous. This means the calling thread can be returned to the thread pool to handle other work while waiting for the tasks to complete. In server applications (like ASP.NET Core), this is critical for scalability—each request handler can initiate multiple AI model queries without consuming a thread while waiting.

Real-World Analogy: The Restaurant Kitchen

To understand the Scatter-Gather pattern intuitively, consider a high-end restaurant kitchen during dinner service. The head chef receives an order for a three-course meal: appetizer, main course, and dessert. Each course requires different specialized chefs and equipment:

  • The appetizer requires the sauté station (quick, high-heat cooking)
  • The main course requires the grill station (precise temperature control)
  • The dessert requires the pastry station (delicate, time-sensitive preparation)

Sequential Execution (The Inefficient Way): The head chef could wait for the sauté chef to finish the appetizer, then hand off to the grill chef for the main course, and finally to the pastry chef for dessert. This would result in the customer waiting for the sum of all preparation times—potentially 30 minutes for a simple meal.

Concurrent Execution (The Scatter-Gather Way): The head chef simultaneously assigns each course to its specialized station. While the sauté chef prepares the appetizer, the grill chef starts the main course, and the pastry chef begins the dessert. The head chef then waits for all stations to signal completion before plating and serving. The total wait time approaches the duration of the longest individual preparation, not the sum.

In this analogy:

  • The head chef represents the orchestrating code that initiates the Scatter-Gather pattern
  • The specialized stations represent different AI models or endpoints
  • The orders represent the input data or prompts
  • The completion signals represent the Task objects returned by each model query
  • Plating the meal represents aggregating the responses into a unified result

This analogy breaks down in one important aspect: in a real kitchen, resources (chefs) are finite and must be allocated carefully. In C# async/await with Task.WhenAll, the thread pool dynamically manages resources, allowing potentially hundreds of concurrent operations without requiring proportional thread allocation.

Historical Context and Evolution

The Scatter-Gather pattern predates modern async/await syntax but has been revolutionized by it. In the .NET Framework 4.0 era, developers used Task.Factory.StartNew and manual continuation chaining to achieve similar results. The introduction of async/await in C# 5.0 made the pattern more accessible and readable, but the underlying complexity remained.

Consider this historical comparison. Before async/await, implementing Scatter-Gather required explicit continuation handling:

// Pre-C# 5.0 approach (conceptual)
var tasks = new List<Task<ModelResponse>>();
foreach (var model in models)
{
    var task = Task.Factory.StartNew(() => QueryModel(model, input));
    tasks.Add(task);
}

Task.Factory.ContinueWhenAll(tasks.ToArray(), completedTasks => 
{
    // Manual aggregation logic
    var results = completedTasks.Select(t => t.Result).ToArray();
    // Process results...
});

Modern C# simplifies this dramatically while maintaining the same underlying mechanics. The pattern's relevance has actually increased with the rise of AI because:

  1. Model Specialization: AI is moving away from monolithic models toward specialized, task-specific models
  2. Microservices Architecture: AI services are increasingly deployed as independent microservices, making concurrent calls natural
  3. Real-Time Requirements: User expectations for AI applications demand sub-second response times
  4. Cost Optimization: Cloud AI services charge per request, making efficiency critical

Architectural Implications for AI Systems

Implementing the Scatter-Gather pattern in AI applications introduces several architectural considerations:

Resource Management: While Task.WhenAll enables high concurrency, uncontrolled parallelism can overwhelm downstream AI services or exhaust local resources. Production systems need rate limiting, circuit breakers, and connection pooling. The pattern should be combined with SemaphoreSlim or Polly policies to control concurrency.

Latency Distribution: AI model response times often follow a long-tail distribution. A few requests might complete in 100ms, while others take 10 seconds. When using Task.WhenAll, the total latency is determined by the slowest task. This makes outlier detection and timeout management critical. Setting appropriate timeouts for each model query prevents a single slow model from delaying the entire pipeline.

Data Consistency: When querying multiple models, there's no guarantee they'll return consistent or compatible data. A text generation model might return markdown, while a sentiment analysis model returns JSON. The aggregation layer must handle format conversion, validation, and potential conflicts.

Cost Implications: In cloud AI services, concurrent requests mean concurrent billing. While Scatter-Gather reduces latency, it doesn't reduce the total number of API calls. However, the improved user experience and system throughput can justify the cost, especially in high-volume applications.

Integration with Previous Concepts

This pattern builds directly upon the async/await fundamentals covered in Book 1. In particular, it leverages the Task-based Asynchronous Pattern (TAP) that was introduced as the standard for asynchronous APIs in .NET. The pattern also assumes familiarity with TaskCompletionSource<T>, which is essential for creating custom tasks when wrapping non-TAP APIs (common in AI SDKs that haven't adopted async patterns).

Furthermore, the pattern extends the continuation-based execution model introduced in earlier chapters. When Task.WhenAll is called, it doesn't block or spin-wait; instead, it registers continuations on each constituent task. Only when all continuations have executed does the returned task complete. This continuation-based approach is what makes the pattern scalable and efficient.

Edge Cases and Nuances

Empty Collections: Calling Task.WhenAll with an empty collection returns a task that completes immediately. This is useful for conditional parallelism where the number of tasks might be zero.

Null Tasks: The method throws an ArgumentNullException if the collection contains null tasks. This is a design decision to catch programming errors early.

Mixed Task States: If some tasks are already completed when Task.WhenAll is called, it still waits for all tasks to reach a terminal state (completed, faulted, or canceled). This is efficient because completed tasks don't consume additional resources.

Value Task Optimization: In C# 8.0+, with the introduction of ValueTask<T>, developers can optimize for hot-path scenarios where tasks often complete synchronously. Task.WhenAll has overloads for ValueTask<T>, though mixing Task and ValueTask requires careful handling.

Task.WhenAll vs Task.WhenAny: While WhenAll waits for all tasks, WhenAny waits for the first task to complete. In AI applications, WhenAny is useful for implementing fallback strategies—for example, querying multiple models and using the first successful response. The choice between them depends on whether you need all results (consensus) or just one (fastest response).

The Scatter-Gather Pattern in Microservices Architecture

Modern AI applications are rarely monolithic. They typically consist of multiple microservices, each responsible for a specific AI capability. The Scatter-Gather pattern is the natural communication pattern between these services. For example:

  1. API Gateway: Receives a user request and determines which AI services are needed
  2. Service Discovery: Locates available instances of each required AI service
  3. Concurrent Invocation: Uses Task.WhenAll to query all services simultaneously
  4. Response Aggregation: Combines results into a unified response
  5. Error Handling: Manages partial failures and retries

This architecture requires careful consideration of network latency, serialization/deserialization overhead, and service mesh integration. The pattern's efficiency depends on the underlying transport (HTTP/2, gRPC, etc.) and whether the services are co-located or geographically distributed.

Performance Characteristics and Benchmarks

The performance benefits of Scatter-Gather are most pronounced when:

  1. Tasks are I/O-bound: AI model queries are typically network I/O bound, not CPU bound. The pattern allows the thread pool to handle other work while waiting for responses.
  2. Tasks have similar durations: If one task consistently takes 10x longer than others, the benefit diminishes. However, even with variance, the pattern is still superior to sequential execution.
  3. Resource constraints are managed: Without proper concurrency limits, the pattern can cause resource exhaustion or service degradation.

In practice, the performance gain follows the formula:

Speedup = Total Sequential Time / Total Concurrent Time
Total Sequential Time = Σ(task_i_duration)
Total Concurrent Time ≈ Max(task_i_duration) + Overhead

For AI applications, where task durations are often in the hundreds of milliseconds to seconds range, and overhead is typically in the tens of milliseconds, speedups of 2x to 10x are common.

Visualizing the Pattern

The following diagram illustrates the Scatter-Gather pattern in an AI context:

A diagram illustrating the Scatter-Gather pattern shows a single request splitting into multiple parallel AI tasks, which are processed concurrently and then aggregated back into a single response, highlighting how the total time is dominated by the slowest task plus overhead.
Hold "Ctrl" to enable pan & zoom

A diagram illustrating the Scatter-Gather pattern shows a single request splitting into multiple parallel AI tasks, which are processed concurrently and then aggregated back into a single response, highlighting how the total time is dominated by the slowest task plus overhead.

This visualization shows how a single client request triggers concurrent execution across multiple AI models, with the orchestrator using Task.WhenAll to synchronize completion before aggregation.

Advanced Considerations for Production Systems

Dynamic Task Discovery: In some AI applications, the set of models to query isn't static. It might depend on the input data or configuration. The Scatter-Gather pattern can accommodate this by building the task collection dynamically:

var tasks = new List<Task<ModelResponse>>();
if (input.Contains("financial"))
{
    tasks.Add(QueryFinancialModel(input));
}
if (input.Contains("medical"))
{
    tasks.Add(QueryMedicalModel(input));
}
// ... etc
var results = await Task.WhenAll(tasks);

Partial Result Processing: For very large numbers of tasks, waiting for all to complete might be suboptimal. The pattern can be extended to process results as they arrive, using techniques like Task.WhenAny in a loop or reactive extensions (Rx.NET).

Cancellation and Timeouts: AI model queries should always have timeouts to prevent hanging. Combining Task.WhenAll with CancellationTokenSource and Task.Delay allows implementing sophisticated timeout strategies:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var tasks = models.Select(m => QueryModelWithTimeout(m, input, cts.Token));
try
{
    var results = await Task.WhenAll(tasks);
}
catch (OperationCanceledException)
{
    // Handle timeout
}

Result Ordering: Task.WhenAll doesn't guarantee the order of results matches the order of input tasks. The results array is indexed by task order, not completion order. This is important when aggregating results from models that might complete in different orders.

Memory Considerations: When querying many models simultaneously, memory usage can spike as all responses are held in memory simultaneously. For very large responses (e.g., image generation), consider streaming or chunked processing.

The Future of Scatter-Gather in AI

As AI systems become more distributed and specialized, the Scatter-Gather pattern will evolve. Emerging trends include:

  1. Edge AI Coordination: Coordinating between cloud models and edge models running on devices
  2. Federated Learning Integration: Combining results from models trained on different data sources
  3. Real-Time Model Switching: Dynamically selecting the best model combination based on current conditions
  4. Streaming Aggregation: Processing partial results as they arrive for ultra-low-latency applications

The core principle—concurrent execution with synchronized completion—remains constant, but the implementation details will continue to evolve with the AI landscape.

In summary, the Scatter-Gather pattern implemented via Task.WhenAll is not merely a convenience feature; it's a fundamental architectural pattern that enables building responsive, efficient, and scalable AI applications. It transforms the way we think about AI pipelines, moving from sequential, monolithic processing to concurrent, specialized, and composable systems. The pattern's power lies in its simplicity: scatter work, gather results, and let the runtime handle the complexity of concurrent execution.

Basic Code Example

Let's imagine a scenario: You are building a dashboard for a financial analyst. To get a complete picture of a company, the analyst needs to summarize the latest news, analyze recent stock trends, and check for any regulatory filings. These are three distinct tasks that can be performed simultaneously by different specialized AI models (or APIs). Waiting for one to finish before starting the next would make the dashboard sluggish. The Scatter-Gather pattern solves this by "scattering" the requests concurrently and "gathering" the results once all are complete.

Here is a simple, self-contained C# console application demonstrating this pattern using Task.WhenAll.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;

namespace ScatterGatherDemo
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            Console.WriteLine("--- Starting Scatter-Gather Demo ---");

            // Define the data sources we want to query
            var queries = new Dictionary<string, string>
            {
                { "News Summary", "Latest headlines for TechCorp" },
                { "Stock Analysis", "Trend analysis for Ticker: TC" },
                { "Regulatory Filings", "Recent filings for CIK: 000123456" }
            };

            // 1. SCATTER: Initiate all tasks concurrently without awaiting them immediately.
            // We store the "hot" tasks in a collection.
            var processingTasks = queries.Select(kvp => 
                FetchModelDataAsync(kvp.Key, kvp.Value)
            ).ToList();

            // 2. GATHER: Wait for ALL tasks to complete. 
            // If one fails, Task.WhenAll will throw an AggregateException (wrapped in TaskCanceledException in .NET).
            // We use WhenAll to ensure we have all results (or know exactly which ones failed) before proceeding.
            var results = await Task.WhenAll(processingTasks);

            Console.WriteLine("\n--- All Models Completed ---");

            // 3. PROCESS: Iterate over the consolidated results.
            foreach (var result in results)
            {
                Console.WriteLine($"[{result.Source}]: {result.Summary}");
            }

            Console.WriteLine("\n--- Demo Complete ---");
        }

        /// <summary>
        /// Simulates calling an external AI model or API endpoint.
        /// </summary>
        /// <param name="modelName">The name of the model/service.</param>
        /// <param name="input">The query/prompt.</param>
        /// <returns>A tuple containing the source and the generated response.</returns>
        private static async Task<ModelResponse> FetchModelDataAsync(string modelName, string input)
        {
            // Simulate network latency (random between 1 to 3 seconds)
            var randomDelay = new Random().Next(1000, 3000);

            Console.WriteLine($"[Request] Sent to {modelName} (Delay: {randomDelay}ms)...");

            // Simulate the asynchronous I/O operation
            await Task.Delay(randomDelay);

            // Simulate a random failure for demonstration purposes (10% chance)
            if (new Random().Next(0, 10) == 0)
            {
                Console.WriteLine($"[Error] {modelName} failed to respond.");
                throw new HttpRequestException($"Connection timeout to {modelName}");
            }

            // Simulate a successful response
            Console.WriteLine($"[Success] Received from {modelName}.");
            return new ModelResponse
            {
                Source = modelName,
                Summary = $"Processed input: '{input}' (Latency: {randomDelay}ms)"
            };
        }
    }

    // Simple DTO for the response
    public class ModelResponse
    {
        public string Source { get; set; } = string.Empty;
        public string Summary { get; set; } = string.Empty;
    }
}

Visualizing the Execution Flow

The following diagram illustrates the timeline. Notice how the requests are sent out simultaneously (Scatter), and the program waits at the await Task.WhenAll line until the slowest task finishes (Gather).

The diagram illustrates a scatter-gather pattern where multiple requests are dispatched concurrently and a single await point synchronizes the program until all tasks, including the slowest one, have completed.
Hold "Ctrl" to enable pan & zoom

The diagram illustrates a scatter-gather pattern where multiple requests are dispatched concurrently and a single await point synchronizes the program until all tasks, including the slowest one, have completed.

Line-by-Line Explanation

  1. using System.Threading.Tasks;

    • This namespace contains the fundamental types for asynchronous programming in C#, specifically Task and Task<T>, which are essential for the Scatter-Gather pattern.
  2. var queries = new Dictionary<string, string> { ... };

    • We define our input data. In a real-world scenario, this might come from a user interface or a database. We use a Dictionary to map a readable name (Key) to the specific query/prompt (Value).
  3. var processingTasks = queries.Select(kvp => FetchModelDataAsync(kvp.Key, kvp.Value)).ToList();

    • The "Scatter" Phase: This is the most critical line. We iterate over the queries using LINQ's Select.
    • Crucial Detail: We call FetchModelDataAsync but do not use await here. If we had written await FetchModelDataAsync(...), the loop would pause and wait for the first request to finish before starting the second.
    • Instead, the method returns a Task<ModelResponse> immediately (a "hot" task representing a pending operation). We convert these tasks into a List<Task<ModelResponse>>.
  4. var results = await Task.WhenAll(processingTasks);

    • The "Gather" Phase: Task.WhenAll takes a collection of tasks and returns a single task that completes when all supplied tasks have completed.
    • Return Type: Because processingTasks is a list of Task<ModelResponse>, Task.WhenAll returns Task<ModelResponse[]>. The await keyword unwraps this, giving us an array of ModelResponse objects.
    • Error Handling: If any of the tasks in the collection throws an exception, Task.WhenAll will also throw. The exception thrown is typically an AggregateException containing all the individual failures.
  5. foreach (var result in results)

    • Once we pass the await line, we are guaranteed that all network requests have finished (either successfully or with exceptions). We can now safely iterate over the results array to display or aggregate data.
  6. private static async Task<ModelResponse> FetchModelDataAsync(...)

    • This helper method simulates an external API call.
    • await Task.Delay(randomDelay);: This simulates network latency without blocking the thread. While this task is "delaying," the main thread is free to start the next request immediately (concurrency).
    • Random Failure: We included a random failure chance to demonstrate how Task.WhenAll behaves in error scenarios (explained below).

Common Pitfalls

1. Awaiting Inside the Loop (Sequential Execution) A frequent mistake is wrapping the call inside a foreach loop with await:

// ❌ BAD: This runs sequentially, not in parallel.
foreach (var query in queries)
{
    var result = await FetchModelDataAsync(query.Key, query.Value);
    results.Add(result);
}
Why it fails: The loop pauses at every iteration. If each request takes 2 seconds and you have 3 requests, the total time will be 6 seconds. With Task.WhenAll, the total time is roughly 2 seconds (the duration of the longest request).

2. Handling Partial Failures When using Task.WhenAll, if one task fails, the await line throws an exception immediately. This means you might lose successful results from other tasks that finished but haven't been assigned to the variable yet (though they are technically completed in memory).

  • Solution: If you need to handle failures gracefully (e.g., "show me what you got, even if some failed"), use Task.WhenAll combined with exception handling, or inspect the Task objects in the collection manually before awaiting. Alternatively, use Task.WhenAny to process results as they arrive, though that changes the logic significantly.

3. Exception Unwrapping Task.WhenAll throws an AggregateException if multiple tasks fail. However, in async/await contexts, the compiler often unwraps the first exception. If you need to inspect all errors that occurred, you must catch the AggregateException or inspect the .Exception property of the individual tasks in the collection.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon


Loading knowledge check...



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.