Introduction: Why Asynchronous Programming in Go?
Go (Golang) was designed from the ground up for efficient concurrency. Unlike many languages where asynchrony is implemented through complex callbacks or promises, Go offers an elegant model based on goroutines and channels. This model allows you to write asynchronous code that reads like synchronous code but executes in parallel, maximizing the utilization of multi-core processor resources.
In this guide, we will cover all key concepts of asynchronous programming in Go: from creating goroutines to complex synchronization patterns using channels and primitives from the sync package. The material will be useful both for beginners just learning Go and for experienced developers looking to systematize their knowledge.
1. Goroutines: Lightweight Threads
A goroutine is a lightweight thread of execution managed by the Go runtime. Unlike OS threads, goroutines have a tiny stack (starting at a few KB) that can dynamically grow and shrink. This allows tens of thousands of goroutines to run simultaneously without significant memory consumption.
Starting a Goroutine
To run a function as a goroutine, simply add the keyword go before the function call:
package main
import ( "fmt" "time")
func printNumbers() { for i := 1; i <= 5; i++ { fmt.Println(i) time.Sleep(100 * time.Millisecond) }}
func main() { // Run the function as a goroutine go printNumbers()
// The main goroutine continues working fmt.Println("Main goroutine is working")
// Give time for the child goroutine to execute time.Sleep(1 * time.Second) fmt.Println("Program finished")}Important: The program terminates when the main() function finishes. If the main goroutine finishes earlier, all child goroutines will be forcibly stopped. Therefore, in the example we use time.Sleep for waiting — in practice, channels or sync.WaitGroup are used for synchronization.
Anonymous Functions in Goroutines
Goroutines are often created using anonymous functions:
go func() { fmt.Println("Executing in goroutine")}()2. Channels: Communication Between Goroutines
Channels are typed "pipes" through which goroutines can send and receive values. This is the primary mechanism for synchronization and data exchange in Go. Channels provide safe access to data without using explicit locks (mutexes).
Creating and Using a Channel
A channel is created using the make() function. The <- operator is used to send and receive data:
package main
import "fmt"
func sum(s []int, c chan int) { total := 0 for _, v := range s { total += v } c <- total // send 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 // receive data from the channel
fmt.Println(x, y, x+y)}In this example, we launch two goroutines, each calculating the sum of half the slice. The results are sent to the channel, and the main goroutine receives them and prints the total sum.
Buffered Channels
By default, channels are unbuffered — sending blocks until the receiver is ready to accept the value. Buffered channels have a queue of a specified size:
ch := make(chan int, 2) // channel with a buffer of 2 elementsch <- 1ch <- 2// ch <- 3 // error: deadlock, buffer fullfmt.Println(<-ch) // 1fmt.Println(<-ch) // 2