Description of new feature
PR: Introduce LoadBalancer interface for extensible MultiPool load balancing
1. Are you opening this pull request for bug-fixes, optimizations or new feature?
New feature — with an internal refactor that is fully backward compatible.
2. Please describe how these code changes achieve your intention.
Problem
The current load-balancing strategy in MultiPool, MultiPoolWithFunc, and MultiPoolWithFuncGeneric is implemented as a closed enum (LoadBalancingStrategy) with a switch inside a next() method. This has three limitations:
- Not extensible — users cannot plug in a custom strategy without waiting for upstream changes. For example, a "least-waiting" strategy (picking the pool with the fewest queued tasks) better reflects real backlog pressure under saturation, but there is no way to add it without forking.
- Duplicated logic —
next() is copy-pasted verbatim across all three MultiPool variants.
- State coupling — the
RoundRobin counter (mp.index) lives on the pool struct rather than the strategy itself.
Solution
Introduce two interfaces in a new lbs.go file:
// PoolMetrics exposes the read-only stats a LoadBalancer needs to make a pick decision.
type PoolMetrics interface {
Running() int
Waiting() int
Free() int
Cap() int
}
// LoadBalancer picks a pool index from a slice of PoolMetrics.
type LoadBalancer interface {
Pick(pools []PoolMetrics) int
// Fallback is called when the pool chosen by Pick is overloaded.
// Return -1 to indicate no fallback is supported.
Fallback(pools []PoolMetrics) int
}
All three pool types (*Pool, *PoolWithFunc, *PoolWithFuncGeneric[T]) already satisfy PoolMetrics via the embedded *poolCommon — no extra code needed.
Built-in strategies become small structs:
| Constructor |
Strategy |
Fallback on overload |
NewRoundRobinLB() |
Atomic round-robin |
Falls back to least-running |
NewLeastTasksLB() |
Fewest running workers |
None |
NewLeastWaitingLB() |
Fewest waiting tasks |
None |
Three new constructors are added — one per MultiPool variant — that accept any LoadBalancer:
func NewMultiPoolWithLB(size, sizePerPool int, lb LoadBalancer, options ...Option) (*MultiPool, error)
func NewMultiPoolWithFuncAndLB(size, sizePerPool int, fn func(any), lb LoadBalancer, options ...Option) (*MultiPoolWithFunc, error)
func NewMultiPoolWithFuncGenericAndLB[T any](size, sizePerPool int, fn func(T), lb LoadBalancer, options ...Option) (*MultiPoolWithFuncGeneric[T], error)
The existing NewMultiPool / NewMultiPoolWithFunc / NewMultiPoolWithFuncGeneric signatures are unchanged — they now delegate to the new constructors internally after converting the enum to the corresponding built-in LoadBalancer. Zero breaking changes.
Custom strategy example
type myLB struct{}
func (m *myLB) Pick(pools []ants.PoolMetrics) int {
// pick the pool with the most free capacity
idx, most := 0, -1
for i, p := range pools {
if f := p.Free(); f > most {
most = f
idx = i
}
}
return idx
}
func (m *myLB) Fallback(pools []ants.PoolMetrics) int { return -1 }
mp, _ := ants.NewMultiPoolWithLB(10, 100, &myLB{})
Why LeastWaitingLB is worth adding as a built-in
Benchmarks below show that under mixed workloads (uneven task durations — the most common real-world scenario for IO-bound services), LeastTasksLB degrades severely because Running() is a poor proxy for actual backlog pressure. LeastWaitingLB uses Waiting() instead and avoids the problem:
goos: windows / goarch: amd64 / cpu: AMD Ryzen 7 5700X
BenchmarkMultiPool_RoundRobin_CPUThroughput-16 70173303 86.76 ns/op 0 B/op 0 allocs/op
BenchmarkMultiPool_LeastTasks_CPUThroughput-16 18660650 330.9 ns/op 0 B/op 0 allocs/op
BenchmarkMultiPool_LeastWaiting_CPUThroughput-16 18016323 331.9 ns/op 0 B/op 0 allocs/op
BenchmarkMultiPool_RoundRobin_IOThroughput-16 40508790 139.5 ns/op 0 B/op 0 allocs/op
BenchmarkMultiPool_LeastTasks_IOThroughput-16 26041756 302.2 ns/op 1 B/op 0 allocs/op
BenchmarkMultiPool_LeastWaiting_IOThroughput-16 19795819 286.1 ns/op 0 B/op 0 allocs/op
BenchmarkMultiPool_RoundRobin_MixedThroughput-16 22110253 254.1 ns/op 1 B/op 0 allocs/op
BenchmarkMultiPool_LeastTasks_MixedThroughput-16 20750173 1864 ns/op 2 B/op 0 allocs/op ← degrades ~7x
BenchmarkMultiPool_LeastWaiting_MixedThroughput-16 14748758 360.2 ns/op 1 B/op 0 allocs/op
LeastWaitingLB is also shown as a concrete example of what a custom LoadBalancer implementation looks like, making it a useful reference for users building their own strategies.
3. Please link to the relevant issues (if any).
No existing issue. This PR is self-contained and was designed to be fully backward compatible.
4. Which documentation changes (if any) need to be made/updated because of this PR?
The README section covering MultiPool should be updated to mention:
- The new
NewMultiPoolWithLB / NewMultiPoolWithFuncAndLB / NewMultiPoolWithFuncGenericAndLB constructors
- The
LoadBalancer and PoolMetrics interfaces
- A short custom strategy example (similar to the one above)
- The new built-in
NewLeastWaitingLB() strategy
I am happy to update the README as part of this PR if the overall direction is accepted.
4. Checklist
Scenarios for new feature
- Users running IO-bound services with uneven task durations (e.g. mixed fast/slow RPC handlers) who want a smarter pool selection policy
- Users who need application-specific routing — e.g. pinning certain task types to specific pools, or implementing priority-based scheduling across pools
- Anyone who wants to experiment with scheduling strategies without waiting for upstream changes
Breaking changes or not?
No
Code snippets (optional)
Alternatives for new feature
None.
Additional context (optional)
None.
Description of new feature
PR: Introduce
LoadBalancerinterface for extensible MultiPool load balancing1. Are you opening this pull request for bug-fixes, optimizations or new feature?
New feature — with an internal refactor that is fully backward compatible.
2. Please describe how these code changes achieve your intention.
Problem
The current load-balancing strategy in
MultiPool,MultiPoolWithFunc, andMultiPoolWithFuncGenericis implemented as a closed enum (LoadBalancingStrategy) with aswitchinside anext()method. This has three limitations:next()is copy-pasted verbatim across all three MultiPool variants.RoundRobincounter (mp.index) lives on the pool struct rather than the strategy itself.Solution
Introduce two interfaces in a new
lbs.gofile:All three pool types (
*Pool,*PoolWithFunc,*PoolWithFuncGeneric[T]) already satisfyPoolMetricsvia the embedded*poolCommon— no extra code needed.Built-in strategies become small structs:
NewRoundRobinLB()NewLeastTasksLB()NewLeastWaitingLB()Three new constructors are added — one per MultiPool variant — that accept any
LoadBalancer:The existing
NewMultiPool/NewMultiPoolWithFunc/NewMultiPoolWithFuncGenericsignatures are unchanged — they now delegate to the new constructors internally after converting the enum to the corresponding built-inLoadBalancer. Zero breaking changes.Custom strategy example
Why
LeastWaitingLBis worth adding as a built-inBenchmarks below show that under mixed workloads (uneven task durations — the most common real-world scenario for IO-bound services),
LeastTasksLBdegrades severely becauseRunning()is a poor proxy for actual backlog pressure.LeastWaitingLBusesWaiting()instead and avoids the problem:LeastWaitingLBis also shown as a concrete example of what a customLoadBalancerimplementation looks like, making it a useful reference for users building their own strategies.3. Please link to the relevant issues (if any).
No existing issue. This PR is self-contained and was designed to be fully backward compatible.
4. Which documentation changes (if any) need to be made/updated because of this PR?
The README section covering
MultiPoolshould be updated to mention:NewMultiPoolWithLB/NewMultiPoolWithFuncAndLB/NewMultiPoolWithFuncGenericAndLBconstructorsLoadBalancerandPoolMetricsinterfacesNewLeastWaitingLB()strategyI am happy to update the README as part of this PR if the overall direction is accepted.
4. Checklist
Scenarios for new feature
Breaking changes or not?
No
Code snippets (optional)
Alternatives for new feature
None.
Additional context (optional)
None.