2025-05-04 Unity 网络基础6——TCP心跳消息


​ 在客户端主动退出时,我们会调用 socket 的 ShutDown()Close() 方法,但调用这两个方法后,服务器端无法得知客户端已经主动断开。

​ 本文主要介绍在网络通信中,如何服务端如何判断客户端断开连接。

1 Disconnect 方法

​ Socket 当中有一个专门在客户端使用的方法:Disconnect 方法。

  • 此方法将结束连接并将 Connected 属性设置为 false。但是,如果 reuseSockettrue,则可以重用套接字。
  • 若要确保在关闭套接字之前发送和接收所有数据,应在调用 Disconnect 方法之前调用 Shutdown

客户端

​ 在程序退出时,主动断开连接。

public class NetManager : MonoBehaviour
{
    ...
        
    public void OnDestroy()
    {
        if (_socket != null)
        {
            Debug.Log("客户端主动断开连接...");

            _isConnected = false;
            _socket.Shutdown(SocketShutdown.Both);
            _socket.Disconnect(false);
            _socket.Close();
            _socket = null;
        }
    }
    
    ...
}

服务端

  1. 收发消息时判断 socket 是否已经断开。

    namespace NetLearningTcpServerExercise2;
    
    using System.Net.Sockets;
    
    public class ClientSocket
    {
        private static int _ClientBeginId = 1;
    
        private Socket _socket;
    
        private byte[] _cacheBytes = new byte[1024 * 1024]; // 缓冲区,大小为 1MB
        private int    _cacheBytesLength;
    
        public int Id;
        
        public bool Connected
        {
            get => _socket == null ? false : _socket.Connected;
        }
        
        ...
    
        public void ReceiveMessage()
        {
            if (!Connected) // 判断是否连接
            {
                Program.ServerSocket.AddDelSocket(this);
                return;
            }
    
            try
            {
                if (_socket.Available > 0)
                {
                    var buffer        = new byte[1024 * 5];
                    var receiveLength = _socket.Receive(buffer);
                    HandleReceiveMessage(buffer, receiveLength);
                }
            }
            catch (Exception e)
            {
                Console.WriteLine("ReceiveMessage Wrong: " + e);
    
                Program.ServerSocket.AddDelSocket(this); // 解析错误,也认为把消息断开
            }
        }
        
        ...
    }
    
  2. 处理删除记录的 socket 的相关逻辑(使用线程锁)。

namespace NetLearningTcpServerExercise2;

using System.Net;
using System.Net.Sockets;

public class ServerSocket
{
    private readonly Socket _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

    private Dictionary<int, ClientSocket> _clientSockets = new Dictionary<int, ClientSocket>();

    private List<ClientSocket> _delList = new List<ClientSocket>(); // 待移除列表

    private bool _Running;
    
    ...

    private void ReceiveMessage(object? state)
    {
        while (_Running)
        {
            lock (_clientSockets)
            {
                if (_clientSockets.Count > 0)
                {
                    foreach (var clientSocket in _clientSockets.Values)
                    {
                        clientSocket.ReceiveMessage();
                    }

                    ClearDelSocket(); // 每次循环,检查是否有待移除的 socket
                }
            }
        }
    }

    private void ClearDelSocket()
    {
        // 移除
        for (int i = 0; i < _delList.Count; i++)
        {
            CloseClientSocket(_delList[i]);
        }
        _delList.Clear();
    }

    public void CloseClientSocket(ClientSocket socket)
    {
        lock (_clientSockets)
        {
            Console.WriteLine("ClientSocket Close: " + socket.Id);
            _clientSockets.Remove(socket.Id);
            socket.Close();
        }
    }

    public void AddDelSocket(ClientSocket socket)
    {
        if (!_delList.Contains(socket))
        {
            _delList.Add(socket);
            // Console.WriteLine(socket);
        }
    }
}

测试

​ 启动服务器后,运行 Unity 并立刻结束运行,服务器中可以看到如下消息:

image-20250504092837596

2 心跳消息

​ 很多情况下,客户端并不会像上述一样正常断开连接。例如

  1. 非正常关闭客户端时,服务器无法正常收到关闭连接消息。
  2. 客户端长期不发送消息,防火墙或者路由器会断开连接。

​ 因此,在长连接中,客户端和服务端之间会定期发送的一种特殊数据包,用于通知对方自己还在线,以确保长连接的有效性。

​ 由于其发送的时间间隔往往是固定的持续的,就像是心跳一样一直存在,所以我们称之为**“心跳消息”**。

客户端

  1. 定义心跳消息

     public class HeartMessage : INetMessage
    {
        public int MessageId { get => 999; }
    
        public int BytesLength { get => sizeof(int) + sizeof(int); }
    
        public byte[] ToBytes()
        {
            var length = BytesLength;
            var bytes  = new byte[length];
            var index  = 0;
            index = this.Write(bytes, index, MessageId);
    
            // 写入消息长度
            index = this.Write(bytes, index, length - sizeof(int) * 2); // 减去消息长度和消息 Id 的长度
            return bytes;
        }
    
        public int FromBytes(byte[] bytes, int index)
        {
            return index;
        }
    }
    
  2. 定时发送消息。

    public class NetManager : MonoBehaviour
    {
        public static NetManager Instance { get; private set; }
    
        private Socket _socket;
    
        /// <summary>
        /// 发送消息的公共队列,主线程塞消息,发送线程拿消息进行发送
        /// </summary>
        private Queue<INetMessage> _sendMessages = new Queue<INetMessage>();
    
        /// <summary>
        /// 接收消息的公共队列,主线程拿消息,接收线程获取消息塞进去
        /// </summary>
        private Queue<INetMessage> _receiveMessages = new Queue<INetMessage>();
    
        private bool _isConnected
        {
            get => _socket == null ? false : _socket.Connected;
        }
    
        private byte[] _cacheBytes = new byte[1024 * 1024]; // 缓冲区,大小为 1MB
        private int    _cacheBytesLength;
    
        private static readonly int _SEND_HEART_MSG_TIME = 2;
    
        private void Awake()
        {
            Instance = this;
    
            // 循环定时给服务端发送心跳消息
            InvokeRepeating(nameof(SendHeartMsg), 0, _SEND_HEART_MSG_TIME);
        }
        
        public void SendHeartMsg()
        {
            if (_isConnected)
            {
                Send(new HeartMessage());
            }
            Debug.Log("发送心跳消息: " + _isConnected);
        }
        
        ...
    }
    

服务器

​ 不停检测上次收到某客户端消息的时间,如果超时则认为连接已经断开

namespace NetLearningTcpServerExercise2;

using System.Net.Sockets;

public class ClientSocket
{
    private static int _ClientBeginId = 1;

    private Socket _socket;

    private byte[] _cacheBytes = new byte[1024 * 1024]; // 缓冲区,大小为 1MB
    private int    _cacheBytesLength;

    public int Id;

    private        long _frontTime     = -1; // 上次收到的心跳时间
    private static int  _TIME_OUT_TIME = 5;

    public bool Connected
    {
        get => _socket == null ? false : _socket.Connected;
    }

    public ClientSocket(Socket socket)
    {
        Id      = _ClientBeginId++;
        _socket = socket;

        ThreadPool.QueueUserWorkItem(CheckTimeOut, null);
    }

    /// <summary>
    /// 间隔一段时间检测超时
    /// </summary>
    /// <param name="state"></param>
    private void CheckTimeOut(object? state)
    {
        while (Connected)
        {
            if (_frontTime != -1 &&
                DateTime.Now.Ticks / TimeSpan.TicksPerSecond - _frontTime > _TIME_OUT_TIME)
            {
                Program.ServerSocket.AddDelSocket(this);
                break;
            }

            Thread.Sleep(1000);
        }
    }

    public void Close()
    {
        if (_socket != null)
        {
            _socket.Shutdown(SocketShutdown.Both);
            _socket.Close();
            _socket = null!;
        }
    }

    public void SendMessage(INetMessage message)
    {
        if (!Connected)
        {
            Program.ServerSocket.AddDelSocket(this);
            return;
        }

        try
        {
            _socket.Send(message.ToBytes());
        }
        catch (Exception e)
        {
            Console.WriteLine("SendMessage Wrong: " + e);

            Program.ServerSocket.AddDelSocket(this);
        }
    }

    public void ReceiveMessage()
    {
        if (!Connected)
        {
            Program.ServerSocket.AddDelSocket(this);
            return;
        }

        try
        {
            if (_socket.Available > 0)
            {
                var buffer        = new byte[1024 * 5];
                var receiveLength = _socket.Receive(buffer);
                HandleReceiveMessage(buffer, receiveLength);
            }
        }
        catch (Exception e)
        {
            Console.WriteLine("ReceiveMessage Wrong: " + e);

            Program.ServerSocket.AddDelSocket(this); // 解析错误,也认为把消息断开
        }
    }

    private void MessageHandle(object? state)
    {
        if (state == null) return;

        var msg = (INetMessage) state;
        if (msg is PlayerMessage playerMsg)
        {
            Console.WriteLine($"Receive message from client {_socket} (ID {Id}): {playerMsg}");
        }
        else if (msg is QuitMessage quitMsg)
        {
            Program.ServerSocket.AddDelSocket(this); // 客户端断开连接
        }
        else if (msg is HeartMessage heartMsg)
        {
            _frontTime = DateTime.Now.Ticks / TimeSpan.TicksPerSecond;

            Console.WriteLine($"Receive heart message from client {_socket} (ID {Id}): {heartMsg}");
        }
    }

    private void HandleReceiveMessage(byte[] receiveBytes, int receiveNum)
    {
        var messageId = 0;
        var index     = 0;

        // 收到消息时看之前有没有缓存
        // 如果有,直接拼接到后面
        receiveBytes.CopyTo(_cacheBytes, _cacheBytesLength);
        _cacheBytesLength += receiveNum;

        while (true)
        {
            var messageLength = -1;

            // 处理前置信息
            if (_cacheBytesLength - index >= 8)
            {
                // 解析 Id
                messageId =  BitConverter.ToInt32(_cacheBytes, index);
                index     += sizeof(int);

                // 解析长度
                messageLength =  BitConverter.ToInt32(_cacheBytes, index);
                index         += sizeof(int);
            }

            // 处理消息体
            if (messageLength != -1 && _cacheBytesLength - index >= messageLength)
            {
                // 解析消息体
                INetMessage message = default;
                switch (messageId)
                {
                    case 1001:
                        message = new PlayerMessage();
                        message.FromBytes(_cacheBytes, index);
                        break;
                    case 1003:
                        message = new QuitMessage();
                        message.FromBytes(_cacheBytes, index);
                        break;
                    case 999:
                        message = new HeartMessage();
                        message.FromBytes(_cacheBytes, index);
                        break;
                }

                if (message != default)
                {
                    ThreadPool.QueueUserWorkItem(MessageHandle, message);
                }
                index += messageLength;

                // 如果消息体长度等于缓存长度,证明缓存已经处理完毕
                if (index == _cacheBytesLength)
                {
                    _cacheBytesLength = 0;
                    break;
                }
            }
            else // 消息体还没有接收完毕
            {
                // 解析了前置信息,但是没有成功解析消息体
                if (messageLength != -1)
                {
                    index -= 8; // 回退到解析 Id 的位置
                }

                // 缓存剩余的数据
                _cacheBytesLength -= index;
                Array.Copy(_cacheBytes, index, _cacheBytes, 0, _cacheBytesLength);

                break;
            }
        }
    }
}

测试

​ 启动服务器后,运行 Unity,服务器中可以定时收到心跳消息:

image-20250504093828255

​ 结束运行 Unity,等待 5s 后,可看到服务器显示断开连接:

image-20250504095602541
### 处理Unity中Socket断开连接的原因及解决方案 当处理Unity中的Socket断开连接情况时,主要关注点在于确保客户端和服务端能够正确识别并响应断开事件。以下是具体分析: #### 客户端主动断开连接 为了使服务器能及时感知到客户端的断开动作,可以在客户端设计专门的消息机制来通知服务器。这通常涉及向服务器发送特定命令或信号表明即将关闭连接。 ```csharp // Unity C# Client Code Example public void Disconnect() { try { byte[] message = Encoding.UTF8.GetBytes("disconnect"); socket.Send(message); socket.Shutdown(SocketShutdown.Both); socket.Close(); Debug.Log("Client has disconnected from server."); } catch (Exception ex) { Debug.LogError($"Error during disconnect: {ex.Message}"); } } ``` 此代码片段展示了如何构建一个简单的`Disconnect()`函数用于优雅地终止会话[^2]。 #### 服务端检测异常断开 对于未预期的网络中断或其他非正常结束的情形,服务端应具备监控心跳包的能力以判断某个连接是否仍然活跃。一旦发现长时间未能接收到指定客户的心跳,则假定其已掉线,并清理相应资源。 ```javascript // Node.js Server Side Handling Disconnection Logic io.on('connection', function(socket){ console.log('a user connected'); // Heartbeat mechanism to detect unexpected disconnections let heartbeatInterval; const HEARTBEAT_TIMEOUT = 5000; // milliseconds function startHeartbeat() { clearInterval(heartbeatInterval); heartbeatInterval = setInterval(() => { if (!socket.connected) return cleanupResources(); socket.emit('ping'); }, HEARTBEAT_TIMEOUT / 2); setTimeout(() => { if (!socket.connected) cleanupResources(); }, HEARTBEAT_TIMEOUT); } startHeartbeat(); socket.on('pong', () => startHeartbeat()); function cleanupResources(){ clearInterval(heartbeatInterval); io.to(socket.id).emit('disconnected'); console.log(`User ${socket.id} was unexpectedly disconnected.`); } }); ``` 上述脚本实现了基本的心跳监测逻辑以及相应的清理流程[^1]。 #### 连接状态管理 无论何时发生任何类型的断开——无论是计划内的还是意外发生的——都应当更新应用程序内部关于当前活动连接的信息库。这样可以防止残留数据干扰后续操作,并有助于诊断潜在问题。 ```csharp private void OnClose(object sender, EventArgs e) { var client = (TcpClient)sender; lock (_clientsSyncRoot) { _clients.Remove(client.Client.RemoteEndPoint); } Console.WriteLine($"{client.Client.RemoteEndPoint} closed the connection."); } ``` 这段C#代码示范了怎样安全移除不再有效的TCP客户端实例[^3]。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

蔗理苦

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值