3

I'm working on an application to be able to monitor production information on a 3x3 (so 9 screen) video wall. One of the screen sets that I'm working on right now retrieves information and then formats for display on the screen. It takes about 2 seconds to retrieve and format this data (just a rough guess, not actually measured). Because it does 9 screen, one after the other, there is a very noticeable amount of time to switch to this screen set. The PC driving this video wall has 8 processing cores, so while one processor is chugging away doing all this work, there's plenty of processing power just sitting idle.

My first thought is that I need to use multi-threading. Unfortunately, I'm very new to this concept. I really have only used it one other time. I tried creating a BackgroundWorker and having the DoWork routine generate my UI. Unfortunately, it crashes the first time I try to create a UI element (Dim grLine as New Grid). I did manage to get around that by having a dummy DoWork routine and generating all my UI in the RunWorkerCompleted routine. This does allow my blank window to show up immediately, but none of the UI I'm generating shows up until it has all been rendered.

Here's a very cleaned up version of what I'm trying to do:

For i As Integer = 1 to 9
    Dim win As New MyCustomWindow
    win.DisplayScreen = i   ' This function in MyCustomWindow sets the Bounds
    win.MyShow({1, 2})  ' Sample args
    Globals.VideoWall.Windows(i) = win
Next

The MyCustomWindow Class:

Class MyCustomWindow

    Public Sub MyShow(a() as Integer)
        Me.Show()  ' Has a "Loading..." TextBlock
        Dim bw as New ComponentModel.BackgroundWorker
        AddHandler bw.DoWork, AddressOf Generate_UI_DoWork
        AddHandler bw.RunWorkerCompleted, AddressOf Generate_UI_Complete
        bw.RunWorkerAsync(a) 
    End Sub

    Private Sub Generate_UI_DoWork((sender As Object, e As ComponentModel.DoWorkEventArgs)
        ' Pass our arguments to the Complete routine.
        e.Result = e.Argument
    End Sub

    Private Sub Generate_OpsMarket_Complete(sender As Object, e As ComponentModel.RunWorkerCompletedEventArgs)
        Dim IDs() as Integer
        IDs = e.Result

        Dim grLine As New Grid  ' We crash here if this code is in DoWork instead of RunWorkerCompleted
        For Each id As Integer In IDs
            grLine.RowDefinitions.Add(New RowDefinition)
            Dim txt as New TextBlock  ' For a header
            grLine.Children.Add(txt)

            grLine.RowDefinitions.Add(New RowDefinition)
            Dim MyCtrl as New MyCustomControl()
            MyCustomControl.GetData(id)
            grLine.Children.Add(MyCtrl.MyGrid)

            txt.Text = MyCtrl.Header
        Next

        txLoading.Visibility = Visibility.Hidden
        grRoot.Children.Add(grLine)
    End Sub
End Class

I tried to leave enough detail in the code so hopefully it'll be evident what I'm trying to accomplish, but keeping it small enough to not be overwhelming.

Edited to add:
The bulk of the work happens in MyCustomControl.GetData(id) ... that Sub downloads the data from a web server (in JSON format), parses the JSON, then generates the rows (3) and columns (30 or 31, depending on the month) for the Grid and fills in the data it received from the web server.

StarDestroyer
  • 63
  • 1
  • 8
  • Welcome to the world of multithreading! A couple things. You want to take full advantage of 8 logical cores, and the way to do that is to use 8 or more threads to do a bunch of work which can be separated into discrete units of work. For instance, you want to calculate the sqrt of all integers from 1 to 1 million. These are distinct units of work which can each be performed independently of each other, so you would let the OS decide how to divvy up the work. A single background worker will still run in just a single thread (single core) so not much benefit there. cont'd... – djv Nov 11 '16 at 19:48
  • The benefit of a backgroundworker in a forms application is to allow work to be done on a thread other that the UI thread. This will allow the UI to continue to refresh and respond to inputs without clogging it up by processing non-UI stuff. You probably do want to do your computations on a non-UI thread, as it's a very important part of UI programming in .NET. But in order for these computations to be done quickly, (by leveraging 8 logical cores) you would want to multi-thread the work being done in the background worker. If this makes sense, I can write up a simple example to get you going. – djv Nov 11 '16 at 19:52
  • It's not so much that I need to keep all 8 cores busy, it's that the way I'm doing things now is taking much longer than it should need to. I'm generating the UI for 9 different screens, one after the other, and each screen takes about 2 seconds. In my original code, everything in the example above that's inside `Generate_OpsMarket_Complete` was in `MyShow`... by moving the bulk of the code to a BackgroundWorker the hope was that the UI for all 9 screen could generate in parallel instead of sequentially. – StarDestroyer Nov 11 '16 at 20:23
  • The time consuming code to generate the UI should be stripped of anything related to the UI, and just handle data. Then you can run it on a background worker. The background worker should return the data to `Generate_UI_Complete` which can be used to generate the UI. – djv Nov 11 '16 at 21:33

2 Answers2

2

Your current BackgroundWorker implementation give you no benefits, as you noticed by your self.
Your main problem is that your current code/logic tightly depend on UI controls. Which strict you with UI thread. Because creating/updating UI controls can be done only on UI thread - that's why you got Exception when trying create/update UI controls in BackgroundWorker.DoWork handler.

Suggest to separate logic which retrieve monitoring information in parallel and then you can create/update control on UI with already formatted data.

This is raw/pseudo example

Class DataService
    Public Function GetData(ids As Integer()) As YourData
        ' Get data from web service
        ' Validate and Format it to YourData type or List(Of Yourdata)
        Return data
    End Function
End Class

Class MyCustomWindow

    Public Sub MyShow(a() as Integer)
        Me.Show()  ' Has a "Loading..." TextBlock
        Dim bw as New ComponentModel.BackgroundWorker
        AddHandler bw.DoWork, AddressOf Generate_UI_DoWork
        AddHandler bw.RunWorkerCompleted, AddressOf Generate_UI_Complete
        bw.RunWorkerAsync(a) 
    End Sub

    Private Sub Generate_UI_DoWork((sender As Object, e As ComponentModel.DoWorkEventArgs)
        Dim service As New DataService()
        Dim data = service.GetData(e.Argument)
        e.Result = data
    End Sub

    Private Sub Generate_OpsMarket_Complete(sender As Object, 
                                        e As ComponentModel.RunWorkerCompletedEventArgs)

    Dim data As Yourdata = DirectCast(e.Result, YourData)      
    'Update UI controls with already formatted data

    End Sub
End Class

Update on Sub downloads the data from a web server
In this case you don't need multi-threading/parallel at all. Because you loading time is waiting time for response. In this case my advice will be using async/await approach, which will release UI thread(make it responsive) while you waiting for response from web-service.

Class DataService
    Public Async Function GetDataAsync(ids As Integer()) As Task(Of YourData)
        Using client As HttpClient = New HttpClient()
            Dim response As HttpResponseMessage = Await client.GetAsync(yourUrl)
            If response.IsSuccessStatusCode = True Then
                Return Await response.Content.ReadAsAsync<YourData>()
            End If
        End Using
    End Function
End Class

Then in the view you don't need BackgroundWorker

Class MyCustomWindow

    Public Async Sub MyShow(a() as Integer) As Task
        Me.Show()  ' Has a "Loading..." TextBlock

        Dim service As New DataService()
        Dim data As YourData = Await service.GetDataAsync(a)
        UpdateControlsWithData(data)
    End Sub

    Private Sub UpdateControlsWithData(data As YourData)
        ' Update controls with received data
    End Sub
End Class
Fabio
  • 31,528
  • 4
  • 33
  • 72
  • I think I may be starting to understand this better. So it looks like the `DoWork` Sub runs as a separate thread and passes its result to the `RunWorkerCompleted` Sub on the UI thread. So all I've really done with my pseudo code above is shift when the blocking happens. My hope was to be able to generate the UI on a separate thread then pass the final `grLine` object to the UI thread to be inserted into the Window. – StarDestroyer Nov 11 '16 at 20:51
  • `generate the UI on a separate thread` no. You do the time/resource consuming operations on a separate thread, then pass enough information back to the UI thread to generate the UI. You can even do these time consuming operations in parallel in most cases, where you could leverage all your cores. – djv Nov 11 '16 at 21:01
  • I may be barking up the wrong tree altogether, then. I have a slew of `Debug.Print` statements so I just added some timing to them. It takes anywhere from 30 to 70 milliseconds to download and parse the JSON. Then each 'row' (which is actually 3 `Grid.RowDefinitions`) takes about 30ms to add. To run this whole process twice takes about 300ms. There's then a break of about 400ms before the next screen starts to load. (Continued) – StarDestroyer Nov 11 '16 at 21:35
  • I'm assuming this gap between when I add the `grLine` to `grRoot` and when the next window starts to load is when the grid is rendering. And if I can't generate/render the UI in a different thread, then all I can cut out by using a separate thread is 70ms (or less) that it takes to download and parse my JSON data. – StarDestroyer Nov 11 '16 at 21:37
  • 30ms is a very long time to add a row. If it's a custom control, then it's possible it's doing something other than drawing lines and letters on the screen (calculations / tcpip / anything?) For example, I can add 10k rows to a DataGridView in 5ms. You would need to separate all non-UI related stuff out including in that control if applicable. – djv Nov 11 '16 at 22:05
1

For what it's worth, here are a few examples which do 9 x 500ms of data operations, then simple UI operation.

The first example runs on a background thread but the main loop runs in sequence. The messagebox at the end shows that it takes around 4500 ms because of the 500 ms thread sleep is run sequentially. Notice how the DoWork method has nothing to do with the UI. It uses two threads: the UI thread and one background worker. Since it's not doing work on the UI, the form is responsive while the background worker is working.

Private bw_single As New BackgroundWorker()

Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
    AddHandler bw_single.DoWork, AddressOf bw_single_DoWork
    AddHandler bw_single.RunWorkerCompleted, AddressOf bw_single_Complete
    bw_single.RunWorkerAsync()
End Sub

Private Sub bw_single_DoWork(sender As Object, e As DoWorkEventArgs)
    ' runs on background thread
    Dim data As New List(Of Integer)()
    Dim sw As New Stopwatch
    sw.Start()
    For i As Integer = 1 To 9
        ' simulate downloading data, etc.
        Threading.Thread.Sleep(500)
        data.Add(i)
    Next
    sw.Stop()
    e.Result = New Result(data, sw.ElapsedMilliseconds)
End Sub

Private Sub bw_single_Complete(sender As Object, e As RunWorkerCompletedEventArgs)
    RemoveHandler bw_single.DoWork, AddressOf bw_single_DoWork
    RemoveHandler bw_single.RunWorkerCompleted, AddressOf bw_single_Complete
    ' runs on UI thread
    Dim res = CType(e.Result, Result)
    Me.DataGridView1.DataSource = res.Data
    MessageBox.Show(
        String.Format("Performed on bw (single), took {0} ms, data: {1}", 
                      res.Elapsed, String.Join(", ", res.Data)))
End Sub

(This is the class which holds the result of the background worker)

Private Class Result
    Public Property Data As IEnumerable(Of Integer)
    Public Property Elapsed As Long
    Public Sub New(data As IEnumerable(Of Integer), elapsed As Long)
        Me.Data = data
        Me.Elapsed = elapsed
    End Sub
End Class

The second example runs on a background thread but the main loop runs in parallel. The messagebox at the end shows that it takes around 1000 ms ... why? Because my machine like yours has 8 logical cores but we are sleeping 9 times. So at least one core is doing two sleeps and this will gate the entire operation. Again, there is one thread for the UI, one for the background worker, but for the parallel loop, the OS will allocate CPU time from the remaining cores to each additional thread. The UI is responsive and it takes a fraction of the time of the first example to do the same thing

Private bw_multi As New BackgroundWorker()

Private Sub Button2_Click(sender As Object, e As EventArgs) Handles Button2.Click
    AddHandler bw_multi.DoWork, AddressOf bw_multi_DoWork
    AddHandler bw_multi.RunWorkerCompleted, AddressOf bw_multi_Complete
    bw_multi.RunWorkerAsync()
End Sub

Private Sub bw_multi_DoWork(sender As Object, e As DoWorkEventArgs)
    ' runs on background thread
    Dim data As New ConcurrentBag(Of Integer)()
    Dim sw As New Stopwatch
    sw.Start()
    Parallel.For(1, 9,
    Sub(i)
        data.Add(i)
        Threading.Thread.Sleep(500)
    End Sub)
    sw.Stop()
    e.Result = New Result(data, sw.ElapsedMilliseconds)
End Sub

Private Sub bw_multi_Complete(sender As Object, e As RunWorkerCompletedEventArgs)
    RemoveHandler bw_multi.DoWork, AddressOf bw_multi_DoWork
    RemoveHandler bw_multi.RunWorkerCompleted, AddressOf bw_multi_Complete
    ' runs on UI thread
    Dim res = CType(e.Result, Result)
    Me.DataGridView1.DataSource = res.Data
    MessageBox.Show(
        String.Format("Performed on bw (multi), took {0} ms, data: {1}",
                      res.Elapsed, String.Join(", ", res.Data)))
End Sub

Since the above two examples utilize background workers to do their work, they will not freeze the UI thread. The only code running on the UI is in the button click handlers and the RunWorkerCompleted handler.

Lastly, this example uses only a single UI thread. It will freeze the UI while it's running for 4500 seconds. Just so you know what to avoid...

Private Sub Button3_Click(sender As Object, e As EventArgs) Handles Button3.Click
    Dim data As New List(Of Integer)()
    Dim sw As New Stopwatch
    sw.Start()
    For i As Integer = 1 To 9
        ' simulate downloading data, etc.
        Threading.Thread.Sleep(500)
        data.Add(i)
    Next
    sw.Stop()
    Dim res = New Result(data, sw.ElapsedMilliseconds)
    Me.DataGridView1.DataSource = res.Data
    MessageBox.Show(
        String.Format("Performed on bw (single), took {0} ms, data: {1}",
                      res.Elapsed, String.Join(", ", res.Data)))
End Sub

Summarily, you should figure out how to separate the data layer from the UI. See separation of concerns and the SO question, Why is good UI design so hard for some Developers?, and this one What UI design principles like “separation of concerns” can I use to convince developers that the UI needs fixing?

Community
  • 1
  • 1
djv
  • 15,168
  • 7
  • 48
  • 72
  • The main difficulty I have with separating the data is that the UI is generated based on the data. Which is why I'm doing it in Code Behind rather than XAML. But, I did start the process of separating it out. In the process of doing that, I noticed that setting a `.Background`, `.BorderBrush`, and `.BorderThinkness` on each of the cells in my grid is the biggest contributor to my speed issue. Removing those three lines goes for 12.9 to 2.7 seconds to generate all 9 windows. Generating the empty UI only makes about .5s of difference. – StarDestroyer Nov 14 '16 at 18:29
  • I can't think of anything you can do about that. The fastest you can load the UI would be the sum of each UI operation's time. Maybe you can apply the borders to a template cell and duplicate the cell? Not sure about the grid class you're using. – djv Nov 14 '16 at 20:10
  • 1
    Adding two different styles (even and odd) to the "root" of my user control and assigning those styles instead of explicitly setting the `.Background`, `.BorderBursh`, and `.BorderThickness` properties made a world of difference. The whole set of screens now loads in about 3.1 seconds. Splitting the data load into the UI will help as well, but this single change has already made a huge difference. Thanks so much for all the help. – StarDestroyer Nov 14 '16 at 21:20
  • Good to know, maybe you can add some info to your question about the control class you used and how you improved the situation. – djv Nov 14 '16 at 21:25