These are the patterns you'll see AI generate when you ask for concurrent Go code. AI can reproduce the structure of each pattern reliably. What it consistently gets wrong is lifecycle management, how goroutines start, how they stop, and what happens when things go wrong. That's what this lesson focuses on.
Worker pool
The most common concurrent pattern in Go. A fixed number of goroutines pull jobs from a shared channelWhat is channel?A typed conduit in Go used to pass values between goroutines - can be unbuffered (synchronous) or buffered (async queue).:
func workerPool(ctx context.Context, numWorkers int, jobs <-chan Job) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
select {
case job, ok := <-jobs:
if !ok {
return
}
results <- Result{
JobID: job.ID,
Output: process(job),
}
case <-ctx.Done():
return
}
}
}(i)
}
go func() {
wg.Wait()
close(results) // only close after ALL workers are done
}()
return results
}The critical pieces AI often misses:
| Component | Purpose | AI omits? |
|---|---|---|
ctx.Done() case | Cancellation support | Often |
ok check on receive | Detect channel close | Sometimes |
| WaitGroup + close coordinator | Clean shutdown | Often |
| Buffered results channel | Prevent worker blocking | Sometimes |
// WRONG - AI generates this
go func() {
defer wg.Done()
defer close(results) // PANIC: multiple workers close same channel
for job := range jobs {
results <- process(job)
}
}
// CORRECT - single coordinator closes
go func() {
wg.Wait()
close(results)
}()Fan-out / fan-in
Fan-out distributes work to multiple goroutines. Fan-in merges multiple channels into one. Together they parallelize work and collect results:
// Fan-out: start N workers on the same input channel
func fanOut(ctx context.Context, input <-chan int, n int) []<-chan int {
outputs := make([]<-chan int, n)
for i := 0; i < n; i++ {
ch := make(chan int)
outputs[i] = ch
go func() {
defer close(ch)
for {
select {
case val, ok := <-input:
if !ok {
return
}
ch <- val * val // process
case <-ctx.Done():
return
}
}
}()
}
return outputs
}
// Fan-in: merge multiple channels into one
func fanIn(ctx context.Context, channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for {
select {
case val, ok := <-c:
if !ok {
return
}
select {
case out <- val:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}select on the output send. If the consumer stops reading, the goroutine blocks on out <- val forever, a goroutine leak. Always wrap channel sends in a select with ctx.Done().PipelineWhat is pipeline?A sequence of automated steps (install, lint, test, build, deploy) that code passes through before reaching production.
A chain of stages where each stage receives from the previous and sends to the next. Each stage runs in its own goroutineWhat is goroutine?A lightweight concurrent function in Go launched with the go keyword - much cheaper than an OS thread (~2KB vs ~1MB of stack).:
func generate(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
func filter(ctx context.Context, in <-chan int, pred func(int) bool) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
if pred(n) {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}
}()
return out
}
// Usage
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
nums := generate(ctx, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
squares := square(ctx, nums)
evens := filter(ctx, squares, func(n int) bool { return n%2 == 0 })
for v := range evens {
fmt.Println(v) // 4, 16, 36, 64, 100
}| Pipeline aspect | AI gets right | AI gets wrong |
|---|---|---|
| Channel creation | Yes | N/A |
| Goroutine per stage | Yes | N/A |
| Closing output channels | Sometimes | Forgets defer close(out) |
| Context cancellation | Rarely | Omits ctx.Done() in sends |
| Early termination | Rarely | Goroutines leak if consumer stops early |
errgroup
The golang.org/x/sync/errgroup package is the standard way to run goroutines with error handling:
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) ([]Response, error) {
g, ctx := errgroup.WithContext(ctx)
responses := make([]Response, len(urls))
for i, url := range urls {
i, url := i, url // capture for goroutine
g.Go(func() error {
resp, err := fetch(ctx, url)
if err != nil {
return err // cancels ctx, stops other goroutines
}
responses[i] = resp
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return responses, nil
}When any goroutineWhat is goroutine?A lightweight concurrent function in Go launched with the go keyword - much cheaper than an OS thread (~2KB vs ~1MB of stack). returns an error, errgroup.WithContext cancels the context, signaling all other goroutines to stop. g.Wait() returns the first error.
errgroup would be cleaner. If you see a pattern of WaitGroup + error channel + goroutines, suggest errgroup, it handles all of that in a tested, well-understood package.results slice by index without a mutex. In the errgroup example above, responses[i] = resp is safe because each goroutine writes to a different index. But if goroutines append to a shared slice, that's a race condition. AI conflates the two patterns.Rate limitingWhat is rate limiting?Restricting how many requests a client can make within a time window. Prevents brute-force attacks and protects your API from being overwhelmed.
Control the rate of concurrent operations:
// Token bucket rate limiter
func rateLimited(ctx context.Context, jobs <-chan Job, ratePerSec int) <-chan Result {
results := make(chan Result)
limiter := time.NewTicker(time.Second / time.Duration(ratePerSec))
go func() {
defer close(results)
defer limiter.Stop()
for {
select {
case <-limiter.C:
select {
case job, ok := <-jobs:
if !ok {
return
}
results <- process(job)
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
return results
}time.Sleep inside worker goroutines. This limits each individual worker's rate but not the aggregate rate across all workers. Three workers each sleeping 1 second produce 3 requests/second, not 1. Use a shared ticker or golang.org/x/time/rate.Limiter for accurate rate limiting.Graceful shutdownWhat is graceful shutdown?Finishing all in-progress requests and closing connections cleanly before your server exits, instead of cutting off users mid-response.
The pattern for stopping a system cleanly, finish current work, don't accept new work:
func serve(ctx context.Context) error {
jobs := make(chan Job, 100)
var wg sync.WaitGroup
// Start workers
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
process(job)
}
}()
}
// Accept work until context cancelled
for {
select {
case <-ctx.Done():
close(jobs) // signal workers to drain and exit
wg.Wait() // wait for in-flight work
return ctx.Err()
default:
job, err := getNextJob()
if err != nil {
continue
}
jobs <- job
}
}
}os.Exit() or just returns without waiting for goroutines. In-flight database writes, HTTP responses, and file operations get cut off mid-stream. Always drain queues and wait for workers before exiting.Evaluating AI-generated concurrent code
When AI gives you concurrent Go code, run through this checklist:
| Check | Question | Red flag |
|---|---|---|
| Exit paths | How does each goroutine exit? | No context or channel close check |
| Channel ownership | Who closes each channel? | Multiple closers or no closer |
| Error handling | What happens when something fails? | Errors silently swallowed |
| Cancellation | Does it respect context cancellation? | No ctx.Done() case in select |
| Resource cleanup | Are timers stopped? Files closed? | defer missing on resources |
| Lock ordering | Are multiple locks acquired consistently? | Different order in different functions |
| Copy safety | Are mutex-containing structs passed by pointer? | Value receivers on mutex structs |
| Buffer sizing | Why this buffer size? | Magic numbers like make(chan T, 100) |
go test -race and stress-test concurrent code before shipping.