Producer Consumer in VB

I am writing an network application.  I create many threads to go off and do stuff.  I need them to store data in a datatable that is bound to a datagridview.  

I am worried about being threadsafe.  

I've done a lot of googling for ideas and examples.  What I have come up with is the Producer-Consumer model with a threadsafe queue.  

Each Producer thread will store data into a threadsafe queue.  

A single consumer thread will pick records out of the queue (when available) and put them into the datatable that is bound to the datagridview.  

I in am a bit over my head here.  I am finding threading tough enough to learn (and just scraping by)

I've seen examples that are very different to each other, often over complicated and I am finding it tough to understand them and work out which way to do things.  Some are dependent upon .net features that are in newer versions of .net than I am targeting.  

Could someone please help me with a sample (with explanation) of how to do this.  

I have some ideas about how to write the producer (they could be wrong), but I don't know where to start with the consumer.  

There will be many producers, but a single consumer.  I am targeting .net 4.0 and using visual studio 2010 so that I can still work with some Windows XP machines.  

(yes I know that the XP machines should be retired, but they are separated from the internet and locked down a lot)

Thanks in advance
LVL 5
JohnAsked:
Who is Participating?
 
it_saigeConnect With a Mentor DeveloperCommented:
The thread sleep is unneeded; it's sole purpose is to simulate a long running task...

As for event driven, you don't have to use an event and it is perfectly reasonable to use a timer, the event is used so that you can alert the consumer that it has work to complete...  The consumer will continue to consume queued items until there are none and then it will go back to sleep; the reason you did not see that here is because of the Reset.WaitOne(), in order to emulate the behaviour you are expecting we would move Reset.WaitOne outside of the loop it is in (you would still want it inside the cancellation loop); e.g. -
Imports System.Collections.Concurrent
Imports System.Threading

Module Module1
	ReadOnly [Messages] As Messages = New Messages()
	ReadOnly Reset As AutoResetEvent = New AutoResetEvent(True)

	Sub Main()
		Dim source As CancellationTokenSource = New CancellationTokenSource()
		Dim token As CancellationToken = source.Token
		AddHandler Messages.Arrived, AddressOf OnArrived

		Task.Factory.StartNew(Sub()
								  Console.WriteLine("Producer has started...")
								  For i As Integer = 0 To 10
									  Console.WriteLine("Creating message for {0}", i)
									  Messages.Enqueue(String.Format("Message{0}", i))
									  Thread.Sleep(100)
								  Next
								  source.Cancel()
								  Reset.Set()
							  End Sub)

		Task.Factory.StartNew(Sub()
								  Dim result As String = String.Empty
								  Console.WriteLine("Consumer has started...")
								  Thread.Sleep(500)
								  While (Not token.IsCancellationRequested)
									  While (Messages.Count > 0)
										  If (Messages.TryDequeue(result)) Then
											  Console.WriteLine("Dequeued - {0}", result)
										  End If
									  End While
									  Reset.WaitOne()
								  End While
							  End Sub, token) _
		.ContinueWith(Sub()
						  Dim result As String = String.Empty
						  While (Messages.Count > 0)
							  If (Messages.TryDequeue(result)) Then
								  Console.WriteLine("Dequeued - {0}", result)
							  End If
						  End While
						  Console.WriteLine("Finished dequeuing all messages...")
						  Console.WriteLine("Press any key to exit...")
					  End Sub)

		Console.ReadKey()
		RemoveHandler Messages.Arrived, AddressOf OnArrived
	End Sub

	Sub OnArrived(sender As Object, e As EventArgs)
		Reset.Set()
	End Sub
End Module

Class Messages
	Inherits ConcurrentQueue(Of String)

	Private _arrived As List(Of EventHandler(Of EventArgs)) = New List(Of EventHandler(Of EventArgs))
	Public Custom Event Arrived As EventHandler(Of EventArgs)
		AddHandler(value As EventHandler(Of EventArgs))
			_arrived.Add(value)
		End AddHandler
		RemoveHandler(value As EventHandler(Of EventArgs))
			_arrived.Remove(value)
		End RemoveHandler
		RaiseEvent(sender As Object, e As EventArgs)
			For Each handler As EventHandler(Of EventArgs) In _arrived
				Try
					handler(sender, e)
				Catch ex As Exception

				End Try
			Next
		End RaiseEvent
	End Event

	Public Sub New()
		MyBase.New()
	End Sub

	Public Sub New(source As IEnumerable(Of String))
		MyBase.New(source)
	End Sub

	Public Overloads Sub Enqueue(item As String)
		MyBase.Enqueue(item)
		RaiseEvent Arrived(Me, EventArgs.Empty)
	End Sub
End Class

Open in new window

Which now produces something like -Capture.PNGBy way of comparison, if we remove the Thread.Sleep(100) line, this is the results -Capture.PNG
Be wary of using too many threads, if you need to do this then it is safer to use a ThreadPool to consume QueueUserWorkItems or Tasks.

-saige-
0
 
it_saigeDeveloperCommented:
Here is an extremely simple implementation that uses a ConcurrentQueue which is thread-safe:
Imports System.Collections.Concurrent
Imports System.Threading

Module Module1
	ReadOnly [Messages] As Messages = New Messages()
	ReadOnly Reset As AutoResetEvent = New AutoResetEvent(True)

	Sub Main()
		Dim source As CancellationTokenSource = New CancellationTokenSource()
		Dim token As CancellationToken = source.Token
		AddHandler Messages.Arrived, AddressOf OnArrived

		Task.Factory.StartNew(Sub()
								  Console.WriteLine("Producer has started...")
								  For i As Integer = 0 To 10
									  Console.WriteLine("Creating message for {0}", i)
									  Messages.Enqueue(String.Format("Message{0}", i))
									  Thread.Sleep(500)
								  Next
								  source.Cancel()
								  Reset.Set()
							  End Sub)

		Task.Factory.StartNew(Sub()
								  Dim result As String = String.Empty
								  Console.WriteLine("Consumer has started...")
								  While (Not token.IsCancellationRequested)
									  While (Messages.Count > 0)
										  If (Messages.TryDequeue(result)) Then
											  Console.WriteLine("Dequeued - {0}", result)
											  Reset.WaitOne()
										  End If
									  End While
								  End While
							  End Sub, token) _
		.ContinueWith(Sub()
						  Dim result As String = String.Empty
						  While (Messages.Count > 0)
							  If (Messages.TryDequeue(result)) Then
								  Console.WriteLine("Dequeued - {0}", result)
							  End If
						  End While
						  Console.WriteLine("Finished dequeuing all messages...")
						  Console.WriteLine("Press any key to exit...")
					  End Sub)

		Console.ReadKey()
		RemoveHandler Messages.Arrived, AddressOf OnArrived
	End Sub

	Sub OnArrived(sender As Object, e As EventArgs)
		Reset.Set()
	End Sub
End Module

Class Messages
	Inherits ConcurrentQueue(Of String)

	Private _arrived As List(Of EventHandler(Of EventArgs)) = New List(Of EventHandler(Of EventArgs))
	Public Custom Event Arrived As EventHandler(Of EventArgs)
		AddHandler(value As EventHandler(Of EventArgs))
			_arrived.Add(value)
		End AddHandler
		RemoveHandler(value As EventHandler(Of EventArgs))
			_arrived.Remove(value)
		End RemoveHandler
		RaiseEvent(sender As Object, e As EventArgs)
			For Each handler As EventHandler(Of EventArgs) In _arrived
				Try
					handler(sender, e)
				Catch ex As Exception

				End Try
			Next
		End RaiseEvent
	End Event

	Public Sub New()
		MyBase.New()
	End Sub

	Public Sub New(source As IEnumerable(Of String))
		MyBase.New(source)
	End Sub

	Public Overloads Sub Enqueue(item As String)
		MyBase.Enqueue(item)
		RaiseEvent Arrived(Me, EventArgs.Empty)
	End Sub
End Class

Open in new window


Which produces the following output -Capture.PNG
-saige-
0
 
JohnAuthor Commented:
Wow, thanks for the hard work.  I'll check it out this afternoon and see how well I get on with it, I may come back asking a couple of questions...

Thanks again!
0
Cloud Class® Course: Microsoft Azure 2017

Azure has a changed a lot since it was originally introduce by adding new services and features. Do you know everything you need to about Azure? This course will teach you about the Azure App Service, monitoring and application insights, DevOps, and Team Services.

 
JohnAuthor Commented:
Hi IT_Saige

I tried this and it looks good.  

In testing, I changed the line

Thread.Sleep(500)

Open in new window



to

Thread.Sleep(100)

Open in new window

 

So it would go quicker as I am expecting a lot of threads to run in my app and it performed well.  

My situation is different to your example though, because I will have many several producers.  

When this runs, it is one in, one out and so the queue never grows.  

With several producers, I don't know what to expect.  So I made another change.  

After the line

Console.WriteLine("Consumer has started...")

Open in new window


I put

thread.sleep(500)

Open in new window


I did this so that several would enter the queue before any are dequeued.  In my app, many could be added to the queue in a very short time.  

When I ran it, I could see the queue grow, but then it didn't get all of the items from the queue.  It looked as if the program had hung when in actual fact I believe the event to dequeue an item had run and there were still items in the queue, but no more events were raised to dequeue.  

Not all items de-queue
It seems to be a problem with the event handler.  

Is event driven like this the best way to do it?  

Could I just start a thread and check back say, every 200 milliseconds to see if there are any items in the queue?  Would this be heavy on resources?n  Are there any reasons that this is bad?

Perhaps, keeping with your example, when an event is raised I dequeue all items instead of just 1 ?  I'm not sure  how to do this.  

As I said, this is all new to me and I'm don't know where to go with it.  

Thankyou again for your help.  

I am going to fire off a new thread for every item in a list and I want to put them in the queue (many producers).  I need another [single] thread to de-queue these items and put them in a datatable that is bound to a datagridview.
0
 
JohnAuthor Commented:
I added a couple more producer threads in there, kept the Thread.sleep commands and moved the reset.waitone command as you suggested.  

It works well thanks.  

Results from working sample
I was going to try it with a timer, but this is great for what I am doing, so I decided against it.  

    I still don't understand it all, but it will allow me to further the project and I can study this at a later stage.  

    Thanks again for your help.  

    John
    0
     
    JohnAuthor Commented:
    Thanks again.  I'll post my working code below in case others come to this thread...
    0
     
    JohnAuthor Commented:
    There are slight differences tot he code above.  This works on VS 2010

    Imports System.Collections.Concurrent
    Imports System.Threading
    Imports System.Threading.Tasks
    
    Module Module1
        ReadOnly [Messages] As Messages = New Messages()
        ReadOnly Reset As AutoResetEvent = New AutoResetEvent(True)
    
        Sub Main()
            Dim source As CancellationTokenSource = New CancellationTokenSource()
            Dim token As CancellationToken = source.Token
            AddHandler Messages.Arrived, AddressOf OnArrived
    
            Task.Factory.StartNew(Sub()
                                      Console.WriteLine("Producer 1 has started...")
                                      For i As Integer = 0 To 10
                                          Console.WriteLine("Creating message for {0}", i)
                                          Messages.Enqueue(String.Format("Message{0}", i))
                                          Thread.Sleep(200)
                                      Next
                                      source.Cancel()
                                      Reset.Set()
                                  End Sub)
    
            Task.Factory.StartNew(Sub()
                                      Console.WriteLine("Producer 2 has started...")
                                      For i As Integer = 100 To 110
                                          Console.WriteLine("Creating message for {0}", i)
                                          Messages.Enqueue(String.Format("Message{0}", i))
                                          Thread.Sleep(200)
                                      Next
                                      source.Cancel()
                                      Reset.Set()
                                  End Sub)
    
            Task.Factory.StartNew(Sub()
                                      Console.WriteLine("Producer 3 has started...")
                                      For i As Integer = 200 To 210
                                          Console.WriteLine("Creating message for {0}", i)
                                          Messages.Enqueue(String.Format("Message{0}", i))
                                          Thread.Sleep(200)
                                      Next
                                      source.Cancel()
                                      Reset.Set()
                                  End Sub)
    
    
    
            Task.Factory.StartNew(Sub()
                                      Dim result As String = String.Empty
                                      Console.WriteLine("Consumer has started...")
                                      Thread.Sleep(500)
                                      While (Not token.IsCancellationRequested)
                                          While (Messages.Count > 0)
                                              If (Messages.TryDequeue(result)) Then
                                                  Console.WriteLine("Dequeued          - {0}", result)
                                              End If
                                          End While
                                          Reset.WaitOne()
                                      End While
                                  End Sub, token) _
            .ContinueWith(Sub()
                              Dim result As String = String.Empty
                              While (Messages.Count > 0)
                                  If (Messages.TryDequeue(result)) Then
                                      Console.WriteLine("Dequeued - {0}", result)
                                  End If
                              End While
                              Console.WriteLine("Finished dequeuing all messages...")
                              Console.WriteLine("Press any key to exit...")
                          End Sub)
    
            Console.ReadKey()
            RemoveHandler Messages.Arrived, AddressOf OnArrived
        End Sub
    
        Sub OnArrived(sender As Object, e As EventArgs)
            Reset.Set()
        End Sub
    End Module
    
    Class Messages
        Inherits ConcurrentQueue(Of String)
    
        Private _arrived As List(Of EventHandler(Of EventArgs)) = New List(Of EventHandler(Of EventArgs))
        Public Custom Event Arrived As EventHandler(Of EventArgs)
            AddHandler(value As EventHandler(Of EventArgs))
                _arrived.Add(value)
            End AddHandler
            RemoveHandler(value As EventHandler(Of EventArgs))
                _arrived.Remove(value)
            End RemoveHandler
            RaiseEvent(sender As Object, e As EventArgs)
                For Each handler As EventHandler(Of EventArgs) In _arrived
                    Try
                        handler(sender, e)
                    Catch ex As Exception
    
                    End Try
                Next
            End RaiseEvent
        End Event
    
        Public Sub New()
            MyBase.New()
        End Sub
    
        Public Sub New(source As IEnumerable(Of String))
            MyBase.New(source)
        End Sub
    
        Public Overloads Sub Enqueue(item As String)
            MyBase.Enqueue(item)
            RaiseEvent Arrived(Me, EventArgs.Empty)
        End Sub
    End Class
    

    Open in new window

    0
     
    JohnAuthor Commented:
    I spoke too soon.  I have one further issue.  

    My producers may stop producing and continue later.  To test this, I put a 2 second delay on the thread for producer 3

            Task.Factory.StartNew(Sub()
                                      Thread.Sleep(2000)
                                      Console.WriteLine("Producer 3 has started...")
                                      For i As Integer = 200 To 210
                                          Console.WriteLine("Creating message for {0}", i)
                                          Messages.Enqueue(String.Format("Message{0}", i))
                                          Thread.Sleep(200)
                                      Next
                                      source.Cancel()
                                      Reset.Set()
                                  End Sub)
    

    Open in new window


    and I get the following result.  

    Error when the consumer thread stops
    The items queued by producer 3 don't get dequeued.  I don't understand why they are not triggering the event and calling the thread that dequeues them.  

    I don't understand this part of the code.  Can you show me how to keep the consumer listening and possibly how to signal it to stop?

    Thanks

    John
    0
     
    it_saigeDeveloperCommented:
    Certainly.  Just give me a bit, got a priority at work right now.

    -saige-
    0
     
    it_saigeDeveloperCommented:
    As I had stated, the Thread.Sleep methods are really just to simulate a long running process.  They should not be used in your production code.  That being said, here I created 5 producers, each producer has it's own cancellation token source.  The cancellation token source is what is used to cancel each task, you only need link the token to the task with the overloaded TaskFactory.StartNew constructor.  As each Producer finishes it's run, it can be removed from the list; New producers can be added at any time.  While the consumer is not cancelled, it will continue to run.
    Imports System.Collections.Concurrent
    Imports System.Threading
    
    Module Module1
    	ReadOnly Producers As Dictionary(Of CancellationTokenSource, Task) = New Dictionary(Of CancellationTokenSource, Task)()
    	ReadOnly [Messages] As Messages = New Messages()
    	ReadOnly Reset As AutoResetEvent = New AutoResetEvent(True)
    
    	Sub Main()
    		Dim consumerTokenSource = New CancellationTokenSource()
    		AddHandler Messages.Arrived, AddressOf OnArrived
    
    		For i = 0 To 4
    			Dim local = i
    			Dim source As CancellationTokenSource = New CancellationTokenSource()
    			Producers.Add(source, Task.Factory.StartNew(Sub()
    															Dim num = local
    															Dim x As Integer = 0
    															Console.WriteLine("Producer {0} has started...", num)
    															While Not source.Token.IsCancellationRequested
    																Console.WriteLine("Producer {0} - Creating message for {1}", num, x)
    																Messages.Enqueue(String.Format("Message{0} from Producer{1}", x, num))
    																x = x + 1
    																Thread.Sleep(num * 10)
    															End While
    														End Sub, source.Token))
    		Next
    
    		Task.Factory.StartNew(Sub()
    								  Dim result As String = String.Empty
    								  Console.WriteLine("Consumer has started...")
    								  While Not consumerTokenSource.Token.IsCancellationRequested
    									  While Messages.Count > 0
    										  If Messages.TryDequeue(result) Then
    											  Console.WriteLine("Dequeued - {0}", result)
    										  End If
    									  End While
    									  Reset.WaitOne()
    								  End While
    							  End Sub, consumerTokenSource.Token) _
    		.ContinueWith(Sub()
    						  Dim result As String = String.Empty
    						  While Messages.Count > 0
    							  If Messages.TryDequeue(result) Then
    								  Console.WriteLine("Dequeued - {0}", result)
    							  End If
    						  End While
    						  Console.WriteLine("Finished dequeuing all messages...")
    						  Console.WriteLine("Press any key to exit...")
    					  End Sub)
    
    		For i = 0 To 5000
    			If i Mod 1000 = 0 Then
    				Dim pair = Producers.FirstOrDefault()
    				If pair.Key IsNot Nothing Then
    					pair.Key.Cancel()
    					While Not pair.Value.IsCompleted
    					End While
    					Producers.Remove(pair.Key)
    				End If
    				Thread.Sleep(i / 1000)
    			End If
    		Next
    		consumerTokenSource.Cancel()
    		Console.ReadKey()
    		RemoveHandler Messages.Arrived, AddressOf OnArrived
    	End Sub
    
    	Sub OnArrived(sender As Object, e As EventArgs)
    		Reset.Set()
    	End Sub
    End Module
    
    Class Messages
    	Inherits ConcurrentQueue(Of String)
    
    	Private _arrived As List(Of EventHandler(Of EventArgs)) = New List(Of EventHandler(Of EventArgs))
    	Public Custom Event Arrived As EventHandler(Of EventArgs)
    		AddHandler(value As EventHandler(Of EventArgs))
    			_arrived.Add(value)
    		End AddHandler
    		RemoveHandler(value As EventHandler(Of EventArgs))
    			_arrived.Remove(value)
    		End RemoveHandler
    		RaiseEvent(sender As Object, e As EventArgs)
    			For Each handler As EventHandler(Of EventArgs) In _arrived
    				Try
    					handler(sender, e)
    				Catch ex As Exception
    
    				End Try
    			Next
    		End RaiseEvent
    	End Event
    
    	Public Sub New()
    		MyBase.New()
    	End Sub
    
    	Public Sub New(source As IEnumerable(Of String))
    		MyBase.New(source)
    	End Sub
    
    	Public Overloads Sub Enqueue(item As String)
    		MyBase.Enqueue(item)
    		RaiseEvent Arrived(Me, EventArgs.Empty)
    	End Sub
    End Class
    

    Open in new window

    Realistically, if you did not even want to use a reset event, you could just let the consumer run, cancelling producers as they end their runs and spawning new producers.  When the consumer is cancelled, it in turn cancels any remaining producers; e.g. -
    Imports System.Collections.Concurrent
    Imports System.Threading
    
    Module Module1
    	ReadOnly Producers As Dictionary(Of CancellationTokenSource, Task) = New Dictionary(Of CancellationTokenSource, Task)()
    	ReadOnly [Messages] As Messages = New Messages()
    
    	Sub Main()
    		Dim consumerTokenSource = New CancellationTokenSource()
    
    		For i = 0 To 4
    			Dim local = i
    			Dim source As CancellationTokenSource = New CancellationTokenSource()
    			Producers.Add(source, Task.Factory.StartNew(Sub()
    															Dim num = local
    															Dim x As Integer = 0
    															Console.WriteLine("Producer {0} has started...", num)
    															While Not source.Token.IsCancellationRequested
    																Console.WriteLine("Producer {0} - Creating message for {1}", num, x)
    																Messages.Enqueue(String.Format("Message{0} from Producer{1}", x, num))
    																x = x + 1
    																Thread.Sleep(num * 10)
    															End While
    														End Sub, source.Token))
    		Next
    
    		Task.Factory.StartNew(Sub()
    								  Dim result As String = String.Empty
    								  Console.WriteLine("Consumer has started...")
    								  While Not consumerTokenSource.Token.IsCancellationRequested
    									  While Messages.Count > 0
    										  If Messages.TryDequeue(result) Then
    											  Console.WriteLine("Dequeued - {0}", result)
    										  End If
    									  End While
    								  End While
    
    								  For Each producer In Producers.Reverse().Cast(Of KeyValuePair(Of CancellationTokenSource, Task))
    									  If producer.Key IsNot Nothing Then
    										  producer.Key.Cancel()
    										  While Not producer.Value.IsCompleted
    										  End While
    										  Producers.Remove(producer.Key)
    									  End If
    								  Next
    							  End Sub, consumerTokenSource.Token) _
    		.ContinueWith(Sub()
    						  Dim result As String = String.Empty
    						  While Messages.Count > 0
    							  If Messages.TryDequeue(result) Then
    								  Console.WriteLine("Dequeued - {0}", result)
    							  End If
    						  End While
    						  Console.WriteLine("Finished dequeuing all messages...")
    						  Console.WriteLine("Press any key to exit...")
    					  End Sub)
    
    		For i = 0 To 5000
    			If i Mod 1000 = 0 Then
    				Dim producer = Producers.FirstOrDefault()
    				If producer.Key IsNot Nothing Then
    					producer.Key.Cancel()
    					While Not producer.Value.IsCompleted
    					End While
    					Producers.Remove(producer.Key)
    				End If
    				Thread.Sleep(i / 20)
    			End If
    		Next
    
    		For i = 5 To 9
    			Dim local = i
    			Dim source As CancellationTokenSource = New CancellationTokenSource()
    			Producers.Add(source, Task.Factory.StartNew(Sub()
    															Dim num = local
    															Dim x As Integer = 0
    															Console.WriteLine("Producer {0} has started...", num)
    															While Not source.Token.IsCancellationRequested
    																Console.WriteLine("Producer {0} - Creating message for {1}", num, x)
    																Messages.Enqueue(String.Format("Message{0} from Producer{1}", x, num))
    																x = x + 1
    																Thread.Sleep(num * 2)
    															End While
    														End Sub, source.Token))
    		Next
    		Thread.Sleep(500)
    		consumerTokenSource.Cancel()
    		Console.ReadKey()
    	End Sub
    End Module
    
    Class Messages
    	Inherits ConcurrentQueue(Of String)
    
    	Private _arrived As List(Of EventHandler(Of EventArgs)) = New List(Of EventHandler(Of EventArgs))
    	Public Custom Event Arrived As EventHandler(Of EventArgs)
    		AddHandler(value As EventHandler(Of EventArgs))
    			_arrived.Add(value)
    		End AddHandler
    		RemoveHandler(value As EventHandler(Of EventArgs))
    			_arrived.Remove(value)
    		End RemoveHandler
    		RaiseEvent(sender As Object, e As EventArgs)
    			For Each handler As EventHandler(Of EventArgs) In _arrived
    				Try
    					handler(sender, e)
    				Catch ex As Exception
    
    				End Try
    			Next
    		End RaiseEvent
    	End Event
    
    	Public Sub New()
    		MyBase.New()
    	End Sub
    
    	Public Sub New(source As IEnumerable(Of String))
    		MyBase.New(source)
    	End Sub
    
    	Public Overloads Sub Enqueue(item As String)
    		MyBase.Enqueue(item)
    		RaiseEvent Arrived(Me, EventArgs.Empty)
    	End Sub
    End Class
    

    Open in new window


    -saige-
    0
     
    JohnAuthor Commented:
    Wow.  Thanks a lot.  I have learnt loads from this.  There seems to be so many 'versions' of multithreading support depending upon the version of .net and the version of visual studio.  I daresay I have been mixing up non-compatible code and wondering why I can't get it working.  

    I can use this as a template/pattern now without having to learn all the different stuff about tasks, threads, threadpools, TPL etc.  

    Thanks again

    John
    0
    Question has a verified solution.

    Are you are experiencing a similar issue? Get a personalized answer when you ask a related question.

    Have a better answer? Share it in a comment.

    All Courses

    From novice to tech pro — start learning today.