Scheduling Model
Scheduling determines which worker receives a submitted task.
A ThreadPool owns several workers, and each worker owns its own task queue. When work is submitted, the scheduler chooses one worker and places the task into that worker's queue.
ThreadPool
↓
Scheduler
↓
select one worker
↓
Worker
↓
local TaskQueueThe current ThreadPool uses affinity when a task provides a worker hint. Otherwise, it selects the worker with the smallest local queue.
The default scheduling path
Consider a pool with four workers:
vix::threadpool::ThreadPool pool(4);
auto future = pool.submit([](){
return 42;
});Because the task has no worker affinity, the scheduler compares the worker queue sizes and selects the least loaded worker.
Conceptually:
Worker 1 → 3 queued tasks
Worker 2 → 1 queued task
Worker 3 → 2 queued tasks
Worker 4 → 4 queued tasks
↑
selected workerThe task is then placed into Worker 2's local queue.
The scheduler makes this decision at submission time.
SchedulingPolicy
The low-level scheduler supports four worker-selection policies:
enum class SchedulingPolicy : std::uint8_t
{
round_robin,
least_loaded,
affinity,
affinity_then_least_loaded
};They control how a Scheduler selects the destination worker.
The default policy is:
vix::threadpool::SchedulingPolicy::affinity_then_least_loadedThis is also the policy currently used internally by ThreadPool.
ThreadPool uses a fixed scheduling policy
ThreadPoolConfig does not currently expose a scheduling-policy field.
When a ThreadPool creates its internal scheduler, it uses:
vix::threadpool::default_scheduling_policy();which returns:
vix::threadpool::SchedulingPolicy::affinity_then_least_loadedTherefore, normal ThreadPool users currently get this behavior:
valid affinity hint
↓
use affinity worker
no affinity hint
↓
use least-loaded workerThe other scheduling policies are available through the lower-level Scheduler API.
Round-robin
round_robin distributes submissions across workers in sequence.
For four workers:
task 1 → Worker 1
task 2 → Worker 2
task 3 → Worker 3
task 4 → Worker 4
task 5 → Worker 1
task 6 → Worker 2The scheduler maintains an atomic cursor and advances it for each worker selection.
Conceptually:
next worker
↓
0 → 1 → 2 → 3 → 0 → 1 ...Round-robin does not inspect queue sizes or task duration.
It provides predictable distribution based only on submission order.
Least loaded
least_loaded chooses the worker with the smallest local queue.
Suppose:
Worker 1 queue size = 5
Worker 2 queue size = 2
Worker 3 queue size = 4
Worker 4 queue size = 3The scheduler selects:
Worker 2because its queue contains the fewest pending tasks.
This comparison uses:
worker queue sizeIt does not measure:
current task execution time
CPU usage
task complexity
predicted task durationA worker executing a long-running task can therefore still appear lightly loaded if its local queue is small.
Load means queue length
The scheduling model uses local queue length as its load signal.
Conceptually:
load(worker) = worker.size()This means:
Worker 1
active task: expensive
queued: 0
Worker 2
active task: cheap
queued: 2can cause least-loaded selection to prefer Worker 1 because:
Worker 1 queue size = 0
Worker 2 queue size = 2The scheduler does not estimate how much work remains in an active callable.
This makes least-loaded scheduling simple and inexpensive, but queue length should not be interpreted as a complete CPU-load measurement.
Affinity
The affinity policy prefers the worker identified by a task's affinity hint.
A task can provide affinity through TaskOptions:
vix::threadpool::TaskOptions options = vix::threadpool::TaskOptions::with_affinity(
vix::threadpool::WorkerId{2}
);
auto future = pool.submit([](){
return 42;
}, options);The scheduler converts the affinity value into a worker index.
Worker IDs begin at 1.
For a four-worker scheduler:
WorkerId 1 → worker index 0
WorkerId 2 → worker index 1
WorkerId 3 → worker index 2
WorkerId 4 → worker index 3WorkerId{0} represents no affinity because zero is invalid_worker_id.
See Worker Affinity for the complete mapping behavior.
Affinity policy fallback
With:
vix::threadpool::SchedulingPolicy::affinitythe scheduler first tries task affinity.
If no affinity is present, it falls back to round-robin scheduling.
Conceptually:
task
↓
has affinity?
┌────┴────┐
yes no
│ │
▼ ▼
affinity round-robin
workerThis differs from the default ThreadPool policy, which falls back to least-loaded selection.
Affinity then least loaded
affinity_then_least_loaded combines worker locality with queue balancing.
Its decision is:
task
↓
has affinity?
┌────┴────┐
yes no
│ │
▼ ▼
affinity least loaded
worker workerThis is the current default scheduling policy.
For ordinary tasks without affinity:
auto future = pool.submit([](){
return 42;
});the least-loaded worker is selected.
For a task with affinity:
vix::threadpool::TaskOptions options = vix::threadpool::TaskOptions::with_affinity(
vix::threadpool::WorkerId{3}
);
auto future = pool.submit([](){
return 42;
}, options);the affinity mapping takes precedence over queue load.
Affinity takes precedence over load
With the default policy, affinity is honored even when another worker has a smaller queue.
For example:
task affinity = Worker 3
Worker 1 queue size = 0
Worker 2 queue size = 1
Worker 3 queue size = 8
Worker 4 queue size = 2The scheduler still selects:
Worker 3The affinity hint therefore expresses a stronger placement preference than load balancing.
Do not add affinity merely to spread work evenly. The scheduler already handles ordinary distribution through least-loaded selection.
Use affinity when worker locality itself matters.
Selection happens before queue insertion
Scheduling chooses the destination worker first:
task
↓
Scheduler
↓
select worker
↓
worker.submit(task)
↓
local queueThe scheduler does not place the task into a global queue and choose a worker later.
This has several consequences:
worker selection happens once
task enters one local queue
queued task stays associated with that workerThe current runtime does not continuously rebalance queued tasks after submission.
There is no global task queue
The runtime uses one queue per worker:
Scheduler
┌──────┼──────┐
▼ ▼ ▼
Worker 1 Worker 2 Worker 3
│ │ │
▼ ▼ ▼
Queue 1 Queue 2 Queue 3This differs from a design where every worker consumes from one shared queue:
Global Queue
┌─────┼─────┐
▼ ▼ ▼
Worker Worker WorkerVix ThreadPool currently uses the first model.
The scheduler attempts to make a good placement decision when work is submitted.
No work stealing
Workers currently execute work only from their own local queues.
An idle worker does not take a queued task from another worker.
For example:
Worker 1
active: 1
queued: 5
Worker 2
active: 0
queued: 0Worker 2 does not automatically steal one of Worker 1's five queued tasks.
This means task placement remains fixed after scheduling.
The runtime currently relies on submission-time worker selection rather than post-submission work stealing.
Concurrent submissions
Multiple threads can submit work concurrently.
The scheduling state used by round-robin is atomic, and worker queues provide their own synchronization for task insertion and removal.
For ordinary ThreadPool scheduling, concurrent callers can submit work through the same pool:
Thread A ──┐
Thread B ──┼──► ThreadPool ──► Scheduler ──► Workers
Thread C ──┘The exact execution order across those submissions is not globally deterministic.
Concurrency means several workers can begin different tasks independently.
Scheduling does not guarantee execution order
A scheduling decision determines the destination worker.
It does not establish a total ordering across the pool.
Suppose:
Task A → Worker 1
Task B → Worker 2The runtime does not guarantee whether A or B begins first.
Both worker threads execute independently.
This applies even when the tasks were submitted sequentially:
pool.post(task_a);
pool.post(task_b);Submission order alone does not provide a global execution-order guarantee when tasks can reach different workers.
If one operation must happen after another, express that dependency explicitly rather than relying on scheduling order.
Priority and scheduling solve different problems
Worker selection and task priority happen at different stages.
Scheduler
↓
choose worker
↓
Worker queue
↓
order by priorityScheduling answers:
Which worker receives this task?Priority answers:
Where does this task appear relative to other queued tasks
inside that worker?For example:
Worker 1
high task
Worker 2
normal taskthe normal-priority task can start at the same time as the high-priority task.
Priority therefore does not override the independent execution of another worker.
See Priorities.
FIFO applies only inside one local priority level
Within a worker queue, tasks are ordered by priority first and sequence number second.
For equal priority:
Task A sequence 10
Task B sequence 11
Task C sequence 12the queue preserves:
A
↓
B
↓
CThis FIFO relationship is local to that worker queue.
It does not create FIFO ordering between tasks placed on different workers.
Worker IDs and worker indexes
The scheduler uses both worker IDs and zero-based indexes.
For four workers:
index 0 → WorkerId 1
index 1 → WorkerId 2
index 2 → WorkerId 3
index 3 → WorkerId 4WorkerId is the public identity type:
using WorkerId = std::uint32_t;and:
vix::threadpool::invalid_worker_idhas value zero.
This distinction matters when working with affinity or this_worker.
Low-level Scheduler
Scheduler is publicly available for code that needs direct control over scheduling configuration.
A scheduler can be configured independently:
vix::threadpool::SchedulerConfig config;
config.worker_count = 4;
config.scheduling_policy =
vix::threadpool::SchedulingPolicy::round_robin;
vix::threadpool::Scheduler scheduler(config);Workers are created during scheduler construction but are not started automatically.
Start them explicitly:
if (!scheduler.start())
{
return 1;
}ThreadPool manages this lifecycle automatically, so most application code should prefer ThreadPool.
Direct Scheduler usage is appropriate only when the lower-level scheduling layer is required.
SchedulerConfig
The low-level configuration contains:
worker_count
max_queue_size_per_worker
scheduling_policy
rejection_policy
drain_on_stop
worker_name_prefixFor example:
vix::threadpool::SchedulerConfig config;
config.worker_count = 4;
config.max_queue_size_per_worker = 256;
config.scheduling_policy =
vix::threadpool::SchedulingPolicy::round_robin;A zero worker count is normalized to one.
An empty worker-name prefix is normalized to:
vix-tpThis configuration belongs to direct Scheduler use. It is not the same structure as ThreadPoolConfig.
Scheduling policy helpers
The module provides:
vix::threadpool::default_scheduling_policy();which returns:
vix::threadpool::SchedulingPolicy::affinity_then_least_loadedYou can test whether a policy uses affinity:
const bool usesAffinity = vix::threadpool::uses_affinity(
vix::threadpool::SchedulingPolicy::affinity
);The result is true for:
affinity
affinity_then_least_loadedYou can test whether it uses queue-load balancing:
const bool balancesLoad = vix::threadpool::uses_load_balancing(
vix::threadpool::SchedulingPolicy::least_loaded
);The result is true for:
least_loaded
affinity_then_least_loadedReadable policy names
Use to_string() when a readable scheduling-policy name is needed:
const char* name = vix::threadpool::to_string(
vix::threadpool::SchedulingPolicy::least_loaded
);The result is:
least_loadedThe available names are:
round_robin
least_loaded
affinity
affinity_then_least_loadedAn unknown enum value produces:
unknownWorker queue capacity affects scheduling outcomes
A scheduler can select a worker whose bounded queue cannot accept another task.
For example:
Worker 1 queue size = 4, capacity = 4
Worker 2 queue size = 5, capacity = 8least-loaded selection sees:
4 < 5and selects Worker 1.
The selected worker can then reject the task because its queue is already full.
The current least-loaded algorithm compares queue sizes. It does not filter workers according to remaining queue capacity before selecting one.
Queue-capacity behavior and rejection are covered in Queue and Rejection Policies.
Scheduling and active tasks
The least-loaded policy compares:
worker->size()which represents queued tasks.
It does not include the worker's currently executing task in the selection value.
Consider:
Worker 1
active = 1
queued = 0
Worker 2
active = 0
queued = 1least-loaded scheduling sees:
Worker 1 size = 0
Worker 2 size = 1and selects Worker 1.
This is the current definition of scheduling load.
Pool metrics expose active work separately from pending work.
Choosing a scheduling strategy
For normal ThreadPool use, no scheduling decision is required.
The current policy already provides:
affinity when explicitly requested
+
least-loaded selection otherwiseWhen using the low-level Scheduler directly:
Use round_robin when predictable worker rotation is more important than queue-load information.
Use least_loaded when submissions should prefer the worker with the shortest current queue.
Use affinity when affinity should be honored and non-affinity tasks should use round-robin placement.
Use affinity_then_least_loaded when affinity should be honored while ordinary tasks should use queue-based balancing.
Scheduling model summary
The current ThreadPool path is:
submit task
↓
has affinity?
┌──────┴──────┐
yes no
│ │
▼ ▼
map affinity compare
to worker queue sizes
│ │
└──────┬───────┘
▼
selected worker
↓
worker local queue
↓
priority ordering
↓
worker thread executesThe important properties are:
ThreadPoolcurrently usesaffinity_then_least_loaded.- Worker selection occurs when work is submitted.
- Each worker owns a separate queue.
- Least-loaded scheduling compares queued task counts.
- Active task cost is not part of the load calculation.
- Affinity takes precedence over load balancing.
- There is no global task queue.
- There is currently no work stealing.
- Scheduling does not guarantee global execution order.
- Priority controls ordering inside the selected worker queue, not worker selection.
Continue with Priorities for local queue ordering or Worker Affinity for explicit worker placement.