Design Patterns for Concurrent Programming : Master-Slave pattern

Niraj Ranasinghe
6 min readDec 16, 2023

Managing multiple tasks in software is complex. Concurrency design patterns act like blueprints for handling tasks simultaneously. This article explores the Master-Slave pattern, a strategy for efficient task delegation in scenarios requiring parallelism.

Generated with AI

Master-Slave pattern

The Master-Slave pattern is a concurrency design approach that optimizes the execution of tasks by delegating them between a master component and multiple slave components. The master, acting as the controller, divides a complex task into smaller, independent subtasks and assigns these subtasks to individual slaves. Each slave processes its designated subtask concurrently, enabling parallel execution and improving overall performance. The master coordinates the workflow, distributes tasks, and gathers results from the slaves. This pattern is particularly effective in scenarios where parallelism can be harnessed to enhance efficiency, such as in computational tasks, data processing, or simulations.

Let’s understand this in a simple example, Consider a scenario where a large dataset needs to be analyzed in parallel. The Master-Slave pattern can be applied to distribute the data processing across multiple nodes. The master divides the dataset into segments and assigns each segment to a slave for parallel analysis. The following simple code illustrates this concept.

The Main method initiates the program by calling GenerateLargeDataset to simulate the creation of a substantial dataset containing numbers from 1 to 1,000,000. This dataset is then passed to the MasterProcess method, responsible for orchestrating the master-slave parallel processing.

static void Main()
{
List<int> dataset = GenerateLargeDataset();
MasterProcess(dataset);
}

Within the MasterProcess method, the dataset is divided into segments, with each segment assigned to a separate task for parallel computation. The code dynamically determines the segment size based on the number of available processors using Environment.ProcessorCount. Each task, encapsulated in the tasks list, runs the SlaveProcess method using Task.Run(), which simulates parallel processing of its assigned dataset segment.

static void MasterProcess(List<int> dataset)
{
int segmentSize = dataset.Count / Environment.ProcessorCount;
List<Task<long>> tasks = new List<Task<long>>();
for (int i = 0; i < Environment.ProcessorCount; i++)
{
int start = i * segmentSize;
int end = (i == Environment.ProcessorCount - 1) ? dataset.Count : (i + 1) * segmentSize;
List<int> segment = dataset.GetRange(start, end - start);
tasks.Add(Task.Run(() => SlaveProcess(segment)));
}
Task.WaitAll(tasks.ToArray());
long totalResult = tasks.Sum(t => t.Result);
Console.WriteLine($"Master Process Result: {totalResult}");
}

The program then waits for all tasks to complete using Task.WaitAll, and their results are aggregated to produce a final total result. This aggregated result is printed, revealing the outcome of the master-slave parallel processing.

The SlaveProcess method, invoked by each task, simulates parallel computation by summing up the numbers in its assigned dataset segment. The computed result is printed to the console, providing insight into the individual outcomes of each slave process. The computed result is then returned to contribute to the final aggregated result calculated by the master process.

static long SlaveProcess(List<int> segment)
{
long result = segment.Sum(x => (long)x);
Console.WriteLine($"Slave Process Result: {result}");
return result;
}

Overall, this code demonstrates a basic yet effective implementation of the master-slave pattern.

Let’s work on a more advanced example

Let’s consider a more advanced example demonstrating the master-slave design pattern in the context of parallel image processing. In this scenario, the master divides a large image into smaller segments and assigns each segment to a slave for parallel processing, we wii be applying image filters in this example. The slaves process their assigned segments concurrently, and the master aggregates the results to generate the final processed image.

In the Main method, the paths for the input and output images are specified. The input image is loaded, and the number of image segments is determined based on the available processors. The MasterProcessAsync method is then called to asynchronously process the image in parallel segments. The final result is obtained by aggregating the processed segments, and the resulting image is saved to the specified output path.

static async Task Main()
{
string inputImagePath = "C:\\Users\\NirajRanasinghe\\sample-image.png";
string outputImagePath = "C:\\Users\\NirajRanasinghe\\sample-image-output.png";
using (var originalImage = LoadImage(inputImagePath))
{
int numSegments = Environment.ProcessorCount * 2;
var processedSegments = await MasterProcessAsync(originalImage, numSegments, CancellationToken.None);
var finalResult = await AggregateResultsAsync(processedSegments);
await SaveImageAsync(finalResult, outputImagePath);
Console.WriteLine("Image processing completed successfully.");
}
}

The LoadImage method takes a file path as input and returns a Bitmap object representing the image loaded from the specified path. This method is used to load the original image that will be processed.

static Bitmap LoadImage(string filePath)
{
return new Bitmap(filePath);
}

The MasterProcessAsync method divides the original image into segments based on the specified number. For each segment, a task is created to asynchronously process it using the ProcessSegmentAsync method. The method returns a list of processed image segments.

static async Task<List<Bitmap>> MasterProcessAsync(Bitmap originalImage, int numSegments, CancellationToken cancellationToken)
{
int segmentHeight = originalImage.Height / numSegments;
var tasks = new List<Task<Bitmap>>();

for (int i = 0; i < numSegments; i++)
{
int startY = i * segmentHeight;
int endY = (i == numSegments - 1) ? originalImage.Height : (i + 1) * segmentHeight;

Bitmap imageSegment = originalImage.Clone(new Rectangle(0, startY, originalImage.Width, endY - startY),originalImage.PixelFormat);

tasks.Add(ProcessSegmentAsync(imageSegment, cancellationToken));
}

Bitmap[] results = await Task.WhenAll(tasks);
// Convert the array to a List<Bitmap>
return results.ToList();
}

The ProcessSegmentAsync method takes an image segment and a cancellation token as input. It simulates asynchronous image processing, here we are applying a filter. The ApplyFilterAsync method is called within a Task.Run to perform the processing. The thread ID is printed for each processed segment.

static async Task<Bitmap> ProcessSegmentAsync(Bitmap imageSegment, CancellationToken cancellationToken)
{
await Task.Run(() => ApplyFilterAsync(imageSegment, cancellationToken));
Console.WriteLine($"Slave Processed a segment asynchronously. Thread ID: {Thread.CurrentThread.ManagedThreadId}");
return imageSegment;
}

The ApplyFilterAsync method simulates asynchronous image filter application, used grayscale conversion in this example. It iterates through each pixel of the image, calculates the average color intensity, and sets the pixel color to grayscale. The method checks for cancellation before processing each pixel.

static async Task ApplyFilterAsync(Bitmap image, CancellationToken cancellationToken)
{
await Task.Run(() =>
{
for (int x = 0; x < image.Width; x++)
{
for (int y = 0; y < image.Height; y++)
{
cancellationToken.ThrowIfCancellationRequested();
Color pixel = image.GetPixel(x, y);
int average = (pixel.R + pixel.G + pixel.B) / 3;
image.SetPixel(x, y, Color.FromArgb(average, average, average));
}
}
}, cancellationToken);
}

The SaveImageAsync method takes a Bitmap image and a file path as input. It runs a Task.Run to save the image to the specified file path asynchronously. This method is used to save the final processed image.

static async Task SaveImageAsync(Bitmap image, string filePath)
{
await Task.Run(() => image.Save(filePath));
}

The AggregateResultsAsync method takes a collection of processed image segments and aggregates them into a final result. It calculates the total height of the final image, creates a new Bitmap with the required dimensions, and draws each segment onto the final image without black stripes. The resulting image is then returned.

static async Task<Bitmap> AggregateResultsAsync(IEnumerable<Bitmap> processedSegments)
{
int totalHeight = processedSegments.Sum(segment => segment.Height);
var finalResult = new Bitmap(processedSegments.First().Width, totalHeight);

using (Graphics g = Graphics.FromImage(finalResult))
{
int yOffset = 0;
foreach (var segment in processedSegments)
{
g.DrawImage(segment, new Rectangle(0, yOffset, finalResult.Width, segment.Height));
yOffset += segment.Height;
}
}

return finalResult;
}

Here is the input and output images.

You can find the Complete code Here!

Conclusion

In conclusion, the Master-Slave design pattern provides an effective approach for parallelizing tasks, making the most of available computational resources. By breaking down a complex problem into smaller, manageable tasks distributed across multiple processors, the Master-Slave pattern optimizes performance and responsiveness in concurrent systems.

Whether handling large-scale computations or managing distributed systems, the Master-Slave pattern stands as a valuable tool in the realm of concurrent programming, providing a structured and scalable solution for tackling intricate challenges.

A special thanks to the AI tools that contributed to enhancing this article. I hope you found it valuable. Your support is truly appreciated, and I look forward to delivering more content to you soon. Stay tuned for my upcoming articles, where we’ll explore another design pattern in Concurrent Programming.

Happy coding!

--

--

Niraj Ranasinghe

I love sharing insights on Software Development, Emerging Tech, and Industry Trends. Join me on my journey to explore the exciting World of Technology!