go 128 lines · 3 tabs

Bounded Worker Pool Processing Jobs from a Buffered Channel in Go

Shared by codesnips Jul 2026
3 tabs
package workpool

import (
	"context"
	"sync"
)

type Pool struct {
	jobs    chan Job
	results chan Result
	wg      sync.WaitGroup
	size    int
}

func New(size, buffer int) *Pool {
	return &Pool{
		jobs:    make(chan Job, buffer),
		results: make(chan Result, buffer),
		size:    size,
	}
}

func (p *Pool) Start(ctx context.Context) {
	for i := 0; i < p.size; i++ {
		p.wg.Add(1)
		go p.worker(ctx, i)
	}
}

func (p *Pool) worker(ctx context.Context, id int) {
	defer p.wg.Done()
	for job := range p.jobs {
		select {
		case <-ctx.Done():
			return
		default:
			p.results <- process(id, job)
		}
	}
}

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

func (p *Pool) Results() <-chan Result {
	return p.results
}

func (p *Pool) Close() {
	close(p.jobs)
	p.wg.Wait()
	close(p.results)
}
3 files · go Explain with highlit

This snippet shows the classic Go fan-out worker pool: a fixed number of goroutines drain a shared buffered channel of jobs while a single sync.WaitGroup coordinates shutdown. The pattern is useful whenever there is more work than it makes sense to run concurrently — HTTP fetches, image resizes, database writes — and unbounded goroutine spawning would exhaust memory or overwhelm a downstream service. Bounding the pool size caps concurrency, and the buffered channel provides backpressure so producers block once the queue is full instead of piling up work indefinitely.

In pool.go, the Pool owns a jobs channel sized by buffer and a results channel. Start launches size goroutines, each running worker, and increments the WaitGroup once per goroutine. Each worker ranges over jobs, which is the idiomatic way to consume a channel until it is closed — the loop exits automatically when Submit/Close closes p.jobs, so no sentinel value is needed. The select inside the loop also watches ctx.Done(), allowing an in-flight batch to abort early on cancellation rather than finishing the entire queue.

Submit is deliberately guarded by select against ctx.Done() so a caller isn't blocked forever pushing into a full channel during shutdown; it returns ctx.Err() instead. Close closes p.jobs to signal there is no more work, waits for every worker to finish via wg.Wait(), and only then closes results. Closing results after the wait is critical: closing it while a worker might still send would panic. This ordering — close input, wait, close output — is the safe teardown recipe for any pipeline stage.

In job.go, Job and Result are plain structs; process simulates real work and returns an error that is threaded into Result so failures are observable per job rather than crashing a worker. main.go wires it together: it feeds jobs in a separate goroutine so the producer and the result-draining loop run concurrently, avoiding a deadlock where a full results channel would stall the workers. A context.WithTimeout bounds total runtime, demonstrating cooperative cancellation. The trade-off is that jobs already dequeued when the deadline hits still attempt to run unless process itself checks the context.


Related snips

Share this code

Here's the card — post it anywhere.

Bounded Worker Pool Processing Jobs from a Buffered Channel in Go — share card
Link copied