In the last part we ended with a simple server that accepts a connection, and echoes back any data the client sends back to it. But right now, the server is only capable of accepting one connection, and after that connection ends the server terminates entirely. That's not a very exciting server.
So how do we go about accepting several clients, and deal with them all at the same time? And more to the point, how do we do this without involving multi-threading?
First we still need to do everything as before by creating a listener socket, binding it, and calling the listen
function. To make sure we don't get too confused, I'm going to rename the variable to listener
rather than socket
to clarify the intent behind the code. This will become more important as our application grows, so it's important we know which socket is which.
However now, we also need to create a list to keep track of all the sockets the server currently knows about - including the listener socket itself. I'll declare this right at the top and add the listener
variable to it. The reason for this will become clear in just a moment:
<mark>var activeSockets = new List<Socket>();</mark>
var <mark>listener</mark> = new Socket(SocketType.Stream, ProtocolType.Tcp);
var endpoint = new IPEndPoint(IPAddress.Any, 12345);
<mark>listener</mark>.Bind(endpoint);
<mark>listener</mark>.Listen(5);
<mark>activeSockets.Add(listener);</mark>
// ...
listener.Close();
Next we need to change our infinite loop. You might think in order to allow multiple clients, we'd need to call the accept
function inside the loop. But heads up! Remember this function is a blocking function. Calling this immediately inside the loop would be very bad for us indeed because we'd never be able to read from the first client, but instead hanging and waiting on a second to connect.
This is where I'm going to break against the common advice and .NET conventions in a bid to make this guide as absolutely portable as possible. We're not going to utilise .NET threads or tasks, we're going to utilise one of the socket-related functions which will allow us to keep our server responsive at all times.
This function is called select
(man page, Winsock docs). In .NET, it's provided as the static Socket.Select
method. What this function does is essentially take in a collection of sockets, determines if any of them have pending reads/writes to handle (or if any of them have any errors we need to deal with), and crucially modifies that collection we provide as a way to tell us which ones we need to handle.
In simpler terms: we call select
with a list of sockets we want to check for status. The function then modifies the list by removing any sockets that aren't ready for reading/writing. When the function completes, the list we passed will now only contain the sockets which we can read from or write to. (Or error check.)
This means we'll need to create a new list to pass into it. If we pass it our original list of sockets, it's going to modify it and we'll lose track of every connected client! No bueno. So inside the loop, let's create second List<Socket>
by copying all the elements from the first list into it:
while (true)
{
var checkSockets = new List<Socket>(<mark>activeSockets</mark>);
}
Now we can call Select
. This function has 4 parameters. A list of sockets to check for reading, a list of sockets to check for writing, a list of sockets to check for errors, and a timeout value which will become the most important part of it.
We don't need to worry about pending writes or errors right now as we only need to focus on checking for any pending data that clients send, as well as check if the listener socket has any pending connections which also counts as a “read” operation. So let's call Select
by passing in the checkSockets
list, two null arguments, and a timeout value I'll leave out for now and explain below:
while (true)
{
var checkSockets = new List<Socket>(activeSockets);
Socket.Select(checkSockets, null, null, ...);
}
Right. The timeout. This value tells the function how long to wait before it essentially “gives up”. If we didn't give it a valid timeout, it would in fact block the thread and wait for at least one socket to become available and ready for reading. In our very simple server project right now that's not too much of a concern. However once your server becomes quite large and you need to handle socket operations alongside other things such as database communication, or file I/O, or really anything else, setting this to an infinite timeout would be a tremendously bad idea. So for now, we're going to give it a timeout of 1 millisecond, which should be fine for the duration of this guide, but keep in mind this value may need to be adjusted dynamically as your server grows.
The various abstractions of this function behave a bit differently depending on the language you're using, so I'll try to explain some common languages and frameworks:
In .NET (C#, VB, etc.), two overloads for this method exist. One accepts an int
which represents the number of microseconds it should wait. So passing in 1000
(microseconds) here would mean 1 millisecond. Alternatively, you can use the TimeSpan
overload by passing in TimeSpan.FromMilliseconds(1)
. Passing in -1 is the way to represent an “infinite” timeout causing the function to block.
The native function accepts a pointer to a timeval
struct (man page, Winsock docs). The two fields in this struct are - in order - tv_sec
(seconds) and tv_usec
(microseconds). An infinite timeout is represented by passing NULL
or nullptr
, and 1 millisecond can be achieved like so:
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 1000;
In Python, the timeout is actually in seconds - passed as a floating point number. To achieve one millisecond you just need to pass 0.001
.
Be sure to read the documentation for the language you're using, as the timeout parameter tends to be a framework-specific structure or value and will generally differ among languages.
Selecting the sockets
Now that we have a list of ready sockets, we can simply iterate over that list and do what we need to do.
First we'll check if the socket is the listener
socket and - if it is - call Accept
on it to accept the new client. It's important to note here we should not wrap this socket in a using statement nor should we call the Close
method, because we need it to stay alive for the duration of the exchange. We're going to dispose it manually later. Once we have the socket, we simply add it to the initial activeSockets
list ready to be processed the next time around.
while (true)
{
var checkSockets = new List<Socket>(activeSockets);
Socket.Select(checkSockets, null, null, TimeSpan.FromMilliseconds(1));
<mark>foreach (Socket socket in checkSockets)</mark>
<mark>{</mark>
<mark>if (socket == listener)</mark>
<mark>{</mark>
<mark>// accept a new connection on the listener</mark>
<mark>Socket client = socket.Accept();</mark>
<mark>activeSockets.Add(client);</mark>
<mark>}</mark>
<mark>}</mark>
}
If the socket is anything but the listener
socket, this means it's actually one of the clients which we added to the list. So we should now move our creation of NetworkStream
, StreamReader
, and StreamWriter
, into this loop. Importantly, we need to set the leaveOpen
parameter of both the StreamReader
and StreamWriter
to true
. By default, disposal of these classes also causes a disposal of the underlying stream - we don't want that here because that would close the client's connection prematurely.
Instead we'll do our cleanup when we receive a null
line. (Remember, this is how we know when the client disconnects!) This is where we'll move the call to Close
. Once we know the connection has ended, we'll remove it from the activeSockets
list so that we don't perform any additional operations on it, and continue
to the next socket.
The full code should now look like this:
using System.Net;
using System.Net.Sockets;
var activeSockets = new List<Socket>();
var listener = new Socket(SocketType.Stream, ProtocolType.Tcp);
var endpoint = new IPEndPoint(IPAddress.Any, 12345);
listener.Bind(endpoint);
listener.Listen(5);
activeSockets.Add(listener);
while (true)
{
var checkSockets = new List<Socket>(activeSockets);
Socket.Select(checkSockets, null, null, TimeSpan.FromMilliseconds(1));
foreach (Socket socket in checkSockets)
{
if (socket == listener)
{
// accept a new connection on the listener
Socket client = socket.Accept();
activeSockets.Add(client);
}
else
{
// deal with data coming in from the client
var stream = new NetworkStream(socket);
var reader = new StreamReader(stream, leaveOpen: true);
var writer = new StreamWriter(stream, leaveOpen: true);
string? input = reader.ReadLine();
if (input is null)
{
// the connection has been closed
socket.Close();
activeSockets.Remove(socket);
continue;
}
writer.WriteLine($"Your message was: {input}");
writer.Flush();
}
}
}
// since the loop is now infinite, this line will never be reached.
// but poor heuristic analysis may shout at you, so best to keep it anyway
listener.Close();
To test this, we need to run two instances of Telnet at the same time. For me, I'm going to open up two WSL terminals and run the telnet
command in each of them. This is just a preference thing. I prefer running everything in WSL because as we've already pointed out, the Windows implementation of Telnet is atrocious and not intuitive to use. But if it's what you want to use, I promise I won't judge you… aloud.
If all goes well, you should be able to have as many connections as you want and have data be exchanged for each separately!
We've managed to accept and handle multiple clients at the same time, without the need for platform-specific multi-threading code or .NET tasks.
In the next part, we're going to try and create a simple chat application!