课前预备知识:什么是“异步(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();
    }
}
知识点提炼与总结:
  1. 多线程与主线程通信:Unity的规定是,UI和游戏对象的操作必须在主线程。但网络回调在后台线程。所以作者非常聪明地用了 Queue<BaseMsg> 作为信箱。后台线程往信箱塞信(Enqueue),主线程在 Update 里读信(Dequeue),并用 lock 保证信箱安全。

  2. 异步API使用SocketAsyncEventArgs。流程是:实例化 -> 绑定回调 Completed += ... -> 调用 Async 方法。
    异步 API 使用 SocketAsyncEventArgs

利弊分析:

  • :使用 SocketAsyncEventArgs 是微软推荐的高性能做法,它可以复用内存,适合高并发(虽然客户端一般不需要,但性能很好);不卡顿Unity主画面。

  • :代码逻辑被回调函数切得比较碎(这叫“回调地狱”),不够直观。

  • 扩展:未来的商业项目中,你可能会学到 C# 的 async/await 语法,它能让异步代码写起来像同步代码一样顺畅。

👨‍🏫 课后测试 - 客户端篇:

问题1: 如果我把 Update() 里面的 lock (queueLock) 删掉,会发生什么后果?
问题2: ReceiveCallBack 执行到最后,为什么要再次调用 socket.ReceiveAsync(args);

答案解析:

  1. 极小概率下,当主线程正在执行 Dequeue(拿数据)的瞬间,后台正好收到网络消息在执行 Enqueue(塞数据),会导致 Queue 内部结构被破坏,引发游戏闪退报错。

  2. 就像去河边打水,你打完一桶水(收到一次数据)后,需要把空桶再次放进河里(再次调用 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
        }
    }
}

知识点提炼:
  1. APM 异步模型:BeginXXX 和 EndXXX 是成对出现的。Begin 发起操作,在回调里必须调用 End 来获取结果并释放系统资源。

  2. 死亡名单设计模式(延迟删除):这是极其常用的技巧。为什么不直接删?因为网络断开是由后台线程触发的,如果后台线程直接从字典里 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;
            }
        }
    }
}

知识点提炼:
  1. 心跳机制(HeartBeat):非常非常关键!为什么需要心跳?如果玩家在玩的时候突然被拔了网线、停电了,服务器是收不到0字节断开通知的(这叫半开连接)。如果没有心跳,服务器会永远认为这个玩家还在,导致内存泄漏。心跳就是客户端每隔2秒喊一句“我还活着”,服务器如果10秒没听到,就认为他死了,主动清理。

  2. 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() 清理了它。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐