Pangram verdict · v3.3
We believe that this entire text is human-written.
AI likelihood · overall
HumanArticle text · 1,735 words · 1 segments analyzed
This mini-book provides a brief overview of many concurrency topics in Go. Each topic comes with interactive examples — feel free to experiment with them by changing the code and clicking Run. There's also a PDF version with static examples.This is a quick refresher on Go concurrency, not a beginner's guide. If you want to learn concurrency from the ground up with practical exercises, check out my other book — Gist of Go: Concurrency.The book is AI-free.Goroutines • Channels • Select • Pipelines • Time • Context • Wait groups • Data races • Race conditions • Mutexes • Semaphores • Signaling • Run once • Object pool • Atomics • Testing • Scheduling • Diagnostics • Final thoughts# GoroutinesThe foundation of concurrency in Go is goroutines – functions started with the go keyword:func main() { var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() fmt.Println("worker 1") }() go func() { defer wg.Done() fmt.Println("worker 2") }() wg.Wait() } worker 2 worker 1 The Go runtime juggles these goroutines and distributes them among operating system threads running on CPU cores. Compared to OS threads, goroutines are lightweight, so you can create hundreds or thousands of them.Goroutines are completely independent. The main function is also a goroutine, but it starts implicitly when the program starts. When main ends, other goroutines also shut down.We use a wait group (sync.WaitGroup) to wait for goroutines to finish in the example above. A wait group has a counter inside. Calling Add(n) increments it by n, while Done() decrements it by one. Wait() blocks the calling goroutine (in this case, main) until the counter reaches zero. This way, main waits for both workers to finish before it exits.WaitGroup.Go automatically increments the wait group counter, runs a function in a goroutine, and decrements the counter when it's done:func main() { var wg sync.WaitGroup wg.Go(func() { fmt.Println("worker 1") }) wg.Go(func() { fmt.Println("worker 2") }) wg.Wait() } worker 2 worker 1 # ChannelsGoroutines can pass values to each other through channels. A channel is like a window where one goroutine can throw something and another can catch it:func main() { messages := make(chan string) go func() { messages <- "ping" }() msg := <-messages fmt.Println(msg) } ping Sending a value through a channel is a synchronous operation. When the sending goroutine writes a value to the channel (ch <- val), it blocks and waits for someone to receive that value (<-ch). Only then does it continue.Output channelReturning an output channel from a function and filling it within an internal goroutine is a common pattern in Go. This allows the caller to receive values through the channel while the owning function retains control of it:func generate(start, stop int) chan int { out := make(chan int) go func() { for i := start; i < stop; i++ { out <- i } }() return out } Closing a channelTo signal readers that all data has been sent, the writer goroutine closes the channel with close():func generate(start, stop int) chan int { out := make(chan int) go func() { defer close(out) for i := start; i < stop; i++ { out <- i } }() return out } The reader checks the channel's status with a second value ("comma OK") when reading:func main() { in := generate(5, 10) for { num, ok := <-in if !ok { break } fmt.Print(num, " ") } } 5 6 7 8 9 While the channel is open, the reader receives the next value and a true status. If the channel is closed, the reader gets a zero value and a false status.A channel can only be closed once. Closing it again or writing to a closed channel causes a panic.The only reason to close a channel is to signal to its readers that all data has been sent. If this isn't important to the readers, then you don't need to close it. When a channel is no longer used, Go's garbage collector will free its resources, whether it's closed or not.Channel iterationrange automatically reads the next value from the channel and checks if it's closed. If the channel is closed, it exits the loop:func main() { nums := generate(5, 10) for n := range nums { fmt.Print(n, " ") } } 5 6 7 8 9 Range over a channel returns a single value, not a pair, unlike range over a slice.Directional channelsYou can protect yourself from accidental write/close errors by setting the channel direction. Channels can be:chan (bidirectional): for reading and writing (default);chan<- (send-only): for writing only;<-chan (receive-only): for reading only.You can't read from a send-only channel or write to a receive-only channel (nor can you close it).Channels are usually initialized for both reading and writing, and specified as directional in function parameters. Go automatically converts a regular channel to a directional one:stream := make(chan int) go func(in chan<- int) { in <- 42 }(stream) func(out <-chan int) { fmt.Println(<-out) }(stream) 42 Buffered channelsBuffered channels work like a FIFO queue with a fixed-size buffer for storing values.As long as the buffer has free space, writing to the channel doesn't block the goroutine. Similarly, as long as the buffer contains values, reading from the channel doesn't block the goroutine:stream := make(chan int, 3) stream <- 11 stream <- 12 stream <- 13 fmt.Println(<-stream) fmt.Println(<-stream) 11 13 By default, if you don't specify a buffer size, a channel is unbuffered (buffer size equals zero).Buffered channels work with the built-in len() and cap() functions:stream := make(chan int, 3) stream <- 11 fmt.Println(cap(stream), len(stream)) 3 1 Reading from a closed buffered channel returns values from the buffer and a true status. Once all values are taken, it returns a zero value and a false status, like a regular channel:stream := make(chan int, 1) stream <- 11 close(stream) val, ok := <-stream fmt.Println(val, ok) // 11 true val, ok = <-stream fmt.Println(val, ok) // 0 false 11 true 0 false nil channelLike any type in Go, channels have a zero value, which is nil.Writing to or reading from a nil channel blocks the goroutine indefinitely:var stream chan int go func() { // blocks forever stream <- 1 }() // blocks forever <-stream Closing a nil channel causes a panic:var stream chan int close(stream) // panic: close of nil channel # SelectThe select statement is somewhat like switch, but specifically designed for channels. Here's what it does:Checks which cases are not blocked.If multiple cases are ready, randomly selects one to execute.If all cases are blocked and there is a default case, executes it.If all cases are blocked and there is no default case, waits until one is ready.Select is used to manage data flow in pipelines:// merge sends values from in1 and in2 to the output channel. func merge(in1, in2 <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) for in1 != nil || in2 != nil { select { case val1, ok := <-in1: if ok { out <- val1 } else { in1 = nil } case val2, ok := <-in2: if ok { out <- val2 } else { in2 = nil } } } }() return out } // Suppose we send 10..12 to in1, 20..22 to in2, // and call merge(in1, in2) 10 11 20 12 21 22 To cancel goroutines:// process modifies values from in and send them to out // until in is exhausted or cancel is closed. func process(cancel chan struct{}, in <-chan int) <-chan int { out := make(chan int) go func() { for val := range in { select { case out <- val*10: case <-cancel: fmt.Println("canceled") return } } }() return out } // Suppose we send values 11 and 12 to in // and then call close(cancel) 110 120 canceled For non-blocking operations:// multiplier returns a function that multiplies // the input by 10 and sends it to the channel // or returns an error if the channel is busy. func multiplier(ch chan<- int) func(n int) error { return func(n int) error { select { case ch <- n*10: return nil default: return errors.New("busy") } } } func main() { nums := make(chan int, 1) multiply := multiplier(nums) err := multiply(11) fmt.Println(<-nums, err) // 110 <nil> err = multiply(12) fmt.Println(<-nums, err) // 120 <nil> err = multiply(13) err = multiply(14) fmt.Println(err) // busy } 110 <nil> 120 <nil> busy And for much more.# PipelinesA pipeline is a sequence of operations where each step takes input data, processes it in a specific way, and outputs it. The input and output of each operation is a channel.A typical pipeline looks like this:Reader: Reads input data from a file, database, or network.N processors: Transform, filter, aggregate, or enrich data using external sources.Writer: Writes the processed data to a file, database, or network.func read[T any]() <-chan T { out := make(chan T) go func() { defer close(out) for { // read data from somewere data := // ... out <- data } }() return out } func process[T any](in <-chan T) <-chan T { out := make(chan T) go func() { defer close(out) for inData := range in { // process the data outData = // ... out <- outData } }() return out } func write[T any](in <-chan T) <-chan struct{} { done := make(chan struct{}) go func() { defer close(done) for data := range in { // write the data } }() return done } Output channelA goroutine can signal other goroutines that it has finished its work using an output channel:func generate(start, stop int) <-chan int { out := make(chan int) go func() { defer close(out) for i := start; i < stop; i++ { out <- i } }() return out } func main() { nums := generate(5, 10) for n := range nums { fmt.Print(n, " ") } } 5 6 7 8 9 Done channelIf a goroutine doesn't need to return results, it can signal completion using a done channel:func work() <-chan struct{} { done := make(chan struct{}) go func() { defer close(done) fmt.Println("work done") }() return done } func main() { done := work() <-done } work done Cancel channelTo terminate a goroutine early, a calling goroutine can use a cancel channel:func generate(cancel chan struct{}, n int) <-chan int { out := make(chan int) go func() { defer close(out) for i := 1; i <= n; i++ { select { case out <- i: case <-cancel: return } } }() return out } func main() {