├── run-client.bat
├── run-server.bat
├── Client
├── Client.csproj
└── Program.cs
├── Server
├── ISocketConnectionProxy.cs
├── Server.csproj
├── SocketConnectionProxy.cs
├── Application.cs
├── StatusReporter.cs
├── Program.cs
├── QueueingLogWriter.cs
├── SocketStreamReader.cs
└── LocalhostSocketListener.cs
├── .gitignore
├── ServerTests
├── ServerTests.csproj
├── StatusReporterTests.cs
└── SocketStreamReaderTests.cs
├── dotnetcore-socket-server.sln
└── readme.md
/run-client.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 |
3 | pushd "%~dp0Client\"
4 | start "Client" dotnet run
5 | popd
6 |
--------------------------------------------------------------------------------
/run-server.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 |
3 | pushd "%~dp0Server\"
4 | start "Server" dotnet run
5 | popd
6 |
--------------------------------------------------------------------------------
/Client/Client.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | netcoreapp1.1
6 | Client
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/Server/ISocketConnectionProxy.cs:
--------------------------------------------------------------------------------
1 | namespace Server
2 | {
3 | ///
4 | /// A proxy for a socket connection.
5 | ///
6 | ///
7 | /// This interface exists to make
8 | /// testable.
9 | ///
10 | public interface ISocketConnectionProxy
11 | {
12 | int Receive(byte[] buffer, int offset, int size);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | *.*~
3 | project.lock.json
4 | .DS_Store
5 | *.pyc
6 |
7 | # Visual Studio Code
8 | .vscode
9 |
10 | # User-specific files
11 | *.suo
12 | *.user
13 | *.userosscache
14 | *.sln.docstates
15 |
16 | # Build results
17 | [Dd]ebug/
18 | [Dd]ebugPublic/
19 | [Rr]elease/
20 | [Rr]eleases/
21 | x64/
22 | x86/
23 | build/
24 | bld/
25 | [Bb]in/
26 | [Oo]bj/
27 | msbuild.log
28 | msbuild.err
29 | msbuild.wrn
30 |
31 | # Visual Studio 2015
32 | .vs/
33 |
34 | numbers.log
35 |
--------------------------------------------------------------------------------
/Server/Server.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | netcoreapp1.1
6 | Server
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/ServerTests/ServerTests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | netcoreapp1.1
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/Server/SocketConnectionProxy.cs:
--------------------------------------------------------------------------------
1 | using System.Net.Sockets;
2 |
3 | namespace Server
4 | {
5 | ///
6 | /// A implementation of
7 | /// which provides access to an underlying .
8 | ///
9 | public class SocketConnectionProxy : ISocketConnectionProxy
10 | {
11 | private readonly Socket _socket;
12 |
13 | public SocketConnectionProxy(Socket socket)
14 | {
15 | _socket = socket;
16 | }
17 |
18 | public int Receive(byte[] buffer, int offset, int size)
19 | {
20 | return _socket.Receive(buffer, offset, size, SocketFlags.None);
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/dotnetcore-socket-server.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio 15
4 | VisualStudioVersion = 15.0.26228.13
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "Server\Server.csproj", "{5658F0AF-4548-4FA5-98BD-DF7753C338B6}"
7 | EndProject
8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client", "Client\Client.csproj", "{703C361C-F55B-475A-BFC9-73B2912CA6FC}"
9 | EndProject
10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServerTests", "ServerTests\ServerTests.csproj", "{D2675C13-1253-4264-ABA1-F04FD4432B87}"
11 | ProjectSection(ProjectDependencies) = postProject
12 | {5658F0AF-4548-4FA5-98BD-DF7753C338B6} = {5658F0AF-4548-4FA5-98BD-DF7753C338B6}
13 | EndProjectSection
14 | EndProject
15 | Global
16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
17 | Debug|Any CPU = Debug|Any CPU
18 | Release|Any CPU = Release|Any CPU
19 | EndGlobalSection
20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
21 | {5658F0AF-4548-4FA5-98BD-DF7753C338B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
22 | {5658F0AF-4548-4FA5-98BD-DF7753C338B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
23 | {5658F0AF-4548-4FA5-98BD-DF7753C338B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
24 | {5658F0AF-4548-4FA5-98BD-DF7753C338B6}.Release|Any CPU.Build.0 = Release|Any CPU
25 | {703C361C-F55B-475A-BFC9-73B2912CA6FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
26 | {703C361C-F55B-475A-BFC9-73B2912CA6FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
27 | {703C361C-F55B-475A-BFC9-73B2912CA6FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
28 | {703C361C-F55B-475A-BFC9-73B2912CA6FC}.Release|Any CPU.Build.0 = Release|Any CPU
29 | {D2675C13-1253-4264-ABA1-F04FD4432B87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
30 | {D2675C13-1253-4264-ABA1-F04FD4432B87}.Debug|Any CPU.Build.0 = Debug|Any CPU
31 | {D2675C13-1253-4264-ABA1-F04FD4432B87}.Release|Any CPU.ActiveCfg = Release|Any CPU
32 | {D2675C13-1253-4264-ABA1-F04FD4432B87}.Release|Any CPU.Build.0 = Release|Any CPU
33 | EndGlobalSection
34 | GlobalSection(SolutionProperties) = preSolution
35 | HideSolutionNode = FALSE
36 | EndGlobalSection
37 | EndGlobal
38 |
--------------------------------------------------------------------------------
/ServerTests/StatusReporterTests.cs:
--------------------------------------------------------------------------------
1 | using Server;
2 | using System.IO;
3 | using Xunit;
4 |
5 | namespace ServerTests
6 | {
7 | public class StatusReporterTests
8 | {
9 | [Fact]
10 | public void TestRecordUnique()
11 | {
12 | var reporter = new StatusReporter();
13 |
14 | Assert.Equal(0, reporter.IncrementalUnique);
15 | Assert.Equal(0, reporter.TotalUnique);
16 |
17 | for (var i = 0; i < 10; i++)
18 | {
19 | reporter.RecordUnique();
20 |
21 | Assert.Equal(i + 1, reporter.IncrementalUnique);
22 | Assert.Equal(i + 1, reporter.TotalUnique);
23 | }
24 | }
25 |
26 | [Fact]
27 | public void TestRecordDuplicates()
28 | {
29 | var reporter = new StatusReporter();
30 |
31 | Assert.Equal(0, reporter.IncrementalDuplicates);
32 | Assert.Equal(0, reporter.TotalDuplicates);
33 |
34 | for (var i = 0; i < 10; i++)
35 | {
36 | reporter.RecordDuplicate();
37 |
38 | Assert.Equal(i + 1, reporter.IncrementalDuplicates);
39 | Assert.Equal(i + 1, reporter.TotalDuplicates);
40 | }
41 | }
42 |
43 | [Fact]
44 | public void TestReport()
45 | {
46 | var reporter = new StatusReporter();
47 |
48 | for (var i = 0; i < 32; i++)
49 | reporter.RecordUnique();
50 |
51 | for (var i = 0; i < 4; i++)
52 | reporter.RecordDuplicate();
53 |
54 | using (var writer = new StringWriter())
55 | {
56 | reporter.Report(writer);
57 | Assert.Equal(
58 | "Received 32 unique numbers, 4 duplicates. Unique total: 32",
59 | writer.ToString());
60 | }
61 |
62 | Assert.Equal(32, reporter.TotalUnique);
63 | Assert.Equal(4, reporter.TotalDuplicates);
64 | Assert.Equal(0, reporter.IncrementalUnique);
65 | Assert.Equal(0, reporter.IncrementalDuplicates);
66 |
67 | for (var i = 0; i < 31; i++)
68 | reporter.RecordUnique();
69 |
70 | for (var i = 0; i < 3; i++)
71 | reporter.RecordDuplicate();
72 |
73 | using (var writer = new StringWriter())
74 | {
75 | reporter.Report(writer);
76 | Assert.Equal(
77 | "Received 31 unique numbers, 3 duplicates. Unique total: 63",
78 | writer.ToString());
79 | }
80 |
81 | Assert.Equal(63, reporter.TotalUnique);
82 | Assert.Equal(7, reporter.TotalDuplicates);
83 | Assert.Equal(0, reporter.IncrementalUnique);
84 | Assert.Equal(0, reporter.IncrementalDuplicates);
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Server/Application.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.Text;
4 |
5 | namespace Server
6 | {
7 | ///
8 | /// Class that orchestrates and controls the lifetime of the different
9 | /// components in the application, including the socket listener, the
10 | /// log file writer, and the status reporter.
11 | ///
12 | ///
13 | /// This class doesn't directly create/manage threads. The classes
14 | /// to which it delegates do:
15 | /// - : Manages background thread
16 | /// for server socket, and additional threads for each socket connection.
17 | /// - : Manages background thread that pulls
18 | /// values off a queue and writes them to disk.
19 | /// - : Manages background thread that periodically
20 | /// writes status to the console.
21 | ///
22 | /// It is critical that instances of this class are disposed of properly.
23 | ///
24 | public class Application : IDisposable
25 | {
26 | private readonly int _statusInterval;
27 | private readonly StatusReporter _reporter;
28 | private readonly LocalhostSocketListener _listener;
29 | private readonly FileStream _logFile;
30 | private readonly QueueingLogWriter _logWriter;
31 |
32 | public Application(int port, int maxConnections, int statusInterval, string logFilePath)
33 | {
34 | _statusInterval = statusInterval;
35 |
36 | _reporter = new StatusReporter();
37 | _listener = new LocalhostSocketListener(port, maxConnections);
38 |
39 | _logFile = new FileStream(logFilePath, FileMode.Create);
40 | _logWriter = new QueueingLogWriter(new StreamWriter(_logFile, Encoding.ASCII));
41 | }
42 |
43 | public void Run(Action terminationCallback = null)
44 | {
45 | _reporter.Start(_statusInterval);
46 |
47 | // Start listening on the specified port, and get callback whenever
48 | // new socket connection is established.
49 | _listener.Start(socket =>
50 | {
51 | // New connection. Start reading data from the network stream.
52 | // Socket stream reader will call back when a valid value is read
53 | // and/or when a terminate command is received.
54 | var reader = new SocketStreamReader(socket);
55 | reader.Read(ProcessValue, terminationCallback);
56 | });
57 | }
58 |
59 | private void ProcessValue(int value)
60 | {
61 | // Logger is responsible to write the value to disk. It
62 | // will tell us if the value was unique, so we can record that
63 | // correctly with the status reporter.
64 | if (_logWriter.WriteUnique(value))
65 | {
66 | _reporter.RecordUnique();
67 | }
68 | else
69 | {
70 | _reporter.RecordDuplicate();
71 | }
72 | }
73 |
74 | public void Dispose()
75 | {
76 | _logWriter.Dispose();
77 | _logFile.Dispose();
78 |
79 | _listener.Stop();
80 | _reporter.Stop();
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/Server/StatusReporter.cs:
--------------------------------------------------------------------------------
1 | using log4net;
2 | using System.IO;
3 | using System.Threading;
4 |
5 | namespace Server
6 | {
7 | ///
8 | /// Keeps track of aggregate statistics and periodically writes
9 | /// status report to the output stream.
10 | ///
11 | ///
12 | /// Creates a background thread to write status reports to the console
13 | /// on a specified interval.
14 | ///
15 | /// Uses lock to synchronize access to the aggregate
16 | /// numbers, since [many] other threads processing data from
17 | /// connections will be recording new numbers.
18 | ///
19 | /// _stopSignal reset event used to release background thread
20 | /// and stop writing status reports when Stop is called.
21 | ///
22 | public class StatusReporter
23 | {
24 | public int TotalUnique => _totalUnique;
25 | public int TotalDuplicates => _totalDuplicates;
26 | public int IncrementalUnique => _incrementalUnique;
27 | public int IncrementalDuplicates => _incrementalDuplicates;
28 |
29 | private int _totalUnique;
30 | private int _totalDuplicates;
31 | private int _incrementalUnique;
32 | private int _incrementalDuplicates;
33 | private readonly object _lock;
34 | private readonly ManualResetEventSlim _stopSignal;
35 |
36 | private static ILog _log = LogManager.GetLogger(typeof(StatusReporter));
37 |
38 | public StatusReporter()
39 | {
40 | _lock = new object();
41 | _stopSignal = new ManualResetEventSlim();
42 | }
43 |
44 | public void Start(int reportingInterval)
45 | {
46 | _stopSignal.Reset();
47 | var thread = new Thread(new ThreadStart(() =>
48 | {
49 | while (!_stopSignal.IsSet)
50 | {
51 | _stopSignal.Wait(reportingInterval * 1000);
52 | if (_stopSignal.IsSet)
53 | {
54 | break;
55 | }
56 | Report();
57 | }
58 | }));
59 | thread.Start();
60 | }
61 |
62 | public void Stop()
63 | {
64 | _stopSignal.Set();
65 | }
66 |
67 | public void RecordUnique()
68 | {
69 | lock (_lock)
70 | {
71 | _incrementalUnique++;
72 | _totalUnique++;
73 | }
74 | }
75 |
76 | public void RecordDuplicate()
77 | {
78 | lock (_lock)
79 | {
80 | _incrementalDuplicates++;
81 | _totalDuplicates++;
82 | }
83 | }
84 |
85 | public void Report()
86 | {
87 | using (var writer = new StringWriter())
88 | {
89 | Report(writer);
90 | _log.Info(writer.ToString());
91 | }
92 |
93 | }
94 | public void Report(TextWriter writer)
95 | {
96 | int incrementalUnique, incrementalDups, totalUnique;
97 |
98 | // Dereference the numbers we need, and write them afterword,
99 | // to minimize the amount of time we need to have a write lock
100 | // and be blocking other processing threads that are reporting status.
101 | lock (_lock)
102 | {
103 | incrementalUnique = _incrementalUnique;
104 | incrementalDups = _incrementalDuplicates;
105 | totalUnique = _totalUnique;
106 |
107 | _incrementalUnique = _incrementalDuplicates = 0;
108 | }
109 |
110 | writer.Write($"Received {incrementalUnique} unique numbers, {incrementalDups} duplicates. Unique total: {totalUnique}");
111 | }
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/Server/Program.cs:
--------------------------------------------------------------------------------
1 | using log4net;
2 | using log4net.Appender;
3 | using log4net.Config;
4 | using log4net.Core;
5 | using log4net.Layout;
6 | using log4net.Repository.Hierarchy;
7 | using Microsoft.Extensions.CommandLineUtils;
8 | using System;
9 | using System.IO;
10 | using System.Reflection;
11 | using System.Threading;
12 |
13 | namespace Server
14 | {
15 | ///
16 | /// Entry point for server console app.
17 | ///
18 | ///
19 | /// Responsible for setting up the log4net logger (use for writing to stdout)
20 | /// and parsing command-line options. The real work is delegated to the
21 | /// Application class.
22 | ///
23 | /// The _exitSignal reset event is used to gracefully shutdown the server
24 | /// if/when the user types CTRL-C.
25 | ///
26 | class Program
27 | {
28 | private static Application _app;
29 | private static ManualResetEventSlim _exitSignal = new ManualResetEventSlim();
30 |
31 | static void Main(string[] args)
32 | {
33 | ConfigureLogging(Level.Info);
34 |
35 | var port = 4000;
36 | var maxConnections = 5;
37 | var statusInterval = 10;
38 | var logFile = "numbers.log";
39 |
40 | var cmd = new CommandLineApplication()
41 | {
42 | FullName = "A socket server which will log 9-digit numbers that are sent to it.",
43 | Name = "dotnet run --"
44 | };
45 | var portOption = cmd.Option("-p|--port ", $"The port on which the server should run. Default: {port}", CommandOptionType.SingleValue);
46 | var maxConnectionsOption = cmd.Option("-m|--max ", $"The maximum number of socket connections the server should allow. Default: {maxConnections}", CommandOptionType.SingleValue);
47 | var statusIntervalOption = cmd.Option("-s|--status ", $"The number of seconds between each status report. Default: {statusInterval}", CommandOptionType.SingleValue);
48 | var logFileOption = cmd.Option("-l|--log ", $"The path to which value should be logged. Default: {logFile} in the working dir", CommandOptionType.SingleValue);
49 | cmd.HelpOption("-?|-h|--help");
50 | cmd.OnExecute(() =>
51 | {
52 | if (portOption.HasValue()) port = int.Parse(portOption.Value());
53 | if (maxConnectionsOption.HasValue()) maxConnections = int.Parse(maxConnectionsOption.Value());
54 | if (statusIntervalOption.HasValue()) statusInterval = int.Parse(statusIntervalOption.Value());
55 | if (logFileOption.HasValue()) logFile = logFileOption.Value();
56 |
57 | Run(port, maxConnections, statusInterval, logFile);
58 |
59 | return 0;
60 | });
61 | cmd.Execute(args);
62 | }
63 |
64 | private static void ConfigureLogging(Level level)
65 | {
66 | var repository = LogManager.GetRepository(Assembly.GetEntryAssembly());
67 | var appender = new ConsoleAppender
68 | {
69 | Layout = new PatternLayout("%message%newline")
70 | };
71 | ((Hierarchy)repository).Root.Level = level;
72 | BasicConfigurator.Configure(repository, appender);
73 | }
74 |
75 | private static void Run(int port, int maxConnections, int statusInterval, string logFile)
76 | {
77 | Console.WriteLine("Note: Press to stop server.");
78 |
79 | // Create and run application, which does all the real work.
80 | _app = new Application(
81 | port, maxConnections, statusInterval,
82 | Path.Combine(Directory.GetCurrentDirectory(), logFile));
83 | _app.Run(TerminateCommandReceived);
84 |
85 | Console.CancelKeyPress += delegate { StopServer(); };
86 |
87 | // Block on exit signal to keep process running until exit event encountered
88 | _exitSignal.Wait();
89 | }
90 |
91 | private static void StopServer()
92 | {
93 | Console.WriteLine("Stopping server...");
94 | try
95 | {
96 | _app.Dispose();
97 | }
98 | catch { }
99 | }
100 |
101 | private static void TerminateCommandReceived()
102 | {
103 | Console.WriteLine("Terminate command received.");
104 | StopServer();
105 | _exitSignal.Set();
106 | }
107 | }
108 | }
--------------------------------------------------------------------------------
/Server/QueueingLogWriter.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using System.Threading;
5 |
6 | namespace Server
7 | {
8 | ///
9 | /// Writes numeric values to the specified .
10 | /// Does this by managing an in-memory queue of values to be written,
11 | /// and processing that queue on a background worker thread. De-duplicates
12 | /// the values, such that only unique values are written to the stream.
13 | ///
14 | ///
15 | /// Queueing design here intended to prevent file I/O from blocking
16 | /// calls to WriteUnique. Processing threads will be
17 | /// calling that as they are reading/processing data from network
18 | /// connections, and we don't want to slow them down.
19 | ///
20 | /// This class has a few shared resources that it must synchronize:
21 | /// - The hash set used to enforce uniqueness of values we write.
22 | /// - The underlying queue.
23 | ///
24 | /// Uses manual reset event (_workerSignal) to let the worker
25 | /// thread rest when there are no items on the queue. When new items
26 | /// are added, it gets signaled and picks back up again.
27 | ///
28 | public class QueueingLogWriter : IDisposable
29 | {
30 | private readonly TextWriter _writer;
31 | private readonly Queue _queue;
32 | private readonly HashSet _deduper;
33 | private readonly object _lock;
34 | private readonly ManualResetEventSlim _stopSignal;
35 | private readonly ManualResetEventSlim _workerSignal;
36 |
37 | public QueueingLogWriter(TextWriter writer)
38 | {
39 | _writer = writer;
40 | _queue = new Queue();
41 | _deduper = new HashSet();
42 | _lock = new object();
43 | _stopSignal = new ManualResetEventSlim();
44 | _workerSignal = new ManualResetEventSlim();
45 |
46 | StartWatchingQueue();
47 | }
48 |
49 | public bool WriteUnique(int value)
50 | {
51 | lock (_lock)
52 | {
53 | // If value is unique, add to queue and return quickly.
54 | if (!_deduper.Add(value))
55 | {
56 | return false;
57 | }
58 | _queue.Enqueue(value);
59 | _workerSignal.Set();
60 | return true;
61 | }
62 | }
63 |
64 | public void Dispose()
65 | {
66 | StopWatchingQueue();
67 | }
68 |
69 | private void StartWatchingQueue()
70 | {
71 | // Create background thread to watch and process queue.
72 | _stopSignal.Reset();
73 | var thread = new Thread(new ThreadStart(() =>
74 | {
75 | int value;
76 | bool flush;
77 | var total = 0;
78 | while (!_stopSignal.IsSet) // Allows us to stop processing if writer is being disabled.
79 | {
80 | _workerSignal.Wait(); // Block here until there is work to do (values to write).
81 | if (_stopSignal.IsSet) break; // Check again. May have changed by now (see StopWatchingQueue).
82 |
83 | // Synchronize access just to the queue here. Get value as quickly
84 | // as possible and release lock. Write to file afterword.
85 | lock (_lock)
86 | {
87 | total++;
88 | value = _queue.Dequeue();
89 | flush = (total % 100000) == 0; // We'll force a flush every 10k values for good measure.
90 |
91 | if (_queue.Count == 0)
92 | {
93 | // If there's no items left in queue, we can return worker to resting state.
94 | _workerSignal.Reset();
95 | flush = true; // Also might as well force flush if we've written all values.
96 | }
97 | }
98 |
99 | _writer.WriteLine(value);
100 |
101 | if (flush)
102 | {
103 | _writer.Flush();
104 | }
105 | }
106 | }));
107 | thread.Start();
108 | }
109 |
110 | private void StopWatchingQueue()
111 | {
112 | _writer.Flush();
113 | _workerSignal.Set();
114 | _stopSignal.Set();
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | Introduction
2 | ============
3 | This is an exercise to experiment with raw TCP socket comms, using .NET Core, striving to optimize throughput. Just for fun.
4 |
5 | In some areas of the implementation there are CLR constructs (async/await) and BCL classes that could make this simpler, but I'm striving to do all the thread management, parallelization, and synchronization of resources manually. Also just for fun.
6 |
7 | The project contains a server app and a client app, which behave as follows:
8 | - Server accepts input from up to five concurrent clients (port 4000 by default).
9 | - Server expects lines of input containing exactly nine decimal digits (zero-padded if necessary) and terminated with a server-native newline sequence.
10 | - Server writes any unique numbers it receives to a log file.
11 | - Server writes a status report to the console every 10s.
12 | - If client sends a line of input with a "terminate" command, server will close all connections and shut itself down.
13 |
14 | Building
15 | ========
16 | #### Get .NET Core SDK with .NET 1.1 runtime
17 |
18 | On Windows:
19 | ```
20 | choco install -y dotnetcore-sdk
21 | ```
22 |
23 | #### Clone repo
24 |
25 | ```
26 | git clone https://github.com/rtomac/dotnetcore-socket-server.git
27 | ```
28 |
29 | #### Build
30 |
31 | ```
32 | cd dotnetcore-socket-server
33 | build.bat
34 | ```
35 |
36 | build.bat simply calls `dotnet restore`, `dotnet build`, and `dotnet test`.
37 |
38 | Running
39 | =======
40 | There are two command-line applications here:
41 |
42 | #### Server
43 |
44 | Fire it up, and it will start listening for connections on port 4000.
45 |
46 | ```
47 | run-server.bat
48 | ```
49 |
50 | `run-server.bat` simply calls `dotnet run` from the server app/project dir.
51 |
52 | For command-line usage of the server app, run:
53 |
54 | ```
55 | cd Server
56 | dotnet run -- -?
57 | ```
58 |
59 | #### Client
60 |
61 | When started, it will connect to the server on port 4000 and start blasting data at it.
62 |
63 | ```
64 | run-client.bat
65 | ```
66 |
67 | `run-client.bat` simply calls `dotnet run` from the client app/project dir.
68 |
69 | Again, for command-line usage, run:
70 |
71 | ```
72 | cd Client
73 | dotnet run -- -?
74 | ```
75 |
76 | The code
77 | =========
78 | Here is a brief overview of the code/class design (in the server app):
79 | - `Program`: Entry point for the server app. Handles command-line parsing, etc., but delegates the real work to the `Application` class.
80 | - `Application`: Class that orchestrates and controls the lifetime of the different components in the application, including the socket listener, the log file writer, and the status reporter.
81 | - `LocalhostSocketListener`: Binds to localhost:4000 (or specified port), listens for incoming connections on a background thread, and dispatches new threads to handle each of them.
82 | - `SocketStreamReader`: Reads data from a network stream over a socket connection, parses and processes it, and hands the good stuff off to other components for further processing (i.e. writing to log file).
83 | - `QueueingLogWriter`: Writes de-duped values transmitted to the server into a log file. Does this by managing an in-memory queue of values to be written, and processing that queue on a background worker thread, to isolate the file I/O and prevent it from blocking the other threads that are processing data from their network connections.
84 | - `StatusReporter`: Keeps track of aggregate statistics and periodically (on a background thread) writes a status report to stdout.
85 |
86 | The source code has a bit more documentation on how all of these classes are doing their job. Check it out.
87 |
88 | Key optimizations
89 | =================
90 | - Multithreading and parallelization: All distinct components in the application are running on separate threads. On multi-core systems, that means true parallelization.
91 | - Non-blocking interactions: Interactions between components on different threads are made to be as lightweight as possible to ensure that work in one component on one thread can never block work in another component on another thread. In some cases that means asynchronous interaction (e.g. in-memory queue) and in others it just means minimizing the amount of time that access to shared resources are being synchronzied. In no cases is work on one thread held up by I/O on another thread.
92 | - Binary data processing: All data processing is on raw binary data. No reliance on string conversion and/or parsing. (Both a performance and memory optimization.)
93 |
94 | Further optimizations to try
95 | ============================
96 | A note on some other things to try to improve throughput and resiliency:
97 | - Read bigger blocks of data in from network stream at a single time: Right now, we're reading in 10- or 11-byte blocks of data at a time (known chunk size of a single value). There may be overhead associated with each read--it may be faster to read in 1k of data at a time, for instance, and send that through processing.
98 | - Parallelize reading of data from network stream and data processing/validation: It would be theoretically possible to read raw data from the network stream and shove it on a queue for a processing worker to pick up and analyze/process. Could improve throughput. (Creates a new challenge, which is messaging back to I/O thread to terminate connection when invalid data is detected.)
99 |
--------------------------------------------------------------------------------
/Client/Program.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.CommandLineUtils;
2 | using System;
3 | using System.Net;
4 | using System.Net.Sockets;
5 | using System.Security.Cryptography;
6 | using System.Text;
7 |
8 | namespace Client
9 | {
10 | ///
11 | /// Entry point for client console app.
12 | ///
13 | ///
14 | /// Creates a socket connection on the specified port and starts blasting the
15 | /// specified number of values to the server.
16 | ///
17 | /// The _exitSignal reset event is used to gracefully stop sending data
18 | /// and close the socket connection if/when the user types CTRL-C.
19 | ///
20 | class Program
21 | {
22 | private static Socket _socket;
23 | private static bool _exitSignal;
24 |
25 | static int Main(string[] args)
26 | {
27 | var port = 4000;
28 | var numberToSend = 10000000;
29 | var terminate = false;
30 |
31 | var cmd = new CommandLineApplication()
32 | {
33 | FullName = "A console application that will send data to the number server.",
34 | Name = "dotnet run --"
35 | };
36 | var portOption = cmd.Option("-p|--port ", $"The port on which the server is running. Default: {port}", CommandOptionType.SingleValue);
37 | var numberOption = cmd.Option("-n|--number ", "The number of values to send to the server. Default: 10M", CommandOptionType.SingleValue);
38 | var terminateOption = cmd.Option("-t|--terminate", "Send a terminate command after all values are sent to the server.", CommandOptionType.NoValue);
39 | cmd.HelpOption("-?|-h|--help");
40 | cmd.OnExecute(() =>
41 | {
42 | if (portOption.HasValue()) port = int.Parse(portOption.Value());
43 | if (numberOption.HasValue()) numberToSend = int.Parse(numberOption.Value());
44 | terminate = terminateOption.HasValue();
45 |
46 | return Run(port, numberToSend, terminate);
47 | });
48 | return cmd.Execute(args);
49 | }
50 |
51 | private static int Run(int port, int numberToSend, bool terminate)
52 | {
53 | if (!ConnectToServer(port))
54 | {
55 | return 1;
56 | }
57 |
58 | Console.CancelKeyPress += delegate
59 | {
60 | _exitSignal = true;
61 | DisconnectFromServer();
62 | };
63 |
64 | SendData(numberToSend, terminate);
65 | DisconnectFromServer();
66 |
67 | return 0;
68 | }
69 |
70 | private static bool ConnectToServer(int port)
71 | {
72 | Console.WriteLine($"Connecting to port {port}...");
73 |
74 | _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
75 | try
76 | {
77 | _socket.Connect(new IPEndPoint(IPAddress.Loopback, port));
78 | }
79 | catch (SocketException)
80 | {
81 | Console.WriteLine("Failed to connect to server.");
82 | return false;
83 | }
84 |
85 | Console.WriteLine("Connected.");
86 | return true;
87 | }
88 |
89 | private static void SendData(int numberToSend, bool terminate)
90 | {
91 | var rng = RandomNumberGenerator.Create();
92 | int count = 0;
93 | var bytes = new byte[4];
94 | uint value = 0;
95 |
96 | while (!_exitSignal && count++ < numberToSend)
97 | {
98 | rng.GetBytes(bytes);
99 | value = BitConverter.ToUInt32(bytes, 0);
100 | try
101 | {
102 | _socket.Send(Encoding.ASCII.GetBytes(ToPaddedString(value) + Environment.NewLine));
103 | }
104 | catch (SocketException)
105 | {
106 | Console.WriteLine("Socket connection forcibly closed.");
107 | break;
108 | }
109 |
110 | if (count % 100000 == 0)
111 | {
112 | Console.WriteLine($"{count} values sent.");
113 | }
114 | }
115 |
116 | if (!_exitSignal && terminate)
117 | {
118 | _socket.Send(Encoding.ASCII.GetBytes("terminate" + Environment.NewLine));
119 | Console.WriteLine($"Terminate command sent.");
120 | }
121 | }
122 |
123 | private static void DisconnectFromServer()
124 | {
125 | _socket.Shutdown(SocketShutdown.Both);
126 | _socket.Dispose();
127 | }
128 |
129 | private static string ToPaddedString(uint value)
130 | {
131 | var str = value.ToString();
132 | if (str.Length > 9)
133 | {
134 | str = str.Substring(0, 9);
135 | }
136 | else if (str.Length < 9)
137 | {
138 | str = (new String('0', 9 - str.Length)) + str;
139 | }
140 | return str;
141 | }
142 | }
143 | }
--------------------------------------------------------------------------------
/Server/SocketStreamReader.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Net.Sockets;
3 | using System.Text;
4 |
5 | namespace Server
6 | {
7 | ///
8 | /// Reads data from a network stream over a socket connection,
9 | /// processes it, and calls back to event handlers to do something with.
10 | ///
11 | ///
12 | /// This class contains all logic for reading numeric values
13 | /// from the stream and ensuring that the data is in the correct
14 | /// format. If it's not, it ceases reading (which would allows
15 | /// the parent thread to close the socket connection and complete/die).
16 | ///
17 | /// It's also responsible to detect the 'terminate' sequence/command.
18 | ///
19 | /// For performance, bytes are read and processed mathematically to construct
20 | /// the numbers, rather than transforming them into strings.
21 | ///
22 | /// Server-native new line sequences are properly detected to support
23 | /// different OS platforms.
24 | ///
25 | public class SocketStreamReader
26 | {
27 | public static int ValueSize => 9;
28 | public static byte[] NewLineSequence => Encoding.ASCII.GetBytes(Environment.NewLine);
29 | public static int NewLineSize => NewLineSequence.Length;
30 | public static int ChunkSize => ValueSize + NewLineSize;
31 |
32 | private static readonly string _terminateSequence = "terminate" + Environment.NewLine;
33 |
34 | private readonly ISocketConnectionProxy _socketConnection;
35 |
36 | public SocketStreamReader(Socket socket) : this(new SocketConnectionProxy(socket))
37 | {
38 | }
39 | public SocketStreamReader(ISocketConnectionProxy socketConnection)
40 | {
41 | _socketConnection = socketConnection;
42 | }
43 |
44 | public void Read(Action valueReadCallback, Action terminationCallback = null)
45 | {
46 | var buffer = new byte[ChunkSize];
47 | int bytesRead;
48 |
49 | // Read data in blocks of known chunk size.
50 | while ((bytesRead = TryReadChunk(buffer)) == ChunkSize)
51 | {
52 | // Convert to 32-bit int. If not valid number, we're done.
53 | if (!TryConvertToInt32(buffer, out int value))
54 | {
55 | // Check for terminate command. If found, invoke callback
56 | // so caller can act on it.
57 | if (IsTerminateSequence(buffer))
58 | {
59 | terminationCallback?.Invoke();
60 | break;
61 | }
62 | break;
63 | }
64 |
65 | // When we get a good value, invoke callback so value can be processed.
66 | valueReadCallback?.Invoke(value);
67 | }
68 | }
69 |
70 | private int TryReadChunk(byte[] buffer)
71 | {
72 | // We can't be sure that we're receiving the full 9+ bytes at the same
73 | // time, so loop to read data until we fill the buffer. Under normal
74 | // circumstances, we should, in which case there's just a single
75 | // Receive call here.
76 |
77 | int bytesRead;
78 | int bufferOffset = 0;
79 | while (bufferOffset < buffer.Length)
80 | {
81 | bytesRead = _socketConnection.Receive(buffer, bufferOffset, buffer.Length - bufferOffset);
82 | if (bytesRead == 0)
83 | {
84 | break;
85 | }
86 | bufferOffset += bytesRead;
87 | }
88 | return bufferOffset;
89 | }
90 |
91 | private bool TryConvertToInt32(byte[] buffer, out int value)
92 | {
93 | value = 0;
94 |
95 | // Make sure chunk correctly terminates with new line sequence.
96 | // Loop here to support Windows with two-byte sequence.
97 | for (var i = 0; i < NewLineSize; i++)
98 | {
99 | if (buffer[ValueSize + i] != NewLineSequence[i])
100 | {
101 | return false;
102 | }
103 | }
104 |
105 | // Read through first 9 bytes and look for numeric digit. Use
106 | // the proper multiplier for its place and construct the numeric value.
107 | // If we find a non-numeric char, we short-circuit and return false.
108 | byte b;
109 | int place;
110 | for (var i = 0; i < ValueSize; i++)
111 | {
112 | b = buffer[i];
113 | if (b < 48 || b > 57)
114 | {
115 | return false;
116 | }
117 | place = (int)Math.Pow(10, ValueSize - i - 1);
118 | value += ((b - 48) * place);
119 | }
120 | return true;
121 | }
122 |
123 | private bool IsTerminateSequence(byte[] buffer)
124 | {
125 | if (buffer[0] == 84 || buffer[0] == 116) // Check first byte before transforming to string.
126 | {
127 | return Encoding.ASCII.GetString(buffer)
128 | .Equals(_terminateSequence, StringComparison.OrdinalIgnoreCase);
129 | }
130 | return false;
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/ServerTests/SocketStreamReaderTests.cs:
--------------------------------------------------------------------------------
1 | using FakeItEasy;
2 | using Server;
3 | using System;
4 | using System.Collections.Generic;
5 | using System.Text;
6 | using Xunit;
7 |
8 | namespace ServerTests
9 | {
10 | public class SocketStreamReaderTests
11 | {
12 | [Fact]
13 | public void TestReadSuccess()
14 | {
15 | var socket = A.Fake();
16 | var reader = new SocketStreamReader(socket);
17 | var values = new List();
18 |
19 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("003456789"));
20 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("023456789"));
21 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("123456789"));
22 |
23 | reader.Read(value => values.Add(value));
24 |
25 | Assert.Equal(
26 | new int[] { 123456789, 23456789, 3456789 },
27 | values.ToArray());
28 | }
29 |
30 | [Fact]
31 | public void TestReadSuccessWithLatentReceipt()
32 | {
33 | var socket = A.Fake();
34 | var reader = new SocketStreamReader(socket);
35 | var values = new List();
36 |
37 | StubReceive(socket, 5, SocketStreamReader.ChunkSize - 5, GetBytes("6789"));
38 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("12345", false));
39 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("123456789"));
40 |
41 | reader.Read(value => values.Add(value));
42 |
43 | Assert.Equal(
44 | new int[] { 123456789, 123456789 },
45 | values.ToArray());
46 | }
47 |
48 | [Fact]
49 | public void TestReadSuccessWithTerminateSequence()
50 | {
51 | var socket = A.Fake();
52 | var reader = new SocketStreamReader(socket);
53 | var values = new List();
54 | var terminated = false;
55 |
56 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("123456789"));
57 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("terminate"));
58 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("123456789"));
59 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("123456789"));
60 |
61 | reader.Read(value => values.Add(value), () => terminated = true);
62 |
63 | Assert.Equal(
64 | new int[] { 123456789, 123456789 },
65 | values.ToArray());
66 | Assert.True(terminated);
67 | }
68 |
69 | [Theory]
70 | [InlineData("1")]
71 | [InlineData("12")]
72 | [InlineData("123")]
73 | [InlineData("1234")]
74 | [InlineData("12345")]
75 | [InlineData("123456")]
76 | [InlineData("1234567")]
77 | [InlineData("12345678")]
78 | [InlineData("123456789")]
79 | [InlineData("123456789 ")]
80 | public void TestReadInvalidInputFirstLine(string line)
81 | {
82 | var socket = A.Fake();
83 | var reader = new SocketStreamReader(socket);
84 | var values = new List();
85 |
86 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes(line, false));
87 | reader.Read(value => values.Add(value));
88 |
89 | Assert.Equal(0, values.Count);
90 | }
91 |
92 | [Fact]
93 | public void TestReadInvalidSubsequentLine()
94 | {
95 | var socket = A.Fake();
96 | var reader = new SocketStreamReader(socket);
97 | var values = new List();
98 |
99 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("123456789"));
100 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("12345678", false));
101 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("003456789"));
102 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("023456789"));
103 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("123456789"));
104 |
105 | reader.Read(value => values.Add(value));
106 |
107 | Assert.Equal(
108 | new int[] { 123456789, 23456789, 3456789 },
109 | values.ToArray());
110 | }
111 |
112 | [Fact]
113 | public void TestTerminations()
114 | {
115 | var socket = A.Fake();
116 | var reader = new SocketStreamReader(socket);
117 | var values = new List();
118 |
119 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("123456789"));
120 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("12345678", false));
121 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("003456789"));
122 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("023456789"));
123 | StubReceive(socket, 0, SocketStreamReader.ChunkSize, GetBytes("123456789"));
124 |
125 | reader.Read(value => values.Add(value));
126 |
127 | Assert.Equal(
128 | new int[] { 123456789, 23456789, 3456789 },
129 | values.ToArray());
130 | }
131 |
132 | private static byte[] GetBytes(string str, bool newline = true)
133 | {
134 | return Encoding.ASCII.GetBytes(str + (newline ? Environment.NewLine : ""));
135 | }
136 |
137 | private static void StubReceive(ISocketConnectionProxy socket, int offset, int size, byte[] bytes)
138 | {
139 | A.CallTo(() => socket.Receive(A.Ignored, offset, size))
140 | .Invokes((byte[] buffer, int offsetArg, int sizeArg) => { bytes.CopyTo(buffer, offset); })
141 | .Returns(bytes.Length)
142 | .Once();
143 | }
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/Server/LocalhostSocketListener.cs:
--------------------------------------------------------------------------------
1 | using log4net;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Net;
5 | using System.Net.Sockets;
6 | using System.Threading;
7 |
8 | namespace Server
9 | {
10 | ///
11 | /// Opens a server socket on the local host bound to the specified port which
12 | /// listens for incoming connections and dispatches new threads to handle each
13 | /// of those connections.
14 | ///
15 | ///
16 | /// Manages the lifetime of threads created to handle connections. Keeps
17 | /// track of the number of connections, and refuses connections past the
18 | /// specified threshold.
19 | ///
20 | /// The server socket thread stays alive by virtue of the Accept method.
21 | ///
22 | /// Each connection thread will stay alive as long as the callback in
23 | /// the class is processing it. Once that completes,
24 | /// it will disconnect the client and let the thread die.
25 | ///
26 | /// The only shared resource in this class that needs to be synchronized
27 | /// is the list of sockets that we use to keep track of the open
28 | /// connections.
29 | ///
30 | public class LocalhostSocketListener
31 | {
32 | private readonly int _port;
33 | private readonly int _maxConnections;
34 | private Socket _socket;
35 | private List _connections;
36 | private object _connectionsLock;
37 |
38 | private static ILog _log = LogManager.GetLogger(typeof(LocalhostSocketListener));
39 |
40 | public LocalhostSocketListener(int port, int maxConnections)
41 | {
42 | _port = port;
43 | _maxConnections = maxConnections;
44 | _connections = new List();
45 | _connectionsLock = new object();
46 | }
47 |
48 | public void Start(Action newSocketConnectionCallback)
49 | {
50 | // Start thread for server socket that will listen for
51 | // new connections.
52 | var thread = new Thread(new ThreadStart(() =>
53 | {
54 | BindAndListen(newSocketConnectionCallback);
55 | }));
56 | thread.Start();
57 | }
58 |
59 | public void Stop()
60 | {
61 | lock (_connectionsLock)
62 | {
63 | // Close socket connections (thereby release threads)
64 | // for each open connection.
65 | _connections.ForEach(ShutdownSocket);
66 | }
67 |
68 | if (_socket != null)
69 | {
70 | // Close server socket and stop listening on port.
71 | // Will release the thread on which that's running.
72 | _socket.Dispose();
73 | _socket = null;
74 | }
75 | }
76 |
77 | private void BindAndListen(Action newSocketConnectionCallback)
78 | {
79 | _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
80 | _socket.Bind(new IPEndPoint(IPAddress.Loopback, _port));
81 | _socket.Listen(_maxConnections);
82 |
83 | _log.Info($"Listening for socket connections on port {_port}...");
84 |
85 | BlockAndAcceptConnections(newSocketConnectionCallback);
86 | }
87 |
88 | private void BlockAndAcceptConnections(Action newSocketConnectionCallback)
89 | {
90 | while (_socket != null)
91 | {
92 | Socket connection;
93 | try
94 | {
95 | // Blocking method
96 | connection = _socket.Accept();
97 | }
98 | catch (SocketException ex)
99 | {
100 | _log.Debug($"Socket accept failed: {ex.Message}");
101 | continue;
102 | }
103 |
104 | if (ShouldRefuseConnection())
105 | {
106 | // We already have the max number of connections.
107 | ShutdownSocket(connection);
108 | _log.Info("Socket connection refused.");
109 | continue;
110 | }
111 |
112 | _log.Info("Socket connection accepted.");
113 |
114 | DispatchThreadForNewConnection(connection, newSocketConnectionCallback);
115 | }
116 | }
117 |
118 | private bool ShouldRefuseConnection()
119 | {
120 | lock (_connectionsLock)
121 | {
122 | return _connections.Count >= _maxConnections;
123 | }
124 | }
125 |
126 | private void DispatchThreadForNewConnection(Socket connection, Action newSocketConnectionCallback)
127 | {
128 | // Create thread to manage new socket connection.
129 | // Will stay alive as long as callback is executing.
130 | var thread = new Thread(new ThreadStart(() =>
131 | {
132 | ExecuteCallback(connection, newSocketConnectionCallback);
133 |
134 | lock (_connectionsLock)
135 | {
136 | _connections.Remove(connection);
137 | }
138 | }));
139 | thread.Start();
140 |
141 | lock (_connectionsLock)
142 | {
143 | _connections.Add(connection);
144 | }
145 | }
146 |
147 | private static void ExecuteCallback(Socket connection, Action newSocketConnectionCallback)
148 | {
149 | try
150 | {
151 | newSocketConnectionCallback(connection);
152 | }
153 | catch (SocketException ex)
154 | {
155 | _log.Debug($"Socket connection closed forcibly: {ex.Message}");
156 | }
157 | finally
158 | {
159 | ShutdownSocket(connection);
160 | _log.Info("Socket connection closed.");
161 | }
162 | }
163 |
164 | private static void ShutdownSocket(Socket socket)
165 | {
166 | try
167 | {
168 | socket.Shutdown(SocketShutdown.Both);
169 | }
170 | catch (SocketException ex)
171 | {
172 | _log.Debug($"Socket could not be shutdown: {ex.Message}");
173 | }
174 | }
175 | }
176 | }
177 |
--------------------------------------------------------------------------------