Go/
Lesson

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:

ComponentPurposeAI omits?
ctx.Done() caseCancellation supportOften
ok check on receiveDetect channel closeSometimes
WaitGroup + close coordinatorClean shutdownOften
Buffered results channelPrevent worker blockingSometimes
AI pitfall
AI generates worker pools that close the results channel inside each worker goroutine. The first worker to finish closes the channel, and the remaining workers panic when they try to send. Only one goroutine should close a channel, the coordinator that waits for all workers.
// 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)
}()
02

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
}
AI pitfall
AI generates fan-in functions without the inner 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().
03

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 aspectAI gets rightAI gets wrong
Channel creationYesN/A
Goroutine per stageYesN/A
Closing output channelsSometimesForgets defer close(out)
Context cancellationRarelyOmits ctx.Done() in sends
Early terminationRarelyGoroutines leak if consumer stops early
AI pitfall
AI generates "clean" pipeline code without context cancellation. This means if the consumer only needs the first 3 results and stops reading, every upstream goroutine blocks forever on its send operation. Always pass context through every pipeline stage.
04

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.

AI pitfall
AI writes manual WaitGroup + error collection code when 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.
AI pitfall
AI accesses a shared 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.
05

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
}
AI pitfall
AI implements rate limiting with 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.
06

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
        }
    }
}
AI pitfall
AI generates shutdown code that calls 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.
07

Evaluating AI-generated concurrent code

When AI gives you concurrent Go code, run through this checklist:

CheckQuestionRed flag
Exit pathsHow does each goroutine exit?No context or channel close check
Channel ownershipWho closes each channel?Multiple closers or no closer
Error handlingWhat happens when something fails?Errors silently swallowed
CancellationDoes it respect context cancellation?No ctx.Done() case in select
Resource cleanupAre timers stopped? Files closed?defer missing on resources
Lock orderingAre multiple locks acquired consistently?Different order in different functions
Copy safetyAre mutex-containing structs passed by pointer?Value receivers on mutex structs
Buffer sizingWhy this buffer size?Magic numbers like make(chan T, 100)
The hardest bugs in concurrent code are the ones that work in testing but fail in production. AI-generated concurrent code passes tests because tests run with low parallelism and short durations. Race conditions, goroutine leaks, and deadlocks only show up under real load. Always run go test -race and stress-test concurrent code before shipping.