views:

33

answers:

1

VB.NET 2010, .NET 4

Hello,

I have been using a pretty slick generic invoke method for UI updating from background threads. I forget where I copied it from (converted it to VB.NET from C#), but here it is:

Public Sub InvokeControl(Of T As Control)(ByVal Control As t, ByVal Action As Action(Of t))
    If Control.InvokeRequired Then
        Try
            Control.Invoke(New Action(Of T, Action(Of T))(AddressOf InvokeControl), New Object() {Control, Action})
        Catch ex As Exception
        End Try
    Else
        Action(Control)
    End If
End Sub

Now, I want to modify this to make a function that returns Nothing if no invoke was required (or an exception was thrown) or the IAsyncResult returned from BeginInvoke if invoke was required. Here's what I have:

Public Function InvokeControl(Of T As Control)(ByVal Control As t, ByVal Action As Action(Of t)) As IAsyncResult
    If Control.InvokeRequired Then
        Try
            Return Control.BeginInvoke(New Action(Of T, Action(Of T))(AddressOf InvokeControl), New Object() {Control, Action})
        Catch ex As Exception
            Return Nothing
        End Try
    Else
        Action(Control)
        Return Nothing
    End If
End Function

I wanted to do this primarily to avoid blocking. The problem is that I now get errors when making calls such as this:

InvokeControl(SomeTextBox, Sub(x) x.Text = "Some text")

This worked fine with the original Invoke (rather than BeginInvoke) method. Now I get a "Object reference not set to an instance of an object" exception. If I put a watch on SomeTextBox, it says

SomeTextBox {Text = (Text) threw an exception of type Microsoft.VisualStudio.Debugger.Runtime.CrossThreadMessagingException.}

It might be relevant that such InvokeControl calls come from within a System.Timers.Timer's Elapsed event. Its Interval is 500ms, which should be more than long enough for the UI updates to complete (if that matters). What is going on?

Thanks in advance for the help!

Edit: More details

Here is my System.Timer.Timer's Elapsed handler:

Private Sub MasterTimer_Elapsed(ByVal sender As Object, ByVal e As System.Timers.ElapsedEventArgs) Handles MasterTimer.Elapsed
    MasterTimer.Enabled = False
    If Not MasterTimer.Interval = My.Settings.TimingMasterTimerInterval Then
        MasterTimer.Interval = My.Settings.TimingMasterTimerInterval
        NewEventLogEntry("The master timer's interval has been changed to " & MasterTimer.Interval.ToString & " milliseconds.")
    End If
    InvokeControl(TimerPictureBox, Sub(x) x.Toggle(True))
    ReadFromDevices()
    UpdateIndicators()
    'This block is not executing when the error is thrown
    If Mode > RunMode.NotRunning Then
        UpdateProcessTime()
        UpdateRemainingTime()
        UpdateStatusTime()
    End If
    'This block is not executing when the error is thrown
    If Mode = RunMode.Running Then
        CheckMillerCurrent()
        CheckTolerances()
    End If

    MasterTimer.Enabled = True
End Sub

Private Sub ReadFromDevices()
    For Each dev As Device In Devices
        Try
            If dev.GetType.Equals(GetType(Miller)) Then
                Dim devAsMiller As Miller = CType(dev, Miller)
                With devAsMiller
                    If .PowerOn.Enabled Then .PowerOn.Read()
                    If .CurrentRead.Enabled Then .CurrentRead.Read()
                    If .VoltageRead.Enabled Then .VoltageRead.Read()
                    If .Trigger.Enabled Then .Trigger.Read()
                    If .Shutter.Enabled Then .Shutter.Read()
                End With
            ElseIf dev.GetType.Equals(GetType(SubstrateBiasVoltage)) Then
                Dim devAsSubstrateBiasVoltage As SubstrateBiasVoltage = CType(dev, SubstrateBiasVoltage)
                With devAsSubstrateBiasVoltage
                    If .LambdaCurrentRead.Enabled Then .LambdaCurrentRead.Read()
                    If .LambdaVoltageRead.Enabled Then .LambdaVoltageRead.Read()
                    If .BiasResistor.Enabled Then .BiasResistor.Read()
                    If .Pinnacle.Enabled Then .Pinnacle.Read()
                End With
            Else
                If dev.Enabled Then dev.Read()
            End If
        Catch ex As Exception
            NewEventLogEntry("An error occurred while trying to read from a device.", ex, EventLogItem.Types.Warning)
        End Try
    Next
End Sub

Private Sub UpdateIndicators()
    Dim ObjLock As New Object
    SyncLock ObjLock
        With Devices
            InvokeControl(EmergencyStopPictureBox, Sub(x As DigitalPictureBox) x.Toggle(Mode > RunMode.NotRunning))

            InvokeControl(MillerCurrentIndicator, Sub(x) x.Text = .Miller1.CurrentRead.GetParsedValue.ToString)
            InvokeControl(MillerVoltageIndicator, Sub(x) x.Text = .Miller1.VoltageRead.GetParsedValue.ToString)
            With .SubstrateBiasVoltage
                InvokeControl(LambdaVoltageIndicator, Sub(x) x.Text = .LambdaVoltageRead.GetParsedValue.ToString)
                InvokeControl(LambdaCurrentIndicator, Sub(x) x.Text = .LambdaCurrentRead.GetParsedValue.ToString)
                InvokeControl(PinnacleVoltageIndicator, Sub(x) x.Text = .Pinnacle.GetParsedValue.ToString)
                InvokeControl(PinnacleCurrentIndicator, Sub(x) x.Text = .Pinnacle.ReadCurrent.ToString)
            End With
            InvokeControl(HeaterPowerIndicator, Sub(x) x.Text = .HeaterPower.GetParsedValue.ToString)
            InvokeControl(ConvectronIndicator, Sub(x) x.Text = .Convectron.GetParsedValue.ToString)
            If .Baratron.GetParsedValue > 200 Then
                InvokeControl(BaratronIndicator, Sub(x) x.Text = "OFF")
            Else
                InvokeControl(BaratronIndicator, Sub(x) x.Text = .Baratron.GetParsedValue.ToString)
            End If
            If .Ion.GetParsedValue > 0.01 Then
                InvokeControl(IonIndicator, Sub(x) x.Text = "OFF")
            Else
                InvokeControl(IonIndicator, Sub(x) x.Text = .Ion.GetParsedValue.ToString)
            End If
            InvokeControl(ArgonFlowRateIndicator, Sub(x) x.Text = .ArgonFlowRate.GetParsedValue.ToString)
            InvokeControl(NitrogenFlowRateIndicator, Sub(x) x.Text = .NitrogenFlowRate.GetParsedValue.ToString)
            InvokeControl(GateValvePositionIndicator, Sub(x) x.Text = .GateValvePosition.GetParsedValue.ToString)

            InvokeControl(RoughingPumpPowerOnIndicator, Sub(x As PowerButton) x.IsOn = .RoughingPumpPowerOn.Value = Power.On)

            ToggleImageList(.Miller1.CurrentRead.ImageList, .Miller1.CurrentRead.GetParsedValue > My.Settings.MinimumMillerCurrent)
            ToggleImageList(.Miller1.Trigger.ImageList, .Miller1.Trigger.GetParsedValue = Power.On)
            ToggleImageList(.HeaterPower.ImageList, .HeaterPower.Value > 0)
            With .SubstrateBiasVoltage
                ToggleImageList(.LambdaVoltageRead.ImageList, .LambdaVoltageRead.GetParsedValue > 0 And .BiasResistor.GetParsedValue = BiasResistor.Lambda)
                ToggleImageList(.Pinnacle.ImageList, .Pinnacle.GetParsedValue > 10 And .BiasResistor.GetParsedValue = BiasResistor.Pinnacle)
            End With
            ToggleImageList(.ArgonValveOpen.ImageList, .ArgonValveOpen.Value = Valve.Open)
            ToggleImageList(.NitrogenValveOpen.ImageList, .NitrogenValveOpen.Value = Valve.Open)
            ToggleImageList(.RoughingPumpValveOpen.ImageList, .RoughingPumpValveOpen.Value = Valve.Open)
            ToggleImageList(.SlowPumpDownValve.ImageList, .SlowPumpDownValve.Value = Valve.Open)
            ToggleImageList(.RotationPowerOn.ImageList, .RotationPowerOn.Value = Power.On)
            ToggleImageList(.WaterMonitor1.ImageList, .WaterMonitor1.Value = Power.On And .WaterMonitor2.Value = Power.On)
            ToggleImageList(.GateValvePosition.ImageList, .GateValvePosition.SetValue > 0)
        End With
    End SyncLock
End Sub

Private Sub ToggleImageList(ByRef ImageList As ImageList, ByVal IsOn As Boolean)
    For Each img As OnOffPictureBox In ImageList
        SafeInvokeControl(img, Sub(x As OnOffPictureBox) x.Toggle(IsOn))
    Next
End Sub

I hope that's not TMI, but hopefully it'll help spot what going wrong.

Also, with the watch on one of the textboxes and some breakpoints, I found that the error is somehow magically thrown after ReadFromDevices but before UpdateIndicators. By that, I mean that a breakpoint at the very end of ReadFromDevices shows the textboxes haven't thrown the error, but a breakpoint at the beginning of UpdateIndicators (before any InvokeControl calls have been made) shows that they have...

+2  A: 

It is difficult to use debugging to catch the exception as it will occur on any one of multiple PostMessage calls to the UI message pump (caused by InvokeControl & the BeginInvoke calls). Visual Studio will have a hard time breaking on the exception. This is possibly why it appears that the exception is "magically" being thrown.

Your issue lies not in your implementation of InvokeControl but in the UpdateIndicators method. It is because of the use of the With statement and the asynchronous UI thread calls like this:

With Devices
    ...
    InvokeControl(MillerCurrentIndicator, Sub(x) x.Text = .Miller1.CurrentRead.GetParsedValue.ToString)
    ... 
 End With

Because the Sub(x) code is being executed on the UI thread by posting a message on the UI thread it is extremely likely that the calling code on the current thread will have completed before the UI thread has been executed.

The problem is with the underlying implementation of the Visual Basic With statement. In essence, the compiler creates an anonymous local variable for the With statement that gets set to Nothing at the End With statement.

As an example, if you have this code:

Dim p As New Person
With p
    .Name = "James"
    .Age = 40
End With

The Visual Basic compiler turns this into:

Dim p As New Person
Dim VB$t_ref$L0 As Person = p
VB$t_ref$L0.Name = "James"
VB$t_ref$L0.Age = 40
VB$t_ref$L0 = Nothing

So, in your case, when the UI thread code is executed this anonymous local variable is now Nothing and you get your "Object reference not set to an instance of an object" exception.

Your code essentially is equivalent to this:

Dim VB$t_ref$L0 = Devices
Dim action = new Action(Sub(x) x.Text = VB$t_ref$L0.Miller1.CurrentRead.GetParsedValue.ToString);
VB$t_ref$L0 = Nothing
action(MillerCurrentIndicator);

By the time that the action is called the VB$t_ref$L0 variable is already set to Nothing and whammo!

The answer is not to use a With statement. They are bad.


That should be your answer, but there are a number of other issues in your code though that you should look at too.

Your SyncLock code is using a local locking variable which essentially renders the lock useless. So don't do this:

Private Sub UpdateIndicators()
    Dim ObjLock As New Object
    SyncLock ObjLock
    With Devices
        ...
    End With
    End SyncLock
End Sub

Do this instead:

Private ObjLock As New Object
Private Sub UpdateIndicators()
    SyncLock ObjLock
    With Devices
        ...
    End With
    End SyncLock
End Sub

All of the calls to InvokeControl in the UpdateIndicators method are making it difficult to debug your code. Reducing all of these calls to a single call should help you immensely. Try this instead:

Private Sub UpdateIndicators()
    SyncLock ObjLock
        InvokeControl(Me, AddressOf UpdateIndicators)
    End SyncLock
End Sub

Private Sub UpdateIndicators(ByVal form As ControlInvokeForm)
    With Devices
        EmergencyStopPictureBox.Toggle(Mode > RunMode.NotRunning)
        MillerCurrentIndicator.Text = .Miller1.CurrentRead.GetParsedValue.ToString
        ...
        ToggleImageList(.GateValvePosition.ImageList, .GateValvePosition.SetValue > 0)
    End With
End Sub

Obviously you need to remove the With Devices code to make these work.

There are a number of issues with the following type of code:

If .Ion.GetParsedValue > 0.01 Then
    InvokeControl(IonIndicator, Sub(x) x.Text = "OFF")
Else
    InvokeControl(IonIndicator, Sub(x) x.Text = .Ion.GetParsedValue.ToString)
End If

The value of .Ion.GetParsedValue may have changed between the condition being evaluated and the Else statement being executed. This is compounded because the condition on the If statement is evaluated on the current thread, but the Else statement is executed on the UI thread so the delay could be great. Also, if the .Ion. class is not thread-safe you are exposing yourself to potential errors.

Do this instead:

Dim parsedIonValue = .Ion.GetParsedValue
If parsedIonValue > 0.01 Then
    InvokeControl(IonIndicator, Sub(x) x.Text = "OFF")
Else
    InvokeControl(IonIndicator, Sub(x) x.Text = parsedIonValue.ToString)
End If

(This also gets rid of your With issue.)

Use AutoReset = True on your MasterTimer to automatically call Enabled = False when the event fires to avoid the (remote) possibility of race conditions.

Your code also doesn't seem to be correct in that you are using With Devices in the UpdateIndicators method, but you have a For Each loop in the ReadFromDevices method. Devices then appears to be a collection, but the code in UpdateIndicators is using the Devices object as if it were a Device object. And it is calling .SubstrateBiasVoltage on the Devices object. So I'm not sure exactly what the object Devices is actually doing.

In the ToggleImageList method you are passing the ImageList parameter is being passed ByRef but you are not changing the reference to the ImageList. It's better then to pass it in ByVal to avoid potential bugs.

Also, rather than doing this:

If dev.GetType.Equals(GetType(Miller)) Then
    Dim devAsMiller As Miller = CType(dev, Miller)
    With devAsMiller

It would be cleaner to do this:

Dim devAsMiller = TryCast(dev, Miller)
If devAsMiller IsNot Nothing Then
    With devAsMiller

I hope this doesn't seem like I'm sinking the boot in! Hopefully it is helpful.

Enigmativity
Ho-ly crap man! This was exactly what I was hoping for. I'm no computer scientist, I've just been trying to figure things out as I went and these things are exactly what I'm scared I don't understand. Things like assuming how the With statement works and being wrong. This must have taken you a while. I really, really appreciate it. I'm going to implement all you suggestions (here and elsewhere in my code). It blows my mind that you'd go through all that effort to help ignorant me. Cheers dude.
BASnappl
And, to clear up what I was doing with the Devices object: The system for which I'm writing this control program for has about 20 TwinCAT devices, a gate valve controller, and a Pinnacle power-supply. I'm sure this is a dumb way to do this, but basically, I have a class called Device which is inherited by three classes called TwinCatDevice, PinnacleDevice, and GateValveDevice. I then have a class called DeviceList which inherits List(Of Device). Every device is defined as a variable in Devices (a DeviceList object) and is also added to it. So, they can be accessed like Devices.Device or
BASnappl
(continued) I can loop through the Devices object to access the devices.
BASnappl
A follow-up question: You suggest calling InvokeControl only once on my main form to do all of the updates. In your suggested code, you have InvokeControl(Me, AddressOf UpdateIndicators) and the UpdateIndicators looks like: Private Sub UpdateIndicators(ByVal form As ControlInvokeForm). Did you mean to have that "ByVal form As ControlInvokeForm" part there? If so, what's it for and what's the ControlInvokeForm class supposed to be? Thanks again!
BASnappl
Much better. Everything seems to be working now! Thanks a lot Enigmativity.
BASnappl
@`BASnappl` - Yes, it did take a while to go thru everything, but I do like to be thorough when answering a question. At the end of the day, though, I learn as much answering questions as I hope you do in getting the answer. The `InvokeControl(Me, AddressOf UpdateIndicators)` requires the method `Private Sub UpdateIndicators(ByVal form As ControlInvokeForm)` for the call to work. It's there to allow the `BeginInvoke` to be called on the form. `ControlInvokeForm` was the name of the form I used in my testing of the answer.
Enigmativity