返回资讯中心
外部精选
软件工程
#社区热议

Go Concurrency Distilled

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…

Hacker Newschmaynard2026/9/26 · 2026/9/2630 分钟阅读

以下正文同步自 Hacker News,版权归原站所有,已转换为易读排版。

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.

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

The 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

Channels

Goroutines 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.

Returning 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 channel

To 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.

range 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.

You can protect yourself from accidental write/close errors by setting the channel direction. Channels can be:

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 channels

Buffered 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 channel

Like 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

Select

The select statement is somewhat like switch, but specifically designed for channels. Here's what it does:

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.

A 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.

func readT 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 channel

A 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 channel

If 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 channel

To 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() { cancel := make(chan struct{}) defer close(cancel)

nums := generate(cancel, 10) fmt.Println(<-nums) fmt.Println(<-nums) fmt.Println(<-nums) }

1 2 3

Error handling

There are three approaches to error handling in concurrent pipelines.

// calculate produces answers for the given numbers. func process(in <-chan int) (<-chan int, <-chan error) { out := make(chan Answer) errc := make(chan error, 1) go func() { defer close(out) for n := range in { ans, err := fetchAnswer(n) if err != nil { errc <- err // return with error return } out <- ans } errc <- nil // return with nil }() return out, errc }

➋ Use a result type:

// Result contains an answer or an error. type Result struct { answer int err error }

// calculate produces answers for the given numbers. func calculate(in <-chan int) <-chan Result { out := make(chan Result) go func() { defer close(out) for n := range in { ans, err := fetchAnswer(n) out <- Result{ans, err} // return answer + error } }() return out }

➌ Collect errors separately:

// calculate produces answers for the given numbers. func calculate(in <-chan int, errc chan<- error) <-chan int { out := make(chan Answer) go func() { defer close(out) for n := range in { ans, err := fetchAnswer(n) if err == nil { out <- ans // send answer } else { errc <- err // or error } } }() return out }

Time

Besides handling date and time, the time package offers tools for managing time-sensitive operations in concurrent programs.

time.After() returns a channel that is initially empty, but receives a value after the timeout period. It's useful for timing out operations:

// withTimeout executes a function with a given timeout. func withTimeout(timeout time.Duration, fn func()) error { done := make(chan struct{}) go func() { defer close(done) fn() }()

// blocks until fn completes or the timer expires, // whichever happens first select { case <-done: return nil case <-time.After(timeout): return errors.New("timeout") } }

withTimeout() waits for fn() to complete, but thanks to time.After(), it won't wait longer than the timeout duration:

func main() { var err error

// completes in time err = withTimeout( 50*time.Millisecond, func() { fmt.Println("work done") }, ) fmt.Println("err =", err)

// gets canceled on timeout err = withTimeout( 50*time.Millisecond, func() { time.Sleep(100 * time.Milliseco

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →