Skip to Content
Course content

130: Building a Simple Task Scheduler

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've been thinking about how we handle recurring background work. Usually, we just slap a library like Quartz.NET or Hangfire on it, but I think it's worth actually building a primitive scheduler from scratch. It'll show us exactly why those big libraries exist and how to handle the "drift" that happens when tasks take longer than their allotted slot.

The "Simple Loop" Trap

My first instinct is always the simplest: just a while loop and a Task.Delay. Let's try to build a scheduler that runs a "Database Cleanup" task every 5 seconds. I'll start with something like this:

public async Task StartScheduler()
{
    while (true)
    {
        Console.WriteLine($"Running cleanup at {DateTime.Now:T}...");
        await DoCleanupWork(); 
        await Task.Delay(5000);
    }
}

private async Task DoCleanupWork()
{
    // Simulate work
    await Task.Delay(2000); 
}

At first glance, this looks fine. But wait—look at the timing. The cleanup takes 2 seconds, and then we delay for 5 seconds. That means the task actually runs every 7 seconds, not every 5. This is called "drift." If this were a billing system running every hour, we'd be losing minutes every single cycle. That's not going to cut it.

Firing and Forgetting

To fix the drift, I could calculate the remaining time, but that gets messy. Instead, why don't I just fire the task off in the background and let the loop keep ticking? I'll use Task.Run so the loop doesn't wait for the cleanup to finish before starting the next delay.

public async Task StartScheduler()
{
    while (true)
    {
        // Fire and forget the work
        _ = Task.Run(async () => await DoCleanupWork());
        
        await Task.Delay(5000);
    }
}

Now the timing is perfect. The loop wakes up every 5 seconds exactly. But I've just created a new, much scarier problem. What happens if DoCleanupWork suddenly takes 10 seconds because the database is lagging? Every 5 seconds, the scheduler will spawn a new instance of the cleanup task, and they'll start piling up. I'll end up with five concurrent cleanup tasks fighting over the same database tables. That's a recipe for a deadlock or a crashed server.

Managing Task State

I need a way to say, "Run this every 5 seconds, but only if the previous run is actually finished." I'll introduce a flag—a simple boolean—to track if the task is currently busy. I'm thinking of a ScheduledTask class to encapsulate this logic so I can run multiple different jobs without writing five different loops.

public class ScheduledTask
{
    public string Name { get; set; }
    public TimeSpan Interval { get; set; }
    public Func<Task> Action { get; set; }
    private bool _isRunning = false;

    public async Task Tick()
    {
        if (_isRunning) 
        {
            Console.WriteLine($"[{Name}] Skipping: Previous run still active.");
            return;
        }

        _isRunning = true;
        try 
        {
            await Action();
        }
        finally 
        {
            _isRunning = false;
        }
    }
}

I like this. The finally block is the key here; if the task throws an exception, I still need to set _isRunning back to false, otherwise the scheduler will just stop running that task forever. Now I can manage a list of these and iterate through them in one master loop.

Putting the Engine Together

Now let's wrap this into a manager. I'll use a CancellationTokenSource because we can't just have a while(true) loop running forever in a real app—we need a way to shut it down gracefully when the application closes.

public class TaskSchedulerEngine
{
    private readonly List<ScheduledTask> _tasks = new();

    public void AddTask(string name, TimeSpan interval, Func<Task> action)
    {
        _tasks.Add(new ScheduledTask { Name = name, Interval = interval, Action = action });
    }

    public async Task RunAsync(CancellationToken ct)
    {
        // We use a PeriodicTimer for better precision in .NET 6+
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));

        while (await timer.WaitForNextTickAsync(ct))
        {
            foreach (var task in _tasks)
            {
                // This is a simplified check. In a real system, 
                // you'd track the last run time per task.
                await task.Tick();
            }
        }
    }
}

I switched to PeriodicTimer here. Unlike Task.Delay, it doesn't "drift" as much because it tracks the next tick relative to the start time, not the end of the previous execution. It's much cleaner for this kind of "heartbeat" logic.




📋 Practical Task

Build a System Maintenance Orchestrator

Using the concepts from this lesson, create a program that simulates a server maintenance orchestrator. Your program should:

  • Implement a TaskSchedulerEngine (or similar) that can handle multiple registered tasks.
  • Register two specific tasks:
    • "Log Rotation": Runs every 3 seconds. It should take roughly 1 second to complete.
    • "Disk Cleanup": Runs every 5 seconds. To test the "overlap" protection, make this task randomly take between 1 and 7 seconds to complete (use Random.Shared.Next()).
  • Ensure that if "Disk Cleanup" is still running when the next tick occurs, the scheduler prints a message saying the task was skipped rather than starting a second concurrent cleanup.
  • Implement a way to stop the scheduler after 20 seconds of execution using a CancellationTokenSource.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.