.NET Standard中使用TCPListener和TCPClient的高性能TCP客戶端服務器

目錄

介紹

TCP Server

TCP客戶端

結論


介紹

隨着即將出現的.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來解決這個問題。

將支持變量添加ConstructorServer類中:

#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()線程與服務器主機線程之間的陳舊獲取/設置。對於演示目的,這比LockMutex更爲簡單,並且可能佔用較少的CPU/內存。

IP 0.0.0.0IPAddress 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)

TCPClientNetworkStream使用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客戶端的CLIHost類實際上與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速度的限制。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章