// Package worker provides a small dynamically sized pool for func tasks. package worker import ( "errors" "log" "sync/atomic" ) var ( ErrClosed = errors.New("worker pool is closed") ErrNilTask = errors.New("worker task is nil") ErrInvalidConfig = errors.New("invalid worker pool configuration") ) // Pool runs submitted tasks using a bounded number of workers. type Pool struct { submit chan func() closeReq chan struct{} stopping chan struct{} submittersDone chan struct{} done chan struct{} events chan workerEvent min int max int queueCap int submitters atomic.Int64 closing atomic.Bool } const defaultSubmitBuffer = 128 type poolConfig struct{ submitBuffer int } // Option configures a worker pool. type Option func(*poolConfig) error // WithSubmitBuffer sets the capacity of the submission ingress channel. Zero // preserves rendezvous submission; negative values are invalid. func WithSubmitBuffer(size int) Option { return func(config *poolConfig) error { if size < 0 { return ErrInvalidConfig } config.submitBuffer = size return nil } } type worker struct { inbox chan func() stop chan struct{} dead chan struct{} first func() } type eventKind uint8 const ( taskFinished eventKind = iota workerStopped workerPanicked ) type workerEvent struct { worker *worker kind eventKind } type workerState uint8 const ( workerBusy workerState = iota workerIdle workerRetiring workerDying ) // scheduler is the sole owner of worker counts, worker states, idle workers, // queue contents, and the pool's closed state. Its fixed-capacity channels are // queues, never locks. Worker goroutines report state changes on events. type scheduler struct { pool *Pool idle chan *worker queue chan func() workers map[*worker]workerState total int retiring int closed bool done bool // pending occupies the idle worker slot whose handoff failed. It is not // an additional queue slot, so submissions pause until it is dispatched. pending func() hasPending bool } // New starts minWorkers workers before returning. The scheduler queue capacity // is maxWorkers/2. Submit uses a separate ingress channel with a default // capacity of 128, configurable with WithSubmitBuffer. func New(minWorkers, maxWorkers int, options ...Option) (*Pool, error) { if maxWorkers <= 0 || minWorkers < 0 || minWorkers > maxWorkers { return nil, ErrInvalidConfig } config := poolConfig{submitBuffer: defaultSubmitBuffer} for _, option := range options { if option == nil || option(&config) != nil { return nil, ErrInvalidConfig } } p := &Pool{ submit: make(chan func(), config.submitBuffer), closeReq: make(chan struct{}), stopping: make(chan struct{}), submittersDone: make(chan struct{}, 1), done: make(chan struct{}), events: make(chan workerEvent, maxWorkers), min: minWorkers, max: maxWorkers, queueCap: maxWorkers / 2, } s := newScheduler(p) for range minWorkers { s.startWorker(nil, false) } go s.run() return p, nil } // Submit waits until a worker or a bounded queue slot can accept task. A nil // task is rejected before checking whether the pool has closed. func (p *Pool) Submit(task func()) error { if task == nil { return ErrNilTask } if p.closing.Load() { return ErrClosed } p.submitters.Add(1) if p.closing.Load() { p.finishSubmit() return ErrClosed } select { case p.submit <- task: p.finishSubmit() return nil case <-p.stopping: p.finishSubmit() return ErrClosed } } func (p *Pool) finishSubmit() { if p.submitters.Add(-1) == 0 && p.closing.Load() { select { case p.submittersDone <- struct{}{}: default: } } } // Close stops accepting new tasks and waits for all accepted tasks and workers // to finish. It is safe to call concurrently or more than once. Call it outside // pool tasks to avoid waiting for the calling worker itself. func (p *Pool) Close() { if p.closing.CompareAndSwap(false, true) { p.closeReq <- struct{}{} } <-p.done } func newScheduler(p *Pool) *scheduler { return &scheduler{ pool: p, idle: make(chan *worker, p.max), queue: make(chan func(), p.queueCap), workers: make(map[*worker]workerState, p.max), } } func (s *scheduler) startWorker(task func(), withTask bool) *worker { w := &worker{inbox: make(chan func()), stop: make(chan struct{}), dead: make(chan struct{})} s.workers[w] = workerBusy s.total++ if withTask { w.first = task } else { s.setIdle(w) } go runWorker(w, s.pool.events) return w } func (s *scheduler) setIdle(w *worker) { s.workers[w] = workerIdle s.idle <- w } func (s *scheduler) run() { for !s.done { s.pump() if s.hasPending { s.dispatchPending() } s.finishCloseIfReady() if s.done { return } var submit <-chan func() if !s.hasPending && s.canAccept() && (!s.closed || s.pool.submitters.Load() > 0 || len(s.pool.submit) > 0) { submit = s.pool.submit } var closeReq <-chan struct{} if !s.closed { closeReq = s.pool.closeReq } select { case task := <-submit: if !s.accept(task) { // The idle worker's reserved handoff is now represented by // pending until its terminal event frees that worker slot. s.pending = task s.hasPending = true } case event := <-s.pool.events: s.handleEvent(event) case <-closeReq: s.beginClose() case <-s.pool.submittersDone: // Recheck buffered ingress and close completion after an admitted // Submit call leaves the channel operation. } } } func (s *scheduler) dispatchPending() { if !s.hasPending { return } task := s.pending if len(s.idle) > 0 { w := <-s.idle if !s.deliver(w, task) { s.markWorkerDying(w) return } s.workers[w] = workerBusy s.pending = nil s.hasPending = false return } if s.total < s.pool.max { s.startWorker(task, true) s.pending = nil s.hasPending = false } } func (s *scheduler) canAccept() bool { if len(s.queue) > 0 { return len(s.queue) < s.pool.queueCap } return len(s.idle) > 0 || s.total < s.pool.max || len(s.queue) < s.pool.queueCap } func (s *scheduler) accept(task func()) bool { if len(s.queue) > 0 { s.queue <- task return true } if len(s.idle) > 0 { w := <-s.idle if !s.deliver(w, task) { s.markWorkerDying(w) return false } s.workers[w] = workerBusy return true } if s.total < s.pool.max { s.startWorker(task, true) return true } s.queue <- task return true } // pump gives accepted FIFO work to idle workers or newly available worker // slots before accepting more submissions. It never keeps a hidden task slot. func (s *scheduler) pump() { for len(s.queue) > 0 { if len(s.idle) > 0 { w := <-s.idle task := <-s.queue if !s.deliver(w, task) { s.requeueFront(task) s.markWorkerDying(w) continue } s.workers[w] = workerBusy continue } if s.total < s.pool.max { s.startWorker(<-s.queue, true) continue } break } if s.closed && !s.hasPending && s.pool.submitters.Load() == 0 && len(s.pool.submit) == 0 { for len(s.idle) > 0 { s.retire(<-s.idle) } } } func (s *scheduler) beginClose() { if s.closed { return } s.closed = true close(s.pool.stopping) if s.pool.submitters.Load() == 0 && len(s.pool.submit) == 0 { for len(s.idle) > 0 { s.retire(<-s.idle) } } } func (s *scheduler) handleEvent(event workerEvent) { switch event.kind { case taskFinished: s.taskFinished(event.worker) case workerStopped: s.workerExited(event.worker, false) case workerPanicked: s.workerExited(event.worker, true) } // A worker exit can make room for accepted queue work. A normal task finish // may also expose a queue slot to a blocked Submit. s.pump() s.finishCloseIfReady() } func (s *scheduler) taskFinished(w *worker) { state, exists := s.workers[w] if !exists || state != workerBusy { return } if len(s.queue) > 0 { task := <-s.queue if !s.deliver(w, task) { s.requeueFront(task) s.markWorkerDying(w) } return } select { case <-w.dead: s.markWorkerDying(w) return default: } if s.closed && s.pool.submitters.Load() == 0 && len(s.pool.submit) == 0 { s.retire(w) return } otherIdle := len(s.idle) if !s.closed && s.total-s.retiring > s.pool.min && otherIdle > s.pool.min/2 { s.retire(w) return } s.setIdle(w) } // markWorkerDying removes a worker from dispatch immediately, but leaves its // count reserved until the unique terminal event arrives after its logger and // exit reporting have completed. func (s *scheduler) markWorkerDying(w *worker) { state, exists := s.workers[w] if !exists || state == workerDying { return } if state == workerIdle { s.removeIdle(w) } s.workers[w] = workerDying } func (s *scheduler) retire(w *worker) { if s.workers[w] == workerRetiring { return } s.workers[w] = workerRetiring s.retiring++ close(w.stop) } func (s *scheduler) workerExited(w *worker, abnormal bool) { state, exists := s.workers[w] if !exists { return } if state == workerIdle { s.removeIdle(w) } delete(s.workers, w) s.total-- if state == workerRetiring { s.retiring-- } if abnormal && len(s.queue) > 0 { s.startWorker(<-s.queue, true) } } // deliver uses an unbuffered inbox: a successful send means a live worker has // taken ownership of the task. If the worker is dead, no task can be left // buffered behind it. The recover handles a test-injected closed inbox; callers // preserve the task and account for the worker before continuing. func (s *scheduler) deliver(w *worker, task func()) (sent bool) { defer func() { if recover() != nil { sent = false } }() select { case w.inbox <- task: return true case <-w.dead: return false } } // requeueFront restores a task removed for a failed unbuffered handoff while // preserving FIFO order and the original fixed cache capacity. This only runs // on worker failure, never on the normal dispatch path. func (s *scheduler) requeueFront(task func()) { queue := make(chan func(), s.pool.queueCap) queue <- task for len(s.queue) > 0 { queue <- <-s.queue } s.queue = queue } func (s *scheduler) removeIdle(target *worker) { count := len(s.idle) for range count { w := <-s.idle if w != target { s.idle <- w } } } func (s *scheduler) finishCloseIfReady() { if s.closed && s.pool.submitters.Load() == 0 && len(s.pool.submit) == 0 && s.total == 0 && len(s.queue) == 0 && !s.hasPending && !s.done { s.done = true close(s.pool.done) } } func runWorker(w *worker, events chan<- workerEvent) { exitKind := workerStopped defer func() { recovered := recover() close(w.dead) if recovered != nil { exitKind = workerPanicked safeLogf("worker panic: %v", recovered) } // This is the unique terminal notification and the worker's final // potentially blocking operation. The scheduler never accounts it // from the earlier dead signal. events <- workerEvent{worker: w, kind: exitKind} }() if w.first != nil { task := w.first w.first = nil runTask(task) events <- workerEvent{worker: w, kind: taskFinished} } for { select { case <-w.stop: return case task, ok := <-w.inbox: if !ok { panic("worker inbox closed") } runTask(task) events <- workerEvent{worker: w, kind: taskFinished} } } } func safeLogf(format string, args ...any) { defer func() { _ = recover() }() log.Printf(format, args...) } func runTask(task func()) { defer func() { if recovered := recover(); recovered != nil { safeLogf("worker task panic: %v", recovered) } }() task() }