Concurrency and Multithreading in Go: A Complete Guide for Developers

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Concurrency and Multithreading in Go: from goroutines to channels

The Go language (Golang) was developed at Google with modern performance and scalability requirements in mind. Its main "feature" is built-in concurrency support, which is implemented at the language level, not through external libraries. Unlike Python, where multithreading is limited by the GIL (Global Interpreter Lock), Go offers lightweight goroutines that allow you to efficiently execute thousands of tasks simultaneously. In this article, we will break down the key concepts of concurrency and multithreading in Go, including goroutines, channels, mutexes, and wait groups, with practical code examples.



1. Goroutines: lightweight threads in Go

A goroutine is a function that executes concurrently with other functions in the same address space. It runs on top of system threads but uses Go's own scheduler, making it significantly cheaper than a regular OS thread. A goroutine is created using the go keyword.



package main

import ( "fmt" "time")

func say(s string) { for i := 0; i < 5; i++ { time.Sleep(100 * time.Millisecond) fmt.Println(s) }}

func main() { go say("Hello") // Starting a goroutine say("World") // Normal call in the main thread}


In this example, say("Hello") runs in a separate goroutine, while say("World") runs in the main one. The output will be interleaved because both functions work concurrently. It is important to remember: if the main main function finishes, all goroutines will be forcibly stopped.



2. Channels: safe data transfer between goroutines

Channels are typed "pipes" through which goroutines communicate with each other. They provide synchronization without explicitly using mutexes. Channels can be buffered (with capacity) and unbuffered (synchronous).



Unbuffered channel

package main

import "fmt"

func sum(s []int, c chan int) { total := 0 for _, v := range s { total += v } c <- total // sending the result to the channel}

func main() { s := []int{7, 2, 8, -9, 4, 0} c := make(chan int)

go sum(s[:len(s)/2], c) go sum(s[len(s)/2:], c)

x, y := <-c, <-c // receiving from the channel

fmt.Println(x, y, x+y)}


Here, two goroutines calculate the sums of the array halves and send the results to the channel. The main goroutine blocks until it receives both values. An unbuffered channel acts as a synchronization point: sending blocks until the receiver is ready, and vice versa.



Buffered channel

package main

import "fmt"

func main() { ch := make(chan int, 2) // buffer for 2 elements ch <- 1 ch <- 2 // ch <- 3 // this will cause a deadlock because the buffer is full fmt.Println(<-ch) fmt.Println(<-ch)}


A buffered channel allows sending data without blocking until the buffer is full. This is convenient when the sending speed is higher than the processing speed.



3. Synchronization: WaitGroup and mutexes

Although channels cover most scenarios, sometimes finer control over synchronization is needed. For this, the sync package provides WaitGroup (waiting for a group of goroutines to finish) and Mutex (mutual exclusion for protecting shared data).



WaitGroup: waiting for all goroutines

package main

import ( "fmt" "sync")

func worker(id int, wg *sync.WaitGroup) { defer wg.Done() // decrement the counter upon completion fmt.Printf("Worker %d starting", id) // simulating work fmt.Printf("Worker %d done", id)}

func main() { var wg sync.WaitGroup

for i := 1; i <= 3; i++ { wg.Add(1) // increment

Blogs

Book Recommendations