Practical Go Concurrency: Goroutines and Channels
Go's goroutine-and-channel model is simple and powerful, but misuse can cause leaks, deadlocks, and races. This guide covers lightweight goroutines and scheduling, channel synchronization and communication, select multiplexing, worker pools, fan-out, pipelines, and common concurrency traps.
Key Points
- Topic Focus: DevOps、开源
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Concurrency is a first-class citizen in Go. Many teams choose Go not primarily for its concise syntax, but because it is "built for high-concurrency backends." Yet although "goroutines + channels" look simple, production pitfalls are everywhere: leaks, deadlocks, and race conditions can each leave you debugging an incident at three in the morning.
This article does not cover material you can find in the official tutorials. It focuses only on practical engineering: the trade-offs behind each mechanism, where each pattern applies, what each pitfall looks like, and how to prevent it.
1. Go's Concurrency Philosophy: Do Not Communicate by Sharing Memory
The concurrency model taught by most languages is to start a group of threads, share a block of memory, and protect it with locks. There is nothing inherently wrong with this model, but it has a fundamental problem: shared memory plus locks makes program state difficult to reason about. You can read only after I acquire the lock; anyone forgetting a lock, choosing the wrong lock granularity, or nesting locks can cause bugs that are extremely difficult to reproduce.
Go takes a different path, based on the CSP (Communicating Sequential Processes) model proposed by Hoare in 1978. The official Go slogan translates to:
Do not communicate by sharing memory; instead, share memory by communicating.
In other words, instead of making multiple goroutines compete for the same variables and relying on locks to maintain order, let each goroutine own its state and use channels to "pass" data between them. Data ownership is obvious on a channel: whoever receives the data owns it, with no lock required.
This is not dogma. Go also supports shared memory and locks when appropriate (sync.Mutex). During design, however, first consider how data should flow, not how it should be locked. This shift in thinking marks the dividing line between "writing Java/Python concurrency" and "writing Go concurrency."
2. Goroutines: Lightweight Threads That Use Only a Few KB
Starting a goroutine is as simple as go f():
go processRequest(req)
go func(url string) {
fetch(url)
}(u)
A goroutine is not an OS thread. It is scheduled by the Go runtime, starts with a stack of roughly 2 KB, and grows as needed. An ordinary server can easily run hundreds of thousands of goroutines, while a few thousand OS threads are already expensive. This lightweight design is the foundation of Go's concurrency.
Behind it is the GMP scheduling model:
- G (Goroutine): the coroutine you write.
- M (Machine): an OS thread, the actual vehicle that executes code.
- P (Processor): a logical processor that holds a queue of runnable Gs. The number of Ps is determined by
GOMAXPROCS, which defaults to the number of CPU cores.
An M is bound to a P and takes Gs from the P's local queue. When a G blocks on a system call, the M detaches from the P so another M can take over that P and continue running other Gs. This is why Go can sustain high concurrency under heavy I/O: blocking does not waste threads.
The practical significance of understanding GMP is that switching between goroutines happens in user space and is extremely cheap. Therefore, "start one goroutine per request" is standard practice in Go backends, and its overhead is usually not a concern.
3. Channels: The Foundation of Synchronization and Communication
A channel is a pipe for passing data between goroutines:
ch := make(chan int) // Unbuffered
ch2 := make(chan int, 5) // Buffered, capacity 5
An unbuffered channel is synchronous: after sending, the sender must wait for a receiver, or it blocks; the reverse is also true. It is essentially a "handshake" in which sends and receives are strictly paired. It is commonly used as a synchronization point between two goroutines.
done := make(chan struct{})
go func() {
doWork()
close(done) // Notify the main goroutine
}()
<-done // Block until done is closed
A buffered channel is asynchronous: sends do not block until the buffer is full; once it is full, they block. Receives block when the buffer is empty. This decouples the pace of producers and consumers.
Remember the blocking semantics: sending to a closed channel causes a panic; receiving from a closed channel returns the zero value without blocking. close should always be called only by the "sender," and a channel can be closed only once. These are the basic rules of channel usage.
4. select: The Art of Multiplexing
select lets a goroutine wait on multiple channels at once and executes whichever case becomes ready first:
select {
case msg := <-msgs:
handle(msg)
case <-time.After(3 * time.Second):
return // Exit on timeout
default:
// No channel is ready; proceed here without blocking
}
Two common uses are timeout control (time.After / context) and non-blocking operations (the default branch). In production code, nearly every channel wait should have a timeout or cancellation signal; otherwise, the goroutine may remain blocked forever.
When multiple cases are ready, select chooses a branch at random, which is useful for load balancing.
5. Concurrency Patterns: Four Recurring Production Techniques
Worker Pool
A fixed number of workers take tasks from a job channel and write results to a result channel. Its core value is controlling concurrency, preventing a burst of 100,000 goroutines from overwhelming downstream systems.
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * j
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 1; w <= 3; w++ { // 3 workers
go worker(w, jobs, results)
}
for i := 1; i <= 50; i++ { jobs <- i }
close(jobs)
}
Fan-out / Fan-in
Split a task across multiple goroutines for parallel processing (fan-out), then aggregate the results into a single channel (fan-in). This is suitable for CPU-intensive tasks that can be divided, such as querying multiple data sources in parallel and then merging the results.
Pipeline
Each stage consists of a group of goroutines connected through channels. The output of one stage becomes the input of the next, like a Unix pipeline: Read → Parse → Filter → Aggregate. Each stage can scale independently and remains clear and easy to test.
Done Channel
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return // Cancellation received; exit gracefully
case x := <-input:
process(x)
}
}
}()
close(done) // Tell all listeners to exit
In production, context.Context is generally preferred because it allows cancellation signals to propagate automatically through the call chain, making it more maintainable than a bare done channel.
6. The sync Package: Cases Channels Cannot Solve
Not every concurrency problem is a good fit for channels. The sync package provides classic synchronization primitives:
- WaitGroup: waits for a group of goroutines to finish. Essential for batch tasks.
go var wg sync.WaitGroup for _, url := range urls { wg.Add(1) go func(u string) { defer wg.Done() fetch(u) }(url) } wg.Wait() - Mutex / RWMutex: mutual exclusion and read-write locks. They are more direct than channels for protecting a concurrently accessed map or counter.
RWMutexis suitable for read-heavy workloads with few writes. - Once: ensures that a block of initialization code executes only once globally. It is the standard approach for singletons and configuration loading.
A practical rule: use a Mutex to protect "state" and a channel to transfer "data." Using both together is the norm.
7. Pitfalls: Leaks, Deadlocks, and Race Conditions
Goroutine Leaks
After starting, a goroutine remains blocked forever and cannot exit, and the GC cannot reclaim the memory it references. Typical causes include sending to a channel with no receiver or waiting for a done channel that is never closed.
// Leak: ch is full, so the sender blocks forever
func leak() {
ch := make(chan int, 1)
ch <- 1
go func() { ch <- 2 }() // No receiver; this leaks
}
Prevention: every goroutine must have an explicit exit path, controlled through a context or done channel. In production, use the goroutine profile from pprof to investigate leaks.
Deadlocks
All goroutines wait on one another, freezing the entire program. Common causes include an unbuffered channel with senders but no receivers and inconsistent nested lock ordering. The Go runtime can detect a "total deadlock" and panic, but it cannot detect a partial deadlock in which only one goroutine is stuck. Use timeouts and careful design to prevent these cases.
Race Conditions
A race occurs when multiple goroutines concurrently access the same variable, at least one access is a write, and there is no synchronization. The output then depends on scheduling order, making the bug extremely difficult to reproduce. Go includes a race detector; enable it when running tests:
go test -race ./...
go run -race main.go
Race detection should be mandatory in CI because it catches most concurrency bugs before production.
Context Cancellation Propagation
For a ctx created by context.WithCancel / WithTimeout, every goroutine must use select { case <-ctx.Done(): return }. Otherwise, the parent call may be canceled while child goroutines continue running and leak anyway. Long-running tasks must check ctx.Done() periodically.
Summary
- Philosophy first: think in terms of "sharing memory by communicating." Give data ownership to channels to reduce sharing and locking.
- Lightweight does not mean free: even a lightweight goroutine needs an exit path. A goroutine with no termination mechanism is a potential leak.
- Three channel essentials: unbuffered means a synchronous handshake, buffered means a decoupled queue, and
closecan be called only once by the sender. - select + context are production essentials: timeouts, cancellation, and multiplexed waiting are all indispensable.
- The race detector is the baseline: always enable
-racein CI so most race conditions are caught during testing.
Concurrency is not about adding
gobefore a function. It is about deciding how every "start" will "end." You have mastered Go concurrency only when you can design its exit paths.
Related
Related Content
Article
Self-Hosting Open-Source LLMs: Getting Started with Ollama, vLLM, and llama.cpp
Technical trends, engineering practices, and an independent developer's perspective on LLMs and open source.
Article
Learning Technical Community Product Design from dev.to
Technical trends, engineering practices, and an independent developer's perspective on independent development and open source.
Article
Considerations for Deploying a Next.js Community Project with Docker
Technical trends, engineering practices, and an independent developer perspective on DevOps and Next.js.
Article
How Can OpenTelemetry Help You Observe AI Applications?
Technical trends, engineering practices, and an independent developer's perspective on DevOps and AI.