目錄
介紹
隨着即將出現的.NET 5和需要從.NET 4.8和.NET Core遷移的人,此源代碼旨在提供一個示例,說明如何通過本機TCP建立高性能的跨平臺Client Server消息交換。符合.NET Standard,而無需與.NET Framework或.NET Core或其他任何特定關係。
此外,它還解決了TCP會話遇到的一個常見問題:消息自旋鎖問題和異步內存泄漏問題和/或CancellationToken異常問題,這些問題在TCP Client Server實現中都很常見。
TCP Server
爲了簡單起見,我們將使用CLI項目,項目類型本身可以是.NET 5或.NET Core或.NET Framework。客戶端和服務器均以.NET Standard語法編碼,因此它們可以與所有這三個無縫連接。
Main()服務器主機的典型CLI 代碼塊:
using System;
public static class Program
{
public static void Main()
{
Console.WriteLine("Press esc key to stop");
int i = 0;
void PeriodicallyClearScreen()
{
i++;
if (i > 15)
{
Console.Clear();
Console.WriteLine("Press esc key to stop");
i = 0;
}
}
//Write the host messages to the console
void OnHostMessage(string input)
{
PeriodicallyClearScreen();
Console.WriteLine(input);
}
var BLL = new ServerHost.Host(OnHostMessage);
BLL.RunServerThread();
while (Console.ReadKey().Key != ConsoleKey.Escape)
{
Console.Clear();
Console.WriteLine("Press esc key to stop");
}
Console.WriteLine("Attempting clean exit");
BLL.WaitForServerThreadToStop();
Console.WriteLine("Exiting console Main.");
}
}
基本的CLI管道在這裏,沒什麼異常。
Esc鍵退出“客戶端”窗口,每隔15條消息就會清除該窗口(僅用於調試/演示目的)。不要在生產版本中將實際的網絡消息寫入控制檯。
此代碼塊的唯一關鍵之處在於,爲了保持高性能,客戶端和服務器託管在專用線程中。也就是說,與執行Main塊的線程分開。該功能包含在RunServerThread()函數中。
爲此,我們將創建一個Host類並將其添加到.NET Standard庫項目類型中。.NET5,.NET Framework和.NET Core項目可以引用.NET Standard庫,因此它確實是最靈活的選擇在撰寫本文時。
向其添加以下代碼:
using System;
using System.IO;
using System.Net.Sockets;
using System.Threading;
public class Host
{
#region Public Functions
public virtual void RunServerThread()
{
this.ServerThread.Start();
this.OnHostMessages.Invoke("Server started");
}
public virtual void WaitForServerThreadToStop()
{
this.Server.ExitSignal = true;
this.OnHostMessages.Invoke("Exit Signal sent to server thread");
this.OnHostMessages.Invoke("Joining server thread");
this.ServerThread.Join();
this.OnHostMessages.Invoke("Server thread has exited gracefully");
}
#endregion
}
該RunServerThread()函數啓動將運行服務器的Thread。
該WaitForServerThreadToStop()函數向服務器線程發出信號,通知它應該在儘可能快的時間正常退出,然後我們將服務器線程加入到調用線程(Main()在本例中爲線程),否則CLI窗口只會終止/中止,而我們不會不想從清理/異常處理的角度做;優美/乾淨的退出是可取的。
將支持變量和構造方法添加到Server Host類中:
#region Public Delegates
public delegate void HostMessagesDelegate(string message);
#endregion
#region Variables
protected readonly StandardServer.Server Server;
protected readonly Thread ServerThread;
#region Callbacks
protected readonly HostMessagesDelegate OnHostMessages;
#endregion
#endregion
#region Constructor
public Host(HostMessagesDelegate onHostMessages)
{
this.OnHostMessages = onHostMessages ??
throw new ArgumentNullException(nameof(onHostMessages));
this.Server = new StandardServer.Server(this.OnMessage, this.ConnectionHandler);
this.ServerThread = new Thread(this.Server.Run);
}
#endregion
#region Protected Functions
protected virtual void OnMessage(string message)
{
this.OnHostMessages.Invoke(message);
}
protected virtual void ConnectionHandler(NetworkStream connectedAutoDisposedNetStream)
{
}
#endregion
- ServerThread:託管服務器的線程(Server類)
- OnHostMessages和 OnMessage:僅用於演示目的,將消息從TCP連接器線程推送到客戶端CLI窗口。(注意:對於WinForm應用程序,如果要向最終用戶顯示消息,則必須跨GUI線程執行ISynchronizeInvoke操作。Console.Write沒有此限制。)
- ConnectionHandler:我們將回到這一點。
- Server:我們將要編寫的TCP Server類代碼。
創建一個名爲Server的類,並向其中添加以下代碼:
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
public class Server
{
#region Public Functions
public virtual void Run()
{
if (this.IsRunning)
return; //Already running, only one running instance allowed.
this.IsRunning = true;
this.ExitSignal = false;
while (!this.ExitSignal)
this.ConnectionLooper();
this.IsRunning = false;
}
#endregion
}
由於Run()是在專用線程中運行,因此您可能需要添加一個Try Catch塊來調試所有未處理的異常,並根據需要添加日誌記錄。爲了清楚起見,我將由您自己決定。
Run()函數處理ExitSignal檢查,並將其餘的TCP邏輯推入ConnectionLooper()函數。此時,您可能會認爲這是一個無限的自旋循環,您是正確的,但我們將使用await來解決這個問題。
將支持變量添加Constructor到Server類中:
#region Public Properties
private volatile bool _ExitSignal;
public virtual bool ExitSignal
{
get => this._ExitSignal;
set => this._ExitSignal = value;
}
#endregion
#region Public Delegates
public delegate void ConnectionHandlerDelegate(NetworkStream connectedAutoDisposedNetStream);
public delegate void MessageDelegate(string message);
#endregion
#region Variables
#region Init/State
protected readonly int AwaiterTimeoutInMS;
protected readonly string Host;
protected readonly int Port;
protected readonly int MaxConcurrentListeners;
protected readonly TcpListener Listener;
protected bool IsRunning;
protected List<Task> TcpClientTasks = new List<Task>();
#endregion
#region Callbacks
protected readonly ConnectionHandlerDelegate OnHandleConnection;
protected readonly MessageDelegate OnMessage;
#endregion
#endregion
#region Constructor
public Server(
MessageDelegate onMessage,
ConnectionHandlerDelegate connectionHandler,
string host = "0.0.0.0",
int port = 8080,
int maxConcurrentListeners = 10,
int awaiterTimeoutInMS = 500
)
{
this.OnMessage = onMessage ?? throw new ArgumentNullException(nameof(onMessage));
this.OnHandleConnection = connectionHandler ??
throw new ArgumentNullException(nameof(connectionHandler));
this.Host = host ?? throw new ArgumentNullException(nameof(host));
this.Port = port;
this.MaxConcurrentListeners = maxConcurrentListeners;
this.AwaiterTimeoutInMS = awaiterTimeoutInMS;
this.Listener = new TcpListener(IPAddress.Parse(this.Host), this.Port);
}
#endregion
這裏沒有什麼幻想,唯一需要注意的是,_ExitSignal成員變量的類型爲volatile,這有助於防止CLI Main()線程與服務器主機線程之間的陳舊獲取/設置。對於演示目的,這比Lock或Mutex更爲簡單,並且可能佔用較少的CPU/內存。
IP 0.0.0.0是IPAddress Any。您可以根據需要更改或刪除默認值。
在這裏,我們保持TcpClient連接任務(異步任務)的List,這可能是大小(maxConcurrentListeners)的數組代替List。如果這樣做,它的運行速度可能會快幾微秒。
OnMessage 僅用於演示目的,以在CLI窗口中顯示消息。
OnHandleConnection 是回調,此類的使用者將在該回調中編碼其業務案例專用的網絡邏輯。
添加以下代碼以實現ConnectionLooper()函數:
#region Protected Functions
protected virtual void ConnectionLooper()
{
while (this.TcpClientTasks.Count < this.MaxConcurrentListeners)
{
var AwaiterTask = Task.Run(async () =>
{
this.ProcessMessagesFromClient(await this.Listener.AcceptTcpClientAsync());
});
this.TcpClientTasks.Add(AwaiterTask);
}
int RemoveAtIndex = Task.WaitAny(this.TcpClientTasks.ToArray(), this.AwaiterTimeoutInMS);
if (RemoveAtIndex > 0)
this.TcpClientTasks.RemoveAt(RemoveAtIndex);
}
#endregion
在這裏,我們的服務器正在異步偵聽大量TCP連接(僅限於int MaxConcurrentListeners),這可以防止內部.NET ThreadPool線程耗盡。這取決於vm/server/host上的CPU內核數。您的主機越強大,您將能夠支持更多的併發監聽器。
Task await建立一個線程的延續,它進行到ProcessMessagesFromClient當客戶端連接成功,該函數是其中發生所述網絡通信的實際處理。
然後WaitAny(),我們在這些等待的任務列表中,但是對於給定的毫秒數,這部分纔是關鍵。如果沒有超時,我們將不會檢測到ExitSignal變量的更改,而這是在多線程環境中正常退出的關鍵。WaitAny防止自旋鎖定,同時還檢測退出的TCPListener Tasks (.NET內部ThreadPool線程)。
它還避免了內存泄漏無限/過多的等待,線程或任務,同時仍異步處理連接。
如果任務中止,則拋出AcceptTcpClientAsync異常,您可能需要爲每個任務提供一個CancellationToken,並在您要求正常退出的任務列表中的每個任務。由於我們從不調用Listener.Stop(),因此當主機Thread正常退出時,此示例代碼中的.NET GC在內部進行了清理。
當WaitAny檢測到某個任務已完成時,將返回RemoveAt索引,以便我們可以簡單地從列表中刪除該任務,在執行ExitSignal檢查後的下一個遍歷中重新添加一個新任務。
添加以下代碼以實現該ProcessMessagesFromClient()函數:
protected virtual void ProcessMessagesFromClient(TcpClient Connection)
{
using (Connection)
{
if (!Connection.Connected)
return;
using (var netstream = Connection.GetStream())
{
this.OnHandleConnection.Invoke(netstream);
}
}
}
ThreadPool成功連接後,此函數在連續線程中運行,通常不會與ServerThreador Main()線程相同。如果您需要嚴格執行,可以添加ConfigureAwait(false)。
在TCPClient和NetworkStream使用Using語法自動關閉和釋放,這樣,這個類的消費者並不需要關注自身與清理。
OnHandleConnection.Invoke本質上從我們現在會寫的Host類中調用ConnectionHandler()函數:
protected virtual void ConnectionHandler(NetworkStream connectedAutoDisposedNetStream)
{
if (!connectedAutoDisposedNetStream.CanRead && !connectedAutoDisposedNetStream.CanWrite)
return; //We need to be able to read and write
var writer = new StreamWriter(connectedAutoDisposedNetStream) { AutoFlush = true };
var reader = new StreamReader(connectedAutoDisposedNetStream);
var StartTime = DateTime.Now;
int i = 0;
while (!this.Server.ExitSignal) //Tight network message-loop (optional)
{
var JSON_Helper = new Helper.JSON();
string JSON = JSON_Helper.JSONstring();
string Response;
try //Communication block
{
//Synchronously send some JSON to the connected client
writer.WriteLine(JSON);
//Synchronously wait for a response from the connected client
Response = reader.ReadLine();
}
catch (IOException ex)
{
_ = ex; //Add Debug breakpoint and logging here
return; //Swallow exception and Exit function on network error
}
//Put breakpoint here to inspect the JSON string return by the connected client
Helper.SomeDataObject Data = JSON_Helper.DeserializeFromJSON(Response);
_ = Data;
//Update stats
i++;
var ElapsedTime = DateTime.Now - StartTime;
if (ElapsedTime.TotalMilliseconds >= 1000)
{
this.OnHostMessages.Invoke("Messages per second: " + i);
i = 0;
StartTime = DateTime.Now;
}
}
}
這主要是您自己編寫的樣板網絡消息傳遞代碼。如果您只需要推送1條消息,然後刪除while-loop,將ExitSignal檢查保持爲if-statement不會受到損害,則可以根據需要多次檢查volatile bool。
網絡流支持字符串行和二進制字節數組傳輸。
在這個特定的演示中,我們將一個對象序列化和反序列化爲JSON XML作爲string,並通過Write/Read-Line來回發送(如果需要其他字符集,則.NET會將其自動轉換爲ASCII字符數組bytes[]。有幾個轉換助手。)
這裏的一個主題是,如果由於某種原因連接丟失,則對流的寫入和讀取操作可能會拋出一個IOException,您將需要在Try... Catch塊中進行處理。如果您需要處理或冒泡異常,可以使用throw;代替return;
如前所述,當Thread或任務繼續完成或中止/拋出時,該Usings塊將清除網絡連接。
通常,我從不使用throw ex;因爲它拋棄了最頂部的異常信息,其中作爲throw;維護完整的堆棧跟蹤/內部異常。
TCP客戶端
TCP客戶端的CLI和Host類實際上與TCP Server相同,完整版本已附加並可以下載。
下面顯示的唯一區別是客戶端使用TCP Client而不是TCP Listener,並且該ConnectionLooper()函數更簡單,因爲我們不處理多個併發的入站連接。
using System;
using System.Net.Sockets;
using System.Threading;
#region Public Functions
public virtual void Run()
{
if (this.IsRunning)
return; //Already running, only one running instance allowed.
this.IsRunning = true;
this.ExitSignal = false;
while (!this.ExitSignal)
this.ConnectionLooper();
this.IsRunning = false;
}
#endregion
#region Protected Functions
protected virtual void ConnectionLooper()
{
this.OnMessage.Invoke("Attempting server connection... ");
using (var Client = new TcpClient())
{
try
{
Client.Connect(this.Host, this.Port);
}
catch(SocketException ex)
{
this.OnMessage.Invoke(ex.Message);
//Server is unavailable, wait before re-trying
Thread.Sleep(this.ConnectionAttemptDelayInMS);
return; //Swallow exception
}
if (!Client.Connected) //exit function if not connected
return;
using (var netstream = Client.GetStream())
{
//Process the connection
this.OnHandleConnection.Invoke(netstream);
}
}
}
#endregion
在這裏,我們僅使用線程休眠,如果您將客戶端託管在任務(ThreadPool管理器)中,則Task.Delay是首選。
如果您不需要外部設備ExitSignal來正常退出,請務必刪除它,因爲這樣做可以簡化實現,即,您的流程在準備好退出時可以自行退出。我僅將其添加到演示示例中,以說明如何爲需要該功能的用戶完成此操作。
如果僅進行單個連接或間歇連接,則不需要包含While循環。在此示例中,我們無限期地等待服務器上線,如果它們不同,則需要根據您的要求進行自定義。
此外,如果您需要通過同一進程建立多個併發客戶端連接,那麼您也可以這樣做,在這種情況下,您一定要使用與Server類類似的設計,該設計利用了可等待的WaitAny()異步TaskList模式,而不僅僅是一個此示例代碼中顯示了簡單的同步Connect()循環。
結論
希望有人發現此代碼有用。
僅使用一個核心VM,我就可以實現多達74000個雙向JSON交換,因此性能應該不錯。在此示例情況下,.NET4.8中的服務器和Core3.1中的客戶端用於主機。
如果您不序列化對象,則它應該運行得更快,可能僅受網卡帶寬或CPU速度的限制。