Keep pool running through worker failures until explicit close

This commit is contained in:
2026-10-20 22:04:17 +08:00
parent 44cadc6d26
commit 24c7ab3a65
3 changed files with 272 additions and 100 deletions

View File

@@ -26,12 +26,12 @@ 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. A task panic is logged and that worker continues with later tasks.
Tasks should return normally or panic. A panic in worker scheduling code is Only `Close` shuts down the pool. A task panic is logged and the same worker
logged and terminates that worker without replacement. `runtime.Goexit` from a continues. A worker scheduling panic or `runtime.Goexit` terminates that
task has the same worker-exit behavior. If the last worker exits abnormally, worker; the pool starts a replacement as needed, and accepted queued tasks
queued accepted tasks are discarded so `Close` can return; if other workers remain available even while `Close` is draining. The task that panicked or
remain, they keep the queue. Call `Close` from outside pool tasks so it does called `runtime.Goexit` is not retried. Call `Close` from outside pool tasks so
not wait for itself. 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.

96
pool.go
View File

@@ -69,8 +69,13 @@ func (p *Pool) Submit(task func()) error {
if n := len(p.idle); n > 0 { if n := len(p.idle); n > 0 {
w := p.idle[n-1] w := p.idle[n-1]
p.idle = p.idle[:n-1] p.idle = p.idle[:n-1]
w.inbox <- task if trySend(w, task) {
return nil return nil
}
// A worker can have published itself idle immediately before its
// closed inbox is observed. Keep task and let the normal state
// machine assign it again after that worker is accounted for.
continue
} }
if p.total < p.max { if p.total < p.max {
p.startWorkerLocked(task, true) p.startWorkerLocked(task, true)
@@ -85,21 +90,20 @@ func (p *Pool) Submit(task func()) error {
} }
// Close stops accepting tasks and waits for workers to exit. Tasks accepted // Close stops accepting tasks and waits for workers to exit. Tasks accepted
// before Close normally finish before it returns. If runtime.Goexit or a // before Close finish before it returns, even if workers exit abnormally and
// worker panic exits the last worker, queued tasks are discarded. It is safe // need replacement. It is safe to call Close concurrently or more than once.
// to call Close concurrently or more than once.
func (p *Pool) Close() { func (p *Pool) Close() {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock()
p.closed = true p.closed = true
for _, w := range p.idle { for _, w := range p.idle {
close(w.inbox) safeClose(w)
} }
p.idle = nil p.idle = nil
p.submitCond.Broadcast() p.submitCond.Broadcast()
for p.total != 0 { for p.total != 0 {
p.closeCond.Wait() p.closeCond.Wait()
} }
p.mu.Unlock()
} }
// All pool state (worker count, idle set, queue indexes and contents, and // All pool state (worker count, idle set, queue indexes and contents, and
@@ -117,43 +121,49 @@ func (p *Pool) startWorkerLocked(task func(), withTask bool) {
go p.run(w) go p.run(w)
} }
func trySend(w *worker, task func()) (sent bool) {
defer func() {
if recover() != nil {
sent = false
}
}()
w.inbox <- task
return true
}
func safeClose(w *worker) {
defer func() { _ = recover() }()
close(w.inbox)
}
func (p *Pool) run(w *worker) { func (p *Pool) run(w *worker) {
normalExit := false exitAccounted := false
defer func() { defer func() {
if recovered := recover(); recovered != nil { if recovered := recover(); recovered != nil {
safeLogf("worker panic: %v", recovered) safeLogf("worker panic: %v", recovered)
} }
if !normalExit { if !exitAccounted {
p.workerExitedAbnormally() p.accountWorkerExit(w, &exitAccounted)
} }
}() }()
for { for {
task, ok := <-w.inbox task, ok := <-w.inbox
if !ok { if !ok {
p.finishClosedWorker(&normalExit) p.accountWorkerExit(w, &exitAccounted)
return return
} }
runTask(task) runTask(task)
if p.finishTask(w, &normalExit) { if p.finishTask(w, &exitAccounted) {
return return
} }
} }
} }
// finishClosedWorker accounts for a normal Close of an idle worker.
func (p *Pool) finishClosedWorker(normalExit *bool) {
p.mu.Lock()
defer p.mu.Unlock()
p.total--
*normalExit = true
p.workerExitedLocked()
}
// finishTask commits the post-task transition under mu. If queue work exists, // 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 // 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 // a closed inbox panic cannot lose an accepted task. The deferred unlock runs
// before run's panic cleanup acquires the mutex. // before run's panic cleanup acquires the mutex.
func (p *Pool) finishTask(w *worker, normalExit *bool) bool { func (p *Pool) finishTask(w *worker, exitAccounted *bool) bool {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.queueLen > 0 { if p.queueLen > 0 {
@@ -166,7 +176,7 @@ func (p *Pool) finishTask(w *worker, normalExit *bool) bool {
otherIdle := len(p.idle) otherIdle := len(p.idle)
if p.closed || (p.total > p.min && otherIdle > p.min/2) { if p.closed || (p.total > p.min && otherIdle > p.min/2) {
p.total-- p.total--
*normalExit = true *exitAccounted = true
if !p.closed { if !p.closed {
p.submitCond.Signal() p.submitCond.Signal()
} else { } else {
@@ -207,26 +217,34 @@ func (p *Pool) workerExitedLocked() {
} }
} }
// workerExitedAbnormally accounts for a worker terminated by runtime.Goexit. // accountWorkerExit decrements a worker and, while still holding mu, starts a
// If other workers remain, they retain the FIFO queue. The last worker clears // replacement for the oldest accepted task or restores the minimum worker count.
// the queue because no worker remains to consume it; all of this is protected // This applies to panic, Goexit, and a closed inbox. It never retries the task
// by mu so Submit and Close observe the same state transition. // that caused the worker to exit and never discards queued tasks.
func (p *Pool) workerExitedAbnormally() { func (p *Pool) accountWorkerExit(w *worker, exitAccounted *bool) {
p.mu.Lock() p.mu.Lock()
p.total-- defer p.mu.Unlock()
if p.total == 0 { for i, idleWorker := range p.idle {
for i := range p.queue { if idleWorker == w {
p.queue[i] = nil copy(p.idle[i:], p.idle[i+1:])
last := len(p.idle) - 1
p.idle[last] = nil
p.idle = p.idle[:last]
break
} }
p.queueHead = 0
p.queueTail = 0
p.queueLen = 0
p.submitCond.Broadcast()
} else {
p.submitCond.Signal()
} }
p.total--
*exitAccounted = true
if p.queueLen > 0 {
task := p.dequeueLocked()
p.startWorkerLocked(task, true)
} else {
if !p.closed && p.total < p.min {
p.startWorkerLocked(nil, false)
}
}
p.submitCond.Signal()
p.workerExitedLocked() p.workerExitedLocked()
p.mu.Unlock()
} }
func safeLogf(format string, args ...any) { func safeLogf(format string, args ...any) {

View File

@@ -523,7 +523,7 @@ func TestLoggerWriterPanicDoesNotKillTaskWorker(t *testing.T) {
} }
} }
func TestWorkerPanicLeavesQueueForSurvivingWorker(t *testing.T) { func TestWorkerPanicReplacesWorkerAndPreservesQueue(t *testing.T) {
p, err := New(2, 2) p, err := New(2, 2)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -555,32 +555,176 @@ func TestWorkerPanicLeavesQueueForSurvivingWorker(t *testing.T) {
} }
close(victim.inbox) // fault injection while the selected worker is gated close(victim.inbox) // fault injection while the selected worker is gated
close(victimGate) close(victimGate)
waitPoolState(t, p, 1, 0) waitSignal(t, queuedDone)
p.mu.Lock() waitPoolState(t, p, 2, 1)
if p.queueLen != 1 || p.queue[p.queueHead] == nil {
p.mu.Unlock()
t.Fatal("worker panic removed queued work while another worker remained")
}
p.mu.Unlock()
if !strings.Contains(output.String(), "worker panic: send on closed channel") { if !strings.Contains(output.String(), "worker panic: send on closed channel") {
t.Fatalf("worker panic was not logged: %q", output.String()) t.Fatalf("worker panic was not logged: %q", output.String())
} }
close(survivorGate)
waitSignal(t, queuedDone)
waitPoolState(t, p, 1, 1)
p.mu.Lock() p.mu.Lock()
if p.idle[0] != survivor { replacement := p.idle[0]
if replacement == victim || replacement == survivor {
p.mu.Unlock() p.mu.Unlock()
t.Fatal("surviving worker was replaced after another worker panicked") t.Fatal("worker panic did not create a fresh replacement")
} }
p.mu.Unlock() p.mu.Unlock()
close(survivorGate)
waitPoolState(t, p, 2, 2)
p.Close() p.Close()
if output.String() == "" { if output.String() == "" {
t.Fatal("worker panic log was lost") t.Fatal("worker panic log was lost")
} }
} }
func TestLastWorkerPanicDropsQueueAndCloseReturns(t *testing.T) { func TestUnexpectedClosedIdleInboxRestoresMinimum(t *testing.T) {
p, err := New(1, 1)
if err != nil {
t.Fatal(err)
}
p.mu.Lock()
dead := p.idle[0]
p.mu.Unlock()
close(dead.inbox)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
p.mu.Lock()
replaced := p.total == 1 && len(p.idle) == 1 && p.idle[0] != dead
p.mu.Unlock()
if replaced {
break
}
time.Sleep(time.Millisecond)
}
p.mu.Lock()
replaced := p.total == 1 && len(p.idle) == 1 && p.idle[0] != dead
p.mu.Unlock()
if !replaced {
t.Fatal("pool retained an idle worker with a closed inbox")
}
done := make(chan struct{})
if err := p.Submit(func() { close(done) }); err != nil {
t.Fatal(err)
}
waitSignal(t, done)
waitPoolState(t, p, 1, 1)
p.Close()
}
func TestAllOriginalWorkersExitPoolRemainsOpenAndDrainsQueue(t *testing.T) {
p, err := New(2, 2)
if err != nil {
t.Fatal(err)
}
p.mu.Lock()
workerA := p.idle[len(p.idle)-1]
p.mu.Unlock()
gateA, gateB := make(chan struct{}), make(chan struct{})
started := make(chan struct{}, 2)
if err := p.Submit(func() { started <- struct{}{}; <-gateA }); err != nil {
t.Fatal(err)
}
p.mu.Lock()
workerB := p.idle[len(p.idle)-1]
p.mu.Unlock()
if err := p.Submit(func() { started <- struct{}{}; <-gateB }); err != nil {
t.Fatal(err)
}
waitSignal(t, started)
waitSignal(t, started)
var queuedRuns atomic.Int32
queuedDone := make(chan struct{})
if err := p.Submit(func() { queuedRuns.Add(1); close(queuedDone) }); err != nil {
t.Fatal(err)
}
close(workerA.inbox)
close(workerB.inbox)
close(gateA)
waitSignal(t, queuedDone)
waitPoolState(t, p, 2, 1)
close(gateB)
waitPoolState(t, p, 2, 2)
if got := queuedRuns.Load(); got != 1 {
t.Fatalf("queued task ran %d times, want once", got)
}
p.mu.Lock()
closed := p.closed
p.mu.Unlock()
if closed {
t.Fatal("worker panics closed the pool without Close")
}
continued := make(chan struct{})
if err := p.Submit(func() { close(continued) }); err != nil {
t.Fatalf("Submit after worker panics = %v", err)
}
waitSignal(t, continued)
waitPoolState(t, p, 2, 2)
p.Close()
}
func TestSubmitHandlesClosedIdleInboxBeforeWorkerCleanup(t *testing.T) {
p, err := New(1, 1)
if err != nil {
t.Fatal(err)
}
p.mu.Lock()
dead := p.idle[0]
submitted := make(chan error, 1)
done := make(chan struct{})
attempted := make(chan struct{})
go func() {
close(attempted)
submitted <- p.Submit(func() { close(done) })
}()
waitSignal(t, attempted)
// Keep Submit queued on mu first, then make the published idle inbox dead.
time.Sleep(10 * time.Millisecond)
close(dead.inbox)
p.mu.Unlock()
if err := waitSignal(t, submitted); err != nil {
t.Fatalf("Submit after stale idle handoff = %v", err)
}
waitSignal(t, done)
waitPoolState(t, p, 1, 1)
p.mu.Lock()
if p.closed || p.idle[0] == dead {
p.mu.Unlock()
t.Fatal("Submit closed the pool or retained the dead worker")
}
p.mu.Unlock()
p.Close()
}
func TestCloseHandlesClosedIdleInboxBeforeWorkerCleanup(t *testing.T) {
p, err := New(1, 1)
if err != nil {
t.Fatal(err)
}
p.mu.Lock()
dead := p.idle[0]
closeStarted, closeDone := make(chan struct{}), make(chan struct{})
go func() {
close(closeStarted)
p.Close()
close(closeDone)
}()
waitSignal(t, closeStarted)
// Queue Close on mu before waking the worker's closed-inbox cleanup.
time.Sleep(10 * time.Millisecond)
close(dead.inbox)
p.mu.Unlock()
waitSignal(t, closeDone)
p.mu.Lock()
if !p.closed || p.total != 0 || len(p.idle) != 0 {
p.mu.Unlock()
t.Fatalf("pool after Close: closed=%v total=%d idle=%d", p.closed, p.total, len(p.idle))
}
p.mu.Unlock()
if !p.mu.TryLock() {
t.Fatal("Close returned while still holding the pool mutex")
}
p.mu.Unlock()
}
func TestWorkerPanicDrainsQueueAndCloseWaits(t *testing.T) {
p, err := New(2, 2) p, err := New(2, 2)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -606,8 +750,9 @@ func TestLastWorkerPanicDropsQueueAndCloseReturns(t *testing.T) {
} }
waitSignal(t, started) waitSignal(t, started)
waitSignal(t, started) waitSignal(t, started)
queuedStarted, queuedGate, queuedDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
var queuedRuns atomic.Int32 var queuedRuns atomic.Int32
if err := p.Submit(func() { queuedRuns.Add(1) }); err != nil { if err := p.Submit(func() { close(queuedStarted); <-queuedGate; queuedRuns.Add(1); close(queuedDone) }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
const closeCount = 3 const closeCount = 3
@@ -622,18 +767,20 @@ func TestLastWorkerPanicDropsQueueAndCloseReturns(t *testing.T) {
close(workerA.inbox) close(workerA.inbox)
close(workerB.inbox) close(workerB.inbox)
close(gateA) close(gateA)
waitPoolQueueState(t, p, 1, 0, 1) waitSignal(t, queuedStarted)
select { select {
case <-closeDone: case <-closeDone:
t.Fatal("Close returned before the last worker exited") t.Fatal("Close returned before the queued task finished")
case <-time.After(10 * time.Millisecond): case <-time.After(10 * time.Millisecond):
} }
close(gateB) close(gateB)
close(queuedGate)
waitSignal(t, queuedDone)
for range closeCount { for range closeCount {
waitSignal(t, closeDone) waitSignal(t, closeDone)
} }
if got := queuedRuns.Load(); got != 0 { if got := queuedRuns.Load(); got != 1 {
t.Fatalf("queued task ran after last worker panic: %d times", got) t.Fatalf("queued task ran after worker panics %d times, want once", got)
} }
p.mu.Lock() p.mu.Lock()
if p.total != 0 || len(p.idle) != 0 || p.queueLen != 0 || p.queueHead != 0 || p.queueTail != 0 { if p.total != 0 || len(p.idle) != 0 || p.queueLen != 0 || p.queueHead != 0 || p.queueTail != 0 {
@@ -858,7 +1005,7 @@ func TestGoexitMaxOneCloseBeforeAndAfterWorkerExit(t *testing.T) {
}) })
} }
func TestGoexitKeepsQueuedTasksWhileAnotherWorkerLives(t *testing.T) { func TestGoexitReplacesWorkerAndKeepsQueuedTasks(t *testing.T) {
p, err := New(2, 2) p, err := New(2, 2)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -880,20 +1027,20 @@ func TestGoexitKeepsQueuedTasksWhileAnotherWorkerLives(t *testing.T) {
} }
close(goexitGate) close(goexitGate)
waitSignal(t, goexitDone) waitSignal(t, goexitDone)
waitPoolState(t, p, 1, 0) waitSignal(t, queuedDone)
waitPoolState(t, p, 2, 1)
p.mu.Lock() p.mu.Lock()
if p.queueLen != 1 || p.queue[p.queueHead] == nil { if p.queueLen != 0 {
p.mu.Unlock() p.mu.Unlock()
t.Fatal("queue was not retained while another worker remained") t.Fatalf("queue length after replacement task = %d, want 0", p.queueLen)
} }
p.mu.Unlock() p.mu.Unlock()
close(survivorGate) close(survivorGate)
waitSignal(t, queuedDone) waitPoolState(t, p, 2, 2)
waitPoolState(t, p, 1, 1) // no automatic replacement worker
p.Close() p.Close()
} }
func TestCloseWaitsThenDropsQueueAfterLastGoexit(t *testing.T) { func TestCloseWaitsAndDrainsQueueAfterGoexit(t *testing.T) {
p, err := New(2, 2) // queue capacity one p, err := New(2, 2) // queue capacity one
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -913,8 +1060,9 @@ func TestCloseWaitsThenDropsQueueAfterLastGoexit(t *testing.T) {
} }
waitSignal(t, started) waitSignal(t, started)
waitSignal(t, started) waitSignal(t, started)
queuedStarted, queuedGate, queuedDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
var queuedRuns atomic.Int32 var queuedRuns atomic.Int32
if err := p.Submit(func() { queuedRuns.Add(1) }); err != nil { if err := p.Submit(func() { close(queuedStarted); <-queuedGate; queuedRuns.Add(1); close(queuedDone) }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -952,36 +1100,37 @@ func TestCloseWaitsThenDropsQueueAfterLastGoexit(t *testing.T) {
close(gateA) close(gateA)
waitSignal(t, exited) waitSignal(t, exited)
waitPoolQueueState(t, p, 1, 0, 1) waitSignal(t, queuedStarted)
p.mu.Lock()
if p.queue[p.queueHead] == nil {
p.mu.Unlock()
t.Fatal("queue was cleared before the last worker exited")
}
p.mu.Unlock()
select { select {
case <-closeDone: case <-closeDone:
t.Fatal("Close returned while the final worker task was still gated") t.Fatal("Close returned while the replacement task was still gated")
case <-time.After(10 * time.Millisecond): case <-time.After(10 * time.Millisecond):
} }
close(gateB) close(gateB)
waitSignal(t, exited) waitSignal(t, exited)
select {
case <-closeDone:
t.Fatal("Close returned before the queued task finished")
case <-time.After(10 * time.Millisecond):
}
close(queuedGate)
waitSignal(t, queuedDone)
for range closeCount { for range closeCount {
waitSignal(t, closeDone) waitSignal(t, closeDone)
} }
if got := queuedRuns.Load(); got != 0 { if got := queuedRuns.Load(); got != 1 {
t.Fatalf("queued task executed after last Goexit: %d times", got) t.Fatalf("queued task executed after Goexit %d times, want once", got)
} }
p.mu.Lock() p.mu.Lock()
if p.total != 0 || len(p.idle) != 0 || p.queueLen != 0 || p.queueHead != 0 || p.queueTail != 0 { if p.total != 0 || len(p.idle) != 0 || p.queueLen != 0 || p.queueHead != 0 || p.queueTail != 0 {
p.mu.Unlock() p.mu.Unlock()
t.Fatalf("pool state after last Goexit: total=%d idle=%d queue=%d head=%d tail=%d", p.total, len(p.idle), p.queueLen, p.queueHead, p.queueTail) t.Fatalf("pool state after Close: total=%d idle=%d queue=%d head=%d tail=%d", p.total, len(p.idle), p.queueLen, p.queueHead, p.queueTail)
} }
for i, task := range p.queue { for i, task := range p.queue {
if task != nil { if task != nil {
p.mu.Unlock() p.mu.Unlock()
t.Fatalf("queue slot %d retains a task after last Goexit", i) t.Fatalf("queue slot %d retains a task after Close", i)
} }
} }
p.mu.Unlock() p.mu.Unlock()
@@ -1019,7 +1168,7 @@ func TestGoexitWakesSubmitAndAllowsWorkerRecreation(t *testing.T) {
p.Close() p.Close()
} }
func TestLastGoexitDropsQueueAndPoolCanBeReused(t *testing.T) { func TestAllGoexitWorkersAreReplacedAndPoolCanBeReused(t *testing.T) {
p, err := New(4, 4) // queue capacity two; all workers are occupied p, err := New(4, 4) // queue capacity two; all workers are occupied
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -1038,34 +1187,39 @@ func TestLastGoexitDropsQueueAndPoolCanBeReused(t *testing.T) {
for range 4 { for range 4 {
waitSignal(t, started) waitSignal(t, started)
} }
var dropped atomic.Int32 var queuedRuns [2]atomic.Int32
for range 2 { queuedDone := make(chan struct{}, len(queuedRuns))
if err := p.Submit(func() { dropped.Add(1) }); err != nil { for i := range queuedRuns {
if err := p.Submit(func() { queuedRuns[i].Add(1); queuedDone <- struct{}{} }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
for exitedWorkers := 0; exitedWorkers < 3; exitedWorkers++ { close(gates[0])
close(gates[exitedWorkers])
waitSignal(t, exited)
waitPoolQueueState(t, p, 3-exitedWorkers, 0, 2) // queued work stays until the last worker exits
}
close(gates[3])
waitSignal(t, exited) waitSignal(t, exited)
waitPoolState(t, p, 0, 0) for range queuedRuns {
waitSignal(t, queuedDone)
}
for i := 1; i < len(gates); i++ {
close(gates[i])
waitSignal(t, exited)
}
waitPoolState(t, p, 4, 4)
p.mu.Lock() p.mu.Lock()
if p.queueLen != 0 || p.queueHead != 0 || p.queueTail != 0 { if p.closed || p.queueLen != 0 || p.queueHead != 0 || p.queueTail != 0 {
p.mu.Unlock() p.mu.Unlock()
t.Fatalf("queue after last Goexit: len=%d head=%d tail=%d", p.queueLen, p.queueHead, p.queueTail) t.Fatalf("pool after worker Goexit: closed=%v queue len/head/tail=%d/%d/%d", p.closed, p.queueLen, p.queueHead, p.queueTail)
} }
for i, task := range p.queue { for i, task := range p.queue {
if task != nil { if task != nil {
p.mu.Unlock() p.mu.Unlock()
t.Fatalf("queue slot %d still holds a task after last Goexit", i) t.Fatalf("queue slot %d retains a completed task", i)
} }
} }
p.mu.Unlock() p.mu.Unlock()
if got := dropped.Load(); got != 0 { for i := range queuedRuns {
t.Fatalf("queued tasks executed after last Goexit: %d", got) if got := queuedRuns[i].Load(); got != 1 {
t.Fatalf("queued task %d ran %d times, want once", i, got)
}
} }
reused := make(chan struct{}) reused := make(chan struct{})
if err := p.Submit(func() { close(reused) }); err != nil { if err := p.Submit(func() { close(reused) }); err != nil {