Unity网络基础异步TCP通信
课前预备知识:什么是“异步(Async)”?
想象你在餐厅点餐:
-
同步(阻塞):你点完菜,一直站在柜台前等,直到厨师把菜做出来端给你,你才去干别的事。这段时间你什么都干不了(这就叫“线程阻塞”)。
-
异步(非阻塞):你点完菜,拿到一个取餐号,然后你找个座位坐下玩手机。当后厨把菜做好了,广播叫你的号(触发回调函数),你再去拿菜。玩手机和做菜是同时进行的。
在这套代码中,客户端使用的是基于事件的异步模型(SocketAsyncEventArgs),服务器使用的是较老但非常经典的 APM 异步模型(Begin.../End...)。
第一部分:客户端大脑 —— NetAsyncMgr.cs (Unity客户端)
这份代码挂载在Unity的空物体上,负责与服务器连接、发送、接收消息。
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using UnityEngine;
public class NetAsyncMgr : MonoBehaviour
{
// [单例模式] 方便在Unity的其他脚本里随时调用:NetAsyncMgr.Instance.Connect(...)
private static NetAsyncMgr instance;
public static NetAsyncMgr Instance => instance;
// 1. 临时接收缓冲区 (小):每次异步接收网络数据的“小提桶”
private byte[] recvBuffer = new byte[1024];
// 2. 粘包解析缓存区 (大):用来把小提桶里的水倒进来的“大水缸”,方便后续拆解出一个个完整的消息
private byte[] cacheBytes = new byte[1024 * 64];
private int cacheNum = 0; // 当前大水缸里装了多少字节的水
// [核心知识点] 线程锁:防止异步线程(收消息)和主线程(Update读消息)同时操作队列导致内存崩溃
private readonly object queueLock = new object();
// 消息队列:后台收到数据解析好后塞进去,主线程在Update里拿出来处理
private Queue<BaseMsg> receiveQueue = new Queue<BaseMsg>();
private Socket socket;
// 心跳包对象,复用同一个对象节省内存
private HeartMsg heartMsg = new HeartMsg();
private bool isConnected = false; // 连接状态标记
void Awake()
{
instance = this;
// 保证这个网络管理物体在切换Unity场景时不会被销毁
DontDestroyOnLoad(this.gameObject);
// 延迟0秒,每隔2秒调用一次 "SendHeartMsg" 方法,向服务器发送心跳
InvokeRepeating("SendHeartMsg", 0, 2);
}
private void SendHeartMsg()
{
// 只要还连着,就不断发心跳,告诉服务器“我还活着,别踢我”
if(isConnected)
Send(heartMsg);
}
void Update()
{
// 逻辑分发:从队列取出消息并在主线程处理(因为Unity的UI、对象等只能在主线程操作)
while (isConnected)
{
BaseMsg msg = null;
// [加锁保护] 我现在要拿东西了,后台线程这时候千万别往里塞东西!
lock (queueLock)
{
// 如果队列里有消息,就拿出来一个
if (receiveQueue.Count > 0) msg = receiveQueue.Dequeue();
}
// 如果没拿到消息,说明处理完了,直接退出while循环,等下一帧Update
if (msg == null) break;
// 根据取出的消息类型,执行具体的游戏逻辑
if (msg is PlayerMsg playerMsg)
{
Debug.Log($"收到ID:{playerMsg.playerID}, 姓名:{playerMsg.playerData.name}");
}
}
}
public void Connect(string ip, int port)
{
// 防止重复连接
if (socket != null && socket.Connected) return;
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse(ip), port);
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
// [异步知识点] SocketAsyncEventArgs 是C#推荐的高性能网络异步操作类
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.RemoteEndPoint = ipPoint;
// 绑定连接完成后的“回调函数”(等连接成功或失败了,系统会自动来执行这部分代码)
args.Completed += (s, e) =>
{
if (e.SocketError == SocketError.Success) // 如果成功连上服务器
{
Debug.Log("连接成功");
isConnected = true;
// 连接成功,开始准备异步接收数据
SocketAsyncEventArgs receiveArgs = new SocketAsyncEventArgs();
// 设置接收数据用的缓冲区(用我们上面准备好的“小提桶” recvBuffer)
receiveArgs.SetBuffer(recvBuffer, 0, recvBuffer.Length);
// 绑定接收完成后的“回调函数”
receiveArgs.Completed += ReceiveCallBack;
// [异步执行] 发起接收请求,这句话执行后不会卡住,后台会去默默等数据
socket.ReceiveAsync(receiveArgs);
}
else
{
Debug.LogError("连接失败: " + e.SocketError);
Close();
}
};
// [异步执行] 发起连接请求,不会卡住主线程
socket.ConnectAsync(args);
}
private void ReceiveCallBack(object sender, SocketAsyncEventArgs args)
{
// 如果 SocketError 为 Success 且 BytesTransferred > 0,说明收到有效数据
// (BytesTransferred 是本次实际收到了多少字节的数据)
if (args.SocketError == SocketError.Success && args.BytesTransferred > 0 && isConnected )
{
// 将刚收到的数据送去进行“粘包/分包”处理
HandleReceiveMsg(args.Buffer, args.BytesTransferred);
// [关键步骤] 数据处理完后,必须把提桶清空,再次放进水里,等待下一次接水!
args.SetBuffer(0, recvBuffer.Length);
if (socket != null && socket.Connected)
socket.ReceiveAsync(args); // 再次发起异步接收请求,形成闭环
}
else
{
// 如果收到的字节数是 0,或者发生了错误,说明断开了
Debug.Log("断开连接或接收出错: " + args.SocketError);
Close();
}
}
private void HandleReceiveMsg(byte[] receiveBytes, int receiveNum)
{
// 1. 扩容检查:大水缸装不下了怎么办?
if (cacheNum + receiveNum > cacheBytes.Length)
{
// 建议:实际开发中可以动态扩容(新建更大的数组然后拷贝),这里先做报错处理
Debug.LogError("缓存溢出,包体过大!");
Close();
return;
}
// 2. 将刚收到的新数据(小提桶),拷贝到缓存区(大水缸)的尾部
Array.Copy(receiveBytes, 0, cacheBytes, cacheNum, receiveNum);
cacheNum += receiveNum; // 更新大水缸里当前的水量
int nowIndex = 0; // 当前解析到了大水缸的哪个位置
while (true)
{
// 关键:必须保证剩余未解析的长度 >= 8 字节,才能解析包头
// (假设我们的协议规定:前4个字节是消息ID,后4个字节是消息体长度,总共8字节包头)
if (cacheNum - nowIndex >= 8)
{
// 读取消息ID
int msgID = BitConverter.ToInt32(cacheBytes, nowIndex);
// 读取消息体长度
int msgLength = BitConverter.ToInt32(cacheBytes, nowIndex + 4);
// 检查包体是否完整:剩余未解析的数据长度,够不够一个完整的包体?
if (cacheNum - nowIndex - 8 >= msgLength)
{
nowIndex += 8; // 偏移量往后推8字节,跳过包头,准备读包体
BaseMsg baseMsg = null;
// 根据 ID 将字节流反序列化为具体的对象
switch (msgID)
{
case 1:
PlayerMsg msg = new PlayerMsg();
// 让消息体自己去解析字节数组
msg.Reading(cacheBytes, nowIndex);
baseMsg = msg;
break;
}
if (baseMsg != null)
{
// [加锁保护] 把解析好的完整消息塞入队列,供主线程拿取
lock (queueLock) receiveQueue.Enqueue(baseMsg);
}
nowIndex += msgLength; // 偏移量继续往后推,跳过刚刚读完的包体
// while循环继续,尝试看看后面还有没有完整的包(处理粘包)
}
else
{
// [分包情况] 包头够了但包体不够(比如包体要100字节,现在只收到了50字节)
// 退出循环,等下一次 ReceiveCallBack 收到新数据拼进来后再处理
break;
}
}
else
{
// [分包情况] 剩余长度不足 8 字节,连包头都读不全,退出等待下一次数据
break;
}
}
// 3. 统一平移残留数据
// 如果处理了几个完整的包,nowIndex肯定大于0,前面的数据没用了
if (nowIndex > 0)
{
cacheNum -= nowIndex; // 算出还剩多少没处理完的半截数据
if (cacheNum > 0)
{
// 把这半截残留数据,整体往前平移到大水缸的最开头
Array.Copy(cacheBytes, nowIndex, cacheBytes, 0, cacheNum);
}
}
}
public void Close()
{
if (socket != null)
{
try {
print("客户端主动断开连接");
// 发送正常的退出消息给服务器(告诉服务器我正常下线了,不用等心跳超时)
QuitMgr msg = new QuitMgr();
socket.Send(msg.Writing()); // 发送退出包
isConnected = false;
// 正规断开Socket的流程:先Shutdown,再Close
socket.Shutdown(SocketShutdown.Both);
socket.Close();
} catch {} // 忽略异常,确保清理完成
socket = null;
cacheNum = 0; // 清空缓存数量
}
}
public void Send(BaseMsg msg)
{
if (socket != null && socket.Connected)
{
byte[] bytes = msg.Writing(); // 将消息序列化为字节数组
// [异步发送]
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.SetBuffer(bytes, 0, bytes.Length);
args.Completed += (s, e) =>
{
// 发送结束的回调,如果失败打印错误
if (e.SocketError != SocketError.Success) Debug.LogError("发送失败");
};
socket.SendAsync(args); // 发起异步发送,不卡主线程
}
}
// 测试用的同步发送方法
public void SendTest(byte[] bytes)
{
socket.Send(bytes);
}
private void OnDestroy()
{
// 当Unity里的挂载物体被销毁时,确保断开网络连接,防止内存泄漏
Close();
}
}
知识点提炼与总结:
-
多线程与主线程通信:Unity的规定是,UI和游戏对象的操作必须在主线程。但网络回调在后台线程。所以作者非常聪明地用了 Queue<BaseMsg> 作为信箱。后台线程往信箱塞信(Enqueue),主线程在 Update 里读信(Dequeue),并用 lock 保证信箱安全。
-
异步API使用:SocketAsyncEventArgs。流程是:实例化 -> 绑定回调 Completed += ... -> 调用 Async 方法。
异步 API 使用 :SocketAsyncEventArgs。
利弊分析:
-
利:使用 SocketAsyncEventArgs 是微软推荐的高性能做法,它可以复用内存,适合高并发(虽然客户端一般不需要,但性能很好);不卡顿Unity主画面。
-
弊:代码逻辑被回调函数切得比较碎(这叫“回调地狱”),不够直观。
-
扩展:未来的商业项目中,你可能会学到 C# 的 async/await 语法,它能让异步代码写起来像同步代码一样顺畅。
👨🏫 课后测试 - 客户端篇:
问题1: 如果我把 Update() 里面的 lock (queueLock) 删掉,会发生什么后果?
问题2: ReceiveCallBack 执行到最后,为什么要再次调用 socket.ReceiveAsync(args);?
答案解析:
极小概率下,当主线程正在执行 Dequeue(拿数据)的瞬间,后台正好收到网络消息在执行 Enqueue(塞数据),会导致 Queue 内部结构被破坏,引发游戏闪退报错。
就像去河边打水,你打完一桶水(收到一次数据)后,需要把空桶再次放进河里(再次调用 ReceiveAsync),否则你以后再也收不到服务器的数据了。
第二部分:服务器入口 —— Program.cs
这是控制台应用程序的主入口。
namespace TeachTcpServerAsync
{
class Progarm // (拼写为 Progarm,按照原代码保留)
{
public static void Start()
{
ServerSocket serverSocket = new ServerSocket();
// 启动服务器,监听本地IP的8080端口,最大挂起连接队列为10
serverSocket.Start("127.0.0.1", 8080, 10);
Console.WriteLine("开启服务器成功");
// [核心知识点] 服务器的主循环 (Main Loop)
// 服务器不能一启动就结束,必须用死循环一直运行下去
while (true)
{
// --- 关键修改点:每一圈都清理一下断开的连接 ---
// 统一清理掉线的客户端,防止在其他多线程地方直接删导致冲突
serverSocket.CloseDelListSocket();
// ------------------------------------------
// Console.KeyAvailable 是非阻塞检查键盘是否按下的方法。
// 如果直接用 Console.ReadLine(),主线程会被死死卡住,上面的清理逻辑就无法执行了。
if (Console.KeyAvailable)
{
string input = Console.ReadLine();
if (input == "quit")
{
// 输入 quit,安全关闭服务器并退出死循环
serverSocket.Close();
break;
}
else if (input.Length > 2 && input.Substring(0, 2) == "B:")
{
// 测试用:输入 B:1 广播给所有人一条玩家数据消息
if (input.Substring(2) == "1")
{
PlayerMsg ms = new PlayerMsg();
ms.playerID = 550;
ms.playerData.name = "s1h";
ms.playerData.atk = 501;
ms.playerData.lev = 62;
serverSocket.Broadcast(ms); // 执行广播
}
}
}
// [性能优化] 给 CPU 喘息的时间。
// 否则这个空的 while(true) 会把电脑的一颗CPU核心跑到 100%。休息10毫秒完全不影响体验。
Thread.Sleep(10);
}
}
}
}
扩展:这里用的 while(true) 是游戏服务器最经典的**主循环(Main Loop)**模型。
第三部分:服务器大管家 —— ServerSocket.cs
负责“拉客”(监听连接)和管理所有客户端(增删查改)
using System.Net;
using System.Net.Sockets;
namespace TeachTcpServerAsync
{
class ServerSocket
{
public Socket socket;
// 活人名单字典:存放所有当前连进来的玩家,Key是分配的ID,Value是对应的客户端操作类
public Dictionary<int, ClientSocket> clientDic = new Dictionary<int, ClientSocket>();
// [核心设计:延迟删除] 死亡名单:存放掉线的玩家。
// 为什么不直接在掉线时删掉字典里的人?因为多线程下边遍历边删除会报错,所以先记下来,主循环去统一删。
public static List<ClientSocket> delList = new List<ClientSocket>();
public static readonly object delLock = new object(); // 多线程锁,保护死亡名单不被同时修改
static bool isClose = false; // 服务器关闭状态标志
public void Start(string ip, int port, int num)
{
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse(ip), port);
try
{
socket.Bind(ipPoint); // 绑定IP和端口
socket.Listen(num); // 开始监听
// [异步知识点:APM模型] 异步等待客户端连接
// AcceptCallBack 就是有人连进来的时候自动触发的函数
socket.BeginAccept(AcceptCallBack, socket);
}
catch (Exception e) { Console.WriteLine("启动失败: " + e.Message); }
}
private void AcceptCallBack(IAsyncResult result)
{
if (isClose) return; // 如果服务器关了就不处理了
try
{
// [异步结束] EndAccept 正式拿到刚刚连进来的那个客户端的 Socket
Socket clientSocket = this.socket.EndAccept(result);
// 把它包装成我们自己写的业务类 ClientSocket
ClientSocket client = new ClientSocket(clientSocket);
// [加锁保护] 把新玩家加入活人字典。因为这时候可能有其他线程在广播,所以要加锁
lock (clientDic) { clientDic.Add(client.clientID, client); }
// [关键循环] 接待完这个客人,老板要继续回门口去等下一个客人!再次调用 BeginAccept
socket.BeginAccept(AcceptCallBack, socket);
}
catch { } // 如果捕获到异常(通常是服务器关闭引发的),直接略过
}
public void Broadcast(BaseMsg baseMsg)
{
if (isClose) return;
// [加锁保护] 遍历发送消息,防止此时正好有新玩家进来或者有人掉线导致字典结构被破坏
lock (clientDic)
{
foreach (ClientSocket client in clientDic.Values)
client.Send(baseMsg); // 让每个活着的客户端自己去发消息
}
}
// [由主循环调用] 统一处理死亡名单,安全清理
public void CloseDelListSocket()
{
// 锁定死亡名单,这时候不允许其他线程往里塞新死掉的人
lock (delLock)
{
if (delList.Count > 0)
{
// 遍历所有待删除的客户端
for (int i = 0; i < delList.Count; i++)
{
CloseClientSocket(delList[i]);
}
delList.Clear(); // 清理完了,清空死亡名单
}
}
}
private void CloseClientSocket(ClientSocket client)
{
// 加锁保护字典操作
lock (clientDic)
{
client.Close(); // 断开它的 Socket 连接
// 如果活人字典里确实有他,就把他删掉
if (clientDic.ContainsKey(client.clientID))
{
clientDic.Remove(client.clientID);
Console.WriteLine($"客户端{client.clientID}已移除");
}
}
}
public void Close()
{
isClose = true;
// 关闭所有活着的客户端
lock (clientDic)
{
foreach (var client in clientDic.Values) client.Close();
clientDic.Clear();
}
socket?.Close(); // 关闭总的监听 Socket
}
}
}
知识点提炼:
-
APM 异步模型:BeginXXX 和 EndXXX 是成对出现的。Begin 发起操作,在回调里必须调用 End 来获取结果并释放系统资源。
-
死亡名单设计模式(延迟删除):这是极其常用的技巧。为什么不直接删?因为网络断开是由后台线程触发的,如果后台线程直接从字典里 Remove,而主循环正在 Foreach 给所有人广播消息,C#会直接抛出 InvalidOperationException 异常导致服务器崩溃。所以先放 delList,让主循环自己去删。
👨🏫 课后测试 - 服务端管理篇:
问题3: 为什么要把 clientDic 用 lock 锁起来?
答案解析: AcceptCallBack 运行在线程池的后台线程中,每次有新客户端连入就会执行。而我们的广播、清理操作可能在其他线程。如果不加锁,两个线程同时修改字典,字典的底层结构会损坏。
第四部分:服务器与个人的专线 —— ClientSocket.cs
这里代表服务器上,针对某一个具体玩家的处理逻辑。
using System.Net.Sockets;
using System.Text;
namespace TeachTcpServerAsync
{
class ClientSocket
{
public Socket socket;
public int clientID; // 当前客户端的唯一标识
private static int CLIENT_BEGIN_ID = 1; // 静态变量,用于自增分配ID
// 服务端的粘包处理缓存区(大水缸)和接收缓冲区(小提桶)
private byte[] cacheBytes = new byte[1024 * 1024]; // 1MB 缓存区
private int cacheNum = 0;
private byte[] receiveBytes = new byte[1024 * 1024];
private int receiveNum = 0;
// [心跳机制核心]
private long frontTime = -1; // 上次收到心跳包的时间(秒)
private static int TIME_OUT_TIME = 10; // 超时判定时间:10秒没动静就踢下线
public ClientSocket(Socket socket)
{
this.clientID = CLIENT_BEGIN_ID++; // 分配唯一ID
this.socket = socket;
// 初始化时间戳:当前时间转换为秒
this.frontTime = DateTime.Now.Ticks / TimeSpan.TicksPerSecond;
// [知识点] 将 CheckTimeOut 方法扔进C#系统的“线程池”里去后台自动执行。
// 这样不卡主线程,给每个玩家开一个后台定时任务监控心跳。
ThreadPool.QueueUserWorkItem(CheckTimeOut);
// 刚连上,立即开启异步接收数据
try
{
// [APM异步接水]
this.socket.BeginReceive(receiveBytes, 0, receiveBytes.Length, SocketFlags.None, ReceiveCallBack, this.socket);
}
catch (Exception e)
{
Console.WriteLine($"客户端{clientID}初始接收失败: {e.Message}");
CloseSelf(); // 出错就将自己标记为待删除
}
}
// [心跳检查循环] 线程池中执行
private void CheckTimeOut(object obj)
{
// 只要没断开连接,就一直循环查
while (socket != null && socket.Connected)
{
// 如果当前时间 减去 上次发心跳的时间 >= 10秒
if (frontTime != -1 &&
DateTime.Now.Ticks / TimeSpan.TicksPerSecond - frontTime >= TIME_OUT_TIME)
{
Console.WriteLine($"客户端{clientID}心跳超时");
CloseSelf(); // 判定死亡,加入死亡名单
break; // 退出检查循环
}
// 每次检查完睡5秒,防止死循环耗尽CPU资源
Thread.Sleep(5000);
}
}
private void ReceiveCallBack(IAsyncResult result)
{
try
{
// 基础检查:防止由于断开连接导致 socket 为空时依然访问报错
if (socket == null || !socket.Connected) return;
// EndReceive 返回本次到底收到了多少字节
int bytesRead = socket.EndReceive(result);
// [极其重要] 在TCP协议中,如果对方调用了 Close() 正常断开,
// 我方依然会触发 ReceiveCallBack,但收到的字节数一定是 0!
if (bytesRead <= 0)
{
Console.WriteLine($"客户端{clientID}主动断开");
CloseSelf(); // 正常断开,加入死亡名单
return; // 退出,不再发起下次接收
}
// 处理收到的有效数据(粘包分包处理)
HandleReceiveMsg(receiveBytes, bytesRead);
// 继续接收前再次校验,如果依然连着,就再次把提桶扔下水去等数据
if (socket != null && socket.Connected)
{
socket.BeginReceive(receiveBytes, 0, receiveBytes.Length, SocketFlags.None, ReceiveCallBack, socket);
}
}
catch (Exception e)
{
// 如果拔网线等异常情况,EndReceive 会抛出异常,捕获后清理自己
Console.WriteLine($"客户端{clientID}接收异常: {e.Message}");
CloseSelf();
}
}
// 处理粘包分包逻辑(和客户端的代码一模一样)
private void HandleReceiveMsg(byte[] receiveBytes, int receiveNum)
{
if (cacheNum + receiveNum > cacheBytes.Length)
{
Console.WriteLine("缓存溢出!");
return;
}
// 拼合字节数组
Array.Copy(receiveBytes, 0, cacheBytes, cacheNum, receiveNum);
cacheNum += receiveNum;
int nowIndex = 0;
while (true)
{
// 解析包头(8字节)
if (cacheNum - nowIndex >= 8)
{
int msgID = BitConverter.ToInt32(cacheBytes, nowIndex);
int msgLength = BitConverter.ToInt32(cacheBytes, nowIndex + 4);
// 判断包体是否收全
if (cacheNum - nowIndex - 8 >= msgLength)
{
nowIndex += 8;
BaseMsg baseMsg = null;
// 服务器根据ID反序列化生成对应的消息对象
switch (msgID)
{
case 1:
PlayerMsg msg = new PlayerMsg();
msg.Reading(cacheBytes, nowIndex);
baseMsg = msg;
break;
case 1003:
baseMsg = new QuitMgr(); // 客户端主动退出消息
break;
case 999:
baseMsg = new HeartMsg(); // 心跳消息
break;
}
if (baseMsg != null)
// [处理消息] 不要在网络线程直接处理业务逻辑,交给线程池去处理,解放网络线程
ThreadPool.QueueUserWorkItem(MsgHandle, baseMsg);
nowIndex += msgLength; // 跳过包体
}
else break; // 包体不够,等下次拼包
}
else break; // 包头不够,等下次拼包
}
// 平移残留的半包数据
if (nowIndex > 0)
{
cacheNum -= nowIndex;
if (cacheNum > 0)
{
Array.Copy(cacheBytes, nowIndex, cacheBytes, 0, cacheNum);
}
}
}
// 业务逻辑处理中心
private void MsgHandle(Object obj)
{
BaseMsg basemsg = obj as BaseMsg;
if (basemsg is PlayerMsg msg)
{
Console.WriteLine($"ID:{msg.playerID}, Name:{msg.playerData.name}");
}
else if (basemsg is QuitMgr)
{
// 收到退出消息,主动加进死亡名单
CloseSelf();
}
else if (basemsg is HeartMsg)
{
// [心跳包续命] 收到客户端心跳,立马更新记录的时间!
// 这样 CheckTimeOut 方法算时间差的时候,就会觉得你刚刚还活着,就不会踢你。
frontTime = DateTime.Now.Ticks / TimeSpan.TicksPerSecond;
Console.WriteLine($"收到客户端{clientID}心跳包");
}
}
// 把自己加入到大管家的“死亡名单”中,等待主循环的清理
private void CloseSelf()
{
// 注意!访问 ServerSocket 的全局静态变量 delList 必须加锁!
lock (global::TeachTcpServerAsync.ServerSocket.delLock)
{
// 如果名单里还没有我,我就把自己加进去
if (!global::TeachTcpServerAsync.ServerSocket.delList.Contains(this))
global::TeachTcpServerAsync.ServerSocket.delList.Add(this);
}
}
public void Send(BaseMsg baseMsg)
{
try
{
if (socket != null && socket.Connected)
{
byte[] bytes = baseMsg.Writing(); // 序列化
// [APM异步发送]
socket.BeginSend(bytes, 0, bytes.Length, SocketFlags.None, SendCallBack, null);
}
}
catch { CloseSelf(); } // 发送中出了异常,八成是网断了,自杀
}
private void SendCallBack(IAsyncResult result)
{
// 异步发送结束,清理资源
try { socket?.EndSend(result); }
catch { }
}
// 彻底释放 Socket 资源的最终清理方法(由 ServerSocket 大管家来调用)
public void Close()
{
if (socket != null)
{
try
{
// 先禁用收发,再释放
socket.Shutdown(SocketShutdown.Both);
socket.Close();
}
catch { } // 防止抛出 ObjectDisposedException
socket = null;
}
}
}
}
知识点提炼:
-
心跳机制(HeartBeat):非常非常关键!为什么需要心跳?如果玩家在玩的时候突然被拔了网线、停电了,服务器是收不到0字节断开通知的(这叫半开连接)。如果没有心跳,服务器会永远认为这个玩家还在,导致内存泄漏。心跳就是客户端每隔2秒喊一句“我还活着”,服务器如果10秒没听到,就认为他死了,主动清理。
-
ThreadPool(线程池):不要随便自己 new Thread(),那很重。用 ThreadPool.QueueUserWorkItem 可以让系统自动分配空闲线程去干活(比如这里的查超时和处理消息)。
利弊分析:
-
利:每个 ClientSocket 各司其职,面向对象封装得很好。
-
弊:每个玩家连进来都会占用一个 ThreadPool 去执行 CheckTimeOut 死循环!如果有1万个玩家,就会有1万个线程在 Sleep(5000),服务器会卡死。
-
扩展优化:商业级服务器不会每个玩家开一个检测线程。通常是有一个唯一的全局“定时器中心(TimerManager)”,它一秒钟遍历检查一次所有在线玩家的 frontTime,这样只需要1个线程就能管理一万个人。
👨🏫 课后测试 - 客户端专线篇:
问题4: 为什么客户端突然断网(比如拔掉电脑网线),ReceiveCallBack 里 bytesRead <= 0 这句话可能不会执行?
问题5: 接上题,既然不会执行断开逻辑,那么服务器最后是怎么踢掉这个拔网线的玩家的?
答案解析:
4. TCP是底层协议,正常调用Close()会有个“挥手”过程,告诉对方我要关了,对方才会收到0字节。直接拔网线,连挥手的数据包都发不出来,服务器底层的Socket完全不知道对面已经没了。
5. 靠的就是 CheckTimeOut 方法(心跳机制)!因为网线拔了,客户端发不出心跳包,服务器的 frontTime 得不到刷新,10秒后 CheckTimeOut 发现超时,主动调用 CloseSelf() 清理了它。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)