Skip to content

[Feature]: introduce LoadBalancer interface for extensible MultiPool load balancing #399

Description

@vibe-sudo

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:

  1. 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.
  2. Duplicated logicnext() is copy-pasted verbatim across all three MultiPool variants.
  3. 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

  • I have squashed all insignificant commits.
  • I have commented my code for explaining package types, values, functions, and non-obvious lines.
  • I have written unit tests and verified that all tests pass.
  • I have documented feature info on the README (pending maintainer feedback on the API shape).
  • I am willing to help maintain this change if there are issues with it later.

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.

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions