7 min read

Goroutine vs Simple function

Optimization
Benchmark
Golang
Cover image for Goroutine vs Simple function

Goroutine vs Simple function

This is a simple example of why goroutines might be overkill for some tasks and less efficient than a simple function.


Structures

We got a simple structure that contains sensitive information that we don’t want to be exposed to the outside world. Therefore, we created a second structure that hides the sensitive information and only exposes the information we want to be public.

// Pineapple is a struct that represents a database object with sensitive data that should be hidden
type Pineapple struct {
	Paro       string `faker:"name"`
	Turkey     string `faker:"name"`
	Banana     string `faker:"name"`
	Age        int    `faker:"number"`
	Size       int    `faker:"number"`
	IsAlive    bool
	ID         uint
	SecretCode []byte
	Created    time.Time
	Updated    time.Time
}

// SafePineApple is a struct that represents a Pineapple object without sensitive data
type SafePineApple struct {
    Paro    string
    Turkey  string
    Banana  string
    IsAlive bool
    Age     int
    ID      uint
}

Conversion

We need to convert our Pineapple object to a SafePineApple object. We can do this by creating a method on the Pineapple struct that returns a SafePineApple object.

// ToSafePineApple converts a Pineapple object to a SafePineApple object
func (p *Pineapple) ToSafePineApple() SafePineApple {
	return SafePineApple{
		Paro:    p.Paro,
		Turkey:  p.Turkey,
		Banana:  p.Banana,
		IsAlive: p.IsAlive,
		Age:     p.Age,
		ID:      p.ID,
	}
}

Use case

In our use case we have an array of Pineapple objects coming from our database that we want to convert to SafePineApple objects and store them in a new array. The order of the objects in the array should be the same as the original array as it has already been sorted by a sql query.


Simple function

We can do this by creating a simple function that takes an array of Pineapple objects and returns an array of SafePineApple objects.

// SimpleConvertPineApplesToSafety converts an array of Pineapple objects to an array of SafePineApple objects
func SimpleConvertPineApplesToSafety(pineapples []Pineapple) []SafePineApple {
	safePineApples := make([]SafePineApple, len(pineapples))

	for idx, pineapple := range pineapples {
		safePineApples[idx] = pineapple.ToSafePineApple()
	}

	return safePineApples
}

This function is very simple and easy to understand. We loop through the array of Pineapple objects and convert them to SafePineApple objects.


Goroutine without mutex

We can do this by using goroutines to work on the array concurrently and store the results in a new array.

func GoroutinesNoMutexConvertPineApplesToSafety(pineapples []Pineapple) []SafePineApple {
	// Create a slice to store the SafePineApples
	safePineApples := make([]SafePineApple, len(pineapples)/2, len(pineapples))
	safePineApples2 := make([]SafePineApple, len(pineapples)/2)

	var wg sync.WaitGroup // Create a WaitGroup to wait for all goroutines to finish
	wg.Add(1)            // Add 1 to the WaitGroup

	// Create a goroutine to convert the first half of the Pineapple objects
	go func(chunk []Pineapple) {
		defer wg.Done() // Decrement the WaitGroup when the goroutine is done
		for idx, pineapple := range chunk { // Loop through the chunk of Pineapple objects
			safePineApples[idx] = pineapple.ToSafePineApple() // Convert the Pineapple object to a SafePineApple object
		}
	}(pineapples[:len(pineapples)/2]) // Pass the first half of the Pineapple objects to the goroutine

	// Convert the second half of the Pineapple objects in the main thread
	for idx, pineapple := range pineapples[len(pineapples)/2:] {
		safePineApples2[idx] = pineapple.ToSafePineApple()
	}

	// Wait for all goroutines to finish
	wg.Wait()

	// Group both pineapples
	safePineApples = append(safePineApples, safePineApples2...)

	// Return the SafePineApples
	return safePineApples
}

This function is a bit more complex than the simple function. We use a goroutine to convert the first half while the other half is handled by the main thread. We use a WaitGroup to wait for the goroutine to finish before returning the results.


Goroutine with mutex

We can also add mutexes to the goroutine to make it thread safe.

func GoroutinesConvertPineApplesToSafety(pineapples []Pineapple) []SafePineApple {
	// Create a slice to store the SafePineApples
	safePineApples := make([]SafePineApple, len(pineapples))

	// Split the offers into chunks
	chunks := [][]Pineapple{pineapples[:len(pineapples)/2], pineapples[len(pineapples)/2:]}

	mutex := sync.Mutex{} // Create a mutex to lock the slice when writing to it

	var wg sync.WaitGroup // Create a WaitGroup to wait for all goroutines to finish
	wg.Add(1)           // Add 1 to the WaitGroup

	// Create a goroutine to convert the first half of the Pineapple objects
	go func(chunk []Pineapple) {
		defer wg.Done() // Decrement the WaitGroup when the goroutine is done
		for idx, pineapple := range chunk { // Loop through the chunk of Pineapple objects
			mutex.Lock() // Lock the mutex
			safePineApples[idx] = pineapple.ToSafePineApple() // Convert the Pineapple object to a SafePineApple object
			mutex.Unlock()  // Unlock the mutex
		}
	}(chunks[0]) // Pass the first half of the Pineapple objects to the goroutine

	// Convert the second half of the Pineapple objects in the main thread
	for idx, pineapple := range chunks[1] {
		mutex.Lock() // Lock the mutex
		safePineApples[idx+len(chunks[0])] = pineapple.ToSafePineApple() // Convert the Pineapple object to a SafePineApple object
		mutex.Unlock() // Unlock the mutex
	}

	// Wait for all goroutines to finish
	wg.Wait()

	return safePineApples
}

This function make use of the mutex to lock the slice when writing to it. This makes sure that the goroutine and the main thread don’t write to the same index at the same time but instead wait for the other to finish.


Benchmark

Now that we have our functions we can benchmark them to see which one is the fastest. To benchmark our functions we run them with arrays of different sizes.

Our benchmark function looks like this:

func Benchmark_SimpleConvertPineApplesToSafety(b *testing.B) {
	for _, n := range []int{500, 1000, 2000, 5000, 10000} {
		b.Run(fmt.Sprintf("Benchmark_SimpleConvertPineApplesToSafety-%d", n), func(b *testing.B) {
			pineApples := make([]Pineapple, n)
			var pine Pineapple
			for i := 0; i < n; i++ {
				_ = faker.FakeData(&pine)
				pine.Created = time.Now().AddDate(0, 0, -i)
				pine.ID = uint(i)
				pine.IsAlive = true
				pineApples[i] = pine
			}
			for i := 0; i < b.N; i++ {
				SimpleConvertPineApplesToSafety(pineApples)
			}
		})
	}
}

To run the benchmark we use the following command:

go test -bench=. -benchtime 5s > benchmark.txt && benchstat benchmark.txt

Benchstat is a tool that can be used to compare the results of benchmarks.

The results of the benchmark are as follows:

name                                                                        time/op
Benchmark_SimpleConvertPineApplesToSafety-500-32                          15.8µs ± 0%
Benchmark_SimpleConvertPineApplesToSafety-1000-32                         32.0µs ± 0%
Benchmark_SimpleConvertPineApplesToSafety-2000-32                         66.5µs ± 0%
Benchmark_SimpleConvertPineApplesToSafety-5000-32                          193µs ± 0%
Benchmark_SimpleConvertPineApplesToSafety-10000-32                         465µs ± 0%
Benchmark_GoroutinesConvertPineApplesToSafety-500-32                      23.5µs ± 0%
Benchmark_GoroutinesConvertPineApplesToSafety-1000-32                     46.2µs ± 0%
Benchmark_GoroutinesConvertPineApplesToSafety-2000-32                     87.7µs ± 0%
Benchmark_GoroutinesConvertPineApplesToSafety-5000-32                      242µs ± 0%
Benchmark_GoroutinesConvertPineApplesToSafety-10000-32                     507µs ± 0%
Benchmark_NoMutexGoroutinesConvertPineApplesToSafety-500-32               28.3µs ± 0%
Benchmark_NoMutexGoroutinesConvertPineApplesToSafety-1000-32              48.7µs ± 0%
Benchmark_NoMutexGoroutinesConvertPineApplesToSafety-2000-32               105µs ± 0%
Benchmark_NoMutexGoroutinesConvertPineApplesToSafety-5000-32               257µs ± 0%
Benchmark_NoMutexGoroutinesConvertPineApplesToSafety-10000-32              533µs ± 0%

As you can see the simple function is the fastest.

Why ?

The reason why the simple function is the fastest is that the process of converting the Pineapple objects to SafePineApple objects is very fast. The time it takes to create the goroutines and wait for them to finish is longer than the time it takes to convert the Pineapple objects to SafePineApple objects.


Furthermore, in our goroutines implementation we have to convert the Pineapple objects then lock the mutex, write to the slice and unlock the mutex. This is a lot of overhead for a very simple task.


Conclusion

Don’t use goroutines when you don’t need them. That may seem obvious, but it’s easy to forget when you’re trying to optimize your code. In this example the overhead of creating the goroutines and waiting for them to finish is longer than the time it takes to convert the Pineapple objects to SafePineApple objects.

Goroutines are great for tasks that take a long time to complete and can be done in parallel. I would suggest to write the simplest code possible and then benchmark it to see if you can improve it using goroutines instead.

Moreover, simple code is easier to read and maintain than complex code, that’s why writing complex code might not be necessary if performance is not an issue. I’d prefer to have a simple function that takes a few milliseconds longer to complete than a complex function.


Code

The code for this article can be found on GitHub