Writing a Concurrent "Task" Scheduler in Rust - Part 1
Date: April 21, 2026
Today, I began working on the final project assigned by my systems programming course. As a capstone of the the course, we are tasked with writing a multi-threaded job scheduler which receives some work and sends it to one of many available task executor threads, called workers.
Project Criteria
The project has three major guidelines:
- Write a multi-threaded job scheduler which accepts arbitrary
taskobjects - Generate a large collection of
taskswhich will be used to test the scheduler - Record runtime performance statistics of the scheduler as it's developed and tested
The task objects should, at minimum, include the following attributes:
- An
id - An
arrival_timetimestamp - A
kind, which is eitherCPUorIOto describe what type of task is being scheduled - A
duration, which provides an arbitrary time the task takes to execute.
The "tasks" in this project are not actual computations, but rather simulated work. This means that each task should suspend
the thread it is run on by the aforementioned duration in order to emulate that a job is being performed.
The point of the project is to explore different ways a developer may design and optimize a job scheduler to fit varying applications (hence the focus on task kind and duration) rather than building a robust and general-purpose library from scratch.
I'd argue that doing the latter is not too far of a stretch once the former has been sufficiently explored, which I fully intend to do!
Starting the Project - Testing Code
I typically take a test-driven approach to writing software applications. True to that, I write the testing code before I begin working on the task scheduler itself. The ideas is as follows:
- Generate a collection of 500 tasks (a quantity specified by the rubric)
- Assign each task either
CPUorIOkind (I do a 50/50 weight of either) - Assign each
CPUtask adurationbetween0.05msto250ms, and eachIOtask adurationbetween1msand5s. - Assign each task an
arrival_timebetween1sto10s
Each of these range fields are interpolated in order to generate a broader distribution of execution times. I also decided on the time ranges of each type of task myself based on performance heuristics measured on my own system. In a future post I will define some more generalized heuristics that can be scaled meaningfully to other peoples' systems.
- Push all of the tasks to an array, then sort it by
arrival_time(in descending order, i.e., earliest tasks last) for quick dequeueing of tasks. - Send all of the tasks to the scheduler when their arrival time has elapsed.
The code for this is pretty straightforward (after hours of reading Rust docs to figure out how to do very simple tasks):
// src/main.rs
use rand::rngs::StdRng;
use rand::SeedableRng;
use rand::prelude::*;
use std::time::{Duration, SystemTime};
use crate::td::*; // All of my types are defined here
pub mod td;
fn main() {
let seed: u64 = 123456; // seeded test generator for reproducibility
let mut rng = StdRng::seed_from_u64(seed);
let mut task = vec![]; // the compiler figures out the type on its own
let tasks_total = 500;
for id in 1..=tasks_total {
// I could have used a match, but I'm a stickler for concise (insofar as Rust allows) one-liners
let kind = vec![TaskKind::CPU, TaskKind::IO].choose(&mut rng).unwrap().clone();
let duration = match kind {
TaskKind::CPU => Duration::new(rng.random_range(0..1), rng.random_range(50_000..250_000_000)),
TaskKind::IO => Duration::new(rng.random_range(0..4), rng.random_range(1_000_000..999_000_000)),
};
// I add a buffer so that I can visually and mentally prepare for the tests, don't mind me.
let arrival_time = SystemTime::now() + Duration::new(rng.random_range(3..13), rng.random_range(0..999_000_000));
tasks.push(Task{ id, arrival_time, kind, duration });
}
tasks.sort_by_key(|task| task.arrival_time.clone());
tasks.reverse();
let job_count: usize = 8;
let pool = WorkerPool::new(job_count);
println!("Worker pool opened. Waiting for tasks...");
while !tasks.is_empty() {
if let Some(task) = tasks.last() && task.arrival_time <= SystemTime::now() {
// this debug log honestly might take more time than most CPU tasks...
println!("Dispatching task {:?}, which will take about {:.3?}s...", task.id, task.duration.as_secs_f32());
// enqueue virtual task
pool.execute_task(task.clone());
tasks.pop();
}
}
}
The test code is only meant for simulating incoming jobs, so it's important that we don't optimize it beyond sorting the inbound job queue.
I don't really know how I want to design my performance analysis code until I've built the library, so this concludes the testing code for the time being. With that being said, I will introduce the library itself (so far).
The Library
With the test code laid out, it may be straightforward how the job scheduler is structured from a usage standpoint:
let job_count: usize = 8; // number of jobs that can run at once
let pool = WorkerPool(job_count);
...
pool.execute(|| { println!("im doing some work!"); });
Though, since our library isn't meant to actually do work, we use simulated tasks with arbitrary durations:
let task = Task{
..., // we dont care about the metadata right now
duration: Duration::from_millis(250),
};
pool.execute_task(task); // job will finish in about 250ms.
Pretty simple so far! With that structure decided on
(which took me hours to do because I had to catch up in my software architecture knowledge due to not studying),
I separate the task dispatcher into two modules:
- The
task - The
pool
This is because I wanted to separate the logic of the tasks from the task dispatcher itself, so that scaling my work to a more general-purpose job scheduler is simpler (and compartmentalizing code is generally good practice).
The task module is very simple, so I won't spend much time discussing it:
// src/td/task.rs
use std::time::{Duration, SystemTime};
pub type TaskId = i32;
#[derive(Clone)]
pub enum TaskKind {
CPU,
IO
}
#[derive(Clone)]
pub struct Task {
pub id: TaskId,
pub arrival_time: SystemTime,
pub kind: TaskKind,
pub duration: Duration
}
Most of my time spent on this project today was the actual scheduler, defined in the pool module.
The pool module is comprised of three main components:
- The
Worker - The
WorkerPool, which manages instances of workers. - A multi-producer, single-receiver (MPSC) message channel, which relays messages from the dispatcher to managed workers.
This is technically part of the
WorkerPool, but it's very much worth its own bullet on this list.
The definition of a Worker is very simple. A Worker is comprised of an id, and maybe a thread if it's used:
pub struct Worker {
id: usize,
pub thread: Option<thread::JoinHandle<()>/* returned by functions like `thread::spawn()` */>,
}
Barring its implementation details, the WorkerPool is also rather simple:
pub struct WorkerPool {
workers: Vec<Worker>,
sender: mpsc::Sender<Option<Task>>, // our MPSC sender object
start: SystemTime, // for diagnostic purposes
}
The multi-producer, single-receiver (MPSC) object (defined in Rust's standard library) is a First-In, First-Out (FIFO) queue, and is used to relay tasks from the task dispatcher to available workers. The idea is as follows:
- A quantity n of workers are managed by a worker pool
- Each worker has an MPSC receiver object which is subscribed to a sender
- The worker pool receives a job to be fulfilled
- The MPSC sender object defined in the pool sends the task to the queue until it is received and un-queued by a single receiver.
You may notice that our Worker definition lacks any such receiver attribute. This is because it is internal to the thread spawned
for the worker:
impl Worker {
pub fn get_id(&self) -> usize { self.id }
pub fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Option<Task>>>>) -> Self {
// ...
}
}
I will expand on this in the next article
In the next article, I will delve into my implementation of the Worker and WorkerPool.
Editorial note (April 22 2026): The requirements for the scheduler have slightly shifted since publishing this blog, so I will be changing the implementation details a bit in the next article.
Please listen to GATE OF STEINER (https://www.youtube.com/watch?v=bJC93ehqarY) and watch Steins;Gate