繁体   English   中英

如何编写可扩展的基于 TCP/IP 的服务器

[英]How to write a scalable TCP/IP based server

我正处于编写新的 Windows 服务应用程序的设计阶段,该应用程序接受 TCP/IP 连接以进行长时间运行的连接(即,这不像 HTTP 那样有许多短连接,而是客户端连接并保持连接数小时或数天甚至数周)。

我正在寻找设计网络架构的最佳方式的想法。 我将需要为该服务至少启动一个线程。 我正在考虑使用异步 API(BeginRecieve 等),因为我不知道在任何给定时间(可能是数百个)我将连接多少个客户端。 我绝对不想为每个连接启动一个线程。

数据将主要从我的服务器流出到客户端,但有时会从客户端发送一些命令。 这主要是一个监控应用程序,我的服务器定期向客户端发送状态数据。

使其尽可能可扩展的最佳方法是什么? 基本工作流程?

明确地说,我正在寻找基于 .NET 的解决方案(如果可能,C#,但任何 .NET 语言都可以使用)。

我需要一个解决方案的工作示例,作为指向我可以下载的内容的指针或内嵌的简短示例。 它必须是基于 .NET 和 Windows 的(任何 .NET 语言都是可以接受的)。

我以前写过类似的东西。 我多年前的研究表明,使用异步套接字编写自己的套接字实现是最好的选择。 这意味着客户没有真正做任何事情实际上需要相对较少的资源。 发生的任何事情都由 .NET 线程池处理。

我把它写成一个管理服务器所有连接的类。

我只是使用一个列表来保存所有客户端连接,但是如果您需要更快地查找更大的列表,您可以随心所欲地编写它。

private List<xConnection> _sockets;

此外,您还需要套接字实际侦听传入连接。

private System.Net.Sockets.Socket _serverSocket;

start 方法实际上启动服务器套接字并开始侦听任何传入连接。

public bool Start()
{
  System.Net.IPHostEntry localhost = System.Net.Dns.GetHostEntry(System.Net.Dns.GetHostName());
  System.Net.IPEndPoint serverEndPoint;
  try
  {
     serverEndPoint = new System.Net.IPEndPoint(localhost.AddressList[0], _port);
  }
  catch (System.ArgumentOutOfRangeException e)
  {
    throw new ArgumentOutOfRangeException("Port number entered would seem to be invalid, should be between 1024 and 65000", e);
  }
  try
  {
    _serverSocket = new System.Net.Sockets.Socket(serverEndPoint.Address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
   }
   catch (System.Net.Sockets.SocketException e)
   {
      throw new ApplicationException("Could not create socket, check to make sure not duplicating port", e);
    }
    try
    {
      _serverSocket.Bind(serverEndPoint);
      _serverSocket.Listen(_backlog);
    }
    catch (Exception e)
    {
       throw new ApplicationException("An error occurred while binding socket. Check inner exception", e);
    }
    try
    {
       //warning, only call this once, this is a bug in .net 2.0 that breaks if
       // you're running multiple asynch accepts, this bug may be fixed, but
       // it was a major pain in the rear previously, so make sure there is only one
       //BeginAccept running
       _serverSocket.BeginAccept(new AsyncCallback(acceptCallback), _serverSocket);
    }
    catch (Exception e)
    {
       throw new ApplicationException("An error occurred starting listeners. Check inner exception", e);
    }
    return true;
 }

我只想指出异常处理代码看起来很糟糕,但原因是我在那里有异常抑制代码,因此如果设置了配置选项,任何异常都会被抑制并返回false ,但我想删除它为简洁起见。

上面的 _serverSocket.BeginAccept(new AsyncCallback(acceptCallback)), _serverSocket) 实质上设置了我们的服务器套接字,以便在用户连接时调用 acceptCallback 方法。 此方法从 .NET 线程池运行,如果您有许多阻塞操作,它会自动处理创建额外的工作线程。 这应该以最佳方式处理服务器上的任何负载。

    private void acceptCallback(IAsyncResult result)
    {
       xConnection conn = new xConnection();
       try
       {
         //Finish accepting the connection
         System.Net.Sockets.Socket s = (System.Net.Sockets.Socket)result.AsyncState;
         conn = new xConnection();
         conn.socket = s.EndAccept(result);
         conn.buffer = new byte[_bufferSize];
         lock (_sockets)
         {
           _sockets.Add(conn);
         }
         //Queue receiving of data from the connection
         conn.socket.BeginReceive(conn.buffer, 0, conn.buffer.Length, SocketFlags.None, new AsyncCallback(ReceiveCallback), conn);
         //Queue the accept of the next incoming connection
         _serverSocket.BeginAccept(new AsyncCallback(acceptCallback), _serverSocket);
       }
       catch (SocketException e)
       {
         if (conn.socket != null)
         {
           conn.socket.Close();
           lock (_sockets)
           {
             _sockets.Remove(conn);
           }
         }
         //Queue the next accept, think this should be here, stop attacks based on killing the waiting listeners
         _serverSocket.BeginAccept(new AsyncCallback(acceptCallback), _serverSocket);
       }
       catch (Exception e)
       {
         if (conn.socket != null)
         {
           conn.socket.Close();
           lock (_sockets)
           {
             _sockets.Remove(conn);
           }
         }
         //Queue the next accept, think this should be here, stop attacks based on killing the waiting listeners
         _serverSocket.BeginAccept(new AsyncCallback(acceptCallback), _serverSocket);
       }
     }

上面的代码基本上刚刚完成接受BeginReceive的连接,将BeginReceive排队,这是在客户端发送数据时将运行的回调,然后将下一个acceptCallback排队,后者将接受下一个acceptCallback的客户端连接。

BeginReceive方法调用告诉套接字从客户端接收数据时要做什么。 对于BeginReceive ,您需要给它一个字节数组,这是客户端发送数据时复制数据的地方。 ReceiveCallback方法将被调用,这就是我们处理接收数据的方式。

private void ReceiveCallback(IAsyncResult result)
{
  //get our connection from the callback
  xConnection conn = (xConnection)result.AsyncState;
  //catch any errors, we'd better not have any
  try
  {
    //Grab our buffer and count the number of bytes receives
    int bytesRead = conn.socket.EndReceive(result);
    //make sure we've read something, if we haven't it supposadly means that the client disconnected
    if (bytesRead > 0)
    {
      //put whatever you want to do when you receive data here

      //Queue the next receive
      conn.socket.BeginReceive(conn.buffer, 0, conn.buffer.Length, SocketFlags.None, new AsyncCallback(ReceiveCallback), conn);
     }
     else
     {
       //Callback run but no data, close the connection
       //supposadly means a disconnect
       //and we still have to close the socket, even though we throw the event later
       conn.socket.Close();
       lock (_sockets)
       {
         _sockets.Remove(conn);
       }
     }
   }
   catch (SocketException e)
   {
     //Something went terribly wrong
     //which shouldn't have happened
     if (conn.socket != null)
     {
       conn.socket.Close();
       lock (_sockets)
       {
         _sockets.Remove(conn);
       }
     }
   }
 }

编辑:在这种模式中,我忘了在这方面的代码中提到:

//put whatever you want to do when you receive data here

//Queue the next receive
conn.socket.BeginReceive(conn.buffer, 0, conn.buffer.Length, SocketFlags.None, new AsyncCallback(ReceiveCallback), conn);

通常,在任何你想要的代码中,我都会将数据包重新组装成消息,然后将它们创建为线程池上的作业。 这样,无论消息处理代码正在运行时,来自客户端的下一个块的 BeginReceive 都不会延迟。

接受回调通过调用结束接收完成读取数据套接字。 这会填充开始接收函数中提供的缓冲区。 一旦你在我留下评论的地方做任何你想做的事情,我们就会调用下一个BeginReceive方法,如果客户端发送更多数据,它将再次运行回调。

现在是真正棘手的部分:当客户端发送数据时,您的接收回调可能只用部分消息调用。 重新组装会变得非常复杂。 我使用我自己的方法并创建了一种专有协议来做到这一点。 我省略了它,但如果你要求,我可以添加它。这个处理程序实际上是我写过的最复杂的一段代码。

public bool Send(byte[] message, xConnection conn)
{
  if (conn != null && conn.socket.Connected)
  {
    lock (conn.socket)
    {
    //we use a blocking mode send, no async on the outgoing
    //since this is primarily a multithreaded application, shouldn't cause problems to send in blocking mode
       conn.socket.Send(bytes, bytes.Length, SocketFlags.None);
     }
   }
   else
     return false;
   return true;
 }

上面的send方法实际上使用了一个同步的Send调用。 由于我的应用程序的消息大小和多线程性质,这对我来说很好。 如果你想发送到每个客户端,你只需要遍历 _sockets 列表。

您在上面看到的 xConnection 类基本上是一个简单的套接字包装器,用于包含字节缓冲区,在我的实现中还有一些额外的东西。

public class xConnection : xBase
{
  public byte[] buffer;
  public System.Net.Sockets.Socket socket;
}

这里也供参考的是我包含的using s,因为当它们不包含时我总是很生气。

using System.Net.Sockets;

我希望这会有所帮助。 它可能不是最干净的代码,但它可以工作。 代码也有一些细微差别,您应该对更改感到厌烦。 一方面,在任何时候只调用一个BeginAccept 曾经有一个非常烦人的 .NET 错误,这是几年前的事了,所以我不记得细节了。

此外,在ReceiveCallback代码中,我们在将下一次接收排队之前处理从套接字接收到的任何内容。 这意味着对于单个套接字,我们实际上只在任何时间点处于ReceiveCallback中一次,并且不需要使用线程同步。 但是,如果您重新排序以在拉取数据后立即调用下一个接收,这可能会快一点,您需要确保正确同步线程。

此外,我砍掉了很多代码,但保留了正在发生的事情的本质。 这应该是您设计的良好开端。 如果您对此还有任何疑问,请发表评论。

在 C# 中有很多方法可以进行网络操作。 它们都在幕后使用不同的机制,因此在高并发时会遇到主要的性能问题。 Begin* 操作是其中之一,许多人经常误认为是更快/最快的网络连接方式。

为了解决这些问题,他们引入了Async 方法集:来自 MSDN, SocketAsyncEventArgs 类-

SocketAsyncEventArgs 类是 System.Net.Sockets..::.Socket 类的一组增强功能的一部分,它提供了一种可供专用高性能套接字应用程序使用的替代异步模式。 此类专为需要高性能的网络服务器应用程序而设计。 应用程序可以专门或仅在目标热点区域使用增强型异步模式(例如,当接收大量数据时)。

这些增强功能的主要特点是避免在大容量异步套接字 I/O 期间重复分配和同步对象。 当前由 System.Net.Sockets..::.Socket 类实现的开始/结束设计模式要求为每个异步套接字操作分配一个 System..::.IAsyncResult 对象。

在幕后,*Async API 使用 I/O 完成端口,这是执行网络操作的最快方式,请参阅Windows Sockets 2.0:使用完成端口编写可扩展的 Winsock 应用程序

为了帮助您,我提供了使用 *Async API 编写的 telnet 服务器的源代码。 我只包括相关部分。 还要注意的是,我没有内联处理数据,而是选择将其推送到在单独线程上处理的无锁(无等待)队列中。 请注意,我不包括相应的 Pool 类,它只是一个简单的池,如果它为空,它将创建一个新对象,而 Buffer 类只是一个自扩展缓冲区,除非您收到不确定性,否则实际上并不需要它数据量。

public class Telnet
{
    private readonly Pool<SocketAsyncEventArgs> m_EventArgsPool;
    private Socket m_ListenSocket;

    /// <summary>
    /// This event fires when a connection has been established.
    /// </summary>
    public event EventHandler<SocketAsyncEventArgs> Connected;

    /// <summary>
    /// This event fires when a connection has been shutdown.
    /// </summary>
    public event EventHandler<SocketAsyncEventArgs> Disconnected;

    /// <summary>
    /// This event fires when data is received on the socket.
    /// </summary>
    public event EventHandler<SocketAsyncEventArgs> DataReceived;

    /// <summary>
    /// This event fires when data is finished sending on the socket.
    /// </summary>
    public event EventHandler<SocketAsyncEventArgs> DataSent;

    /// <summary>
    /// This event fires when a line has been received.
    /// </summary>
    public event EventHandler<LineReceivedEventArgs> LineReceived;

    /// <summary>
    /// Specifies the port to listen on.
    /// </summary>
    [DefaultValue(23)]
    public int ListenPort { get; set; }

    /// <summary>
    /// Constructor for Telnet class.
    /// </summary>
    public Telnet()
    {
        m_EventArgsPool = new Pool<SocketAsyncEventArgs>();
        ListenPort = 23;
    }

    /// <summary>
    /// Starts the telnet server listening and accepting data.
    /// </summary>
    public void Start()
    {
        IPEndPoint endpoint = new IPEndPoint(0, ListenPort);
        m_ListenSocket = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);

        m_ListenSocket.Bind(endpoint);
        m_ListenSocket.Listen(100);

        //
        // Post Accept
        //
        StartAccept(null);
    }

    /// <summary>
    /// Not Yet Implemented. Should shutdown all connections gracefully.
    /// </summary>
    public void Stop()
    {
        //throw (new NotImplementedException());
    }

    //
    // ACCEPT
    //

    /// <summary>
    /// Posts a requests for Accepting a connection. If it is being called from the completion of
    /// an AcceptAsync call, then the AcceptSocket is cleared since it will create a new one for
    /// the new user.
    /// </summary>
    /// <param name="e">null if posted from startup, otherwise a <b>SocketAsyncEventArgs</b> for reuse.</param>
    private void StartAccept(SocketAsyncEventArgs e)
    {
        if (e == null)
        {
            e = m_EventArgsPool.Pop();
            e.Completed += Accept_Completed;
        }
        else
        {
            e.AcceptSocket = null;
        }

        if (m_ListenSocket.AcceptAsync(e) == false)
        {
            Accept_Completed(this, e);
        }
    }

    /// <summary>
    /// Completion callback routine for the AcceptAsync post. This will verify that the Accept occured
    /// and then setup a Receive chain to begin receiving data.
    /// </summary>
    /// <param name="sender">object which posted the AcceptAsync</param>
    /// <param name="e">Information about the Accept call.</param>
    private void Accept_Completed(object sender, SocketAsyncEventArgs e)
    {
        //
        // Socket Options
        //
        e.AcceptSocket.NoDelay = true;

        //
        // Create and setup a new connection object for this user
        //
        Connection connection = new Connection(this, e.AcceptSocket);

        //
        // Tell the client that we will be echo'ing data sent
        //
        DisableEcho(connection);

        //
        // Post the first receive
        //
        SocketAsyncEventArgs args = m_EventArgsPool.Pop();
        args.UserToken = connection;

        //
        // Connect Event
        //
        if (Connected != null)
        {
            Connected(this, args);
        }

        args.Completed += Receive_Completed;
        PostReceive(args);

        //
        // Post another accept
        //
        StartAccept(e);
    }

    //
    // RECEIVE
    //

    /// <summary>
    /// Post an asynchronous receive on the socket.
    /// </summary>
    /// <param name="e">Used to store information about the Receive call.</param>
    private void PostReceive(SocketAsyncEventArgs e)
    {
        Connection connection = e.UserToken as Connection;

        if (connection != null)
        {
            connection.ReceiveBuffer.EnsureCapacity(64);
            e.SetBuffer(connection.ReceiveBuffer.DataBuffer, connection.ReceiveBuffer.Count, connection.ReceiveBuffer.Remaining);

            if (connection.Socket.ReceiveAsync(e) == false)
            {
                Receive_Completed(this, e);
            }
        }
    }

    /// <summary>
    /// Receive completion callback. Should verify the connection, and then notify any event listeners
    /// that data has been received. For now it is always expected that the data will be handled by the
    /// listeners and thus the buffer is cleared after every call.
    /// </summary>
    /// <param name="sender">object which posted the ReceiveAsync</param>
    /// <param name="e">Information about the Receive call.</param>
    private void Receive_Completed(object sender, SocketAsyncEventArgs e)
    {
        Connection connection = e.UserToken as Connection;

        if (e.BytesTransferred == 0 || e.SocketError != SocketError.Success || connection == null)
        {
            Disconnect(e);
            return;
        }

        connection.ReceiveBuffer.UpdateCount(e.BytesTransferred);

        OnDataReceived(e);

        HandleCommand(e);
        Echo(e);

        OnLineReceived(connection);

        PostReceive(e);
    }

    /// <summary>
    /// Handles Event of Data being Received.
    /// </summary>
    /// <param name="e">Information about the received data.</param>
    protected void OnDataReceived(SocketAsyncEventArgs e)
    {
        if (DataReceived != null)
        {
            DataReceived(this, e);
        }
    }

    /// <summary>
    /// Handles Event of a Line being Received.
    /// </summary>
    /// <param name="connection">User connection.</param>
    protected void OnLineReceived(Connection connection)
    {
        if (LineReceived != null)
        {
            int index = 0;
            int start = 0;

            while ((index = connection.ReceiveBuffer.IndexOf('\n', index)) != -1)
            {
                string s = connection.ReceiveBuffer.GetString(start, index - start - 1);
                s = s.Backspace();

                LineReceivedEventArgs args = new LineReceivedEventArgs(connection, s);
                Delegate[] delegates = LineReceived.GetInvocationList();

                foreach (Delegate d in delegates)
                {
                    d.DynamicInvoke(new object[] { this, args });

                    if (args.Handled == true)
                    {
                        break;
                    }
                }

                if (args.Handled == false)
                {
                    connection.CommandBuffer.Enqueue(s);
                }

                start = index;
                index++;
            }

            if (start > 0)
            {
                connection.ReceiveBuffer.Reset(0, start + 1);
            }
        }
    }

    //
    // SEND
    //

    /// <summary>
    /// Overloaded. Sends a string over the telnet socket.
    /// </summary>
    /// <param name="connection">Connection to send data on.</param>
    /// <param name="s">Data to send.</param>
    /// <returns>true if the data was sent successfully.</returns>
    public bool Send(Connection connection, string s)
    {
        if (String.IsNullOrEmpty(s) == false)
        {
            return Send(connection, Encoding.Default.GetBytes(s));
        }

        return false;
    }

    /// <summary>
    /// Overloaded. Sends an array of data to the client.
    /// </summary>
    /// <param name="connection">Connection to send data on.</param>
    /// <param name="data">Data to send.</param>
    /// <returns>true if the data was sent successfully.</returns>
    public bool Send(Connection connection, byte[] data)
    {
        return Send(connection, data, 0, data.Length);
    }

    public bool Send(Connection connection, char c)
    {
        return Send(connection, new byte[] { (byte)c }, 0, 1);
    }

    /// <summary>
    /// Sends an array of data to the client.
    /// </summary>
    /// <param name="connection">Connection to send data on.</param>
    /// <param name="data">Data to send.</param>
    /// <param name="offset">Starting offset of date in the buffer.</param>
    /// <param name="length">Amount of data in bytes to send.</param>
    /// <returns></returns>
    public bool Send(Connection connection, byte[] data, int offset, int length)
    {
        bool status = true;

        if (connection.Socket == null || connection.Socket.Connected == false)
        {
            return false;
        }

        SocketAsyncEventArgs args = m_EventArgsPool.Pop();
        args.UserToken = connection;
        args.Completed += Send_Completed;
        args.SetBuffer(data, offset, length);

        try
        {
            if (connection.Socket.SendAsync(args) == false)
            {
                Send_Completed(this, args);
            }
        }
        catch (ObjectDisposedException)
        {
            //
            // return the SocketAsyncEventArgs back to the pool and return as the
            // socket has been shutdown and disposed of
            //
            m_EventArgsPool.Push(args);
            status = false;
        }

        return status;
    }

    /// <summary>
    /// Sends a command telling the client that the server WILL echo data.
    /// </summary>
    /// <param name="connection">Connection to disable echo on.</param>
    public void DisableEcho(Connection connection)
    {
        byte[] b = new byte[] { 255, 251, 1 };
        Send(connection, b);
    }

    /// <summary>
    /// Completion callback for SendAsync.
    /// </summary>
    /// <param name="sender">object which initiated the SendAsync</param>
    /// <param name="e">Information about the SendAsync call.</param>
    private void Send_Completed(object sender, SocketAsyncEventArgs e)
    {
        e.Completed -= Send_Completed;
        m_EventArgsPool.Push(e);
    }

    /// <summary>
    /// Handles a Telnet command.
    /// </summary>
    /// <param name="e">Information about the data received.</param>
    private void HandleCommand(SocketAsyncEventArgs e)
    {
        Connection c = e.UserToken as Connection;

        if (c == null || e.BytesTransferred < 3)
        {
            return;
        }

        for (int i = 0; i < e.BytesTransferred; i += 3)
        {
            if (e.BytesTransferred - i < 3)
            {
                break;
            }

            if (e.Buffer[i] == (int)TelnetCommand.IAC)
            {
                TelnetCommand command = (TelnetCommand)e.Buffer[i + 1];
                TelnetOption option = (TelnetOption)e.Buffer[i + 2];

                switch (command)
                {
                    case TelnetCommand.DO:
                        if (option == TelnetOption.Echo)
                        {
                            // ECHO
                        }
                        break;
                    case TelnetCommand.WILL:
                        if (option == TelnetOption.Echo)
                        {
                            // ECHO
                        }
                        break;
                }

                c.ReceiveBuffer.Remove(i, 3);
            }
        }
    }

    /// <summary>
    /// Echoes data back to the client.
    /// </summary>
    /// <param name="e">Information about the received data to be echoed.</param>
    private void Echo(SocketAsyncEventArgs e)
    {
        Connection connection = e.UserToken as Connection;

        if (connection == null)
        {
            return;
        }

        //
        // backspacing would cause the cursor to proceed beyond the beginning of the input line
        // so prevent this
        //
        string bs = connection.ReceiveBuffer.ToString();

        if (bs.CountAfterBackspace() < 0)
        {
            return;
        }

        //
        // find the starting offset (first non-backspace character)
        //
        int i = 0;

        for (i = 0; i < connection.ReceiveBuffer.Count; i++)
        {
            if (connection.ReceiveBuffer[i] != '\b')
            {
                break;
            }
        }

        string s = Encoding.Default.GetString(e.Buffer, Math.Max(e.Offset, i), e.BytesTransferred);

        if (connection.Secure)
        {
            s = s.ReplaceNot("\r\n\b".ToCharArray(), '*');
        }

        s = s.Replace("\b", "\b \b");

        Send(connection, s);
    }

    //
    // DISCONNECT
    //

    /// <summary>
    /// Disconnects a socket.
    /// </summary>
    /// <remarks>
    /// It is expected that this disconnect is always posted by a failed receive call. Calling the public
    /// version of this method will cause the next posted receive to fail and this will cleanup properly.
    /// It is not advised to call this method directly.
    /// </remarks>
    /// <param name="e">Information about the socket to be disconnected.</param>
    private void Disconnect(SocketAsyncEventArgs e)
    {
        Connection connection = e.UserToken as Connection;

        if (connection == null)
        {
            throw (new ArgumentNullException("e.UserToken"));
        }

        try
        {
            connection.Socket.Shutdown(SocketShutdown.Both);
        }
        catch
        {
        }

        connection.Socket.Close();

        if (Disconnected != null)
        {
            Disconnected(this, e);
        }

        e.Completed -= Receive_Completed;
        m_EventArgsPool.Push(e);
    }

    /// <summary>
    /// Marks a specific connection for graceful shutdown. The next receive or send to be posted
    /// will fail and close the connection.
    /// </summary>
    /// <param name="connection"></param>
    public void Disconnect(Connection connection)
    {
        try
        {
            connection.Socket.Shutdown(SocketShutdown.Both);
        }
        catch (Exception)
        {
        }
    }

    /// <summary>
    /// Telnet command codes.
    /// </summary>
    internal enum TelnetCommand
    {
        SE = 240,
        NOP = 241,
        DM = 242,
        BRK = 243,
        IP = 244,
        AO = 245,
        AYT = 246,
        EC = 247,
        EL = 248,
        GA = 249,
        SB = 250,
        WILL = 251,
        WONT = 252,
        DO = 253,
        DONT = 254,
        IAC = 255
    }

    /// <summary>
    /// Telnet command options.
    /// </summary>
    internal enum TelnetOption
    {
        Echo = 1,
        SuppressGoAhead = 3,
        Status = 5,
        TimingMark = 6,
        TerminalType = 24,
        WindowSize = 31,
        TerminalSpeed = 32,
        RemoteFlowControl = 33,
        LineMode = 34,
        EnvironmentVariables = 36
    }
}

Coversant 的 Chris Mullins 曾经对使用 .NET 的可扩展 TCP/IP 进行了很好的讨论。 不幸的是,他的博客似乎从之前的位置消失了,所以我将尝试从记忆中拼凑他的建议(他的一些有用评论出现在此线程中: C++ vs. C#:开发高度可扩展的 IOCP 服务器

首先,请注意,在Socket类上使用Begin/EndAsync方法都利用I/O 完成端口(IOCP) 来提供可伸缩性。 与您实际选择来实现解决方案的两种方法中的哪一种相比,这对可扩展性产生了更大的差异(如果使用得当;见下文)。

Chris Mullins 的帖子基于使用Begin/End ,这是我个人有经验的。 请注意,Chris 提出了一个基于此的解决方案,该解决方案在具有 2 GB 内存的 32 位机器上扩展到 10,000 个并发客户端连接,在具有足够内存的 64 位平台上扩展到 100,000 个。 根据我自己使用这种技术的经验(尽管远不及这种负载),我没有理由怀疑这些指示性数字。

IOCP 与每连接线程或“选择”原语

您想使用一种在底层使用 IOCP 的机制的原因是它使用了一个非常低级的 Windows 线程池,在您尝试读取的 I/O 通道上有实际数据之前,该线程池不会唤醒任何线程from(注意 IOCP 也可用于文件 I/O)。 这样做的好处是,Windows 不必切换到一个线程,却发现无论如何都没有数据,因此这将您的服务器必须进行的上下文切换次数减少到最低要求。

上下文切换肯定会杀死“每个连接的线程”机制,尽管如果您只处理几十个连接,这是一个可行的解决方案。 然而,这种机制绝不是“可扩展的”。

使用 IOCP 时的重要注意事项

记忆

首先,了解 IOCP 很容易在 .NET 下导致内存问题(如果您的实现过于幼稚)至关重要。 每个 IOCP BeginReceive调用都会导致“固定”您正在读入的缓冲区。 关于为什么会出现这个问题的一个很好的解释,请参见: Yun Jin 的博客:OutOfMemoryException 和 Pinning

幸运的是,这个问题是可以避免的,但这需要一些权衡。 建议的解决方案是在应用程序启动时(或接近启动时)分配一个大byte[]缓冲区,至少 90 KB 左右(从 .NET 2 开始,在更高版本中所需的大小可能更大)。 这样做的原因是大内存分配会自动结束在非压缩内存段( 大对象堆)中,该段有效地自动固定。 通过在启动时分配一个大缓冲区,您可以确保这块不可移动内存位于相对“低地址”,在那里它不会妨碍并导致碎片。

然后,您可以使用偏移量将这个大缓冲区分割为需要读取某些数据的每个连接的单独区域。 这就是权衡发挥作用的地方。 由于此缓冲区需要预先分配,因此您必须决定每个连接需要多少缓冲区空间,以及要为要扩展到的连接数设置多少上限(或者,您可以实现抽象一旦需要,可以分配额外的固定缓冲区)。

最简单的解决方案是在此缓冲区内的唯一偏移量处为每个连接分配一个字节。 然后,您可以对要读取的单个字节进行BeginReceive调用,并根据您获得的回调执行其余的读取操作。

加工

当您从您进行的Begin调用中获取回调时,意识到回调中的代码将在低级 IOCP 线程上执行非常重要。 在此回调中避免冗长的操作是绝对必要的 使用这些线程进行复杂的处理会像使用“thread-per-connection”一样有效地扼杀您的可扩展性。

建议的解决方案是仅使用回调将工作项排队以处理传入数据,该数据将在其他线程上执行。 避免回调中的任何潜在阻塞操作,以便 IOCP 线程可以尽快返回其池。 在 .NET 4.0 中,我建议最简单的解决方案是生成一个Task ,给它一个对客户端套接字的引用和一个已经被BeginReceive调用读取的第一个字节的副本。 然后,此任务负责从代表您正在处理的请求的套接字读取所有数据,执行它,然后进行新的BeginReceive调用以再次将套接字排队以进行 IOCP。 在 .NET 4.0 之前,您可以使用 ThreadPool,或创建您自己的线程化工作队列实现。

概括

基本上,我建议在此解决方案中使用Kevin 的示例代码,并添加以下警告:

  • 确保您传递给BeginReceive的缓冲区已经“固定”
  • 确保传递给BeginReceive的回调只是将任务排队以处理传入数据的实际处理

当您这样做时,我毫不怀疑您可以复制 Chris 的结果,以扩展到潜在的数十万个并发客户端(当然,如果有合适的硬件和您自己的处理代码的有效实现;)

您已经通过上面的代码示例获得了大部分答案。 使用异步 I/O 操作绝对是这里的方法。 异步 I/O 是 Win32 内部设计用于扩展的方式。 您可以获得的最佳性能是使用完成端口实现的,将您的套接字绑定到完成端口并让线程池等待完成端口完成。 常识是每个 CPU(核心)有 2-4 个线程等待完成。 我强烈建议您阅读 Windows 性能团队的 Rick Vicik 撰写的这三篇文章:

  1. 为性能设计应用程序 - 第 1 部分
  2. 为性能设计应用程序 - 第 2 部分
  3. 为性能设计应用程序 - 第 3 部分

所述文章主要涵盖本机 Windows API,但对于任何试图掌握可扩展性和性能的人来说,它们都是必读的。 他们也有一些关于管理方面的简报。

您需要做的第二件事是确保您阅读了在线提供的提高 .NET 应用程序性能和可扩展性一书。 您将在第 5 章中找到有关线程使用、异步调用和锁的相关且有效的建议。但真正的精华在第 17 章,您将在第 17 章中找到诸如调整线程池的实用指南之类的好东西。 我的应用程序遇到了一些严重的问题,直到我按照本章中的建议调整了 maxIothreads/maxWorkerThreads。

你说你想做一个纯TCP服务器,所以我的下一点是假的。 但是,如果您发现自己陷入困境并使用 WebRequest 类及其派生类,请注意有一条龙守卫着那扇门: ServicePointManager 这是一个配置类,在生活中有一个目的:破坏你的表现。 确保您从人工强加的 ServicePoint.ConnectionLimit 中释放您的服务器,否则您的应用程序将永远无法扩展(我让您自己发现默认值是什么......)。 您还可以重新考虑在 HTTP 请求中发送 Expect100Continue 标头的默认策略。

现在关于核心套接字管理 API,发送端的事情相当容易,但接收端的事情要复杂得多。 为了实现高吞吐量和规模,您必须确保套接字不受流量控制,因为您没有发布用于接收的缓冲区。 理想情况下,为了获得高性能,您应该提前发布 3-4 个缓冲区并在收到一个缓冲区后立即发布新缓冲区(处理返回的缓冲区之前),这样您就可以确保套接字总是有地方存放来自网络的数据. 您会明白为什么您可能无法在短期内实现这一目标。

在您使用完 BeginRead/BeginWrite API 并开始认真的工作后,您会意识到您需要对流量进行安全保护,即 NTLM/Kerberos 身份验证和流量加密,或者至少是流量篡改保护。 这样做的方法是使用内置的 System.Net.Security.NegotiateStream(如果需要跨不同的域,则使用 SslStream)。 这意味着您将依赖 AuthenticatedStream 异步操作,而不是依赖直接的套接字异步操作。 一旦您获得套接字(从客户端连接或从服务器接受),您就在套接字上创建一个流并通过调用 BeginAuthenticateAsClient 或 BeginAuthenticateAsServer 将其提交以进行身份​​验证。 身份验证完成后(至少你的安全来自原生的 InitiateSecurityContext/AcceptSecurityContext 疯狂......)你将通过检查你的 Authenticated 流的 RemoteIdentity 属性并执行你的产品必须支持的任何ACL验证来完成你的授权。

之后,您将使用 BeginWrite 发送消息,您将使用 BeginRead 接收它们。 这是我之前谈到的问题,您将无法发布多个接收缓冲区,因为 AuthenticateStream 类不支持这一点。 BeginRead 操作在内部管理所有 I/O,直到您收到整个帧。 否则,它无法处理消息身份验证(解密帧并验证帧上的签名)。 虽然根据我的经验,AuthenticatedStream 类完成的工作相当不错,应该没有任何问题。 即,您应该能够仅使用 4-5% 的 CPU 就使 1 Gbit/s 网络饱和。 AuthenticatedStream 类还将对您施加特定于协议的帧大小限制(SSL 为 16k,Kerberos 为 12k)。

这应该让你开始走上正确的道路。 我不打算在这里发布代码, MSDN 上有一个非常好的例子 我做过很多这样的项目,我能够扩展到大约 1000 个连接的用户而没有问题。 在此之上,您需要修改注册表项以允许内核获得更多套接字句柄。 并确保您部署在服务器操作系统上,即Windows Server 2003 ,而不是 Windows XP 或Windows Vista (即客户端操作系统),这有很大的不同。

顺便说一句,请确保,如果您在服务器或文件 I/O 上有数据库操作,您也对它们使用异步风格,否则您将立即耗尽线程池。 对于 SQL Server 连接,请确保将“Asyncronous Processing=true”添加到连接字符串中。

我的一些解决方案中运行了这样的服务器。 以下是在 .NET 中执行此操作的不同方法的非常详细的说明: Get Closer to the Wire with High-Performance Sockets in .NET

最近,我一直在寻找改进我们代码的方法,并将研究这一点:“ 3.5 版中的套接字性能增强”,专门包含“供使用异步网络 I/O 的应用程序使用以实现最高性能”。

“这些增强的主要特点是避免在大容量异步套接字 I/O 期间重复分配和同步对象。目前由 Socket 类为异步套接字 I/O 实现的 Begin/End 设计模式需要一个 System.为每个异步套接字操作分配 IAsyncResult 对象。”

如果您点击链接,您可以继续阅读。 我个人明天将测试他们的示例代码,以根据我所获得的内容对其进行基准测试。

在这里,您可以找到使用新的 3.5 SocketAsyncEventArgs 的客户端和服务器的工作代码,以便您可以在几分钟内对其进行测试并完成代码。 这是一种简单的方法,但它是启动更大规模实现的基础。 大约两年前 MSDN 杂志上的这篇文章也很有趣。

考虑只使用WCF网络 TCP 绑定和发布/订阅模式。 WCF 将允许您(主要)专注于您的域而不是管道......

IDesign 的下载部分有很多 WCF 示例,甚至还有一个发布/订阅框架,它们可能很有用: http : //www.idesign.net

我想知道一件事:

我绝对不想为每个连接启动一个线程。

这是为什么? 至少从 Windows 2000 开始,Windows 可以处理应用程序中的数百个线程。我已经做到了,如果线程不需要同步,它真的很容易使用。 特别是考虑到您正在执行大量 I/O(因此您不受 CPU 限制,并且很多线程会在磁盘或网络通信上被阻塞),我不理解这个限制。

您是否测试了多线程方式并发现它缺少某些东西? 您是否还打算为每个线程建立一个数据库连接(这会杀死数据库服务器,所以这是一个坏主意,但使用 3 层设计很容易解决)。 您是否担心您将拥有数千个客户而不是数百个客户,然后您真的会遇到问题? (尽管如果我有 32 GB 以上的 RAM,我会尝试一千个线程甚至一万个线程 - 同样,考虑到您不受 CPU 限制,线程切换时间应该绝对无关紧要。)

这是代码 - 要查看它的运行方式,请访问http://mdpopescu.blogspot.com/2009/05/multi-threaded-server.html并单击图片。

服务器类:

  public class Server
  {
    private static readonly TcpListener listener = new TcpListener(IPAddress.Any, 9999);

    public Server()
    {
      listener.Start();
      Console.WriteLine("Started.");

      while (true)
      {
        Console.WriteLine("Waiting for connection...");

        var client = listener.AcceptTcpClient();
        Console.WriteLine("Connected!");

        // each connection has its own thread
        new Thread(ServeData).Start(client);
      }
    }

    private static void ServeData(object clientSocket)
    {
      Console.WriteLine("Started thread " + Thread.CurrentThread.ManagedThreadId);

      var rnd = new Random();
      try
      {
        var client = (TcpClient) clientSocket;
        var stream = client.GetStream();
        while (true)
        {
          if (rnd.NextDouble() < 0.1)
          {
            var msg = Encoding.ASCII.GetBytes("Status update from thread " + Thread.CurrentThread.ManagedThreadId);
            stream.Write(msg, 0, msg.Length);

            Console.WriteLine("Status update from thread " + Thread.CurrentThread.ManagedThreadId);
          }

          // wait until the next update - I made the wait time so small 'cause I was bored :)
          Thread.Sleep(new TimeSpan(0, 0, rnd.Next(1, 5)));
        }
      }
      catch (SocketException e)
      {
        Console.WriteLine("Socket exception in thread {0}: {1}", Thread.CurrentThread.ManagedThreadId, e);
      }
    }
  }

服务器主程序:

namespace ManyThreadsServer
{
  internal class Program
  {
    private static void Main(string[] args)
    {
      new Server();
    }
  }
}

客户端类:

  public class Client
  {
    public Client()
    {
      var client = new TcpClient();
      client.Connect(IPAddress.Loopback, 9999);

      var msg = new byte[1024];

      var stream = client.GetStream();
      try
      {
        while (true)
        {
          int i;
          while ((i = stream.Read(msg, 0, msg.Length)) != 0)
          {
            var data = Encoding.ASCII.GetString(msg, 0, i);
            Console.WriteLine("Received: {0}", data);
          }
        }
      }
      catch (SocketException e)
      {
        Console.WriteLine("Socket exception in thread {0}: {1}", Thread.CurrentThread.ManagedThreadId, e);
      }
    }
  }

客户端主程序:

using System;
using System.Threading;

namespace ManyThreadsClient
{
  internal class Program
  {
    private static void Main(string[] args)
    {
      // first argument is the number of threads
      for (var i = 0; i < Int32.Parse(args[0]); i++)
        new Thread(RunClient).Start();
    }

    private static void RunClient()
    {
      new Client();
    }
  }
}

如果您能正确处理所有细节,则使用 .NET 的集成异步 I/O( BeginRead等)是一个好主意。 当您正确设置套接字/文件句柄时,它将使用操作系统的底层 IOCP 实现,允许您的操作在不使用任何线程的情况下完成(或者,在最坏的情况下,使用我认为来自内核 I/O 线程的线程pool 而不是 .NET 的线程池,这有助于缓解线程池拥塞。)

主要问题是确保以非阻塞模式打开套接字/文件。 大多数默认的便利函数(如File.OpenRead )不这样做,因此您需要编写自己的函数。

另一个主要关注点是错误处理——在编写异步 I/O 代码时正确处理错误比在同步代码中处理要困难得多。 即使您可能没有直接使用线程,也很容易出现竞争条件和死锁,因此您需要注意这一点。

如果可能,您应该尝试使用方便的库来简化执行可伸缩异步 I/O 的过程。

Microsoft 的并发协调运行时是 .NET 库的一个示例,旨在减轻进行此类编程的难度。 它看起来很棒,但由于我没有使用过它,我无法评论它的扩展性如何。

对于需要执行异步网络或磁盘 I/O 的个人项目,我使用了一组我在过去一年中构建的 .NET 并发/I/O 工具,称为Squared.Task 它的灵感来自imvu.tasktwisted等库,我在存储库中包含了一些执行网络 I/O 的工作示例 我还在我编写的一些应用程序中使用了它——最大的公开发布的应用程序是NDexer (它将它用于无线程磁盘 I/O)。 该库是根据我使用 imvu.task 的经验编写的,并且有一组相当全面的单元测试,因此我强烈建议您尝试一下。 如果您有任何问题,我很乐意为您提供帮助。

在我看来,根据我的经验,在 .NET 平台上使用异步/无线程 I/O 而不是线程是值得的,只要您准备好应对学习曲线。 它允许您避免由 Thread 对象的成本带来的可扩展性麻烦,并且在许多情况下,您可以通过谨慎使用并发原语(如futures 和 promises )来完全避免使用锁和互斥锁。

我使用了Kevin 的解决方案,但他说该解决方案缺少用于重新组装消息的代码。 开发人员可以使用此代码重新组装消息:

private static void ReceiveCallback(IAsyncResult asyncResult )
{
    ClientInfo cInfo = (ClientInfo)asyncResult.AsyncState;

    cInfo.BytesReceived += cInfo.Soket.EndReceive(asyncResult);
    if (cInfo.RcvBuffer == null)
    {
        // First 2 byte is lenght
        if (cInfo.BytesReceived >= 2)
        {
            //this calculation depends on format which your client use for lenght info
            byte[] len = new byte[ 2 ] ;
            len[0] = cInfo.LengthBuffer[1];
            len[1] = cInfo.LengthBuffer[0];
            UInt16 length = BitConverter.ToUInt16( len , 0);

            // buffering and nulling is very important
            cInfo.RcvBuffer = new byte[length];
            cInfo.BytesReceived = 0;

        }
    }
    else
    {
        if (cInfo.BytesReceived == cInfo.RcvBuffer.Length)
        {
             //Put your code here, use bytes comes from  "cInfo.RcvBuffer"

             //Send Response but don't use async send , otherwise your code will not work ( RcvBuffer will be null prematurely and it will ruin your code)

            int sendLenghts = cInfo.Soket.Send( sendBack, sendBack.Length, SocketFlags.None);

            // buffering and nulling is very important
            //Important , set RcvBuffer to null because code will decide to get data or 2 bte lenght according to RcvBuffer's value(null or initialized)
            cInfo.RcvBuffer = null;
            cInfo.BytesReceived = 0;
        }
    }

    ContinueReading(cInfo);
 }

private static void ContinueReading(ClientInfo cInfo)
{
    try
    {
        if (cInfo.RcvBuffer != null)
        {
            cInfo.Soket.BeginReceive(cInfo.RcvBuffer, cInfo.BytesReceived, cInfo.RcvBuffer.Length - cInfo.BytesReceived, SocketFlags.None, ReceiveCallback, cInfo);
        }
        else
        {
            cInfo.Soket.BeginReceive(cInfo.LengthBuffer, cInfo.BytesReceived, cInfo.LengthBuffer.Length - cInfo.BytesReceived, SocketFlags.None, ReceiveCallback, cInfo);
        }
    }
    catch (SocketException se)
    {
        //Handle exception and  Close socket here, use your own code
        return;
    }
    catch (Exception ex)
    {
        //Handle exception and  Close socket here, use your own code
        return;
    }
}

class ClientInfo
{
    private const int BUFSIZE = 1024 ; // Max size of buffer , depends on solution
    private const int BUFLENSIZE = 2; // lenght of lenght , depends on solution
    public int BytesReceived = 0 ;
    public byte[] RcvBuffer { get; set; }
    public byte[] LengthBuffer { get; set; }

    public Socket Soket { get; set; }

    public ClientInfo(Socket clntSock)
    {
        Soket = clntSock;
        RcvBuffer = null;
        LengthBuffer = new byte[ BUFLENSIZE ];
    }

}

public static void AcceptCallback(IAsyncResult asyncResult)
{

    Socket servSock = (Socket)asyncResult.AsyncState;
    Socket clntSock = null;

    try
    {

        clntSock = servSock.EndAccept(asyncResult);

        ClientInfo cInfo = new ClientInfo(clntSock);

        Receive( cInfo );

    }
    catch (SocketException se)
    {
        clntSock.Close();
    }
}
private static void Receive(ClientInfo cInfo )
{
    try
    {
        if (cInfo.RcvBuffer == null)
        {
            cInfo.Soket.BeginReceive(cInfo.LengthBuffer, 0, 2, SocketFlags.None, ReceiveCallback, cInfo);

        }
        else
        {
            cInfo.Soket.BeginReceive(cInfo.RcvBuffer, 0, cInfo.BytesReceived, SocketFlags.None, ReceiveCallback, cInfo);

        }

    }
    catch (SocketException se)
    {
        return;
    }
    catch (Exception ex)
    {
        return;
    }

}

您可以在C10k 问题页面上找到对技术的很好的概述。

对于人们复制粘贴已接受的答案,您可以重写 acceptCallback 方法,删除 _serverSocket.BeginAccept(new AsyncCallback(acceptCallback), _serverSocket); 的所有调用; 并将其放入 finally{} 子句中,如下所示:

private void acceptCallback(IAsyncResult result)
    {
       xConnection conn = new xConnection();
       try
       {
         //Finish accepting the connection
         System.Net.Sockets.Socket s = (System.Net.Sockets.Socket)result.AsyncState;
         conn = new xConnection();
         conn.socket = s.EndAccept(result);
         conn.buffer = new byte[_bufferSize];
         lock (_sockets)
         {
           _sockets.Add(conn);
         }
         //Queue recieving of data from the connection
         conn.socket.BeginReceive(conn.buffer, 0, conn.buffer.Length, SocketFlags.None, new AsyncCallback(ReceiveCallback), conn);
       }
       catch (SocketException e)
       {
         if (conn.socket != null)
         {
           conn.socket.Close();
           lock (_sockets)
           {
             _sockets.Remove(conn);
           }
         }
       }
       catch (Exception e)
       {
         if (conn.socket != null)
         {
           conn.socket.Close();
           lock (_sockets)
           {
             _sockets.Remove(conn);
           }
         }
       }
       finally
       {
         //Queue the next accept, think this should be here, stop attacks based on killing the waiting listeners
         _serverSocket.BeginAccept(new AsyncCallback(acceptCallback), _serverSocket);       
       }
     }

您甚至可以删除第一个捕获,因为它的内容相同,但它是一个模板方法,您应该使用类型化异常来更好地处理异常并了解导致错误的原因,因此只需使用一些有用的代码实现这些捕获。

您可以尝试使用称为自适应通信环境(ACE) 的框架,它是用于网络服务器的通用 C++ 框架。 它是一款非常可靠、成熟的产品,旨在支持高达电信级的高可靠性、大容量应用。

该框架处理相当广泛的并发模型,并且可能有一个适合您的开箱即用的应用程序。 这应该使系统更容易调试,因为大多数令人讨厌的并发问题已经解决。 这里的权衡是该框架是用 C++ 编写的,并不是最温暖和最蓬松的代码库。 另一方面,您将获得经过测试的工业级网络基础设施和开箱即用的高度可扩展架构。

我会使用SEDA或轻量级线程库(Erlang或更新的 Linux。请参阅服务器端的 NTPL 可扩展性)。 如果您的通信不是,异步编码非常麻烦:)

好吧,.NET 套接字似乎提供了select() - 这是处理输入的最佳选择 对于输出,我有一个套接字编写器线程池在侦听工作队列,接受套接字描述符/对象作为工作项的一部分,因此每个套接字不需要一个线程。

我将使用 .NET 3.5 中添加的 AcceptAsync/ConnectAsync/ReceiveAsync/SendAsync 方法。 我做了一个基准测试,它们在 100 个用户不断发送和接收数据的情况下快了大约 35%(响应时间和比特率)。

我建议阅读这些关于ACE 的书,

获取有关模式的想法,允许您创建高效的服务器。

尽管 ACE 是用 C++ 实现的,但书中涵盖了许多可用于任何编程语言的有用模式。

您可以使用Push Framework开源框架进行高性能服务器开发。 它建立在IOCP之上,适用于推送场景和消息广播。

明确地说,我正在寻找基于 .NET 的解决方案(如果可能,使用 C#,但任何 .NET 语言都可以)

如果您只使用 .NET,您将无法获得最高级别的可扩展性。 GC 暂停会妨碍延迟。

我将需要为该服务至少启动一个线程。 我正在考虑使用异步 API(BeginReceive 等),因为我不知道在任何给定时间(可能是数百个)我将连接多少个客户端。 我绝对不想为每个连接启动一个线程。

Overlapped I/O通常被认为是 Windows 最快的网络通信 API。 我不知道这是否与您的异步 API 相同。 不要使用select,因为每次调用都需要检查每个打开的套接字,而不是在活动套接字上进行回调。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM