☰
Unity联机demo:ASP.NET与socket实现TCP服务端从零搭建
2026/10/7 10:14:21 网站建设 项目流程

简介:这是一套基于ASP.NET与Socket实现的Unity多人联机游戏Demo源码,主要面向正在学习Unity网络通信、服务端开发以及多人联机机制的初中级开发者,能够帮助读者从零搭建一个可运行的联机项目,并理解前后端交互全流程。包内目录划分清晰:McProject为Unity工程,McServer为VS服务端工程(内含已发布版本),McClient是客户端发布版;同时提供部署好的测试服务端,地址为124.223.118.118、端口8888,下载后直接运行客户端即可体验联机效果。整个压缩包共包含301个文件,以DLL库、C#脚本、Asset资源、Prefab预制体、EXE可执行程序、Config配置文件为主,并辅以材质、图片、XML文档等,工程与发布产物齐全,整体约27.45MB。目前已有407人下载学习,适合作为网络通信与多人游戏的综合实践素材。学习源码时可重点关注Socket的连接与消息处理、ASP.NET服务端的部署方式、客户端联机同步逻辑,以及多人会话管理等关键实现;基于现有框架,还可以继续扩展登录验证、房间管理、玩家同步等联机玩法功能。

1. 一个zip装下的联机demo:ASP.NET、socket和Unity三人各司其职

很多Unity开发者的第一个联机demo不是死在玩法上,而是死在“服务端到底怎么写”这个问题上。单机版跑得再欢,一碰socket网络编程,问题就变成:用什么做服务端、消息怎么编码、Unity主线程跟socket回调线程怎么协调。标题里这个demo源码给的答案是:ASP.NET管服务端进程,socket管TCP通道,Unity只管表现和输入,三个角色各司其职。这套组合没有引入Mirror、Photon这类现成联机框架,而是用最底层的方式把“客户端A发消息→服务端转发→客户端B收到”这条链路完整走通,非常适合想搞清楚联机原理再决定要不要上框架的人,也适合课程设计、毕设演示这类需要快速拿出可运行demo的场景。

先说一个反直觉的结论:这个demo里的ASP.NET其实不是用来处理HTTP请求的,它只是借用了ASP.NET Core的进程模型来托管一个TcpListener后台服务。这样做的理由很现实——你不需要单独部署一个控制台程序,不需要处理进程守护,一个WebApplication跑起来,TCP服务和HTTP接口共存于同一个进程,日志、配置、生命周期统一管理。下面开始拆这套方案,从服务端骨架、协议拆包、Unity接入到避坑和验证,按能复现的顺序走。

2. ASP.NET服务端骨架:TcpListener如何在一个Web进程里常驻

2.1 为什么是ASP.NET:一个进程里同时跑HTTP和TCP的宿主方案

先解决选型疑问。做联机demo的服务端,常见的选择有纯控制台程序、.NET的TcpListener裸写、ASP.NET Core托管后台服务,再往上就是Kestrel自带的WebSocket。控制台程序的问题在于:你要自己管进程重启、日志输出、配置读取,做demo还行,一旦要加HTTP接口做房间列表或者服务器状态查询,就得再起一个Web服务。而ASP.NET Core的IHostedService机制让TCP服务和WebAPI可以共用一个进程——端口可以不同,生命周期由宿主统一管理,程序退出时能优雅关闭监听。

这个方案跟直接用WebSocket的区别也值得说清楚。WebSocket天然跑在HTTP握手之上,Unity侧需要额外引用WebSocket库,而原生socket(TCP)直接走System.Net.Sockets,Unity的Mono运行时自带支持,不需要任何第三方包。对于“基于ASP.NET和socket实现”这个主题,使用TcpListener + IHostedService是最贴近标题本意的实现路径,也最能展示底层原理。

2.2 让TcpListener在后台常驻:IHostedService的最小实现

这里的落地做法是:用WebApplication.CreateBuilder建一个最小ASP.NET Core宿主,注册一个GameTcpServer后台服务,TCP监听与HTTP接口并存。下面给出服务端程序入口:

// Program.cs using GameServer; var builder = WebApplication.CreateBuilder(args); builder.Services.AddHostedService<GameTcpServer>(); // 注册TCP后台服务 var app = builder.Build(); app.MapGet("/health", () => "tcp server is running"); // 顺手留一个HTTP探活接口 app.Run();

AddHostedService是ASP.NET Core提供的后台任务注册方式,GameTcpServer继承BackgroundService后,它的ExecuteAsync方法会在应用启动时自动执行,应用关闭时收到取消令牌并触发停止逻辑。/health接口不是必须的,但建议留着——当你怀疑TCP服务是否还活着的时候,直接在浏览器里访问这个地址就能确定进程本身是否正常。

然后是核心的TCP服务类:

// GameTcpServer.cs using System.Net; using System.Net.Sockets; using System.Text; namespace GameServer; public class GameTcpServer : BackgroundService { private readonly TcpListener _listener; private readonly List<TcpClient> _clients = new(); public GameTcpServer() { // 监听所有网卡的9000端口,backlog为128 _listener = new TcpListener(IPAddress.Any, 9000); _listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listener.Server.ReceiveTimeout = 0; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _listener.Start(128); while (!stoppingToken.IsCancellationRequested) { var tcpClient = await _listener.AcceptTcpClientAsync(stoppingToken); _ = Task.Run(() => HandleClientAsync(tcpClient, stoppingToken)); } } private async Task HandleClientAsync(TcpClient tcpClient, CancellationToken token) { _clients.Add(tcpClient); var buffer = new byte[8192]; // 8KB读缓冲 var stream = tcpClient.GetStream(); try { while (!token.IsCancellationRequested) { var readCount = await stream.ReadAsync(buffer, token); if (readCount == 0) break; // 对端关闭连接 // 这里把buffer交给协议解析层处理,下一章实现 } } catch (Exception ex) { Console.WriteLine($"客户端连接异常: {ex.Message}"); } finally { _clients.Remove(tcpClient); tcpClient.Close(); } } }

这段代码有几个参数需要解释。IPAddress.Any表示监听服务器所有网卡,这样同一局域网内的手机和电脑都能通过服务器IP连上来;如果只写127.0.0.1,那就只有本机才能连,这是个容易踩的细节。端口9000建议避开常见的8080、443,也尽量别用1024以下的系统保留端口,9000-9100这个区间在联机demo里比较安全。backlog设为128表示等待Accept的排队连接数上限,demo几十个客户端足够。ReuseAddress解决的是服务端重启后端口处于TIME_WAIT状态导致绑定失败的问题,这点在后面的避坑章里会细说。读缓冲8192字节是常用经验值——太小说一次读不完一帧数据,太大浪费内存,对demo的网络包来说8KB够用。

2.3 收发循环的参数设置:端口、pending队列与读缓冲

上一段的代码里藏着一个需要单独讲的设计:Task.Run(() => HandleClientAsync(...))。AcceptTcpClientAsync每接收一个新连接,就把这个连接的收发循环扔到线程池去跑。这样做是为了让ExecuteAsync能立刻回到Accept循环,继续接收下一个客户端——如果在ExecuteAsync里直接处理客户端收发,第二个客户端就永远连不进来。

ReceiveTimeout = 0表示不设接收超时,让读取一直阻塞等待数据。这里有个取舍:demo阶段不设超时最省事,但生产环境一定要配合心跳机制,比如30秒没有收到任何数据就主动断开。心跳的实现在后面第6章会讲到,服务端侧只需要在ReadAsync返回0时认定对端断开,配合客户端定时发送心跳包,就能做到连接的健康管理。另外,Task.Run会让异常处理变复杂一点——HandleClientAsync内部自己做了try-catch,这是有意的,因为线程池任务里的异常不能被外层捕获,必须就地处理,否则客户端异常断开时整个服务会静默丢失连接。

服务端还有一个常见参数值得提:Nagle算法。TCP默认开启Nagle,会把小的数据包合并后发送,这会导致联机游戏的输入指令延迟增加。对于需要低延迟的帧同步或快节奏操作,建议在客户端和服务端都关闭它:

tcpClient.NoDelay = true;

NoDelay设为true后,每个小数据包都会立刻发送,不再等合并。对应的代价是网络利用率下降——但一个联机demo的消息量本来就不大,延迟优先于带宽。这一行写在AcceptTcpClientAsync之后、进入收发循环之前。

3. 定协议、拆粘包:联机demo里最决定成败的20行代码

3.1 消息格式先定死:4字节长度头 + JSON体

服务端和客户端能通信,前提是两端对“一条消息长什么样”有一致的约定。最常见的TCP协议格式是“长度头 + 消息体”:前4字节用整数表示消息体的长度,后面跟着的字节就是真正的消息内容。长度头解决了两个问题:接收方知道读多少字节算一条完整消息;消息体里就算包含特殊字符也不会被误判为消息边界。

消息体用JSON还是二进制,取决于游戏类型。帧同步或状态同步要求强一致的场景用二进制序列化,比如把float、int按固定偏移写入byte[];休闲类、策略类以及demo阶段用JSON最省事,Unity侧用JsonUtility,服务端用System.Text.Json,两边都原生支持,不需要引入第三方库。JSON的缺点是体积大、序列化有CPU开销,但对demo完全够用。下面定义一个最小消息结构:

// 消息类型 public enum GameMsgType { Login = 1, // 客户端登录,携带玩家名 Position = 2, // 位置同步 PlayerJoined = 3, // 服务端广播:新玩家加入 PlayerLeft = 4, // 服务端广播:玩家离开 Heartbeat = 5 // 心跳 } // 服务端和客户端共用的消息基类 [System.Serializable] public class GameMessage { public int msgType; // 对应GameMsgType public string playerId = ""; public string payload; // 不同消息类型的附加数据 }

协议的要点在于字段命名必须跨端一致。Unity的JsonUtility对字段名的大小写敏感,msgType在C#里用小写开头,JSON里就也是小写,服务端System.Text.Json默认不区分大小写,但为了不出玄学问题,建议两端的字段名完全一致,不要一端用MsgType一端用msgType。payload字段是偷懒的手法——每个消息类型的具体数据都塞进这个字符串里,虽然丑,但改协议时不需要改基类,对demo来说扩展性最好。

3.2 拆包器:用MemoryStream把“半包”攒成“整包”

TCP是流式协议,没有消息边界。也就是说,客户端连续发送的三条消息,服务端可能一次性收到全部字节,也可能分五次才收完。如果直接按读到的字节数去反序列化,必然翻车。拆包的思路是:先把收到的字节追加到一个待处理缓冲区,然后循环从缓冲区里判断是否够一个“长度头”,够就取出长度、判断缓冲区是否已有完整消息体,有则取走,不够就等下一次读取。

// PacketBuffer.cs using System.Text; public class PacketBuffer { private readonly MemoryStream _stream = new(); private readonly byte[] _lengthBytes = new byte[4]; public void Append(byte[] data, int count) { _stream.Write(data, 0, count); } public List<byte[]> TryParseAll() { var messages = new List<byte[]>(); _stream.Position = 0; while (_stream.Length - _stream.Position >= 4) { _stream.Read(_lengthBytes, 0, 4); int bodyLength = BitConverter.ToInt32(_lengthBytes, 0); if (_stream.Length - _stream.Position < bodyLength) { // 数据不够一个完整消息体,回到长度头之前的位置等待更多数据 _stream.Position -= 4; break; } var body = new byte[bodyLength]; _stream.Read(body, 0, bodyLength); messages.Add(body); } // 把剩余未处理的数据移到缓冲区开头 var remaining = _stream.Length - _stream.Position; var leftover = new byte[remaining]; _stream.Read(leftover, 0, (int)remaining); _stream.SetLength(0); _stream.Write(leftover, 0, leftover.Length); return messages; } }

这个拆包器是全网socket编程里最经典的一段代码,逻辑说明拆成三步。第一步,Append把网络流读到的原始字节追加到MemoryStream,这里的关键是count参数——ReadAsync返回的字节数不一定等于缓冲数组长度,直接用数组长度会把上次遗留的数据污染进去。第二步,TryParseAll进入循环,先读4字节长度头,再判断缓冲区内是否已有完整消息体,这里用了_stream.Position -= 4回退指针的技巧,本质是把“半包”留在缓冲区里,等下次Append后继续。第三步,把未处理完的残留数据搬到缓冲区头部,这是为了下一次TryParseAll时Position = 0能正确从头开始。

这里有一个参数容易被忽略:bodyLength的上限校验。恶意客户端或网络故障可能导致声明长度异常大,比如几十MB,导致内存暴涨。demo阶段最少也要加一个限制:

if (bodyLength <= 0 || bodyLength > 65536) throw new Exception($"非法消息长度: {bodyLength}");

64KB是TCP消息体比较合理的上限,超过这个值就应该考虑分片发送,而不是靠扩大缓冲区硬扛。

3.3 消息分发:登录、广播、离开三个动作

有了拆包器,服务端收到原始字节后就能还原成GameMessage,再根据msgType做分发。一个最小的消息分发逻辑长这样:

private async Task DispatchAsync(TcpClient sender, byte[] messageBytes) { var json = Encoding.UTF8.GetString(messageBytes); var msg = JsonSerializer.Deserialize<GameMessage>(json); switch (msg.msgType) { case (int)GameMsgType.Login: // 把连接和玩家ID绑定,广播新玩家加入 Broadcast($"{msg.playerId} joined the game"); break; case (int)GameMsgType.Position: // 把位置消息转发给其他客户端 BroadcastToOthers(sender, messageBytes); break; case (int)GameMsgType.Heartbeat: // 收到心跳说明客户端还活着,不回包也行 break; } } private void BroadcastToOthers(TcpClient sender, byte[] messageBytes) { foreach (var client in _clients) { if (client == sender || !client.Connected) continue; var stream = client.GetStream(); stream.Write(messageBytes, 0, messageBytes.Length); } }

BroadcastToOthers和Broadcast的区别在第二个参数——前者是转发原始字节,后者是构造一个自动消息再全员发送。转发原始字节的好处是不需要重新序列化,减少CPU开销,但前提是每个消息都带有接收方需要的信息,比如来源玩家ID。这个设计直接决定了客户端能不能在自己的界面上区分“这是我自己的消息”和“这是别人的消息”——建议服务端在转发时统一加一个serverTime字段,客户端可以拿它算延迟。

消息分发这里还有一个容易被忽略的线程安全问题。第2章的HandleClientAsync每个连接跑在一个线程池任务里,多个玩家同时发位置消息时,_clients列表会同时被多个线程操作。List<T>的并发读写会让Count属性错乱,出现诡异的不定时异常。解决方法是加锁或者改用ConcurrentDictionary,最简单的做法:

private readonly object _lockObj = new(); // 在Add/Remove/遍历_clients的所有位置都加上 lock(_lockObj) { ... }

这个锁的粒度虽然粗,但demo的消息量远达不到锁竞争成为瓶颈的程度,图省事的代价完全可接受。

4. Unity客户端接入socket:从BeginReceive回调到主线程派发

4.1 Unity侧用BeginReceive回调收消息:为什么不能直接开一个while线程

Unity客户端和服务器的最大区别在于线程模型。服务端可以随意用await ReadAsync阻塞在后台线程上,但Unity的MonoBehaviour生命周期和所有GameObject操作都必须在主线程执行。socket回调天然跑在线程池线程上,如果直接在回调里改transform.position,在编辑器下偶尔能跑通,打包到真机上大概率闪退或者出现渲染卡死,这是Unity联机开发最典型的翻车点。

Unity侧收消息的常见做法是使用Socket.BeginReceive回调模式,而不是开一个while(true)线程去阻塞读。原因有两个:第一,Unity的Mono运行时对线程的创建和管理不如原生平台高效,频繁的线程上下文切换会挤占渲染线程的时间;第二,BeginReceive的异步回调由.NET线程池调度,配合消息派发队列,可以做到收消息和主线程更新互不阻塞。

// UnitySocketClient.cs using System; using System.Net.Sockets; using System.Text; using UnityEngine; public class UnitySocketClient : MonoBehaviour { private Socket _socket; private readonly byte[] _recvBuffer = new byte[8192]; private readonly PacketBuffer _packetBuffer = new PacketBuffer(); public void ConnectToServer(string ip, int port) { _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _socket.NoDelay = true; _socket.BeginConnect(ip, port, OnConnectCallback, null); } private void OnConnectCallback(IAsyncResult ar) { _socket.EndConnect(ar); _socket.BeginReceive(_recvBuffer, 0, _recvBuffer.Length, SocketFlags.None, OnReceiveCallback, null); } private void OnReceiveCallback(IAsyncResult ar) { int readCount = _socket.EndReceive(ar); if (readCount > 0) { _packetBuffer.Append(_recvBuffer, readCount); var messages = _packetBuffer.TryParseAll(); foreach (var msg in messages) { // 不在这里直接处理消息,入队交给主线程派发 MainThreadDispatcher.Enqueue(msg); } _socket.BeginReceive(_recvBuffer, 0, _recvBuffer.Length, SocketFlags.None, OnReceiveCallback, null); } else { // 对端关闭连接 Debug.LogWarning("服务器断开连接"); } } }

这段代码里最容易被新手忽略的是BeginReceive的连续性——每次回调处理完数据后必须再调一次BeginReceive,否则收完一次数据后就再也不会触发了。_recvBuffer是同一个字节数组,反复传给BeginReceive存在一个风险:如果上一次回调还没处理完数据就触发了下一次回调,同一个缓冲区会被覆盖。解决方法是这里用PacketBuffer.Append立即把数据复制走,而不是等主线程派发时再读,因为主线程派发有延迟,缓冲区早被覆盖了。

4.2 跨线程派发:回调线程到主线程的队列桥

Unity主线程派发器是每个Unity网络项目都要写一次的公共组件。原理很简单:socket回调线程往一个队列里塞消息,主线程的Update每帧把队列里的消息取出来依次处理。这个模式绕开了C#的线程锁问题——利用ConcurrentQueue保证入队出队的线程安全。

// MainThreadDispatcher.cs using System.Collections.Concurrent; using System.Collections.Generic; using UnityEngine; public class MainThreadDispatcher : MonoBehaviour { private static readonly ConcurrentQueue<byte[]> _messageQueue = new(); public static void Enqueue(byte[] message) { _messageQueue.Enqueue(message); } private void Update() { while (_messageQueue.TryDequeue(out byte[] message)) { HandleMessage(message); } } private void HandleMessage(byte[] message) { // 转成GameMessage后switch分发 // 只有在这里才能安全地操作Transform、Rigidbody等 } }

这里有一个Unity特有的坑:Update的调用间隔是帧率相关的,如果游戏帧率掉到10帧,消息处理也跟着变慢,联机延迟会陡增。更好的方案是改成在FixedUpdate里处理网络消息,因为FixedUpdate是固定时间间隔调用,不随帧率波动。但FixedUpdate的默认间隔是0.02秒(50Hz),如果服务器消息频率超过50Hz,队列积压会越来越严重。我的建议是demo阶段直接用Update,因为帧率本身稳定在60帧,队列积压的概率极低;等发现消息处理成为瓶颈时再考虑改FixedUpdate或者把处理逻辑移到子线程只把最终结果派发回主线程。

派发队列还要注意内存问题:如果服务端持续发送大量消息而主线程处理不过来,ConcurrentQueue会无限膨胀。最简单的保护是设定队列上限,超过上限就丢弃最旧的消息,游戏场景里优先保证最新状态而不是所有历史消息都执行。

private static readonly ConcurrentQueue<byte[]> _messageQueue = new(); private const int MaxQueueSize = 256; public static void Enqueue(byte[] message) { if (_messageQueue.Count > MaxQueueSize) { _messageQueue.TryDequeue(out _); // 丢最旧 } _messageQueue.Enqueue(message); }

4.3 位置同步参数:发送频率、插值窗口与Time.timeScale的关系

多人联机demo的核心玩法是看到其他玩家的位置变化。位置同步有两个参数决定体验:发送频率(每次发送间隔)和接收端插值方式。

发送频率方面,我的实践是:位移用10-15Hz,旋转用20Hz,分开两个通道发。为什么不是每帧都发?因为每帧发送会占用大量网络带宽和服务端转发CPU,而且Unity的Update帧率不稳定,会造成位置更新抖动。在FixedUpdate里发送更合适——固定时间间隔,发送频率就等于1 / Time.fixedDeltaTime,默认约50Hz。如果不希望发送那么频繁,可以加一个时间门槛:

private float _lastSendTime; private void FixedUpdate() { // 每0.1秒发送一次位置(10Hz),用Time.fixedDeltaTime累积 if (Time.time - _lastSendTime < 0.1f) return; _lastSendTime = Time.time; var msg = new GameMessage { msgType = (int)GameMsgType.Position, playerId = _myPlayerId, payload = $"{transform.position.x},{transform.position.y},{transform.position.z}" }; SendMessage(msg); }

接收端插值同样关键——如果收到别人的位置就直接赋值,对方的位置会一跳一跳的,因为网络包到达时间不均匀。常见做法是维护一个目标位置队列,Update时用Vector3.Lerp把显示位置平滑过渡到目标位置。插值速度参数要调:太快看起来像瞬移,太慢看起来像橡皮糖。我的经验值是Time.deltaTime * 10,也就是大约0.1秒追平一个身位的距离,demo里手感比较自然。

这里必须提醒一个跟Time.timeScale相关的坑:游戏里如果做了暂停菜单,Time.timeScale = 0会让Update里的Time.deltaTime变成0,插值也跟着卡住,但FixedUpdate会停止调用,导致位置发送也断掉。多人联机游戏里暂停是非常危险的动作——所有客户端的位置都会冻结,但玩家还能移动视角。处理方案是使用Time.unscaledDeltaTime计算发送间隔和插值,让网络逻辑不受Time.timeScale影响。

5. 联机demo避坑:端口冲突、回调线程与真机回环地址

5.1 端口绑定失败:address already in use的三种来历

现象:服务端第二次启动时抛异常,提示“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”,进程直接退出。

原因:第一种是服务端程序刚关闭,端口还处于TIME_WAIT状态,需要等待约120秒才能重新绑定(Windows上默认);第二种是你上一次运行的服务端进程没有真正退出,比如IDE里没停掉就直接重新启动;第三种是另一个程序正好占用了这个端口。这三种情况里第二种最常见,不是代码的问题,是进程管理的问题。

解决:先看进程是否残留,命令行执行netstat -ano | findstr 9000,最后一列是PID,任务管理器里找到对应进程杀掉。确认没有残留后,代码层面的方案是启动前设置ReuseAddress,这不会绕过正常的TIME_WAIT限制,但能避免同端口快速重启时的绑定失败。如果实在排不干净,换一个端口是最快的后悔药——9001、9002随便挑一个,改一行配置的事。

5.2 回调里异常被catch后连接再也用不了

现象:Unity客户端在socket回调里写了try-catch,捕获异常后打了一条日志,然后下次再往这个socket发数据,发现什么都发不出去,也没有任何报错。

原因:TCP socket一旦进入异常状态(比如对端重置连接、网络超时),这个连接就废了。catch只能捕获异常,不能让socket起死回生;更隐蔽的是,Socket对象在抛出异常后其内部状态已经标记为错误,后续即使调用Send不报错,数据也不会到达对方。

解决:在catch里做“连接标记为已断开 + 触发重连流程”,而不是尝试继续使用同一个socket。具体做法是增加一个_isConnected布尔字段,任何收发异常都把它置为false,并调用事件通知游戏逻辑层。重连时重新new Socket、重新BeginConnect,而不是复用旧对象。这是socket编程里最典型的一个“看起来没坏其实全坏了”的陷阱。

5.3 手机上连不上127.0.0.1:回环地址与局域网IP的坑

现象:Unity编辑器里输入127.0.0.1:9000能连上服务端,打包到手机上运行后在手机上也填127.0.0.1,连接失败。

原因:127.0.0.1是设备自身的回环地址,手机上填它表示连手机自己,而服务端跑在电脑上,必须填电脑在局域网里的IP。这是联机demo最常见的第一次真机体验翻车点。

解决:手机和电脑连接同一个WiFi,电脑上执行ipconfig(Windows)或ifconfig(macOS/Linux)查无线网卡的IPv4地址,比如192.168.1.100,在手机上填这个IP。另外Android客户端还必须在AndroidManifest.xml里声明网络权限:

<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

缺了INTERNET权限,socket连接会静默失败或者抛Permission denied,而且Unity打包时不会自动帮你加上这两个权限。

5.4 Unity进入Play Mode后socket卡死的线程问题

现象:编辑器里跑Unity客户端,第一次进入Play Mode能正常连接,停止Play Mode再重新进入,发现socket连不上或者主线程卡死。

原因:MonoBehaviour在Play Mode退出时被销毁,但socket回调所在的线程还在运行,它尝试往已销毁的对象上调用方法,比如往一个已经释放的ConcurrentQueue入队,或者操作一个被销毁的GameObject,导致异常或死锁。Unity的播放模式停止不等于线程停止——socket线程是.NET线程池的,不归Unity管理。

解决:在OnApplicationQuit和OnDestroy里做完整的socket关闭逻辑——先标志停止接收,然后Shutdown(SocketShutdown.Both),最后Close()。注意顺序不能反,Close之前必须Shutdown,否则已挂起的BeginReceive回调可能无法退出,造成socket对象泄漏。另外在主线程派发器里加入是否仍处于Play Mode的判断,退出时清空队列并拒绝新消息。

private void OnDestroy() { _socket?.Shutdown(SocketShutdown.Both); _socket?.Close(); _socket = null; }

6. 验证联机没写错:双开、打点与断线重连的最小做法

验证联机demo最直接的手段是同一台电脑上双开客户端。Unity编辑器支持开多个实例:进入Play Mode后,菜单Edit里勾选Game View的多个窗口、或者手动复制工程目录后用另一个编辑器实例打开。实际操作中用“编辑器 + 打包后的exe”组合最省事——编辑器里跑一个客户端方便看日志和调试,打包exe跑另一个客户端模拟真实环境,两者同时连服务端,就能验证位置同步和消息广播是否正确。

第二件必做的事是给消息处理打时间点。在客户端收到消息的派发入口和服务端转发的出口各打一行Debug.Log,内容带上消息类型和当前Time.realtimeSinceStartup。用realtimeSinceStartup而不是Time.time的原因是后者受Time.timeScale影响,暂停游戏时会造成日志时间线混乱。对比两边的日志时间戳,如果差值稳定在几十毫秒内,说明网络链路通畅;如果差值忽大忽小,优先查Nagle是否关闭、服务端是否有锁竞争。

最后把断线重连做成一个最小模块,这是demo步入可玩状态的分水岭。做法是客户端每秒检查一次_isConnected,断开时进入重连状态,每2秒尝试重新连接一次,连上后重新发送登录消息,并且本地维护一个GameState.version整数,重连后向服务端索要最新状态,服务端把版本号比客户端大的消息重放一遍。这个机制不复杂,但能让demo从“一断就完蛋”进化到“断了能回来继续玩”。

我自己的习惯是demo阶段就把Nagle、重连、超时这三个东西配好,因为它们不是功能需求,等上线后缺了任何一个都得返工。这些参数和代码都不是玄学,每一行都对应一次实际的翻车经历,照着这条路走,至少能把踩坑次数砍掉一半。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询