16 Oct 2011
I’ve been using ASP.NET for years and never thought about the usage of threads within the .NET site. It’s not that I thought it was impossible, it’s just that I hadn’t even considered it.
Earlier this year I did a few experiments with threads and more recently I built upon this and put a generic task manager system in an eCommerce platform I am building, after seeing how nopCommerce implemented one. The first task I built upon this was a thread which loops every 10 minutes and cleans out any inactive customer account sessions from the database.
The easiest way to do this would have been a simple SQL Server Stored Procedure and use SQL Agent to fire it every 10 minutes. The first problem with this is that I don’t necessary have access to full SQL Server and the express version doesn’t have SQL Agent. The second problem is down to management and deployment - I wanted a self contained application that doesn’t require the setting up of SQL Agent tasks and the maintainable of these outside of the main application.
So my dream...
The main layout of my solution is based on the nopCommerce implementation with some tweaks:
One problem I had to get past was that the .NET platform might be ran on an IIS Web Farm scenario with multiple worker threads. This would in turn lead to double the amount of threads. To get around this I used a Mutex so that only one work thread can have the tasks running at a time. Here is the main code:
Imports System.Threading Namespace Tasks Public Class TaskManager Private Shared ReadOnly _TaskManager As New TaskManager() Private ReadOnly _TaskThreads As New List(Of TaskThread) Private Sub New() End Sub Public Sub Initialize() If Configuration.getBooleanSetting("Tasks.CleanOldSessions.Enabled", True) Then _TaskThreads.Add(New TaskCleanOldSessions()) End If End Sub Public Sub StartTaskManager() ' Use a Mutex so only one instance of the task manager can be run at a time Dim createdNewMutex As Boolean = True Using mut As New Mutex(True, "makitTaskManager", createdNewMutex) If createdNewMutex Then ' Start each thread created during initialize in turn For Each taskThread As TaskThread In _TaskThreads taskThread.InitTimer() Next ' Stop the garbage collecting the mutex, which would be bad GC.KeepAlive(mut) End If End Using End Sub Public Sub StopTaskManager() Using mut As New Mutex(True, "makitTaskManager") For Each taskThread As TaskThread In _TaskThreads taskThread.Dispose() Next ' The mutex can now be released, due to it not being used any more mut.ReleaseMutex() End Using End Sub Public Shared ReadOnly Property Instance() As TaskManager Get Return _TaskManager End Get End Property End Class End Namespace
Imports System.Threading Namespace Tasks Public MustInherit Class TaskThread Implements IDisposable Protected _timer As Timer Protected _disposed As Boolean Protected _started As DateTime Protected _isRunning As Boolean Protected Sub New() End Sub Protected Overridable Sub Run() _started = DateTime.Now _isRunning = True ' Overriden class does the actual task here before setting isRunning to false End Sub Public Sub InitTimer() ' Create a timer which executes the timer handler sub between intervals If _timer Is Nothing Then _timer = New Timer(New TimerCallback(AddressOf TimerHandler), Nothing, Interval, Interval) End If End Sub Private Sub TimerHandler(ByVal state As Object) Try ' Stop the timer whilst the task runs, otherwise if the task is slow then will get an overlap _timer.Change(-1, -1) ' Run the actual task Run() ' Now reset the timer back to the interval again, so it will execute again in 10 minutes _timer.Change(Interval, Interval) Catch ex As Exception Logging.logAsError("Exception during execution of task. Caught error and task will be stopped until problem solved. Info: " & ex.ToString) _timer.Dispose() _timer = Nothing _disposed = True End Try End Sub Public ReadOnly Property Started() As DateTime Get Return _started End Get End Property Public ReadOnly Property IsRunning() As Boolean Get Return _isRunning End Get End Property Public ReadOnly Property Interval() As Integer Get Return 600000 '10 Minutes - could be configurable End Get End Property Public Sub Dispose() Implements IDisposable.Dispose If (_timer IsNot Nothing) AndAlso Not _disposed Then SyncLock Me _timer.Dispose() _timer = Nothing _disposed = True End SyncLock End If End Sub End Class End Namespace
Namespace Tasks Public Class TaskCleanOldSessions Inherits TaskThread Protected Overrides Sub Run() MyBase.Run() Execute() _isRunning = False End Sub Private Sub Execute() ' Get the number of hours in the past to delete sessions older than this Dim hoursOldToDelete As Single = Configuration.getSingleSetting("Tasks.CleanOldSessions.HoursOld", 48) ' Pass in the current date with the number of hours in the past removed so the sessions are deleted from the DB ' This returns a list of the session IDs that have been logged out for reporting purposes. Dim loggedOutSessions As List(Of String) = AccountRepository.clearOldAuths(Now().AddHours(-hoursOldToDelete)) ' If some people were logged out then log the fact If loggedOutSessions IsNot Nothing Then Logging.logAsInfo("Logged out " & loggedOutSessions.Count & " sessions.") For Each curHUID As String In loggedOutSessions Logging.logAsInfo("Logged out session " & curHUID) Next End If ' Make sure the memory is released If loggedOutSessions IsNot Nothing Then loggedOutSessions.Clear() End If loggedOutSessions = Nothing hoursOldToDelete = Nothing GC.Collect() End Sub End Class End Namespace
That’s the basic premise, it has so far worked well. With the only major problem being a memory leak that was cured by implementing IDisposable in the threads and making sure all variables were cleared up before garbage collecting.
The threads are currently logging to text files, but they will be extended to link in with the admin area of the site so the history and status can be seen easily.