在客户端主动退出时,我们会调用 socket 的
ShutDown()
和
Close()
方法,但调用这两个方法后,服务器端无法得知客户端已经主动断开。
本文主要介绍在网络通信中,如何服务端如何判断客户端断开连接。
1 Disconnect 方法
Socket 当中有一个专门在客户端使用的方法:Disconnect 方法。
- 此方法将结束连接并将 Connected 属性设置为
false
。但是,如果reuseSocket
为true
,则可以重用套接字。 - 若要确保在关闭套接字之前发送和接收所有数据,应在调用 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;
}
}
...
}
服务端
-
收发消息时判断 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); // 解析错误,也认为把消息断开 } } ... }
-
处理删除记录的 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 并立刻结束运行,服务器中可以看到如下消息:

2 心跳消息
很多情况下,客户端并不会像上述一样正常断开连接。例如
- 非正常关闭客户端时,服务器无法正常收到关闭连接消息。
- 客户端长期不发送消息,防火墙或者路由器会断开连接。
因此,在长连接中,客户端和服务端之间会定期发送的一种特殊数据包,用于通知对方自己还在线,以确保长连接的有效性。
由于其发送的时间间隔往往是固定的持续的,就像是心跳一样一直存在,所以我们称之为**“心跳消息”**。
客户端
-
定义心跳消息
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; } }
-
定时发送消息。
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,服务器中可以定时收到心跳消息:

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