异步编程踩坑——服务端停止阻塞

在编写程序的通讯模块时,涉及到了TCP通讯。模块的设计思路是,开启TCP端服务器后在一个线程中实时监听端口号,为每一个连接进来的客户端分配一个线程用来实时通信。
服务端在关闭时通过将标志位取反实现线程的结束,从而关闭服务端。

下面这段代码看着没什么问题,但关键就在于this.ServerSocket.Accept();,这个方法会阻塞当前线程。

点击查看代码
public void StartSocketListening()
{
    try
    {
        while (this.IsStartListening)
        {
            //有客户端链接 就接受,此处会直接阻塞当前线程 可能导致线程无法及时终端。
            System.Net.Sockets.Socket socket = this.ServerSocket.Accept();
            try
            {
                Thread.Sleep(100);
                //添加到 队列中 进行管理
                this.ClientSocketList.Add(socket);
                //获取端口号  和IP 地址
                string text = ((IPEndPoint)socket.RemoteEndPoint).Address.ToString();
                string text2 = ((IPEndPoint)socket.RemoteEndPoint).Port.ToString();
                //通过委托 把链接进来的客户端 信息返回出去
                this.OnTcpServerStateInfoEnterHead(string.Concat(new string[]
                {
                     "<",
                     text,
                     ":",
                     text2,
                     ">---上线"
                }), SocketState.ClientOnline);
                //返回当前客户端 对应的socket
                this.OnTcpServerOnlineClientEnterHead(socket);
                //传递 当前客户端数量
                this.OnTcpServerReturnClientCountEnterHead(this.ClientSocketList.Count);
                //在线程池中 异步调用这个方法(ClientSocketCallBack),并把客户端的socket作为变量传入ClientSocketCallBack
                ThreadPool.QueueUserWorkItem(new WaitCallback(this.ClientSocketCallBack), socket);
                Log.Info($"{text} 客户端已经连接");
            }
            catch (Exception)
            {
                //异常则关闭连接 移除客户端
                socket.Shutdown(SocketShutdown.Both);
                this.OnTcpServerOfflineClientEnterHead(socket);
                this.ClientSocketList.Remove(socket);
            }
        }
    }
    catch (Exception ex)
    {
        this.OnTcpServerErrorMsgEnterHead(ex.Message);
    }
}

接着看下面关闭服务端时的逻辑处理。这段代码看着没什么问题,通过设置标志位为false,让上面的线程退出循环,然后通过Interrupt和Join打断线程后等待其结束回收资源。没有采用Abort()是因为dotnet早以不推荐这种方式进行线程退出,替代方法是通过Token抛出异常信息来强制打断线程执行。
回到下面的代码,我没有采用强制方法终端线程执行,这就导致即便我将标志位设为false,线程依旧因为Accept()方法处于阻塞状态。,更糟糕的问题是,我后面还调用了Join方法,这个方法直接阻塞了主线程。具体表现就是,每当我关闭TCP服务端,整个程序直接卡死。

替代方法
void ProcessPendingWorkItemsNew(CancellationToken cancellationToken)
{
    if (QueryIsMoreWorkPending())
    {
        // If the CancellationToken is marked as "needs to cancel",
        // this will throw the appropriate exception.
        cancellationToken.ThrowIfCancellationRequested();

        WorkItem work = DequeueWorkItem();
        ProcessWorkItem(work);
    }
}
服务端关闭
public void Stop()
{
    try
    {
        this.IsStartListening = false;
        if (this.StartSockst != null)
        {
            //关闭监听客户端的线程
            this.StartSockst.Interrupt();
            //等待线程完全结束 回收资源
            this.StartSockst.Join();
        }
        if (this.ServerSocket != null)
        {
            this.ServerSocket.Shutdown(SocketShutdown.Both);
            this.ServerSocket.Close();
        }
        //传出 服务端数据
        this.OnTcpServerStateInfoEnterHead(string.Format("服务端Ip:{0},端口:{1}已停止监听", this.ServerIp, this.ServerPort), SocketState.StopListening);
        for (int i = 0; i < this.ClientSocketList.Count; i++)
        {
            this.OnTcpServerOfflineClientEnterHead(this.ClientSocketList[i]);
            //关闭客户端链接
            this.ClientSocketList[i].Shutdown(SocketShutdown.Both);
            //关闭套接字 释放资源
            this.ClientSocketList[i].Close();
        }
        // GC.Collect();
    }
    catch (SocketException)
    {
    }
}

通过上面的分析,问题已经十分清晰了。重要的是避免线程的阻塞,所以调整为如下的代码逻辑即可顺利取消线程。我们直接关闭TCP的Socket,由于线程调用了Accept()方法,因此该线程会报错,然后就能顺利取消线程。

正确的方法
public void Stop()
{
    try
    {
        this.IsStartListening = false;
        if (this.StartSockst != null)
        {
            //如果在此处 我们不先关闭 服务端的socket  那么在对应线程的循环中
            //调用的accept方法会一直阻塞,导致IsStartListening不能即使中止方法。(记一下这里)
            //关闭socket让线程中的accept方法  强制抛出异常 结束执行
            if (this.ServerSocket != null)
            {
                //服务端不需要调用该方法
                //this.ServerSocket.Shutdown(SocketShutdown.Both);
                this.ServerSocket.Close();
            }
            //关闭监听客户端的线程
            this.StartSockst.Interrupt();
            //this.StartSockst.Abort();
            //等待线程完全结束 回收资源
            this.StartSockst.Join();
        }
        
        //传出 服务端数据
        this.OnTcpServerStateInfoEnterHead(string.Format("服务端Ip:{0},端口:{1}已停止监听", this.ServerIp, this.ServerPort), SocketState.StopListening);
        for (int i = 0; i < this.ClientSocketList.Count; i++)
        {
            this.OnTcpServerOfflineClientEnterHead(this.ClientSocketList[i]);
            //关闭客户端链接
            this.ClientSocketList[i].Shutdown(SocketShutdown.Both);
            //关闭套接字 释放资源
            this.ClientSocketList[i].Close();
        }
        // GC.Collect();
    }
    catch (SocketException)
    {
    }
}

最后,我再补充一个更好的方法。Accept()还有一个异步版本AcceptAsync()方法,我们直接在当前通讯类中创建一个Cts,将其作为循环监听以及AcceptAsync()的控制信号,这样当我们需要取消通讯时,直接调用Cancel()方法,就能同时取消通讯以及退出循环。

2025.9.10补充

更深入的解释: Interrupt()本意是通过抛出异常的方式,打断处于阻塞状态的线程的。我这里的问题,实际上是直接出现死锁了。由于Join()导致主线程被阻塞。Interupt能够打断的线程的前提条件是处于以下几种状态时(Wait、Sleep、Join),但是调用Accept的阻塞,并不会让线程处于这几种状态中,因此导致了死锁。
image

posted @ 2025-08-17 22:56  Ytytyty  阅读(7)  评论(0)    收藏  举报