【问题标题】:Cancel blocking AcceptTcpClient call取消阻塞 AcceptTcpClient 调用
【发布时间】:2012-09-01 22:04:30
【问题描述】:

大家可能已经知道,在 C# 中接受传入 TCP 连接的最简单方法是循环 TcpListener.AcceptTcpClient()。此外,这种方式将阻止代码执行,直到获得连接。这对 GUI 非常有限,所以我想在单独的线程或任务中监听连接。

有人告诉我,线程有几个缺点,但是没有人向我解释这些是什么。所以我没有使用线程,而是使用了任务。这很好用,但是由于 AcceptTcpClient 方法阻塞了执行,我找不到任何处理任务取消的方法。

目前代码如下所示,但我不知道当我希望程序停止侦听连接时如何取消任务。

首先关闭任务中执行的函数:

static void Listen () {
// Create listener object
TcpListener serverSocket = new TcpListener ( serverAddr, serverPort );

// Begin listening for connections
while ( true ) {
    try {
        serverSocket.Start ();
    } catch ( SocketException ) {
        MessageBox.Show ( "Another server is currently listening at port " + serverPort );
    }

    // Block and wait for incoming connection
    if ( serverSocket.Pending() ) {
        TcpClient serverClient = serverSocket.AcceptTcpClient ();
        // Retrieve data from network stream
        NetworkStream serverStream = serverClient.GetStream ();
        serverStream.Read ( data, 0, data.Length );
        string serverMsg = ascii.GetString ( data );
        MessageBox.Show ( "Message recieved: " + serverMsg );

        // Close stream and TcpClient connection
        serverClient.Close ();
        serverStream.Close ();

        // Empty buffer
        data = new Byte[256];
        serverMsg = null;
    }
}

二、启动和停止监听服务的函数:

private void btnListen_Click (object sender, EventArgs e) {
    btnListen.Enabled = false;
    btnStop.Enabled = true;
    Task listenTask = new Task ( Listen );
    listenTask.Start();
}

private void btnStop_Click ( object sender, EventArgs e ) {
    btnListen.Enabled = true;
    btnStop.Enabled = false;
    //listenTask.Abort();
}

我只需要一些东西来替换 listenTask.Abort() 调用(我将其注释掉,因为该方法不存在)

【问题讨论】:

    标签: c# task blocked-threads


    【解决方案1】:

    取消 AcceptTcpClient

    取消阻塞AcceptTcpClient 操作的最佳选择是调用TcpListener.Stop,这将抛出一个SocketException,如果您想明确检查操作是否已取消,您可以捕获它。

           TcpListener serverSocket = new TcpListener ( serverAddr, serverPort );
    
           ...
    
           try
           {
               TcpClient serverClient = serverSocket.AcceptTcpClient ();
               // do something
           }
           catch (SocketException e)
           {
               if ((e.SocketErrorCode == SocketError.Interrupted))
               // a blocking listen has been cancelled
           }
    
           ...
    
           // somewhere else your code will stop the blocking listen:
           serverSocket.Stop();
    

    任何想在你的 TcpListener 上调用 Stop 的东西都需要某种级别的访问权限,所以你要么将它的范围放在 Listen 方法之外,要么将你的监听器逻辑包装在一个管理 TcpListener 并公开 Start 和 Stop 方法的对象中(停止呼叫TcpListener.Stop())。

    异步终止

    因为接受的答案使用Thread.Abort() 来终止线程,所以在此注意终止异步操作的最佳方法是协作取消而不是硬中止可能会有所帮助。

    在协作模型中,目标操作可以监视由终止器发出的取消指示符。这允许目标检测取消请求,根据需要进行清理,然后在适当的时间将终止的状态传回给终止器。如果没有这样的方法,操作的突然终止可能会使线程的资源甚至可能使托管进程或应用程序域处于损坏状态。

    从 .NET 4.0 开始,实现此模式的最佳方法是使用 CancellationToken。使用线程时,令牌可以作为参数传递给在线程上执行的方法。借助 Tasks,对 CancellationTokens 的支持内置于多个 Task constructors 中。取消令牌在此MSDN article.

    中有更详细的讨论

    【讨论】:

    • 我喜欢检查 SocketException.SocketErrorCode 的部分
    • 答案有一个“异步...”部分。仔细阅读后,读者必须得出结论,这两种变体都是异步的,因为第一个代码展示中优雅的“...”暗示了另一个线程或任务。我知道使用侦听器提示“服务器”上下文,而服务器提示多线程。仍然可以进行单线程对等 TCP 通信。所以值得一提的是,答案仅适用于异步执行的多线程架构。
    • 还有什么可以触发 SocketError.Interrupted 的吗?文档只说“一个阻塞的 Socket 调用被取消了”。但如果有不同的事情可以取消它。客户端或随机 Internet 连接失败会以任何方式导致此问题吗?
    【解决方案2】:

    为了完整起见,answer above 的异步对应项,使用 @Mitch 的建议(确认 here 确认)。

    与等待AcceptTcpClientAsync 的同步函数相比,它似乎在Stop 之后抛出ObjectDisposedException(无论如何我们都在调用它),所以捕获ObjectDisposedException 也是有意义的:

    async Task<TcpClient> AcceptAsync(TcpListener listener, CancellationToken ct)
    {
        using (ct.Register(listener.Stop))
        {
            try
            {
                return await listener.AcceptTcpClientAsync();
            }
            catch (SocketException e) when (e.SocketErrorCode == SocketError.Interrupted)
            {
                throw new OperationCanceledException(ct);
            }
            catch (ObjectDisposedException) when (ct.IsCancellationRequested)
            {
                throw new OperationCanceledException(ct);
            }
        }
    }
    

    从 2021 年开始更新:.NET 5 抛出 SocketException,而 .NET Framework(使用版本 4.5-4.8 测试)和 .NET Core 2.x-3.x 抛出 ObjectDisposedException。所以到今天为止,正确的代码应该是

    #if NET5_0 //_OR_GREATER?
    catch (SocketException ex) when (ct.IsCancellationRequested &&
                                     ex.SocketErrorCode == SocketError.OperationAborted)
    #elif (NETFRAMEWORK && NET40_OR_GREATER) || NETCOREAPP2_0_OR_GREATER
    catch (ObjectDisposedException ex) when (ct.IsCancellationRequested)
    #else
    #error Untested target framework
    #endif
    {
        throw new OperationCanceledException(ct);
    }
    

    同步对应项 (listener.AcceptTcpClient()) 始终抛出 SocketExceptionSocketErrorCode == Interrupted,因此在 .NET 5.0 之前的所有框架中都可以执行以下操作:

    try
    {
        return serverSocket.AcceptTcpClient();
    }
    catch (SocketException e) when (e.SocketErrorCode == SocketError.Interrupted)
    {
        throw new OperationCanceledException(ct);
    }
    

    【讨论】:

    • 对于新的 .NET 版本,这是理想的解决方案。 (好吧,真正需要的解决方案是让 AcceptTcpClientAsync() 获取取消令牌)。
    • 我也有AcceptTcpClientAsyncthrow ObjectDisposedException post-Stop(),所以你可能想为两者都设陷阱。
    【解决方案3】:

    好吧,在异步套接字正常工作之前的过去(今天最好的方式,BitMask 谈到了这一点),我们使用了一个简单的技巧:将 isRunning 设置为 false(同样,理想情况下,您想要改用CancellationTokenpublic static bool isRunning; 不是终止后台工作人员的线程安全方式:)) 并为自己启动一个新的TcpClient.Connect - 这将从Accept 返回调用,您可以优雅地终止。

    正如 BitMask 已经说过的,Thread.Abort 绝对不是终止时的安全方法。事实上,它根本不起作用,因为Accept 是由本机代码处理的,而Thread.Abort 没有权力。它起作用的唯一原因是因为您实际上并没有阻塞 I/O,而是在检查 Pending(非阻塞调用)时运行无限循环。这看起来是在一个内核上拥有 100% 的 CPU 使用率的好方法 :)

    您的代码也有很多其他问题,这些问题不会仅仅因为您正在做非常简单的事情,而且因为 .NET 相当不错而在您面前爆发。例如,您总是对正在读取的整个缓冲区执行GetString - 但这是错误的。事实上,这是一个缓冲区溢出的教科书示例,例如C++ - 它似乎在 C# 中工作的唯一原因是因为它会将缓冲区预置零,因此GetString 会忽略您读取的“真实”字符串之后的数据。相反,您需要获取 Read 调用的返回值 - 它告诉您已读取多少字节,因此需要解码多少字节。

    这样做的另一个非常重要的好处是,这意味着您不再需要在每次读取后重新创建 byte[] - 您可以简单地一遍又一遍地重用缓冲区。

    不要使用 GUI 线程以外的其他线程中的 GUI(是的,您的 Task 正在单独的线程池线程中运行)。 MessageBox.Show 是一个肮脏的 hack,实际上可以从其他线程工作,但这真的不是你想要的。您需要在 GUI 线程上调用 GUI 操作(例如使用 Form.Invoke,或使用在 GUI 线程上具有同步上下文的任务)。这意味着消息框将是您所期望的正确对话框。

    您发布的 sn-p 存在更多问题,但鉴于这不是代码审查,而且它是一个旧线程,我不会再做这个了 :)

    【讨论】:

      【解决方案4】:

      这就是我克服这个问题的方法。希望这有帮助。可能不是最干净的,但对我有用

          public class consoleService {
          private CancellationTokenSource cts;
          private TcpListener listener;
          private frmMain main;
          public bool started = false;
          public bool stopped = false;
      
         public void start() {
              try {
                  if (started) {
                      stop();
                  }
                  cts = new CancellationTokenSource();
                  listener = new TcpListener(IPAddress.Any, CFDPInstanceData.Settings.RemoteConsolePort);
                  listener.Start();
                  Task.Run(() => {
                      AcceptClientsTask(listener, cts.Token);
                  });
      
                  started = true;
                  stopped = false;
                  functions.Logger.log("Started Remote Console on port " + CFDPInstanceData.Settings.RemoteConsolePort, "RemoteConsole", "General", LOGLEVEL.INFO);
      
              } catch (Exception E) {
                  functions.Logger.log("Error starting remote console socket: " + E.Message, "RemoteConsole", "General", LOGLEVEL.ERROR);
              }
          }
      
          public void stop() {
              try {
                  if (!started) { return; }
                  stopped = false;
                  cts.Cancel();
                  listener.Stop();
                  int attempt = 0;
                  while (!stopped && attempt < GlobalSettings.ConsoleStopAttempts) {
                      attempt++;
                      Thread.Sleep(GlobalSettings.ConsoleStopAttemptsDelayMS);
                  }
      
              } catch (Exception E) {
                  functions.Logger.log("Error stopping remote console socket: " + E.Message, "RemoteConsole", "General", LOGLEVEL.ERROR);
              } finally {
                  started = false;
              }
          }
      
           void AcceptClientsTask(TcpListener listener, CancellationToken ct) {
      
              try {
                  while (!ct.IsCancellationRequested) {
                      try {
                          TcpClient client = listener.AcceptTcpClient();
                          if (!ct.IsCancellationRequested) {
                              functions.Logger.log("Client connected from " + client.Client.RemoteEndPoint.ToString(), "RemoteConsole", "General", LOGLEVEL.DEBUG);
                              ParseAndReply(client, ct);
                          }
      
                      } catch (SocketException e) {
                          if (e.SocketErrorCode == SocketError.Interrupted) {
                              break;
                          } else {
                              throw e;
                          }
                       } catch (Exception E) {
                          functions.Logger.log("Error in Remote Console Loop: " + E.Message, "RemoteConsole", "General", LOGLEVEL.ERROR);
                      }
      
                  }
                  functions.Logger.log("Stopping Remote Console Loop", "RemoteConsole", "General", LOGLEVEL.DEBUG); 
      
              } catch (Exception E) {
                  functions.Logger.log("Error in Remote Console: " + E.Message, "RemoteConsole", "General", LOGLEVEL.ERROR);
              } finally {
                  stopped = true;
      
              }
              functions.Logger.log("Stopping Remote Console", "RemoteConsole", "General", LOGLEVEL.INFO);
      
          }
          }
      

      【讨论】:

      • 除非 CancellationTokens 的工作方式与我认为的不同,否则这仍然会产生相同的问题。 AcceptTcpClient 仍然是一种阻塞方法,它会阻塞直到有东西尝试连接。
      【解决方案5】:

      当 isRunning 变量变为 false 时,以下代码将关闭/中止 AcceptTcpClient

      public static bool isRunning;
      
      delegate void mThread(ref book isRunning);
      delegate void AccptTcpClnt(ref TcpClient client, TcpListener listener);
      
      public static main()
      {
         isRunning = true;
         mThread t = new mThread(StartListening);
         Thread masterThread = new Thread(() => t(this, ref isRunning));
         masterThread.IsBackground = true; //better to run it as a background thread
         masterThread.Start();
      }
      
      public static void AccptClnt(ref TcpClient client, TcpListener listener)
      {
        if(client == null)
          client = listener.AcceptTcpClient(); 
      }
      
      public static void StartListening(ref bool isRunning)
      {
        TcpListener listener = new TcpListener(new IPEndPoint(IPAddress.Any, portNum));
      
        try
        {
           listener.Start();
      
           TcpClient handler = null;
           while (isRunning)
           {
              AccptTcpClnt t = new AccptTcpClnt(AccptClnt);
      
              Thread tt = new Thread(() => t(ref handler, listener));
              tt.IsBackground = true;
              // the AcceptTcpClient() is a blocking method, so we are invoking it
              // in a separate dedicated thread 
              tt.Start(); 
              while (isRunning && tt.IsAlive && handler == null) 
              Thread.Sleep(500); //change the time as you prefer
      
      
              if (handler != null)
              {
                 //handle the accepted connection here
              }        
              // as was suggested in comments, aborting the thread this way
              // is not a good practice. so we can omit the else if block
              // else if (!isRunning && tt.IsAlive)
              // {
              //   tt.Abort();
              //}                   
           }
           // when isRunning is set to false, the code exits the while(isRunning)
           // and listner.Stop() is called which throws SocketException 
           listener.Stop();           
        }
        // catching the SocketException as was suggested by the most
        // voted answer
        catch (SocketException e)
        {
      
        }
      
      }
      

      【讨论】:

      • 我正在阅读这个问题和答案。现在我看到这个答案得到了+4和-5。我不知道答案是否正确。我相信在这种情况下,如果减分者写几句话会很有帮助,所以未来的读者(我这种情况下)不应该猜测,而是可以更有效地完成工作。 (本网站的主要目的之一是什么)
      • 我没有投反对票,但我猜在您的解决方案中,对 AccptTcpClnt 的调用会阻塞,无法取消。
      • 就个人而言,任何包含 Thread.Abort() 且没有充分理由的示例都值得一票否决。更不用说其他答案中显示的更清洁,更简单的解决方案
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2011-05-02
      • 1970-01-01
      • 2013-04-19
      • 2013-11-16
      • 1970-01-01
      • 2011-05-25
      • 1970-01-01
      相关资源
      最近更新 更多