go 91 lines · 2 tabs

Fan-Out Worker Pool With Context Cancellation and Graceful Shutdown in Go

Shared by codesnips Sep 2026
2 tabs
package worker

import (
	"context"
	"errors"
	"log"
	"sync"
)

var ErrPoolClosed = errors.New("worker: pool is shutting down")

type Job func()

type Pool struct {
	jobs chan Job
	wg   sync.WaitGroup
}

func NewPool(ctx context.Context, size int) *Pool {
	p := &Pool{jobs: make(chan Job)}
	for i := 0; i < size; i++ {
		p.wg.Add(1)
		go p.worker(ctx, i)
	}
	return p
}

func (p *Pool) worker(ctx context.Context, id int) {
	defer p.wg.Done()
	for {
		select {
		case <-ctx.Done():
			log.Printf("worker %d: stopping (%v)", id, ctx.Err())
			return
		case job := <-p.jobs:
			job()
		}
	}
}

func (p *Pool) Submit(ctx context.Context, job Job) error {
	select {
	case <-ctx.Done():
		return ErrPoolClosed
	case p.jobs <- job:
		return nil
	}
}

func (p *Pool) Wait() {
	p.wg.Wait()
}
2 files · go Explain with highlit

This snippet demonstrates the canonical Go pattern for broadcasting a shutdown signal to a set of goroutines using a closed channel, wrapped in the more modern context.Context machinery. The core idea is that a chan struct{} (or the Done() channel of a context) can be observed by any number of goroutines simultaneously: closing a channel unblocks every pending receive, so one close fans out a stop signal to the whole pool without needing to know how many listeners exist.

In pool.go, the Pool type spins up size workers, each running worker in its own goroutine. Every worker uses a select that races real work (jobs channel) against ctx.Done(). When the context is cancelled, ctx.Done() becomes readable and the worker returns cleanly. This is why cancellation is checked inside the loop rather than only at the top — a worker blocked on a slow job or a receive must still be able to wake up and exit. The sync.WaitGroup guarantees Wait does not return until every goroutine has actually finished draining, which is the difference between signalling shutdown and completing it.

The subtlety in Submit is that sending on jobs is itself guarded by a select on ctx.Done(). Without that guard, a caller could block forever trying to enqueue work into a pool that is already shutting down, since no worker would ever receive it. Returning ErrPoolClosed surfaces that race explicitly instead of deadlocking.

In main.go, signal.NotifyContext ties the whole tree to SIGINT/SIGTERM, so pressing Ctrl-C cancels the root context, which cascades to every worker's ctx.Done(). The defer stop() and defer pool.Wait() ordering ensures the program requests shutdown, then blocks until in-flight jobs drain. This pattern is the standard way to wire OS signals to goroutine lifecycles, and it scales from two workers to thousands because the cost of the broadcast is a single channel close regardless of listener count. The main pitfall to avoid is closing the same channel twice, which panics — using a context sidesteps that entirely since context is safe to cancel repeatedly.


Related snips

Share this code

Here's the card — post it anywhere.

Fan-Out Worker Pool With Context Cancellation and Graceful Shutdown in Go — share card
Link copied