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