bunny bunny bunny bunny bunny

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:

  1. Write a multi-threaded job scheduler which accepts arbitrary task objects
  2. Generate a large collection of tasks which will be used to test the scheduler
  3. Record runtime performance statistics of the scheduler as it's developed and tested

The task objects should, at minimum, include the following attributes:

  1. An id
  2. An arrival_time timestamp
  3. A kind, which is either CPU or IO to describe what type of task is being scheduled
  4. 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:

  1. Generate a collection of 500 tasks (a quantity specified by the rubric)
  2. Assign each task either CPU or IO kind (I do a 50/50 weight of either)
  3. Assign each CPU task a duration between 0.05ms to 250ms, and each IO task a duration between 1ms and 5s.
  4. Assign each task an arrival_time between 1s to 10s

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.

  1. 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.
  2. 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:

  1. The task
  2. 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:

  1. The Worker
  2. The WorkerPool, which manages instances of workers.
  3. 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:

  1. A quantity n of workers are managed by a worker pool
  2. Each worker has an MPSC receiver object which is subscribed to a sender
  3. The worker pool receives a job to be fulfilled
  4. 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


Next Post ยป