Written by WATYF on Thursday, 20 December 2007 (179701 hits)
Category: .NET Programming
OK... here's the problem. I'm lazy. But aren't all programmers? I mean... if it wasn't for laziness, we wouldn't have jobs. The whole point of programming is to find something that people don't want to have to do, and write code that will do it for them so they can keep being lazy. Am I right?
So when I run into an obstacle while programming, I like to find the simplest way to get around it. If the solutions that are already out there on the internet are cumbersome and complicated, then chances are, I'm not gonna use 'em. And that was the case when I ran into this little problem.
The thing is... most of my applications start new threads. Who wants to wait around staring at a frozen Windows form while something gets processed in the background? But at the same time, you can't just kick off a thread and hope that the user doesn't try to do something else while the thread is running. You have to control the work flow... keep the user in line. So you display some kind of "Please Wait..." dialog and use it to show them the progress of what's going on in the background. But what happens if something goes wrong in the background thread? You can handle the exception in that thread, but how will the main/GUI thread (the one that created the new thread) know that an exception occurred?
....I know how.
You see, sometimes it's a really bad idea to just swallow an exception in a new thread, show an error message, and then continue as if nothing happened. Let's say we have a process that allows the user to enter a URL for a particular kind of file and click "Download". A thread is created that downloads the file, unzips it, reads through the file and checks it for consistency, and then after the thread is done, elements on the UI get updated to display information to the user, such as how long the download took and how big the file was and whether it had any records that didn't pass the consistency check, and so on. It might look something like this:
Sub Main
Try
'Create a new thread to do stuff
Dim t as new thread(AddressOf TestThread)
t.start
'Open a "please wait" form modally
myWait = New frmWait
myWait.ShowDialog
'After the thread completes successfully, I update some UI controls so the user can see the results of
'what the thread did. This would show record count of the file, the duration of the process, etc.
'If the thread fails, I DON'T want to do this part, that's why the exception needs to be thrown back here
'so that instead of doing this stuff, it'll jump straight to the Catch below.
lblRecord.Text = "Records found in file: " & sRec
lblDur.Text = "The process took " & sDuration
MsgBox "The operation completed successfully."
Catch ex as Exception
'This is where it should go if an error happens in the worker thread.
MsgBox "An error happened during the thread. Details: " & ex.Message
End Try
End Sub
Sub TestThread()
Try
myWait.lblWait.Text = "Downloading file..."
'Code to download a zip file
myWait.lblWait.Text = "Extracting contents of file..."
'Code to extract the contents of the zip file
myWait.lblWait.Text = "Checking file contents..."
'Loop through the contents of the extracted text file and do some consistency checks on the data in the file
Catch ex as Exception
'If an error occurs... I catch it
'This is where I would want to RE-throw the error back to the main thread. If this thread fails I DON'T want
'the main thread to continue about its business as if this thread completely successfully
Throw ex
End Try
End Sub
So as you can see... it does me no good to catch the error in the Thread if I can't ALSO let the main thread know that an error happened, so it can skip over the "post thread" code and go straight to the Catch. Otherwise, it'll be updating the "how many records" and "how long the process took" controls for a process that never completed.
But what happens if I re-throw the error in the new thread? I get an unhandled exception. Sure, I could create a global "unhandled exception" handler, but that wouldn't do me any good... it would just allow me to let the user know that an unhandled exception occurred, and then the app will close. What we want is a controlled exception. So how do we get it?
Not by using the System.Threading.Thread object... that's for sure. It's just not powerful enough. Unless, of course, we want to litter our app with global variables that we can check and reset based on whether or not a process completely successfully, or unless we want to try marshalling the exception using Invoke, both of which require you to fashion a custom "exception handler" for every thread that you create in your app. That might work if we had one thread to keep track of, but real-world apps and rarely that simple. We need a simple solution that takes care of itself and doesn't require any over-head programming for every new thread we need to create. So instead, we use the new (with .NET 2.0) BackgroundWorker thread. Now, some of you might have already been thinking, "Why don't you use a BackgroundWorker".... well, the reason is, because it's cumbersome. You have to create a DoWork event and a RunWorkerCompleted event and make a new BackgroundWorker for each process in your application.... cumbersome. I want an object that's as SIMPLE as System.Threading.Thread but as POWERFUL as BackgroundWorker thread. And since that object doesn't exist, we simply make our own.
See... I want to just be able to declare a thread object, assign a Sub procedure to it (like I do with System.Threading.Thread) and run "Thread.Start". And if an error happens in that thread, I want it to report the error back to the thread that created it. So to do that, I inherit BackgroundWorker and make a super-thread object, like so:
'Enhanced BackgroundWorkerThread to use for threaded processes
Public Class BGWThread
Inherits BackgroundWorker
'Public var to hold the exception (if any) that is raised by the BGWThread.
Public [Exception] As Exception
Public Delegate Sub MethodDel()
Private RunProc As MethodDel
Sub New(ByVal StartProc As MethodDel)
AddHandler Me.RunWorkerCompleted, AddressOf Me.Completed
AddHandler Me.DoWork, AddressOf RunStart
RunProc = StartProc
Me.WorkerReportsProgress = True
Me.WorkerSupportsCancellation = True
End Sub
Public Function Start() As Boolean
Try
'Run the bgw
Me.RunWorkerAsync()
'Show the Wait form. If you don't want to use a Wait form, you can create an argument in Start that
'determines how to make the user wait
myWait = new frmWait
myWait.ShowDialog()
'If the thread finishes and the bgw.Exception wasn't populated with an exception by Completed, then Return
' True. Otherwise, throw the exception/cancel message to the Catch (this is so I can always dispose of
' the bgw in the Finally of this Sub instead of having to dispose of it in every Sub that creates it).
If Me.Exception Is Nothing Then Return True Else Throw New Exception(Me.Exception.Message)
Catch ex As Exception
If ex.Message = "Thread Canceled." Then Return False Else Throw ex
Finally
Me.Dispose()
End Try
End Function
Private Sub RunStart(ByVal sender As Object, ByVal e As System.ComponentModel.DoWorkEventArgs)
'Run the procedure that was passed to this BGWThread
RunProc()
'After the proc is done, see if it got canceled. If so, set e.Cancel to true
If Me.CancellationPending Then
e.Cancel = True
End If
End Sub
Private Sub Completed(ByVal sender As Object, ByVal e As RunWorkerCompletedEventArgs)
'This Completed event runs after the thread finishes. It catches any errors that happen within the thread
' and allows me to check if the thread was canceled (via the flag that's set at the end of RunStart)
If e.Cancelled Then 'If the user canceled the operation, set bgw.Exception to say, "Thread Canceled."
Me.Exception = New Exception("Thread Canceled.")
ElseIf e.Error IsNot Nothing Then 'If the operation produced an exception, set bgw.Exception to the exception
Me.Exception = New Exception(e.Error.Message, e.Error)
End If
'If the Wait form is still open, close it. This releases the code in RunStart. Since this happens here
' (after any bgw thread ends) there is no need to Close frmWait anywhere in any of the threads themselves
If Not myWait Is Nothing Then
myWait.Close()
End If
End Sub
End Class
OK, so what's happening here is, I've made a Class that inherits BackgroundWorker and enhances it. I created fixed Subs that will be used for the DoWork and RunWorkerCompleted events. And I made a new constructor (Sub New) so I can pass the BGW object the procedure that it's going to run (StartProc), and it assigns the "DoWork" event to that procedure. Then I assign the RunWorkerCompleted event to the Sub that "cleans up" after the thread, and I also set some properties (this may not apply to you) like whether the thread can be cancelled and what not. The Class also has a public variable that stores any Exception that occurs during the thread. The Sub that starts the thread is called (oddly enough) "Start". This Sub will call the RunWorkerAsync (which starts the actual backgroundworker) and then wait for it to complete (using whatever method you want) and then check to see the results of the backgroundworker.
When the thread is done, Completed gets fired... if the user canceled the operation (you'd have to work that out using a modal form) then it stores a "Thread Canceled" exception in Exception, otherwise, it stores the actual error in the Exception variable. So back in "Start", it will check if the Exception var is empty, if so, Start returns "True" (i.e. the thread completed successfully), otherwise, it will throw the actual exception to the Cacth. The Catch will see if the Exception is just a "Thread Canceled." exception, and if so, it will return False (i.e. the thread got canceled)... otherwise, if an actual error occurred, it won't return anything... it will RE-throw the exception back to the main/GUI thread that called "Start" in the first place. This may all sound complicated, but actually USING the BGWThread Class is dead simple. Here's what our new pseudo code would look like using the new Class.
'Declared publicly, so the thread procedure can check it to see if it's been cancelled. Not necessary if you aren't using cancelation
Dim t as BGWThread
Sub Main
Try
'Create a new BGWThread to do stuff
t = new BGWThread(AddressOf TestThread)
'Start the BGWThread... if it returns False (i.e. they canceled), then just exit the try
' If an error were to occur during TestThread, execution here would jump straight to the Catch below
If Not t.start Then Exit Try
lblRecord.Text = "Records found in file: " & sRec
lblDur.Text = "The process took " & sDuration
MsgBox "The operation completed successfully."
Catch ex as Exception
'Now, this is where it goes if an error happens in the worker thread.
MsgBox "An error happened during the thread. Details: " & ex.Message
End Try
End Sub
Sub TestThread()
Try
'Check if the user cancelled the process, if so, exit
If bgw.CancellationPending Then Exit Try
'Update the message to the user
myWait.lblWait.Text = "Downloading file..."
'Code to download a zip file
If bgw.CancellationPending Then Exit Try
myWait.lblWait.Text = "Extracting contents of file..."
'Code to extract the contents of the zip file
If bgw.CancellationPending Then Exit Try
myWait.lblWait.Text = "Checking file contents..."
'Loop through the contents of the extracted text file and do some consistency checks on the data in the file
Catch ex as Exception
'If an error occurs... I catch it and rethrow it. The benefit of this is that I can do any necessary cleanup
'in the Finally statement of this Try, and therefore, there won't be any stray objects laying around from
'whatever was happening before the error occurred
Throw ex
Finally
'Cleanup any objects that may have been created by the download/unzip/file check
End Try
End Sub
That may or may not be as clear as mud, but hey... it works great and now that I've got the BGWThread built, I don't have to do any crazy stuff like checking global variables or marshalling exceptions using Control.Invoke or anything else. I just declare a new BGWThread, assign a procedure to it, start it, and the workflow handles itself.
P.S. Some of you may notice that the Wait form is being updated from the new thread, which would cause a cross thread exception in .NET 2.0. That's true, except for the fact that I turn off CheckForIllegalCrossThreadCalls whenever I open the Wait form. Yes, it's cheating, and sloppy, but I only use it to quickly (and easily) update the progress. Any other time I do cross-thread calls (i.e. updating a ListView from another thread or whatever), I use a delegate. If you wanted to, you could add a new Sub procedure to the BGWThread Class and assign the "ReportProgress" event to it. That way, you'd have one place where you could update your Wait form from.
WATYF
|