Concurrency and Multithreading in Go: From A to Z

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 A to Z



Go (Golang) was designed with modern computing workloads in mind. In the era of multi-core processors and cloud services, the ability to work effectively with concurrency has become a mandatory skill for backend developers. In this article, we will break down how Go solves the problems of parallel code execution, how goroutines differ from OS threads, and how to properly use channels and mutexes.



1. Goroutines: Lightweight Threads



A goroutine is a function or method that executes concurrently with other goroutines. Unlike operating system threads, goroutines have minimal overhead: their stack starts at 2-4 KB and can grow dynamically. Starting a goroutine is incredibly simple — just add the keyword go before a function call.



package main

import ( "fmt" "time")

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

func main() { go say("Hello from goroutine") say("Hello from main") // Give the goroutine time to finish time.Sleep(500 * time.Millisecond)}


Note: the program terminates immediately after the main() function finishes, without waiting for launched goroutines to complete. For synchronization, we use time.Sleep (in real projects — sync.WaitGroup or channels).



2. Channels: Safe Data Transfer



Channels are typed "pipes" through which goroutines exchange data. They solve two key tasks: execution synchronization and value passing. Channels can be buffered or unbuffered.



Unbuffered Channel

A send operation blocks the goroutine until another goroutine performs a read. This provides synchronization "on the fly".



package main

import "fmt"

func main() { ch := make(chan string)

go func() { ch <- "Hello from goroutine!" }()

msg := <-ch fmt.Println(msg)}


Buffered Channel

A channel with a buffer allows sending several values without immediate reading, until the buffer is full.



package main

import "fmt"

func main() { ch := make(chan int, 2) ch <- 10 ch <- 20 // ch <- 30 // deadlock! buffer is full

fmt.Println(<-ch) // 10 fmt.Println(<-ch) // 20}


Range and close

Closing a channel signals to receivers that no more data will be sent. The for range loop automatically terminates when the channel is closed.



package main

import "fmt"

func producer(ch chan int) { for i := 1; i <= 5; i++ { ch <- i } close(ch)}

func main() { ch := make(chan int) go producer(ch)

for val := range ch { fmt.Println("Received:", val) }}


3. Synchronization and Mutexes



Despite the power of channels, sometimes direct access to shared data is required. The sync package provides mutexes (sync.Mutex) and reader-writer mutexes (sync.RWMutex) for protecting critical sections.



package main

import ( "fmt" "sync")

var counter = 0var mu sync.Mutex

func increment(wg *sync.WaitGroup) { defer wg.Done() mu.Lock() counter++ mu.Unlock()}

func main() { var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go increment(&wg) } wg.Wait() fmt.Println("Final counter:", counter)}


Here, sync.WaitGroup waits for all goroutines to finish, and the mutex prevents a data race. Without the mutex, the result would be unpredictable.



4. Concurrency Patterns

Blogs

Book Recommendations