Back

Concurrency in Golang

Nikita Khabya

Nikita Khabya

6 min read

|

3 weeks ago

Concurrency is about "dealing" with many tasks at once. Go offers built-in support for concurrency through goroutines and channels. In this blog, let's explore how concurrency works in Go along with some common concurrency patterns.

World without concurrency ā˜¹ļø

Without concurrency, lines of code are executed one after another i.e blocking the subsequent lines until the current line is finished executing. This synchronous execution can be suitable for simple apps or where performance is not a concern.

However, in real-world applications, this can lead to slow and unpleasant user experiences.

Imagine Youtube app without concurrency 🚩

  1. You click on a video to play, and the video starts buffering...
  2. While the video is buffering, the entire UI freezes
  3. You cannot scroll, pause, see comments, or interact in any way
  4. Only after the video finishes loading, does the app let you interact again (Boy, that would be frustrating!)

So if we are on the same boat, it's a good time to learn about concurrency and how it can help us build better, responsive and fast applications.

Concurrency in Go šŸš€

Concurrency is about "dealing" with many tasks at once. Strong focus on "dealing" with many tasks at once and not "doing" many tasks at once.

When tasks actually run simultaneously on different CPU cores or processors, that is called Parallelism (and that's a topic for another day).

Concurrency in Go consists of two building blocks:

Goroutines šŸ’Ŗ

šŸ’” About "go" keyword:

  • When you prefix a function call with the go keyword, it tells the Go runtime to execute that function as a goroutine. The Go runtime manages the scheduling and execution of goroutines on top of OS threads, due to which goroutines are lightweight and efficient.
  • Main function is also a goroutine. When the main goroutine exits, all other goroutines are terminated as well, irrespective of whether they have completed their execution or not.
  • For this to not happen, we can halt the main goroutine for some time (e.g., using time.Sleep) or use synchronization mechanisms like WaitGroups.
  • time.Sleep is a bad way to think about concurrency, because we don't know how long the other goroutines will take to finish, and you should almost never use it in production code for synchronization.
  • WaitGroup is a better way to wait for goroutines to finish.

Channels ā†”ļø

Concurrency Patterns in Go šŸ“

As the complexity and concurrency requirements of the application increases, we need to follow certain patterns to manage the concurrency effectively and identify potential bugs. For this, it's recommended to follow some well-known concurrency patterns.

Few of the recognized concurrency patterns in Go are~

1. Worker Pool Pattern

A fixed number of "worker" goroutines process jobs from a shared queue.

This pattern is essential when you need to:

2. Fan-Out / Fan-In Pattern

3. Pipeline Pattern

In this pattern, data flows through a series of stages, each represented by a goroutine connected by channels. Each stage

4.Generator Pattern

Used for creating streams of data that can be processed to produce some output.

5. Semaphore Pattern

Controls how many goroutines can access a shared resource simultaneously. Use semaphores to:

There are many more patterns and variations, but these are some of the most common ones used in Go applications.

Check out Go Concurrency Patterns for more details.

Race conditions šŸ

Race conditions occur when multiple goroutines access shared data at the same time, while at least one of them is modifying it. The outcome of this can be unpredictable and difficult to debug.

We can use the -race flag while running or testing our Go code to detect race conditions.

go
  go run -race main.go
  go test -race

This is helpful in identifying potential race conditions during development and testing phases. It's highly recommended and helps in writing safe concurrent code.

Concurrency Implementation 🚧

go
package main

import (
	"fmt"
	"sync"
	"time"
)

// calculateSquare simulates a time-consuming calculation
func calculateSquare(num int) int {
	time.Sleep(time.Second)
	return num * num
}

// Without concurrency: execution is sequential
func sequentialCalculation(numbers []int) {
	fmt.Println("Sequential Calculation")
	start := time.Now()

	for _, num := range numbers {
		result := calculateSquare(num)
		fmt.Printf("%d² = %d\n", num, result)
	}

	fmt.Printf("Time taken: %v\n", time.Since(start))
}

// With concurrency: using goroutines and WaitGroup
func concurrentCalculation(numbers []int) {
	fmt.Println("Concurrent Calculation")
	start := time.Now()

	var wg sync.WaitGroup

	for _, num := range numbers {
		wg.Add(1)

		go func(n int) {
			defer wg.Done()
			result := calculateSquare(n)
			fmt.Printf("%d² = %d\n", n, result)
		}(num)
	}

	wg.Wait()
	fmt.Printf("Time taken: %v\n", time.Since(start))
}

// With channels: using goroutines and channels
func concurrentWithChannels(numbers []int) {
	fmt.Println("Concurrent with Channels")
	start := time.Now()

	results := make(chan string, len(numbers))

	for _, num := range numbers {
		go func(n int) {
			result := calculateSquare(n)
			results <- fmt.Sprintf("%d² = %d", n, result)
		}(num)
	}

	for range numbers {
		fmt.Println(<-results)
	}

	fmt.Printf("Time taken: %v\n", time.Since(start))
}

func main() {
	numbers := []int{1, 2, 3, 4, 5}

	sequentialCalculation(numbers) // Takes ~5 seconds
	fmt.Println("================")
	concurrentCalculation(numbers) // Takes ~1 second
	fmt.Println("================")
	concurrentWithChannels(numbers) // Takes ~1 second
}

P.S: When running concurrently, the output order is not guaranteed because goroutines execute independently and may complete at different times.

"Handle with care": Concurrency Pitfalls āš ļø

To avoid these pitfalls, it's important to carefully design and test concurrent code, and to use synchronization mechanisms correctly.

P.S Do not use concurrency unless it's really necessary!

TL;DR