Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Lightweight Task Scheduling for .NET / Silverlight

0.00/5 (No votes)
13 Feb 2010 1  
A lightweight task scheduling library that allows you to easily schedule the invocation of callback methods at specified times or intervals. Supports .NET 3.5 and Silverlight.

Introduction

I'm currently working on VFS, a virtual file system. For running transfers, VFS internally maintains locks that do have an expiration time. Accordingly, I found myself in need for a job scheduling mechanism in order to properly release expired locks. I looked around for a few alternatives, but eventually ended up writing my own lightweight version. The code presented here can be found within VFS, but also has its own repository at CodePlex.

Quick Feature Overview

  • Simple scheduling and callback mechanisms
  • Silverlight compatible
  • Lightweight
  • Fluent API
  • Detection of system time changes with optional rescheduling
  • Optional forwarding of exceptions during job execution

What Does it Do?

Basically, the library allows you to create a job, and submit that job to a scheduler, along with a callback action. This callback action is invoked as soon as (or every time) the job is due.

Before going into the details, here’s a first code snippet that creates a simple Job that is supposed to run repeatedly (every 1.5 seconds) for a minute. Once the job is created, it is submitted to a Scheduler instance which processes the job and makes sure the submitted callback action is being invoked every time the job is due:

C#
private Scheduler scheduler = new Scheduler();

public void RunJob()
{
  //define a start /end time
  DateTime startTime = DateTime.Now.AddSeconds(5);
  DateTime endTime   = startTime.AddSeconds(60);

  //configure the job
  Job consoleJob = new Job();
  consoleJob.Run.From(startTime)
                .Every.Seconds(1.5)
                .Until(endTime);

  //submit the job with the callback to be invoked
  scheduler.SubmitJob(consoleJob, j => Console.Out.WriteLine("hello world"));
} 

Silverlight

The project provides class libraries for .NET 3.5 and Silverlight 3, along with a Silverlight sample application that shows how to add scheduling functionality to your SL application with just a few lines of code. (Note that the Silverlight projects come as an individual solution, which can be found in the Silverlight sub folder of the download.)

While long-term scheduling isn't probably something you need to do in a Silverlight application, the scheduler simplifies the management of periodic jobs, such as polling a server for updates. Below is a snippet from the Silverlight sample application. This job starts immediately, and runs indefinitely with an interval of 2 seconds:

C#
private void CreateJob2()
{
  //create job
  Job<int> job = new Job<int>("Job 2");
  job.Data = 0;
  job.Run.Every.Seconds(2);

  //submit to scheduler
  scheduler.SubmitJob(job, LogJobExecution);
}

private void LogJobExecution(Job<int> job, int data)
{
  //updates user interface
}

image

Table Of Contents

Jobs

A job is a simple configuration item for the scheduler. There’s two built-in job types: Job and Job<T>. The only difference between the two is that the latter provides a Data property which allows you to attach state information directly to the job and have it delivered back to you when the job runs.

image

Creating Jobs

Creating a job is pretty easy, and can be configured through a fluent interface (optional, you can also set the properties the traditional way). Jobs may run once, several times, or indefinitely – you can configure intervals, an optional expiration time, or a maximum number of executions. Here’s a few configuration samples:

C#
DateTime startTime = DateTime.Now.AddSeconds(5);

//the job runs once in 5 seconds  
Job job = new Job();
job.Run.From(startTime)
       .Once(); 
C#
//start immediately, run every 1.5 hours until we manually cancel it
Job job = new Job();
job.Run.Every.Hours(1.5);  
C#
//create a job with an attached FileInfo
Job<FileInfo> job = new Job<FileInfo>();
job.Data = new FileInfo(@"C:\readme.txt");

//run the job 5 times every 1.5 seconds, start immediately
TimeSpan interval = TimeSpan.FromSeconds(1.5);
job.Run.Every.TimeSpan(interval).Times(5);
C#
//create a simple job
Job job = new Job();

//run the job every 1.5 seconds until it expires
DateTime expiration = DateTime.Now.AddMinutes(5);
job.Run.Every.Seconds(1.5).Until(expiration);

The Scheduler Class

Jobs are being submitted to the Scheduler class, which provides a very simple API. Most methods should be pretty self-explanatory, and they are well documented.

image

Callback Actions

If you add a Job to the Scheduler through one of the SubmitJob overloads, you have to submit a callback action that is being invoked whenever the job runs. Depending on whether you use Job or Job<T>, a different callback delegate may be used:

  • Job invokes a callback action of type Action<Job>
  • Job<T> invokes a callback action of type Action<Job<T>, T>

Here’s a sample that uses two different callback methods:

C#
class Program
{
  private static Scheduler scheduler = new Scheduler();

  static void Main(string[] args)
  {
    Job simple = new Job("simple");
    simple.Run.From(DateTime.Now.AddSeconds(3)).Once();

    Job<int> generic = new Job<int>("generic") {Data = 123};
    generic.StartAt(DateTime.Now.AddSeconds(1)).Run.Once();

    scheduler.SubmitJob(simple, HandleSimpleJob);
    scheduler.SubmitJob(generic, HandleGenericJob);

    Console.ReadLine();
  }

  private static void HandleGenericJob(Job job, int data)
  {
    Console.Out.WriteLine("Job '{0}' submitted data: {1}", job.Id, data);
  }

  private static void HandleSimpleJob(Job job)
  {
    Console.Out.WriteLine("Simple job '{0}' was executed.", job.Id);
  }
} 

The sample above produces the following output on the console:

image

Cancelling or Pausing a Job

If you want to cancel or pause a job, you can do this either via the Scheduler class, or directly on the Job instance that is submitted to you whenever the job runs. Note that cancelling the job is a terminal operation – a cancelled job cannot be resumed.

C#
private void MyJobCallbackAction(Job job)
{
  //stops execution but keeps the job alive
  job.Pause();

  //resumes operation of a paused job
  job.Resume();

  //terminates the job
  job.Cancel();
} 

Actually, doing these operations directly via the Job class is the preferred mechanism – if you use the scheduler’s methods, the scheduler needs to lock its internal list and search for the job itself, which costs you processing power.

Exception Handling

An exception that occurs during the execution of a job does not affect the scheduler – all jobs are being executed on worker threads taken from the .NET thread pool. However, in order not to miss any exceptions, you can instruct the Scheduler class to supervise executing jobs, and forward any unhandled exceptions to a single exception handling routine. All you need to do is registering a callback action to the JobExceptionHandler of the Scheduler class:

C#
private Scheduler scheduler;

private void InitScheduler()
{
  scheduler = new Scheduler();
  scheduler.JobExceptionHandler = LogJobException;
}

private void LogJobException(Job job, Exception exception)
{
  string msg = "Exception occurred while executing job {0}: {1}";
  msg = String.Format(msg, job.JobId, exception.ToString());
  MyLogger.LogError(msg);
}

Performance

The library could surely be tweaked, but it performs pretty well. Running 10000 jobs, each with an interval of 100 ms (that’s around 100 jobs per millisecond) keeps the CPU busy (at around 25% on my machine), but neither eats away your memory nor freezes the PC. Bigger intervals aren’t a problem at all because the scheduler is sleeping most of the time.

Reacting to System Time Change Events

Assume the following:

  • The current time is 21:00, your job is scheduled to run at 22:00
  • The user changes the computer’s time to 21:30

Now, depending on your needs, you might want the scheduler to adjust itself based on two strategies:

  • A: If you want the job to run at a fixed time (22:00), you expect the job to run in 30 minutes. This is the scheduler’s default behavior.
  • B: If you want the job to run based on relative times, you still expect the job to run in an hour, so the execution time would have to be change to 22:30.

In order not to miss such an event, the scheduler performs a quick self test with a fixed configurable interval from time to time - even if no jobs are up for execution. Per default, the scheduler’s SelfTestInterval property is set to two minutes, but this can be configured should you need more (or less) accurate re-scheduling.

If you want the scheduler to just readjust its internal schedule and keep fixed times (A), you don’t have to do anything. However, if you want the scheduler to reschedule its jobs if a system time change was detected (B), you can do by setting the SystemTimeChangeRescheduling property of the Scheduler class, which takes an enum value of type ReschedulingStrategy:

image

As you can see, you can choose not to reschedule at all (default), only reschedule the next execution time (the next time the job runs), or even shift the expiration time of your jobs.

Fixed vs. Relative Rescheduling Depending on Jobs

What if you have a few jobs that should run on a fixed time, while others should be rescheduled?

I decided against making the API more complicated by allowing jobs to be individually configured. As an alternative, I’d suggest to just use two scheduler classes with individual configurations. You could even write a wrapper class that just maintains two schedulers, and forwards all job submissions to the correct one. Here’s a quick but working implementation:

C#
public class MyScheduler
{
  private readonly Scheduler fixedSchedules = new Scheduler();
  private readonly Scheduler relativeSchedules = new Scheduler();

  public MyScheduler()
  {
    //configure relative rescheduling
    var strategy = ReschedulingStrategy.RescheduleNextExecution;
    relativeSchedules.SystemTimeChangeRescheduling = strategy;
  }

  //This method just forwards submitted jobs to one of the schedulers
  public void SubmitJob(Job job, Action<Job> callback, bool hasFixedSchedule)
  {
    if(hasFixedSchedule)
    {
      fixedSchedules.SubmitJob(job, callback);
    }
    else
    {
      relativeSchedules.SubmitJob(job, callback);
    }
  }
}

Persisting Jobs

Persisting jobs is not part of the library, but could be done by subclassing the Scheduler class. The class gives you protected access to all its internals, including its job list. Job is a very simple component, so it should be fairly easy to store and reload all jobs.

However, from an architectural point of view, I’d probably not persist any jobs at all, but rather recreate them during initialization based on persisted business data. A job does execute in a given context, because of “something”. Let’s assume this “something” is a reminder flag in a calendar application:

  • User sets a reminder on a calendar entry.
  • Calendar application updates database record of the calendar entry, then creates a job with the scheduler.
  • User turns off application – the scheduler and running jobs are being disposed.
  • User restarts application.
  • The application retrieves all reminders from the database and schedules jobs for them.

This approach is simple, and clearly defines the responsibilities of the components in your application. It also minimizes dependencies on the scheduling system.

Implementation Notes

Creating a Fluent Interface

The library provides a fluent API that allows you to chain configuration settings:

C#
//job starts at a given start time, repeats until expiration time
Job myJob = new Job();
myJob.Run.From(startTime)
         .Every.Seconds(1.5)
         .Until(expiration);  

The fluent API is optional – a job can also be configured via traditional properties setters. However, the corresponding declaration is more verbose:

C#
//job starts at a given start time, repeats until expiration time
Job myJob = new Job
              {
                Loops = null,
                StartTime = startTime,
                Interval = TimeSpan.FromSeconds(1.5),
                ExpirationTime = expiration
              }; 

Basically, the implementation of the fluent API is very simple:

  • The Run property returns an instance of a helper class called JobSchedule. This helper class provides methods such as From, Until, Every, or Once.
  • All methods of the JobSchedule class have a return value of JobSchedule again, which allows the developer to chain the operations as in the sample above.

Below is a part of JobSchedule’s implementation. You can see that the class receives an instance of type Job, and just sets the properties on this class.

C#
public class JobSchedule
{
  private readonly Job job;

  //created with the job that is being configured
  public JobSchedule(Job job)
  {
    this.job = job;
  }

  //all operations just return the schedule itself again
  public JobSchedule From(DateTimeOffset startTime)
  {
    job.StartTime = startTime;
    return this;
  }

  ...
}

DateTime, DateTimeOffset, and SystemTime

SystemTime Pattern

With a scheduler, everything is about time. However, time-related code is very hard to test, which is why the scheduler does not directly access the current system time, but makes use of Ayende’s SystemTime. This is an awesome pattern, and I highly recommend using it whenever timestamps play an important role in code.

DateTimeOffset vs. DateTime

You might have noticed that usually, the code works with DateTimeOffset rather then the commonly known DateTime struct. DateTimeOffset is basically an alternative to DateTime, but operates on UTC which makes it an ideal candidate for most time-related scenarios. You can read more about it at MSDN or at the BCL team blog.

However, you can even still use DateTime in your code if you feel more comfortable with it:

C#
Job job = new Job();

DateTimeOffset expDate1 = DateTimeOffset.Now.AddHours(2);
DateTime       expDate2 = DateTime.Now.AddHours(2);

//the job takes both DateTimeOffset and DateTime values
job.ExpirationTime = expDate1;
job.ExpirationTime = expDate2; 

Job List, Timer and Pooled Execution

Job List

Internally, the Scheduler class uses a very simple mechanism: All jobs are being cached in a sorted list, which allows the scheduler to easily determine the next execution time. If a new job is being submitted, the scheduler only has to check whether the new job runs before the next scheduled job or not. If yes, the scheduler adjusts its next execution. If no, the job is just added to the end of the list, and a flag is set that reminds the scheduler to reorder the list once the next job was done. This approach has a big advantage: Even if many jobs are being submitted, reordering only takes place once the next job runs.

C#
public void SubmitJob(Job job, Action<Job> callback)
{
  //[validation omitted for brevity]

  JobContext context = new JobContext(job, callback);

  lock(syncRoot)
  {
    //if this job is going to be the next one, we need to reconfigure
    //the timer. Do not reschedule if the next execution is imminent
    if (NextExecution == null || context.NextExecution <= NextExecution.Value)
    {
      //insert at index 0 –> makes sure the job runs first on next timer event
      jobs.Insert(0, context);

      //only reschedule if the next execution is not imminent
      if (NextExecution == null || NextExecution.Value.Subtract(SystemTime.Now())
                                                .TotalMilliseconds > MinJobInterval)
      {
        //no sorting required, but we need to adjust the timer
        Reschedule();
      }
    }
    else
    {
      //add at end of the list and mark list as unsorted
      //the job will be sorted and rescheduled on the next run (which is before
      //this job's execution time)
      jobs.Add(context);
      isSorted = false;
    }
  }
}

Timer and Rescheduling

In order to trigger the execution, a single Timer is used to wake up the scheduler once jobs are due. The timer is set to one of those values:

  • If the scheduler has no jobs at all, the timer is disabled.
  • If jobs are pending, the next interval is the execution time of the next pending job.
  • If the self test interval is lower than the execution time of the next job, the scheduler will run an evaluation at this time (which ensures that a changed system time does not cause the scheduler to oversleep).

The whole timer logic is encapsulated in the Reschedule method:

C#
/// <summary>
/// Reconfigures the timer according to the
/// next pending job execution time.
/// </summary>
private void Reschedule()
{
  if(jobs.Count == 0)
  {
    //disable the timer if we don't have any pending jobs
    NextExecution = null;
    timer.Change(Timeout.Infinite, Timeout.Infinite);
  }
  else
  {
    //schedule next event
    var executionTime = jobs[0].NextExecution;

    DateTimeOffset now = SystemTime.Now();
    TimeSpan delay = executionTime.Value.Subtract(now);

    //in case the next execution is already pending, add a safe delay
    long dueTime = Math.Max(MinJobInterval, (long)delay.TotalMilliseconds);

    //run at least with the self testing interval
    dueTime = Math.Min(dueTime, SelfTestInterval);

    NextExecution = SystemTime.Now().AddMilliseconds(dueTime);
    timer.Change(dueTime, Timeout.Infinite);
  }
} 

Job Execution

“Executing” a job basically means invoking the callback action that was submitted along with the job. This always happens asynchronously through the .NET thread pool:

C#
/// <summary>
/// Invokes the managed job's <see cref="CallbackAction"/> through
/// the thread pool, and updates the job's internal state.
/// </summary>
public void ExecuteAsync()
{
  //only execute if the job is active
  if (...)
  {
    ThreadPool.QueueUserWorkItem(s => CallbackAction(ManagedJob));
  }

  UpdateState();
} 

Conclusion

This is a neat little helper library, and its small footprint makes it a viable alternative to handling timers yourself even for small applications. Happy coding! :)

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here