Asynchronous Programming in Go: A Complete Guide for Beginners
Go (Golang) was created with the needs of modern development in mind, where performance and parallelism play a key role. Unlike many languages where asynchrony is implemented through callbacks or promises, Go offers built-in and elegant mechanisms: goroutines and channels. This guide will introduce you to the basics of asynchronous programming in Go and show you how to write efficient concurrent code.
What are goroutines and how are they different from threads?
A goroutine is a lightweight thread of execution managed by the Go runtime. Unlike OS system threads, goroutines consume much fewer resources: their stack starts at a few kilobytes and can dynamically grow and shrink. This allows you to run millions of goroutines simultaneously without significant memory load.
Starting a goroutine is very simple — just add the keyword go before a function call:
package main
import ( "fmt" "time")
func sayHello() { fmt.Println("Hello from goroutine!")}
func main() { go sayHello() // Start a goroutine time.Sleep(100 * time.Millisecond) // Wait for the goroutine to execute fmt.Println("Main function completed")}It is important to note: if the main main() function finishes, all running goroutines will be forcibly stopped. For synchronization, we use special tools.
Synchronization with WaitGroup
The simplest way to wait for goroutines to finish is to use sync.WaitGroup. It works like a counter: we increment it when starting a goroutine and decrement it upon completion. The main function blocks until the counter reaches zero.
package main
import ( "fmt" "sync" "time")
func worker(id int, wg *sync.WaitGroup) { defer wg.Done() // Decrement the counter upon completion fmt.Printf("Worker %d started work", id) time.Sleep(time.Second) fmt.Printf("Worker %d finished work", id)}
func main() { var wg sync.WaitGroup
for i := 1; i <= 3; i++ { wg.Add(1) // Increment the counter before starting go worker(i, &wg) }
wg.Wait() // Wait for all goroutines to finish fmt.Println("All workers have finished work")}Note: wg.Add(1) is called before starting the goroutine, and wg.Done() is called inside it (usually via defer). This ensures the counter is correctly decremented even in case of a panic.
Channels: Communication between goroutines
Channels (channels) are typed "pipes" through which goroutines can safely exchange data. Channels provide synchronization by default: sending to a channel blocks the goroutine until another goroutine reads the value, and vice versa.
Channels are created using the make function:
package main
import "fmt"
func main() { // Create an unbuffered channel for integers ch := make(chan int)
// Start a goroutine that sends data go func() { ch <- 42 // Send a value to the channel }()
// Read from the channel (blocks until a value arrives) value := <-ch fmt.Println("Received value:", value)}Channels can be:
- Unbuffered — synchronous, sending blocks until received.
- Buffered — asynchronous, sending blocks only when the buffer is full.
// Creating a buffered channel with capacity 3bufferedCh := make(chan string, 3)bufferedCh <- "A"bufferedCh <- "B"bufferedCh <- "C" // Does not block until full