Simple Multi-User TCP/IP Client & Server using TAP
Introduction
In today's connected world it is no surprise that many new developers want to create some kind of client-server application soon after getting the hang of writing simple Windows Forms applications. These tend to be chat clients, data sharing utilities, and simple games meant for use with a small group of friends, family, or colleagues. It seems like it should be easy enough. Classes like System.Net.Sockets.TcpClient and TcpListener allow communication with TCP/IP. Threads or BackgroundWorkers allow multiple simultaneous processing routines. There are loads of these sort of apps live which people use so often they take them for granted. Everyone seems to be doing it - how hard can it be?
It turns out that it can be quite difficult, depending on how you go about it. Interaction between the main Form's GUI thread and those background processing thread(s) and the client and listener instances can get complex in a hurry. There are many pitfalls and it's easy for the inexperienced developer to find they fall into one after another along their way. Taking numerous client connections, processing those and existing clients, and all the while keep a GUI responsive is quite tricky.
Thankfully, the majority of these issues can be eloquently handled through implementation of the Task-based Asynchronous Programming Pattern, or TAP. Through the use of Task instances and the Async/Await keywords we can vastly simplify the processes of the server for scenarios where we have a reasonable number of clients sending reasonably sized messages in reasonable intervals. But before we look at any code, perhaps we should define "reasonable".
Scale and Performance
Before beginning any kind of program meant to perform as a "server" communicating with multiple remote hosts, it is important to understand that writing a good server is hard. There are many variables to consider such as the number of clients to be supported and their connection speeds, the size and frequency of the data to be transmitted between hosts, whether or not the server needs to initiate transmissions to the remote clients, and many other possible factors. For scenarios where there will be a very large number of clients (more than a couple hundred), or where the data transmissions are extremely large (MBs) or extremely frequent (more than once a second) it will be best to utilize an existing, proven solution rather than try to write your own. The primary reason for this is that there is a lot of work to do to support such scenarios and it took teams of people to create the existing solutions. It is unlikely that one person working on their own could come up with an equally sound solution in any reasonable amount of time.
So the first step in designing a multi-user client/server application is determining if an existing framework can meet all of your application requirements. Two of the most common solutions are:
These are robust frameworks and are the recommended solutions whenever the application requirements can be fitted to one.
However, being robust frameworks there is a learning-curve to using them and some specific scaffolding which must be performed to get a project ready to utilize the framework. There is also overhead in the handling of the data transmissions in order to fit the content to the protocol used by the framework. In the case of a very simple set of application requirements, the learning-curve and scaffolding can seem like a lot of effort. In the case of a server which communicates with very light-weight, low-power clients (for example, micro-controllers instead of PCs) the message protocol overhead may require too much processing to be handled efficiently by the client. In these cases writing your own simple client/server framework may be preferable.
To summarize, you should only write your own client-server framework when one of the existing frameworks isn't suitable and when you have "reasonable" application requirements:
- Reasonable number of clients (< 500)
- Reasonable size of messages (< MB)
- Reasonable transmission frequency (< 1/second)
The numbers listed are just rough guidelines. Your specific computer hardware, network infrastructure, and code design will determine the actual limits. The design detailed in this article should generally be good for a couple hundred clients, with message sizes typically under 1KB, and a transmission frequency of one message every few seconds per client.
Message Protocol
After a client/server framework is designed and the server can accept multiple clients and they can send messages back and forth, the next most common issue is determining how to define the actual message content. Most examples of client/server communication show sending a single string value between client and server. The difficultly can then be determining how best to send multiple data values in a single transmission.
The solution is to define a message protocol to be used by your client and server applications. The message protocol is a set of rules that define how a sequence of raw byte data is to be interpreted as a message. With a single string value, the protocol is likely just a simple text encoding. With multiple values, there needs to be a way of delineating the various pieces of data within the message. It also isn't possible to be certain that a message is sent within a single transmission so there needs to be a way of knowing how many bytes make up a message.
While there is no single way to design such a message protocol, there are some commonly used procedures that can be followed. A typical message protocol might contain:
- A Start Sequence
- Message or Data Length Header
- Message Data
- A Stop Sequence (optionally)
The Start Sequence is a series of one or more bytes which always exist at the start of a new message. This indicates to the receiver that the following bytes contain header information and a message payload.
After reading the Start Sequence, which is a known fixed number of bytes, the receiver would then read the next 4 bytes and convert that to an Integer representing the length of the data (or total message).
The receiver now has enough information to know how many bytes to read before considering the message complete and beginning to look for another new message. The complete message data is read from the stream.
The optional Stop Sequence is also a series of one or more bytes, like the Start Sequence, and can be used to by the receiver to verify that the received data represents a real message.
Within the message data you can then further define other value indicators, lengths, and/or separators to combine multiple data values into a single message data blob. Alternatively, you can make use of an existing object that's already good at this job. In this example we'll use the XElement object to define our message commands in XML strings which can be readily parsed and interpreted. The protocol's message data will then simply be the text encoding of this XML string.
Example Solution
The code solution for this example is comprised of three projects:
- AsyncTcpClient
- AsyncTcpServer
- XProtocol
Create Projects and Solution
To begin, open Visual Studio (2013 or later) and create a new Windows Forms project named "AsyncTcpClient". Save the solution, renaming the solution to "AsyncTcpSample". Click File -> Add new project on the menu bar in Visual Studio and add a second Windows Forms project named "AsyncTcpServer". Again, click File -> Add new project and this time select a Class Library project and name it "XProtocol".
Add References
In the solution explorer, right click the AsyncTcpClient project and select Add -> Reference from the context menu. Select Solutions -> Projects in the left panel and then check XProtocol in the list box to add a reference to the XProtocol project. Repeat these steps to add the XProtocol reference to the AsyncTcpServer.
With these steps complete you are now ready to begin inserting the example code from this article.
XProtocol Code
This example defines the "XProtocol"; a message protocol based on XElement instances which are a convenient and powerful representation of an XML element. The XProtocol allows us to easily define any number of arbitrary messages containing multiple data properties and/or embedded data elements. This protocol design favors simplicity and ease of use over efficiency in terms of speed and size. The size of a message is inflated due to XML markup text and to all data values being represented as strings; the latter affects speed as well since all other data types require parsing of the string values. In many cases though (that is, reasonable cases where this kind of custom client/server application can be applied), these size and speed penalties have no discernible negative impact on the final operation of the programs.
The XProtocol namespace contains a single class. XMessage. The XMessage class wraps an XElement instance and provides utility methods for converting the XElement to and from a byte array, according to the rules of the protocol. The object begins with a simple class declaration containing two constants and one instance member:
Public Class XMessage
Const SOH As Byte = 1 'define a start sequence
Const EOF As Byte = 4 'define a stop sequence
Public Property Element As XElement 'declare the object to hold the actual message contents
End Class
The two constants define the protocol's start and stop bytes while the instance member provides a property to get and set the associated XElement content.
The class has a simple constructor which takes an XElement instance, allowing the construction of an XMessage directly from XML in code (more on this later).
Public Sub New(xml As XElement)
Element = xml
End Sub
In addition to the Element property, the class has only one other public instance member which is the method to convert the object instance into a byte array for transmission.
'serialize the XElement content into a byte array according to the message protocol specification
Public Function ToByteArray() As Byte()
Dim result As New List(Of Byte)
Dim data() As Byte = System.Text.Encoding.UTF8.GetBytes(Element.ToString) 'encode the XML string
result.Add(SOH) 'add the message start indicator
result.AddRange(BitConverter.GetBytes(data.Length)) 'add the data length
result.AddRange(data) 'add the message data
result.Add(EOF) 'add the message stop indicator
Return result.ToArray 'return the data array
End Function
This method creates a new List(Of Byte) and uses it to build up the byte array representation of the message instance. This includes writing the start indicator, adding the length of the encoded XML string, adding the encoded string itself, and finally adding the stop indicator.
Finally, the class contains two shared methods used during message processing. The function IsMessageComplete is used to verify that a byte sequence contains a complete message, and the function FromByteArray converts raw message byte data into an XMessage instance.
'define a method to check a series of bytes to determine if they conform to the protocol specification
Public Shared Function IsMessageComplete(data As IEnumerable(Of Byte)) As Boolean
Dim length As Integer = data.Count 'get the number of bytes
If length > 5 Then 'ensure there are enough for at least the start, stop, and length
If data(0) = SOH AndAlso data(length - 1) = EOF Then 'ensure the series begins and ends with start/stop identifiers
Dim l As Integer = BitConverter.ToInt32(data.ToArray, 1) 'interpret the data length by reading bytes 1 through 4 and converting to integer
Return (l = length - 6) 'ensure that the interpreted data length matches the number of bytes supplied
End If
End If
Return False
End Function
The IsMessageComplete method analyzes a sequence of byte data to determine if it conforms to the protocol specification. This includes checking for a minimum length (six bytes; the length of the start and stop indicators plus the data length value), checking for the start and stop indicators, and ensuring that the specified message length matches the number of supplied message data bytes.
The FromByteArray method assumes that the byte data has already been validated by IsMessageComplete and simply decodes the data bytes from the message into a string and parses a new XElement instance from that string.
'parse the XElement content from the supplied data according to the message protocol specification
Public Shared Function FromByteArray(data() As Byte) As XMessage
Return New XMessage(XElement.Parse(System.Text.Encoding.UTF8.GetString(data, 5, data.Length - 6)))
End Function
This completes the XMessage class and the XProtocol project.
AsyncTcpServer Code
The AsyncTcpServer project will require three classes:
- Form1 - The main GUI Form
- ConnectedClient - A utility object used to encapsulate the TcpClient instance and its related data and processing routines
- ConnectedClientCollection - A utility object used to facilitate storing a list of connected clients and accessing individual clients by ID
ConnectedClient Class
The first class to design in AsyncTcpServer will be the ConnectedClient since both other classes depend on it. The ConnectedClient class contains a few fields of associated objects and an event declaration used to facilitate databinding in the example's GUI interface.
Imports System.Net
Imports System.Net.Sockets
Public Class ConnectedClient
Implements System.ComponentModel.INotifyPropertyChanged
Public Event PropertyChanged(sender As Object, e As System.ComponentModel.PropertyChangedEventArgs) Implements System.ComponentModel.INotifyPropertyChanged.PropertyChanged
'store the TcpClient instance
Public ReadOnly TcpClient As TcpClient
'store a unique id for this client connection
Public ReadOnly Id As String
Public Property Task As Task
'store the data received from the remote client
Public ReadOnly Received As New List(Of Byte)
'expose the received data as a string property to facilitate databinding
Private _Text As String = String.Empty
Public ReadOnly Property Text As String
Get
'Return Received.ToString
Return _Text
End Get
End Property
End Class
This class encapsulates the TcpClient instance along with a unique ID, the Task instance handling the client's processing, and the pending data received from the remote host. The class also exposes a Text property with property change notification so that the GUI can easily display the client's received message data via databinding.
The class requires only a simple constructor to assign the associated TcpClient instance and generate the unique ID.
Public Sub New(client As TcpClient)
TcpClient = client
'craft the unique id from the remote client's IP address and the port they connected from
Id = CType(TcpClient.Client.RemoteEndPoint, IPEndPoint).ToString
End Sub
The ConnectedClient class exposes a single public method used to append newly received data bytes to the client's internal buffer, determine if a complete message has been received, and process the message if it has.
Public Sub AppendData(buffer() As Byte, read As Integer)
If read = 0 Then Exit Sub
'add the bytes read this time to the collection of bytes read so far
Received.AddRange(buffer.Take(read))
'check to see if the bytes read so far represent a complete message
If XProtocol.XMessage.IsMessageComplete(Received) Then
'if so, build a message from the byte data and then clear the byte data to prepare for the next message
Dim message As XProtocol.XMessage = XProtocol.XMessage.FromByteArray(Received.ToArray)
Received.Clear()
'read data elements from the message as appropriate
Select Case message.Element.Name
Case "TextMessage"
_Text = message.Element.@text1
RaiseEvent PropertyChanged(Me, New System.ComponentModel.PropertyChangedEventArgs("Text"))
End Select
End If
End Sub
Here we see the XMessage in action. The code analyzes the name of the XML node to determine how to process the message. In this case the code is only processing messages named "TextMessage". Once the kind of message has been determined, the associated property values (XML Attributes) can be read based on the known format of the message. This may become more clear when we look at sending a message later in the article.
Note that processing the actual message contents at this point may not be desirable in your real application. In a real application, this method may simply parse the XMessage instance and reveal that value through a "CurrentMessage" property. The actual analysis and processing of the message instance would occur in the client's processing routine. We'll see more on this later within the server's code for processing connected clients.
The remainder of the class simply involves implementing the standard object method overrides to utilize the ConnectedClient ID property.
'implement primary object method overrides based on unique id
Public Overrides Function Equals(obj As Object) As Boolean
If TypeOf obj Is ConnectedClient Then Return Id = DirectCast(obj, ConnectedClient).Id
Return MyBase.Equals(obj)
End Function
Public Overrides Function GetHashCode() As Integer
Return Id.GetHashCode
End Function
Public Overrides Function ToString() As String
Return Id
End Function
With this code complete we can now write the ConnectedClientCollection class.
ConnectedClientCollection Class
The ConnectedClientCollection class is simply an implementation of System.Collection.ObjectModel.KeyedCollection which reveals the ConnectedClient ID property as the key value for clients in the collection. This allows our code to access an individual client in the collection by ID if needed.
Public Class ConnectedClientCollection
Inherits System.Collections.ObjectModel.KeyedCollection(Of String, ConnectedClient)
Protected Overrides Function GetKeyForItem(item As ConnectedClient) As String
Return item.Id
End Function
End Class
Form1 Class
The Form1 code example contains a number of lines of code used for setting up the GUI and its controls. This article will only highlight the code involved in the actual program operation, and the complete code sample will be provided in an appendix at the end of the article.
The code of the Form1 class will depend on four system namespaces and these should be imported at the top of the code file.
Imports System.IO
Imports System.Net
Imports System.Net.Sockets
Imports System.Threading
The Form1 class contains all of the primary work to be performed by the server. Most of the work occurs in the server's StartButton.Click event handler. In order to perform it's work, the server first needs a handful of local field values:
'specify the TCP/IP Port number that the server will listen on
Private portNumber As Integer = 55001
'create the collection instance to store connected clients
Private clients As New ConnectedClientCollection
'declare a variable to hold the listener instance
Private listener As TcpListener
'declare a variable to hold the cancellation token source instance
Private tokenSource As CancellationTokenSource
'create a list to hold any processing tasks started when clients connect
Private clientTasks As New List(Of Task)
The portNumber defines the TCP port used for communication and will need to be the same value for both the client and server. The clients object is an instance of the ConnectedClientCollection and holds the list of connected client instances. The listener object is the primary TcpListener instance used by the server to accept incoming client connections. The tokenSource provides a CancellationTokenSource instance used to provide a CancellationToken instance to the individual processing tasks for each client. The clientTasks object holds a list of running client task instances for the server to wait on while shutting down and terminating clients. Note that this list isn't explicitly necessary as the required IEnumerable(Of Task) instance could be interpolated from the ConnectedClientCollection. The list is used here to simplify the presentation of the example code.
Moving on to the actual work of the class, the Click event handler for the Start Button handles the server's primary routine for accepting new clients.
Private Async Sub startButton_Click(sender As Object, e As EventArgs) Handles startButton.Click
'this example uses the button text as a state indicator for the server; your real
'application may wish to provide a local boolean or enum field to indicate the server's operational state
If startButton.Text = "Start" Then
'indicate that the server is running
startButton.Text = "Stop"
'create a new cancellation token source instance
tokenSource = New CancellationTokenSource
'create a new listener instance bound to the desired address and port
listener = New TcpListener(IPAddress.Any, portNumber)
'start the listener
listener.Start()
'begin accepting clients until the listener is closed; closing the listener while
'it is waiting for a client connection causes an ObjectDisposedException which can
'be trapped and used to exit the listening routine
While True
Try
'wait for a client
Dim socketClient As TcpClient = Await listener.AcceptTcpClientAsync
'record the new client connection
Dim client As New ConnectedClient(socketClient)
clientBindingSource.Add(client)
'begin executing an async task to process the client's data stream
client.Task = ProcessClientAsync(client, tokenSource.Token)
'store the task so that we can wait for any existing connections to close
'while performing a server shutdown
clientTasks.Add(client.Task)
Catch odex As ObjectDisposedException
'listener stopped, so server is shutting down
Exit While
End Try
End While
'since NetworkStream.ReadAsync does not honor the cancellation signal we
'must manually close all connected clients
For i As Integer = clients.Count - 1 To 0 Step -1
clients(i).TcpClient.Close()
Next
'wait for all of the clients to finish closing
Await Task.WhenAll(clientTasks)
'clean up the cancelation token
tokenSource.Dispose()
'reset the start button text, allowing the server to be started again
startButton.Text = "Start"
Else
'signal any processing of current clients to cancel (if listening)
tokenSource.Cancel()
'abort the current listening operation/prevent any new connections
listener.Stop()
End If
End Sub
By utilizing Async/Await we are able to put the click event handler's code into an infinite loop of accepting new clients. This is because the code loop will spend most of its time being suspended, waiting on a new client to connect. When the listener is closed during server shutdown, the waiting AcceptClientAsync method call will throw an exception which we can catch to then gracefully exit out of the loop and proceed to release any remaining clients and tear down the server.
Note the code comments that the manual looping and closing of existing clients is only required due to an implementation flaw in the NetworkStream.ReadAsync method. If that method honored the cancellation token, this loop would not be necessary as the clients would already have disconnected themselves when the token was signaled (see Connect Issue Report for more information).
The next major method in Form1 is the ProcessClientAsync method which is responsible for manging each connected client's data stream. This method represents the code executed by each client's task.
Private Async Function ProcessClientAsync(client As ConnectedClient, cancel As CancellationToken) As Task
Try
'begin reading from the client's data stream
Using stream As NetworkStream = client.TcpClient.GetStream
Dim buffer(client.TcpClient.ReceiveBufferSize - 1) As Byte
'loop exits when read = 0 which occurs when the client closes the socket,
'or it exits on ReadAsync exception when the connection terminates; exception type indicates termination cause
Dim read As Integer = 1
While read > 0
'wait for data to be read; depending on how you choose to read the data, the cancelation token
'may or may not be honored by the particular method implementation on the chosen stream implementation
read = Await stream.ReadAsync(buffer, 0, buffer.Length, cancel)
'process the received data; in this case the data is simply appended to a StringBuilder; any light
'work (that is, code which does not require a lot of CPU time) can be performed directly within
'the current while loop:
client.AppendData(buffer, read)
'*NOTE: A real application may require significantly more processing of the received data. If lengthy,
'CPU-bound processing is required, a secondary worker method could be started on the thread pool;
'if the processing is I/O-bound, you could continue to await calls to async methods. The following code
'demonstrates the handling of a CPU-bound processing routine (see additional comments in DoHeavyWork):
'Dim workResult As Integer = Await Task.Run(Function() DoHeavyWork(buffer, read, client))
''a real application would likely update the UI at this point, based on the workResult value (which could
''be an object containing the UI data to update).
''TO TEST: uncomment this block; comment-out client.AppendData(buffer, read) above
End While
'client gracefully closed the connection on the remote end
End Using
Catch ocex As OperationCanceledException
'the expected exception if this routines's async method calls honor signaling of the cancelation token
'*NOTE: NetworkStream.ReadAsync() will not honor the cancelation signal
Catch odex As ObjectDisposedException
'server disconnected client while reading
Catch ioex As IOException
'client terminated (remote application terminated without socket close) while reading
Finally
'ensure the client is closed - this is typically a redundant call, but in the
'case of an unhandled exception it may be necessary
client.TcpClient.Close()
'remove the client from the list of connected clients
clientBindingSource.Remove(client)
'remove the client's task from the list of running tasks
clientTasks.Remove(client.Task)
End Try
End Function
Again, thanks to Async/Await and the TAP model, we put the code routine into an infinite loop which will spend most of its time suspended awaiting more data from the remote host. It is this part of the design which requires that no single connected client sends a continuous stream of bytes. If a client were to start sending data and not stop, no other client would have a chance to be processed.
While this simple example code has all of it's processing in the call to client.AppendData(), a real application would likely call AppendData and then check to see if client contained a complete message (AppendData might even be a method which returns either a complete message or nothing, or a status indicator which can be checked to see if the message is complete). If there was a complete message in the client, then the processing routine would consume that message, analyze it, and process it accordingly.
The note in the comments refers to applications which require a lot of time consuming processing of a message. This could include additional message data processing, storing the message data in an external repository such as a database, forwarding the message to all other clients, and other such time consuming or processor intensive work. If the server needs to perform these kinds of operations, and they are all time consuming I/O bound operations (writing to a database, or relaying messages for example) then the processing routine should continue to use Async/Await calls wherever possible. If however the processing is CPU bound (detailed additional parsing of the message data for example), then the code can execute a secondary thread on the threadpool using Task.Run.
Using Task.Run adds an additional layer of complexity and overhead to the server and should only be used if it is proven that one client can block another due to lengthy processing. Until your program actually experiences this problem, you should avoid executing additional processing in secondary tasks. If it is necessary though, the example code includes comments showing how it can be done, and there is an example "DoHeavyWork" method associated with the code comments.
'this method is a rough example of how you would implement secondary threading to handle
'client processing which requires significant CPU time
Private Function DoHeavyWork(buffer() As Byte, read As Integer, client As ConnectedClient) As Integer
'function return type is some kind of status indicator that the caller can use to determine
'if the processing was successful, or just an empty object if no return value is needed (that is,
'if you want to treat the function as a subroutine); although this sample uses Integer, you could
'use any type of your choosing
'function parameters are whatever is required for your program to process the received data
'due to the fact that AppendData will raise the notify property changed event, we need to
'ensure that the method is called from the UI thread; in your real application, the UI
'update would likely occur after this method returns, perhaps based on the result value
'returned by this method
Invoke(Sub()
client.AppendData(buffer, read)
End Sub)
'put the thread to sleep to simulate some long-running CPU-bound processing
System.Threading.Thread.Sleep(500)
Return 0
End Function
Note that the example server code does not use the DoHeavyWork method by default.
The final code block in the server application is for the Send Button to transmit a message to the client currently selected in the GUI. This occurs in the sendButton.Click event handler.
Private Async Sub sendButton_Click(sender As Object, e As EventArgs) Handles sendButton.Click
'ensure a client is selected in the UI
If clientBindingSource.Current IsNot Nothing Then
'disable send button and input text until the current message is sent
sendButton.Enabled = False
inputTextBox.Enabled = False
'get the current client, stream, and data to write
Dim client As ConnectedClient = CType(clientBindingSource.Current, ConnectedClient)
Dim stream As NetworkStream = client.TcpClient.GetStream
Dim message As New XProtocol.XMessage(<TextMessage text1=<%= inputTextBox.Text %>/>)
Dim buffer() As Byte = message.ToByteArray
'wait for the data to be sent to the remote client
Await stream.WriteAsync(buffer, 0, buffer.Length)
'reset and re-enable the input button and text
inputTextBox.Clear()
inputTextBox.Enabled = True
sendButton.Enabled = True
End If
End Sub
This method uses databinding in the form to get the currently selected ConnectedClient instance, access the client's network stream, and then write a XMessage instance to the stream. Here we also see the XMessage in action. A new XMessage instance is constructed by writing in-line XML in the code and inserting variable values into the XML with expressions. For more information on this extremely powerful programming construct, see the LINQ to XML Overview.
With these three classes complete, the AsyncTcpServer is ready to run. Now we just need to write a client and the solution will be complete.
AsyncTcpClient Code
The AsyncTcpClient project is much simpler than the server and only requires a single Form1 class. As with the Server form, the code for setting up the GUI will be provided in an appendix at the end of the article and here we will concentrate on the code performing the actual work. The Form1 class needs only three fields which are essentially subsets of the data needed by the server.
Private portNumber As Integer = 55001
Private client As TcpClient
Private received As New List(Of Byte)
This specifies the TCP port number used for communication (the same as the server), the TcpClient instance and the buffer of pending message data. Notice that since the client program only deals with a single connection we do not necessarily need to create a separate class to encapsulate the client instance.
Similar to the server's design, the majority of the client's work is performed in the connectButton.Click event handler.
Private Async Sub connectButton_Click(sender As Object, e As EventArgs) Handles connectButton.Click
If connectButton.Text = "Connect" Then
client = New TcpClient
Try
'The server and client examples are assumed to be running on the same computer;
'in your real client application you would allow the user to specify the
'server's address and then use that value here instead of GetLocalIP()
Await client.ConnectAsync(GetLocalIP, portNumber)
connectButton.Text = "Disconnect"
If client.Connected Then
'get the client's data stream
Dim stream As NetworkStream = client.GetStream
'while the client is connected, continue to wait for and read data
While client.Connected
Dim buffer(client.ReceiveBufferSize - 1) As Byte
Dim read As Integer = Await stream.ReadAsync(buffer, 0, buffer.Length)
If read > 0 Then
received.AddRange(buffer.Take(read))
If XProtocol.XMessage.IsMessageComplete(received) Then
Dim message As XProtocol.XMessage = XProtocol.XMessage.FromByteArray(received.ToArray)
received.Clear()
Select Case message.Element.Name
Case "TextMessage"
outputTextBox.AppendText(message.Element.@text1)
outputTextBox.AppendText(ControlChars.NewLine)
End Select
End If
Else
'server terminated connection
Exit While
End If
End While
End If
Catch odex As ObjectDisposedException
'client terminated connection
Catch ex As Exception
MessageBox.Show(ex.Message)
client.Close()
End Try
connectButton.Text = "Connect"
Else
client.Close()
End If
End Sub
As we've been doing all along, we put the code into an infinite loop which spends most of it's time suspended, awaiting more data from the remote host. As you can see, the processing routine is very similar to that of the server, and if necessary could be extended in the same ways previously mentioned (additional Async/Await calls or utilizing Task.Run).
The only other work to do in the client is send a message to the server. This is done in a sendButton.Click event handler, very much like the server's code.
Private Async Sub sendButton_Click(sender As Object, e As EventArgs) Handles sendButton.Click
If client IsNot Nothing AndAlso client.Connected Then
Dim stream As NetworkStream = client.GetStream
Dim message As New XProtocol.XMessage(<TextMessage text1=<%= inputTextBox.Text %>/>)
Dim buffer() As Byte = message.ToByteArray
Try
Await stream.WriteAsync(buffer, 0, buffer.Length)
Catch ioex As System.IO.IOException
'server terminated connection
Catch odex As ObjectDisposedException
'client terminated connection
Catch ex As Exception
'unknown error occured
MessageBox.Show(ex.Message)
End Try
inputTextBox.Clear()
End If
End Sub
Here we construct and send a message, just like the server. But unlike the server which is already executing the client process within a task wrapper, we need to handle the various exceptions which might occur while trying to write to the network stream (technically the server might still trap an exception on sending if it wanted to respond specifically to that condition).
This completes the work of the client form. There is also a helper method used for getting the local IP address when both the client and server examples are being executed on the same PC, but it is unrelated the primary functionality of the client. That code is available in the appendix at the end of the article.
This concludes the AsyncTcpClient project, as well as the AsyncTcpSample solution. You can now run the solution, connect client instances to the server, and send messages between them.
Summary
By utilizing Async/Await features in TAP, we can significantly simplify the process of creating a multi-client TCP/IP server application which provides reasonable performance for most reasonable usage scenarios. Many simple application scenarios can make use entirely of asynchronous method calls, allowing the program to perform well while utilizing only the single GUI thread provided by the application.
When sending structured data from one host to another via a stream of bytes, we can significantly simplify the process by taking advantage of LINQ to XML and wrapping an XElement instance in a class containing helper methods for binary serialization of the XML string into a protocol-compliant sequence of bytes. This allows us to construct complex, yet arbitrary messages in infinite variety and complexity.
All of the designs in this example solution represent only the most basic approach and are meant to serve as scaffolding upon which you can build a real application; the example projects are not meant to be working solutions in-and-of themselves.
Credits
Special thanks to Lucian Wischik for his assistance on the TAP model and in isolating the issue found in NetworkStream's async implementation.
Appendix
Appendix A: XProtocol Complete Code
'Define a simple wrapper for XElement which implements our message protocol
Public Class XMessage
Const SOH As Byte = 1 'define a start sequence
Const EOF As Byte = 4 'define a stop sequence
Public Property Element As XElement 'declare the object to hold the actual message contents
'define a method to check a series of bytes to determine if they conform to the protocol specification
Public Shared Function IsMessageComplete(data As IEnumerable(Of Byte)) As Boolean
Dim length As Integer = data.Count 'get the number of bytes
If length > 5 Then 'ensure there are enough for at least the start, stop, and length
If data(0) = SOH AndAlso data(length - 1) = EOF Then 'ensure the series begins and ends with start/stop identifiers
Dim l As Integer = BitConverter.ToInt32(data.ToArray, 1) 'interpret the data length by reading bytes 1 through 4 and converting to integer
Return (l = length - 6) 'ensure that the interpreted data length matches the number of bytes supplied
End If
End If
Return False
End Function
'parse the XElement content from the supplied data according to the message protocol specification
Public Shared Function FromByteArray(data() As Byte) As XMessage
Return New XMessage(XElement.Parse(System.Text.Encoding.UTF8.GetString(data, 5, data.Length - 6)))
End Function
'serialize the XElement content into a byte array according to the message protocol specification
Public Function ToByteArray() As Byte()
Dim result As New List(Of Byte)
Dim data() As Byte = System.Text.Encoding.UTF8.GetBytes(Element.ToString) 'encode the XML string
result.Add(SOH) 'add the message start indicator
result.AddRange(BitConverter.GetBytes(data.Length)) 'add the data length
result.AddRange(data) 'add the message data
result.Add(EOF) 'add the message stop indicator
Return result.ToArray 'return the data array
End Function
Public Sub New(xml As XElement)
Element = xml
End Sub
End Class
Appendix B: AsyncTcpServer Complete Code
Imports System.IO
Imports System.Net
Imports System.Net.Sockets
Imports System.Threading
Public Class Form1
'define the UI controls needed by the sample form
Friend layoutSplit As New SplitContainer With {.Dock = DockStyle.Fill, .FixedPanel = FixedPanel.Panel1}
Friend layoutTable As New TableLayoutPanel With {.Dock = DockStyle.Fill, .ColumnCount = 1, .RowCount = 4}
Friend WithEvents startButton As New Button With {.AutoSize = True, .Text = "Start"}
Friend outputTextBox As New RichTextBox With {.Anchor = 15, .ReadOnly = True}
Friend inputTextBox As New RichTextBox With {.Anchor = 15}
Friend WithEvents sendButton As New Button With {.AutoSize = True, .Text = "Send"}
Friend clientListBox As New ListBox With {.Dock = DockStyle.Fill, .IntegralHeight = False}
Friend WithEvents clientBindingSource As New BindingSource
'specificy the TCP/IP Port number that the server will listen on
Private portNumber As Integer = 55001
'create the collection instance to store connected clients
Private clients As New ConnectedClientCollection
'declare a variable to hold the listener instance
Private listener As TcpListener
'declare a variable to hold the cancellation token source instance
Private tokenSource As CancellationTokenSource
'create a list to hold any processing tasks started when clients connect
Private clientTasks As New List(Of Task)
Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
'setup the sample's user interface
Me.Text = "Server Example"
Controls.Add(layoutSplit)
layoutSplit.Panel1.Controls.Add(clientListBox)
layoutSplit.Panel2.Controls.Add(layoutTable)
layoutTable.RowStyles.Add(New RowStyle(SizeType.Absolute, startButton.Height + 8))
layoutTable.RowStyles.Add(New RowStyle(SizeType.Percent, 50.0!))
layoutTable.RowStyles.Add(New RowStyle(SizeType.Percent, 50.0!))
layoutTable.RowStyles.Add(New RowStyle(SizeType.Absolute, sendButton.Height + 8))
layoutTable.Controls.Add(startButton)
layoutTable.Controls.Add(outputTextBox)
layoutTable.Controls.Add(inputTextBox)
layoutTable.Controls.Add(sendButton)
'use databinding to facilitate displaying received data for each connected client
clientBindingSource.DataSource = clients
clientListBox.DataSource = clientBindingSource
outputTextBox.DataBindings.Add("Text", clientBindingSource, "Text")
End Sub
Private Async Sub startButton_Click(sender As Object, e As EventArgs) Handles startButton.Click
'this example uses the button text as a state indicator for the server; your real
'application may wish to provide a local boolean or enum field to indicate the server's operational state
If startButton.Text = "Start" Then
'indicate that the server is running
startButton.Text = "Stop"
'create a new cancellation token source instance
tokenSource = New CancellationTokenSource
'create a new listener instance bound to the desired address and port
listener = New TcpListener(IPAddress.Any, portNumber)
'start the listener
listener.Start()
'begin accepting clients until the listener is closed; closing the listener while
'it is waiting for a client connection causes an ObjectDisposedException which can
'be trapped and used to exit the listening routine
While True
Try
'wait for a client
Dim socketClient As TcpClient = Await listener.AcceptTcpClientAsync
'record the new client connection
Dim client As New ConnectedClient(socketClient)
clientBindingSource.Add(client)
'begin executing an async task to process the client's data stream
client.Task = ProcessClientAsync(client, tokenSource.Token)
'store the task so that we can wait for any existing connections to close
'while performing a server shutdown
clientTasks.Add(client.Task)
Catch odex As ObjectDisposedException
'listener stopped, so server is shutting down
Exit While
End Try
End While
'since NetworkStream.ReadAsync does not honor the cancellation signal we
'must manually close all connected clients
For i As Integer = clients.Count - 1 To 0 Step -1
clients(i).TcpClient.Close()
Next
'wait for all of the clients to finish closing
Await Task.WhenAll(clientTasks)
'clean up the cancelation token
tokenSource.Dispose()
'reset the start button text, allowing the server to be started again
startButton.Text = "Start"
Else
'signal any processing of current clients to cancel (if listening)
tokenSource.Cancel()
'abort the current listening operation/prevent any new connections
listener.Stop()
End If
End Sub
Private Async Sub sendButton_Click(sender As Object, e As EventArgs) Handles sendButton.Click
'ensure a client is selected in the UI
If clientBindingSource.Current IsNot Nothing Then
'disable send button and input text until the current message is sent
sendButton.Enabled = False
inputTextBox.Enabled = False
'get the current client, stream, and data to write
Dim client As ConnectedClient = CType(clientBindingSource.Current, ConnectedClient)
Dim stream As NetworkStream = client.TcpClient.GetStream
Dim message As New XProtocol.XMessage(<TextMessage text1=<%= inputTextBox.Text %>/>)
Dim buffer() As Byte = message.ToByteArray
'wait for the data to be sent to the remote client
Await stream.WriteAsync(buffer, 0, buffer.Length)
'reset and re-enable the input button and text
inputTextBox.Clear()
inputTextBox.Enabled = True
sendButton.Enabled = True
End If
End Sub
Private Async Function ProcessClientAsync(client As ConnectedClient, cancel As CancellationToken) As Task
Try
'begin reading from the client's data stream
Using stream As NetworkStream = client.TcpClient.GetStream
Dim buffer(client.TcpClient.ReceiveBufferSize - 1) As Byte
'loop exits when read = 0 which occurs when the client closes the socket,
'or it exits on ReadAsync exception when the connection terminates; exception type indicates termination cause
Dim read As Integer = 1
While read > 0
'wait for data to be read; depending on how you choose to read the data, the cancelation token
'may or may not be honored by the particular method implementation on the chosen stream implementation
read = Await stream.ReadAsync(buffer, 0, buffer.Length, cancel)
'process the received data; in this case the data is simply appended to a StringBuilder; any light
'work (that is, code which does not require a lot of CPU time) can be performed directly within
'the current while loop:
client.AppendData(buffer, read)
'*NOTE: A real application may require significantly more processing of the received data. If lengthy,
'CPU-bound processing is required, a secondary worker method could be started on the thread pool;
'if the processing is I/O-bound, you could continue to await calls to async methods. The following code
'demonstrates the handling of a CPU-bound processing routine (see additional comments in DoHeavyWork):
'Dim workResult As Integer = Await Task.Run(Function() DoHeavyWork(buffer, read, client))
''a real application would likely upate the UI at this point, based on the workResult value (which could
''be an object containing the UI data to update).
''TO TEST: uncomment this block; comment-out client.AppendData(buffer, read) above
End While
'client gracefully closed the connection on the remote end
End Using
Catch ocex As OperationCanceledException
'the expected exception if this routines's async method calls honor signaling of the cancelation token
'*NOTE: NetworkStream.ReadAsync() will not honor the cancelation signal
Catch odex As ObjectDisposedException
'server disconnected client while reading
Catch ioex As IOException
'client terminated (remote application terminated without socket close) while reading
Finally
'ensure the client is closed - this is typically a redundant call, but in the
'case of an unhandled exception it may be necessary
client.TcpClient.Close()
'remove the client from the list of connected clients
clientBindingSource.Remove(client)
'remove the client's task from the list of running tasks
clientTasks.Remove(client.Task)
End Try
End Function
'this method is a rough example of how you would implement secondary threading to handle
'client processing which requires significant CPU time
Private Function DoHeavyWork(buffer() As Byte, read As Integer, client As ConnectedClient) As Integer
'function return type is some kind of status indicator that the caller can use to determine
'if the processing was successful, or just an empty object if no return value is needed (that is,
'if you want to treat the function as a subroutine); although this sample uses Integer, you could
'use any type of your choosing
'function parameters are whatever is required for your program to process the received data
'due to the fact that AppendData will raise the notify property changed event, we need to
'ensure that the method is called from the UI thread; in your real application, the UI
'update would likely occur after this method returns, perhaps based on the result value
'returned by this method
Invoke(Sub()
client.AppendData(buffer, read)
End Sub)
'put the thread to sleep to simulate some long-running CPU-bound processing
System.Threading.Thread.Sleep(500)
Return 0
End Function
End Class
'Your program will typically require a custom object which encapsulates the TcpClient instance
'and the data received from that client. There is no single way to design this class as it's
'requirements will depend entirely on the desired functionality of the application being developed.
Public Class ConnectedClient
'implement property change notification to facilitate databinding
Implements System.ComponentModel.INotifyPropertyChanged
'store the TcpClient instance
Public ReadOnly TcpClient As TcpClient
'store a unique id for this client connection
Public ReadOnly Id As String
Public Property Task As Task
'store the data received from the remote client
Public ReadOnly Received As New List(Of Byte)
'expose the received data as a string property to facilitate databinding
Private _Text As String = String.Empty
Public ReadOnly Property Text As String
Get
'Return Received.ToString
Return _Text
End Get
End Property
Public Sub New(client As TcpClient)
TcpClient = client
'craft the unique id from the remote client's IP address and the port they connected from
Id = CType(TcpClient.Client.RemoteEndPoint, IPEndPoint).ToString
End Sub
'expose a helper method for capturing and storing data received from the remote client
Public Sub AppendData(buffer() As Byte, read As Integer)
If read = 0 Then Exit Sub
'add the bytes read this time to the collection of bytes read so far
Received.AddRange(buffer.Take(read))
'check to see if the bytes read so far represent a complete message
If XProtocol.XMessage.IsMessageComplete(Received) Then
'if so, build a message from the byte data and then clear the byte data to prepare for the next message
Dim message As XProtocol.XMessage = XProtocol.XMessage.FromByteArray(Received.ToArray)
Received.Clear()
'read data elements from the message as appropriate
Select Case message.Element.Name
Case "TextMessage"
_Text = message.Element.@text1
RaiseEvent PropertyChanged(Me, New System.ComponentModel.PropertyChangedEventArgs("Text"))
End Select
End If
End Sub
'implement primary object method overrides based on unique id
Public Overrides Function Equals(obj As Object) As Boolean
If TypeOf obj Is ConnectedClient Then Return Id = DirectCast(obj, ConnectedClient).Id
Return MyBase.Equals(obj)
End Function
Public Overrides Function GetHashCode() As Integer
Return Id.GetHashCode
End Function
Public Overrides Function ToString() As String
Return Id
End Function
Public Event PropertyChanged(sender As Object, e As System.ComponentModel.PropertyChangedEventArgs) Implements System.ComponentModel.INotifyPropertyChanged.PropertyChanged
End Class
'This class provides a simple collection of clients where each can be accessed by
'unique id or index in the collection. This is to facilitate working with connected
'clients from your actual application, if needed, and could be replaced with a simple
'List(Of ConnectedClient) if your application will not need to access individual clients
'by their unique id. Typically though this kind of collection will be useful when
'writing your server application. The list of client tasks could also be extrapolated
'from this collection rather than being stored in a separate list.
Public Class ConnectedClientCollection
Inherits System.Collections.ObjectModel.KeyedCollection(Of String, ConnectedClient)
Protected Overrides Function GetKeyForItem(item As ConnectedClient) As String
Return item.Id
End Function
End Class
Appendix C: AsyncTcpClient Complete Code
Imports System.Net
Imports System.Net.Sockets
Public Class Form1
Friend layoutTable As New TableLayoutPanel With {.Dock = DockStyle.Fill, .ColumnCount = 1, .RowCount = 4}
Friend WithEvents connectButton As New Button With {.AutoSize = True, .Text = "Connect"}
Friend outputTextBox As New RichTextBox With {.Anchor = 15, .ReadOnly = True}
Friend inputTextBox As New RichTextBox With {.Anchor = 15}
Friend WithEvents sendButton As New Button With {.AutoSize = True, .Text = "Send"}
Private portNumber As Integer = 55001
Private client As TcpClient
Private received As New List(Of Byte)
Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
Me.Text = "Client Example"
Controls.Add(layoutTable)
layoutTable.RowStyles.Add(New RowStyle(SizeType.Absolute, connectButton.Height + 8))
layoutTable.RowStyles.Add(New RowStyle(SizeType.Percent, 50.0!))
layoutTable.RowStyles.Add(New RowStyle(SizeType.Percent, 50.0!))
layoutTable.RowStyles.Add(New RowStyle(SizeType.Absolute, sendButton.Height + 8))
layoutTable.Controls.Add(connectButton)
layoutTable.Controls.Add(outputTextBox)
layoutTable.Controls.Add(inputTextBox)
layoutTable.Controls.Add(sendButton)
End Sub
Private Async Sub connectButton_Click(sender As Object, e As EventArgs) Handles connectButton.Click
If connectButton.Text = "Connect" Then
client = New TcpClient
Try
'The server and client examples are assumed to be running on the same computer;
'in your real client application you would allow the user to specify the
'server's address and then use that value here instead of GetLocalIP()
Await client.ConnectAsync(GetLocalIP, portNumber)
connectButton.Text = "Disconnect"
If client.Connected Then
'get the client's data stream
Dim stream As NetworkStream = client.GetStream
'while the client is connected, continue to wait for and read data
While client.Connected
Dim buffer(client.ReceiveBufferSize - 1) As Byte
Dim read As Integer = Await stream.ReadAsync(buffer, 0, buffer.Length)
If read > 0 Then
received.AddRange(buffer.Take(read))
If XProtocol.XMessage.IsMessageComplete(received) Then
Dim message As XProtocol.XMessage = XProtocol.XMessage.FromByteArray(received.ToArray)
received.Clear()
Select Case message.Element.Name
Case "TextMessage"
outputTextBox.AppendText(message.Element.@text1)
outputTextBox.AppendText(ControlChars.NewLine)
End Select
End If
Else
'server terminated connection
Exit While
End If
End While
End If
Catch odex As ObjectDisposedException
'client terminated connection
Catch ex As Exception
MessageBox.Show(ex.Message)
client.Close()
End Try
connectButton.Text = "Connect"
Else
client.Close()
End If
End Sub
'send message to server
Private Async Sub sendButton_Click(sender As Object, e As EventArgs) Handles sendButton.Click
If client IsNot Nothing AndAlso client.Connected Then
Dim stream As NetworkStream = client.GetStream
Dim message As New XProtocol.XMessage(<TextMessage text1=<%= inputTextBox.Text %>/>)
Dim buffer() As Byte = message.ToByteArray
Try
Await stream.WriteAsync(buffer, 0, buffer.Length)
Catch ioex As System.IO.IOException
'server terminated connection
Catch odex As ObjectDisposedException
'client terminated connection
Catch ex As Exception
'unknown error occured
MessageBox.Show(ex.Message)
End Try
inputTextBox.Clear()
End If
End Sub
'helper method for getting local IPv4 address
Private Function GetLocalIP() As IPAddress
For Each adapter In NetworkInformation.NetworkInterface.GetAllNetworkInterfaces
If adapter.OperationalStatus = NetworkInformation.OperationalStatus.Up AndAlso
adapter.Supports(NetworkInformation.NetworkInterfaceComponent.IPv4) AndAlso
adapter.NetworkInterfaceType <> NetworkInformation.NetworkInterfaceType.Loopback Then
Dim props As NetworkInformation.IPInterfaceProperties = adapter.GetIPProperties
For Each address In props.UnicastAddresses
If address.Address.AddressFamily = AddressFamily.InterNetwork Then Return address.Address
Next
End If
Next
Return IPAddress.None
End Function
End Class
Appendix D: Code Gallery Sample
The AsyncTcpSample solution is available for download on Code Gallery.