Goroutines & Channels
Irbisa · cheatsheetSeptember 19, 2026

Goroutines & Channels

Channels, select, worker pools, goroutine leaks, errgroup

Middle Developer16 itemscompressed for a skim
  1. 01

    A main that fires off three go process(job) calls and then returns prints nothing at all. Where did the work go?

    Easy

    When main returns the process exits immediately — the runtime never waits for goroutines, and there is no way to detach one so that it outlives the program.

    func process(job string) { /* ... */ }
    
    func main() {
    	jobs := []string{"a", "b", "c"}
    
    	// ❌ main returns before the scheduler has run any of them. The process exits,
    …
  2. 02

    ch := make(chan int) followed by ch <- 1 in main dies with fatal error: all goroutines are asleep - deadlock!, and adding one buffer slot fixes it. What changed?

    Easy

    An unbuffered channel is a rendezvous: the send does not complete until another goroutine is sitting at the matching receive, and in a single-goroutine program nobody ever arrives.

    func deadlock() {
    	ch := make(chan int)
    	ch <- 1 // ❌ fatal error: all goroutines are asleep - deadlock!
    	//         goroutine 1 [chan send]
    	fmt.Println(<-ch)
    }
    …
  3. 03

    A consumer goroutine pegs a core and logs handled 0 forever once the producer finishes. The producer's last statement was close(jobs). What is the loop missing?

    Easy

    A receive from a closed channel returns immediately with the zero value, forever — the loop has to read the second result, j, ok := <-jobs, or use range, to notice that the channel is done.

    // ❌ a closed channel is always ready and always yields the zero value:
    // this spins at 100% of a core the moment close(jobs) runs.
    func consumeBad(jobs <-chan Job) {
    	for {
    		j := <-jobs
    		handle(j)
    …
  4. 04

    A pipeline stage is declared func square(in chan int, out chan int) and the reviewer asks for <-chan and chan<- instead. What do the direction types buy you?

    Easy

    They make each stage's contract checkable at compile time: a <-chan T cannot be sent on or closed, a chan<- T cannot be received from, and the narrowing conversion is implicit, one-way and free at runtime.

    // Each stage owns its output: it creates it, fills it, and closes it.
    func square(in <-chan int) <-chan int {
    	out := make(chan int)
    	go func() {
    		defer close(out) // the only close, by the only goroutine that sends
    		for v := range in {
    …
  5. 05

    A worker's select lists case <-quit: first and case j := <-jobs: second, yet after close(quit) it still handles a dozen more jobs. Why doesn't the first case win?

    Medium

    select makes a uniform pseudo-random choice among the cases that are ready — source order carries no priority whatsoever, and a closed quit is merely one more ready case.

    // ❌ source order is not priority: select draws uniformly among the ready cases,
    // so roughly half the iterations keep taking jobs after close(quit).
    func workerBad(jobs <-chan Job, quit <-chan struct{}) {
    	for {
    		select {
    		case <-quit:
    …
  6. 06

    Metrics are recorded with a plain c.events <- e from inside the request path, and when the collector stalls every handler stalls with it. How do you make that send unable to block?

    Medium

    Wrap it in a select with a defaultthe default case runs when no other case is ready, so the send either fits in the buffer right now or is dropped right now, and the caller never parks.

    type Collector struct {
    	events  chan Event
    	dropped atomic.Uint64
    }
    
    // Called from the request path, so it must never park the caller.
    …
  7. 07

    A fan-in loop merges two producers with select. The moment the first one closes, the loop starts spinning and emitting zeros instead of waiting for the second. What is the one-line fix?

    Medium

    Set the drained channel's variable to nila receive from a nil channel blocks forever, so a nil case can never be ready and select silently stops considering it.

    func merge(a, b <-chan int) <-chan int {
    	out := make(chan int)
    	go func() {
    		defer close(out)
    		for a != nil || b != nil { // both nil means both drained
    			select {
    …
  8. 08

    The goroutine profile grows by one every time a request times out, and every leaked stack ends in runtime.chansend. The handler itself returns cleanly. What is still running?

    Medium

    The worker goroutine is still trying to deliver its result to a receiver that walked away — an unbuffered send with no receiver parks forever, so every send made by a goroutine whose consumer can leave needs an escape.

    // ❌ the handler returns on timeout, but the worker is still holding the result
    // and parks in chansend forever: one leaked goroutine per timed-out request.
    func handleBad(w http.ResponseWriter, r *http.Request) {
    	out := make(chan Result)
    	go func() { out <- compute(r) }()
    …
  9. 09

    A relay loop with case <-time.After(5 * time.Minute): in its select climbs steadily in RSS on the production service, while the same code in a colleague's newer service is flat. Both are built by the same toolchain. What differs?

    Medium

    time.After allocates a fresh timer on every pass of the loop, and before Go 1.23 the runtime kept each one alive until it fired — five minutes' worth of timers, at loop speed, pinned in the timer heap.

    // ❌ a new Timer on every message. On a module whose go.mod still says go 1.22,
    // the runtime holds each one until it fires: 5 minutes of timers at 10k msg/s.
    func relayBad(in <-chan Msg) error {
    	for {
    		select {
    		case m := <-in:
    …
  10. 10

    Eight workers write to one results channel and the for r := range results in main hangs after the last result arrives. Putting close(results) on the line after the loop changes nothing. Where does the close belong?

    Medium

    In its own goroutine, after wg.Wait()main is inside the range, so the statement after it is unreachable, and no single worker may close a channel the other seven still send on.

    func run(rows []Row) {
    	jobs := make(chan Row)
    	results := make(chan Result)
    
    	var wg sync.WaitGroup
    	wg.Add(8)
    …
  11. 11

    An importer does for _, row := range rows { go insert(db, row) } over a 200k-row file, and Postgres answers sorry, too many clients already. What replaces the loop?

    Medium

    A fixed pool of workers reading one jobs channel — the goroutine count becomes a constant you choose, instead of the size of the input file.

    // ❌ one goroutine per row: 200k concurrent inserts, and the thing that breaks is
    // the database — "sorry, too many clients already".
    // for _, row := range rows { go insert(db, row) }
    
    // ✅ the pool size is the bottleneck's size, not the input's
    func importRows(db *sql.DB, rows []Row) {
    …
  12. 12

    The work already sits inside a range over a channel you do not own, so a worker pool would mean restructuring the caller — but you still need at most eight concurrent uploads. What is the smallest thing that gives you that?

    Medium

    A buffered channel used as a counting semaphore: sem := make(chan struct{}, 8), acquire with a send, release with a receive, and the send blocks precisely when eight are already in flight.

    func uploadAll(items <-chan Item) {
    	sem := make(chan struct{}, 8) // 8 tokens; struct{} is zero-width
    	var wg sync.WaitGroup
    
    	for it := range items {
    		sem <- struct{}{} // ✅ acquire BEFORE go: this loop is what waits
    …
  13. 13

    A three-stage pipeline is green in a test that reads it to exhaustion and leaks two goroutines in production, where the caller stops after the first ten values. What must every stage have that these do not?

    Hard

    A done channel selected on every send, because a stage nobody reads any more parks in chansend forever and takes its upstream with it — one leaked goroutine per stage, per abandoned pipeline.

    func gen(done <-chan struct{}, ns ...int) <-chan int {
    	out := make(chan int)
    	go func() {
    		defer close(out)
    		for _, n := range ns {
    			select {
    …
  14. 14

    Swapping a sync.WaitGroup for an errgroup.Group made the failures visible, but calling g.SetLimit(16) inside the dispatch loop panics with modify limit while 4 goroutines in the group are still active. What is SetLimit actually doing?

    Hard

    SetLimit(n) replaces the group's internal semaphore, a chan token of capacity n, so it may only be called while no goroutine holds a token — in practice, once, before the first g.Go.

    func fetchAll(ctx context.Context, urls []string) ([]Page, error) {
    	g, ctx := errgroup.WithContext(ctx)
    	g.SetLimit(16) // ✅ once, before the first Go: it replaces the token channel
    	pages := make([]Page, len(urls))
    
    	for i, u := range urls {
    …
  15. 15

    Sixteen workers make the enrichment stage eleven times faster and the CSV downstream comes out shuffled. Sorting afterwards is not an option — the stream does not fit in memory. How do you get the order back?

    Hard

    Fix each item's position before the work fans out: push one buffered result channel per item onto an ordered queue, and have the consumer read those queued channels in sequence.

    type job struct {
    	row Row
    	res chan Result // cap 1: the worker never waits for a consumer that is behind
    }
    
    // The dispatcher is single-threaded, so the order of queue is the input order.
    …
  16. 16

    A cache rewritten “the Go way” — one owner goroutine, a request channel, a reply channel per call — benchmarks four times slower than the sync.RWMutex version it replaced. Was the proverb wrong?

    Hard

    The proverb is about ownership of data in flight, not about guarding a field: a cache is state that many goroutines read, and a mutex expresses that directly while a channel adds an allocation and two scheduler handoffs to every lookup.

    // ❌ "the Go way", 4x slower: an allocation plus two scheduler handoffs per Get,
    // and an owner goroutine that serializes readers which used to run concurrently.
    type request struct {
    	key   string
    	reply chan string // allocated per call
    }
    …