Rebuild worker pool around channel-driven scheduling

This commit is contained in:
2026-10-21 01:14:55 +08:00
parent 24c7ab3a65
commit c0657699e4
4 changed files with 1102 additions and 1504 deletions

View File

@@ -16,22 +16,24 @@ if err := pool.Submit(func() { doWork() }); err != nil {
``` ```
The pool creates the minimum workers immediately and grows on demand up to the The pool creates the minimum workers immediately and grows on demand up to the
maximum. Its task queue has a fixed capacity of `maxWorkers/2`; once all workers maximum. A channel-driven scheduler owns worker lifecycle and a FIFO task queue
and queue slots are occupied, `Submit` waits for room. `Close` stops new with fixed capacity `maxWorkers/2`; once all workers and queue slots are
submissions and waits for accepted tasks to finish. A successful `Submit` means occupied, `Submit` waits for room. With `maxWorkers == 1`, the queue has zero
the task was accepted; it does not mean the task has started or finished. With capacity and submissions wait for a worker. A successful `Submit` means the
`maxWorkers == 1`, the queue has zero capacity and submissions wait for a worker. scheduler accepted the task; it does not mean the task has started or finished.
`Close` stops new submissions and waits for accepted tasks to finish.
After a task finishes, a worker exits immediately only when `total > min` and After a task finishes, a worker exits immediately only when `total > min` and
the number of *other* idle workers is greater than `min/2`. There is no idle the number of *other* idle workers is greater than `min/2`. There is no idle
timeout. A task panic is logged and that worker continues with later tasks. timeout.
Only `Close` shuts down the pool. A task panic is logged and the same worker Only `Close` shuts down the pool. A task panic is logged and the same worker
continues. A worker scheduling panic or `runtime.Goexit` terminates that continues with later tasks. A worker scheduling panic is logged and terminates
worker; the pool starts a replacement as needed, and accepted queued tasks that worker; if accepted work is queued, a replacement worker takes the oldest
remain available even while `Close` is draining. The task that panicked or task. A panicking task is not retried. An exceptional worker exit with no
called `runtime.Goexit` is not retried. Call `Close` from outside pool tasks so queued work does not proactively restore the minimum count; later submissions
it does not wait for itself. can grow the pool again. During `Close`, queued accepted work is still drained.
Call `Close` from outside pool tasks so it does not wait for itself.
A task may submit another task to the same pool. If all workers block submitting A task may submit another task to the same pool. If all workers block submitting
to a full queue, the caller must resolve that deadlock in its task logic. to a full queue, the caller must resolve that deadlock in its task logic.

546
pool.go
View File

@@ -4,7 +4,6 @@ package worker
import ( import (
"errors" "errors"
"log" "log"
"sync"
) )
var ( var (
@@ -15,236 +14,433 @@ var (
// Pool runs submitted tasks using a bounded number of workers. // Pool runs submitted tasks using a bounded number of workers.
type Pool struct { type Pool struct {
mu sync.Mutex submit chan func()
submitCond *sync.Cond closeReq chan struct{}
closeCond *sync.Cond stopping chan struct{}
min int done chan struct{}
max int events chan workerEvent
total int min int
idle []*worker max int
queue []func() queueCap int
queueHead int
queueTail int
queueLen int
queueCap int
closed bool
} }
type worker struct { type worker struct {
inbox chan func() inbox chan func()
stop chan struct{}
dead chan struct{}
first func()
} }
// New creates a pool with minWorkers persistent workers and at most type eventKind uint8
// maxWorkers workers. The task queue has capacity maxWorkers/2.
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 task queue capacity is
// maxWorkers/2; no additional task buffering is performed by Submit.
func New(minWorkers, maxWorkers int) (*Pool, error) { func New(minWorkers, maxWorkers int) (*Pool, error) {
if maxWorkers <= 0 || minWorkers < 0 || minWorkers > maxWorkers { if maxWorkers <= 0 || minWorkers < 0 || minWorkers > maxWorkers {
return nil, ErrInvalidConfig return nil, ErrInvalidConfig
} }
p := &Pool{ p := &Pool{
submit: make(chan func()),
closeReq: make(chan struct{}),
stopping: make(chan struct{}),
done: make(chan struct{}),
events: make(chan workerEvent, maxWorkers),
min: minWorkers, min: minWorkers,
max: maxWorkers, max: maxWorkers,
queue: make([]func(), maxWorkers/2),
queueCap: maxWorkers / 2, queueCap: maxWorkers / 2,
} }
p.submitCond = sync.NewCond(&p.mu) s := newScheduler(p)
p.closeCond = sync.NewCond(&p.mu)
for range minWorkers { for range minWorkers {
p.startWorkerLocked(nil, false) s.startWorker(nil, false)
} }
go s.run()
return p, nil return p, nil
} }
// Submit adds task to the pool. It blocks if all workers and the bounded // Submit waits until a worker or a bounded queue slot can accept task. A nil
// queue are full. A successful return means the pool accepted the task. // task is rejected before checking whether the pool has closed.
func (p *Pool) Submit(task func()) error { func (p *Pool) Submit(task func()) error {
if task == nil { if task == nil {
return ErrNilTask return ErrNilTask
} }
p.mu.Lock() select {
defer p.mu.Unlock() case p.submit <- task:
for { return nil
if p.closed { case <-p.stopping:
return ErrClosed return ErrClosed
}
}
// 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() {
select {
case <-p.done:
return
case p.closeReq <- struct{}{}:
case <-p.stopping:
}
<-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()
} }
if n := len(p.idle); n > 0 { s.finishCloseIfReady()
w := p.idle[n-1] if s.done {
p.idle = p.idle[:n-1] return
if trySend(w, task) { }
return nil
var submit <-chan func()
if !s.closed && !s.hasPending && s.canAccept() {
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
} }
// A worker can have published itself idle immediately before its case event := <-s.pool.events:
// closed inbox is observed. Keep task and let the normal state s.handleEvent(event)
// machine assign it again after that worker is accounted for. case <-closeReq:
s.beginClose()
}
}
}
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 continue
} }
if p.total < p.max { if s.total < s.pool.max {
p.startWorkerLocked(task, true) s.startWorker(<-s.queue, true)
return nil continue
} }
if p.queueLen < p.queueCap { break
p.enqueueLocked(task) }
return nil if s.closed && !s.hasPending {
for len(s.idle) > 0 {
s.retire(<-s.idle)
} }
p.submitCond.Wait()
} }
} }
// Close stops accepting tasks and waits for workers to exit. Tasks accepted func (s *scheduler) beginClose() {
// before Close finish before it returns, even if workers exit abnormally and if s.closed {
// need replacement. It is safe to call Close concurrently or more than once. return
func (p *Pool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
p.closed = true
for _, w := range p.idle {
safeClose(w)
} }
p.idle = nil s.closed = true
p.submitCond.Broadcast() close(s.pool.stopping)
for p.total != 0 { for len(s.idle) > 0 {
p.closeCond.Wait() s.retire(<-s.idle)
} }
} }
// All pool state (worker count, idle set, queue indexes and contents, and func (s *scheduler) handleEvent(event workerEvent) {
// closed state) is guarded by mu. submitCond is only for Submit capacity switch event.kind {
// changes; closeCond is only for Close waiting on all workers to exit. Each case taskFinished:
// worker has a one-slot inbox representing an idle worker's reserved handoff. s.taskFinished(event.worker)
func (p *Pool) startWorkerLocked(task func(), withTask bool) { case workerStopped:
w := &worker{inbox: make(chan func(), 1)} s.workerExited(event.worker, false)
p.total++ case workerPanicked:
if withTask { s.workerExited(event.worker, true)
w.inbox <- task
} else {
p.idle = append(p.idle, w)
} }
go p.run(w) // 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 trySend(w *worker, task func()) (sent bool) { 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.retire(w)
return
}
otherIdle := len(s.idle)
if 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() { defer func() {
if recover() != nil { if recover() != nil {
sent = false sent = false
} }
}() }()
w.inbox <- task select {
return true case w.inbox <- task:
} return true
case <-w.dead:
func safeClose(w *worker) {
defer func() { _ = recover() }()
close(w.inbox)
}
func (p *Pool) run(w *worker) {
exitAccounted := false
defer func() {
if recovered := recover(); recovered != nil {
safeLogf("worker panic: %v", recovered)
}
if !exitAccounted {
p.accountWorkerExit(w, &exitAccounted)
}
}()
for {
task, ok := <-w.inbox
if !ok {
p.accountWorkerExit(w, &exitAccounted)
return
}
runTask(task)
if p.finishTask(w, &exitAccounted) {
return
}
}
}
// finishTask commits the post-task transition under mu. If queue work exists,
// it first hands off the head item and only then removes it from the ring, so
// a closed inbox panic cannot lose an accepted task. The deferred unlock runs
// before run's panic cleanup acquires the mutex.
func (p *Pool) finishTask(w *worker, exitAccounted *bool) bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.queueLen > 0 {
next := p.queue[p.queueHead]
w.inbox <- next
p.dequeueLocked()
p.submitCond.Signal()
return false return false
} }
otherIdle := len(p.idle)
if p.closed || (p.total > p.min && otherIdle > p.min/2) {
p.total--
*exitAccounted = true
if !p.closed {
p.submitCond.Signal()
} else {
p.workerExitedLocked()
}
return true
}
p.idle = append(p.idle, w)
p.submitCond.Signal()
return false
} }
// enqueueLocked and dequeueLocked are called with mu held. The queue is a // requeueFront restores a task removed for a failed unbuffered handoff while
// fixed-capacity FIFO ring; clearing the dequeued slot releases its closure. // preserving FIFO order and the original fixed cache capacity. This only runs
func (p *Pool) enqueueLocked(task func()) { // on worker failure, never on the normal dispatch path.
p.queue[p.queueTail] = task func (s *scheduler) requeueFront(task func()) {
p.queueTail++ queue := make(chan func(), s.pool.queueCap)
if p.queueTail == p.queueCap { queue <- task
p.queueTail = 0 for len(s.queue) > 0 {
queue <- <-s.queue
} }
p.queueLen++ s.queue = queue
} }
func (p *Pool) dequeueLocked() func() { func (s *scheduler) removeIdle(target *worker) {
task := p.queue[p.queueHead] count := len(s.idle)
p.queue[p.queueHead] = nil for range count {
p.queueHead++ w := <-s.idle
if p.queueHead == p.queueCap { if w != target {
p.queueHead = 0 s.idle <- w
}
p.queueLen--
return task
}
func (p *Pool) workerExitedLocked() {
if p.closed && p.total == 0 {
p.closeCond.Broadcast()
}
}
// accountWorkerExit decrements a worker and, while still holding mu, starts a
// replacement for the oldest accepted task or restores the minimum worker count.
// This applies to panic, Goexit, and a closed inbox. It never retries the task
// that caused the worker to exit and never discards queued tasks.
func (p *Pool) accountWorkerExit(w *worker, exitAccounted *bool) {
p.mu.Lock()
defer p.mu.Unlock()
for i, idleWorker := range p.idle {
if idleWorker == w {
copy(p.idle[i:], p.idle[i+1:])
last := len(p.idle) - 1
p.idle[last] = nil
p.idle = p.idle[:last]
break
} }
} }
p.total-- }
*exitAccounted = true
if p.queueLen > 0 { func (s *scheduler) finishCloseIfReady() {
task := p.dequeueLocked() if s.closed && s.total == 0 && len(s.queue) == 0 && !s.hasPending && !s.done {
p.startWorkerLocked(task, true) s.done = true
} else { close(s.pool.done)
if !p.closed && p.total < p.min { }
p.startWorkerLocked(nil, false) }
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}
} }
} }
p.submitCond.Signal()
p.workerExitedLocked()
} }
func safeLogf(format string, args ...any) { func safeLogf(format string, args ...any) {

View File

@@ -1,19 +1,44 @@
package worker package worker
import "testing" import (
"runtime"
"sync/atomic"
"testing"
"time"
)
func BenchmarkQueueRingSteady(b *testing.B) { func BenchmarkSubmitNoop(b *testing.B) {
p, err := New(0, 6) benchmarkSubmit(b, func() {})
}
func BenchmarkSubmit10usTask(b *testing.B) {
benchmarkSubmit(b, func() {
deadline := time.Now().Add(10 * time.Microsecond)
for time.Now().Before(deadline) {
}
})
}
func benchmarkSubmit(b *testing.B, task func()) {
p, err := New(6, 6)
if err != nil { if err != nil {
b.Fatal(err) b.Fatal(err)
} }
task := func() {} var completed atomic.Int64
wrapped := func() {
task()
completed.Add(1)
}
b.ReportAllocs() b.ReportAllocs()
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for range b.N {
p.mu.Lock() if err := p.Submit(wrapped); err != nil {
p.enqueueLocked(task) b.Fatal(err)
p.dequeueLocked() }
p.mu.Unlock()
} }
for completed.Load() != int64(b.N) {
runtime.Gosched()
}
b.StopTimer()
p.Close()
} }

File diff suppressed because it is too large Load Diff