The worker pattern in go

The go worker pattern is a design pattern used to manage concurrent tasks efficiently. The worker pattern is to do distribute the workload among a pool of goroutines to maximize CPU utilization.

The key advantage of the worker pattern is that you have a fixed pool of workers that can handle a large volume of tasks efficiently. Workers are constantly busy processing tasks from the queue.

Benefits

By using a worker pattern can have the following advantage:

  • Improved Performance: By using multiple workers concurrently, you can significantly speed up the execution of tasks compared to a single-threaded approach.
  • Efficient Resource Management: The worker pattern reuses a fixed number of goroutines, reducing the overhead of creating and destroying them for each task.
  • Scalability: You can easily adjust the number of workers to match your workload requirements.

How it work

  • Worker pool creation: the first step is to create a pool of goroutines that will be use to perform tasks.
  • Task queue: then you need to create a queue to hold the tasks that need to be executed.
  • Dispatcher: this is a function or goroutine that listens for incoming tasks on the task queue and assigns them to available workers in the pool.
  • Execution: then each worker will pick up a task from the queue, processes it, and becomes available to pick up another task.
  • Concurrency Control: Depending on the requirements, you might need add mechanisms for controlling the number of concurrent workers (e.g., limiting the maximum number of workers or dynamically adjusting the number based on system load).

Implementation

First, lets create a the task that will be executed

// File: main.go

package main

import (
	"fmt"
	"time"
)

type Task struct {
	Id int
}

func (t Task) Process() string {
	time.Sleep(1 * time.Second)
	return fmt.Sprintf("Processing task %d", t.Id)
}

The Task.Id we be used to identify the task being executed. We added a time.Sleep in Process function to simulate a heavy task.

Now we need to create the worker function. The worker function processes tasks by chunk and then send each task result into the results channel.

func worker(tasks chan Task, results chan string) {
	for task := range tasks {
		results <- task.Process()
	}
}

Now let’s create the queue and feed the worker with them.

func main() {
	numOfTasks := 10
	numOfWorkers := 5
  //
	tasks := make(chan Task, numOfTasks)
	results := make(chan string, numOfTasks)

	for w := 1; w <= numOfWorkers; w++ {
		go worker(tasks, results)
	}

	for i := 0; i < numOfTasks; i++ {
		tasks <- Task{Id: i}
	}

	close(tasks)

	for a := 1; a <= numOfTasks; a++ {
		fmt.Println(<-results)
	}
}

Here, we have created five workers that will process 10 tasks.

  1. We start by defining the tasks and results channel.
  2. Then we run the 5 workers in a goroutine
  3. We feed the tasks channel with the 10 tasks and close the tasks channel because it will not receive any more tasks.
    1. Finally, we print each job result from the results channel.

A simple test shows that all tasks are processed in 2 seconds.

$ time go run main.go 
Processing task 4
Processing task 1
Processing task 0
Processing task 2
Processing task 3
Processing task 5
Processing task 6
Processing task 7
Processing task 8
Processing task 9

real    0m2,075s
user    0m0,078s
sys     0m0,078s

Conclusion

By using the worker pattern, you can achieve better scalability and responsiveness in your Go applications, especially when dealing with I/O-bound or CPU-bound tasks that can benefit from concurrent execution. It helps in efficiently utilizing resources and managing concurrency without overwhelming the system.