Unity网络编程基础:从Socket到TCP客户端服务器实现
1. 项目概述与核心价值最近在带几个刚入行的Unity新人发现他们一提到网络通讯第一反应就是去找Asset Store里的插件或者直接上Unity自带的UNet虽然现在官方主推Netcode for GameObjects。这其实挺可惜的因为跳过Socket和TCP/IP协议这一层就像学开车只学怎么踩油门和打方向盘却不了解发动机和变速箱的工作原理。一旦遇到网络延迟高、连接不稳定或者需要定制协议时就会非常被动。这个项目我们就来“造一次轮子”——在Unity里不依赖任何高级网络框架只用最基础的C#Socket来实现一个简单的TCP客户端与服务器通讯。这绝不是为了在实际项目里这么干生产环境当然要用成熟框架而是为了彻底搞懂网络编程的“地基”。当你亲手用Socket.Bind、Socket.Listen、Socket.Accept和Socket.Connect这几个核心方法走通一次流程后再去看Photon、Mirror甚至是Unity自己的Netcode你会瞬间明白它们底层在做什么封装了什么以及为什么某些参数要那么设置。这对于排查“连接失败”、“数据收不到”、“延迟抖动”这些网络疑难杂症有根本性的帮助。简单来说通过这个实践你将掌握TCP协议在代码层面的生命周期管理三次握手、数据传输、四次挥手、Socket API的阻塞与非阻塞模式、字节流数据的拆包与粘包处理以及在Unity主线程与网络线程间安全传递数据的方法。这些都是网络游戏程序员的核心内功。2. 核心原理与设计思路拆解2.1 TCP协议与Socket的关系从理论到代码很多人会把TCP和Socket混为一谈其实它们是完全不同的两层概念。你可以把TCP/IP协议栈想象成一套完整的邮政系统标准规定了信件数据包如何分拣、路由、确保送达。而Socket套接字则是这套标准暴露给程序员使用的“邮箱”和“邮递员”接口。具体到我们的项目TCP协议它提供的是面向连接的、可靠的、基于字节流的传输服务。它的核心是“三次握手”建立连接和“四次挥手”断开连接确保数据按序、完整地到达。Socket编程它是操作系统提供的一组API在C#里就是System.Net.Sockets命名空间让我们能够创建“邮箱”绑定IP和端口、“寄信”Send和“收信”Receive。我们通过调用Socket的API间接地驱动了底层的TCP协议栈工作。在Unity中实现关键点在于理解线程模型。Socket的Receive方法是阻塞的如果调用时没有数据可读线程就会停在那里等待。Unity的主线程需要持续处理渲染和游戏逻辑绝不能阻塞。因此我们必须将网络操作尤其是接收数据放在独立的线程中。这就是我们设计思路的核心主线程与网络线程分离通过线程安全的队列进行数据交换。2.2 整体架构设计客户端与服务器我们将构建一个最简单的C/S客户端/服务器架构服务器端Server一个独立的控制台应用或Unity应用。它创建一个监听Socket等待客户端连接。每接受一个客户端连接就为其创建一个单独的Socket和线程来处理该客户端的收发。客户端Client集成在Unity场景中的应用。它创建一个Socket去连接服务器并启动一个后台线程专门用于接收服务器推送的消息。它们之间的交互流程严格遵循TCP的生命周期客户端Socket() - Connect() - Send()/Receive() - Close() 服务器端Socket() - Bind() - Listen() - Accept() - Send()/Receive() - Close()这个流程背后就是TCP三次握手发生在Connect和Accept之间和四次挥手发生在双方Close时的体现。3. 核心细节解析与实操要点3.1 关键Socket API深度解析在C#中我们主要与System.Net.Sockets.Socket类打交道。以下几个方法是基石Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)构造函数。AddressFamily.InterNetwork表示IPv4SocketType.Stream表示流式Socket对应TCPProtocolType.Tcp指定TCP协议。这三个参数是固定搭配。Bind(EndPoint localEP)将Socket绑定到一个本地终结点IP地址和端口。这是服务器端专属操作。客户端通常不需要显式调用Bind系统会自动分配一个临时端口。注意这里常会遇到错误“通常每个套接字地址(协议/网络地址/端口)只允许使用一次。(10048)”。这意味你试图绑定的端口已被占用。可能是程序上次未正常关闭Socket处于TIME_WAIT状态。解决方法换一个端口或者在创建Socket后设置socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true)允许地址重用需谨慎了解其影响。Listen(int backlog)启动监听backlog参数指定挂起连接队列的最大长度。不是越大越好一般设为5-10即可。Accept()阻塞方法从连接队列中取出一个客户端连接并返回一个用于与该客户端通信的新Socket。这是理解服务器并发处理的关键监听Socket只负责“接电话”而Accept返回的通信Socket才负责“通话”。Connect(EndPoint remoteEP)客户端向指定的服务器终结点发起连接。Send(byte[] buffer)/Receive(byte[] buffer)发送和接收数据。它们操作的是字节数组(byte[])这意味着所有你要发送的消息字符串、数字、对象都必须先序列化成字节流。这也是网络编程中“粘包/拆包”问题的根源。Close()/Dispose()关闭Socket释放资源。务必在finally块或using语句中确保执行否则会导致资源泄漏和端口占用。3.2 数据序列化与粘包/拆包问题这是网络编程中最经典的坑。TCP是字节流协议它保证字节的顺序但不保证“消息”的边界。比如客户端快速发送了两条消息“Hello”和“World”。服务器端一次Receive调用可能会收到“HelloWorld”也可能先收到“He”下次再收到“lloWorld”。这就是粘包和拆包。解决方案是定义应用层协议。一个最简单实用的方案是消息头消息体。在每条实际数据前固定加上一个表示数据长度的头例如4个字节的int。发送时先发送长度头的字节再发送实际数据。接收时先读取4个字节解析出长度N然后再读取N个字节这才是一条完整的消息。// 发送示例 string message Hello Server!; byte[] data Encoding.UTF8.GetBytes(message); byte[] lengthHeader BitConverter.GetBytes(data.Length); // 4字节头 socket.Send(lengthHeader); socket.Send(data); // 接收端需要复杂一些要循环读取直到攒够一个完整消息我们会在后续的实操部分实现一个简单的MessageBuffer类来处理这个逻辑。3.3 Unity中的线程安全与主线程回调Unity的API如GameObject.Find,Transform.position,Debug.Log绝大多数只能在主线程中调用。而我们的网络接收线程是工作线程不能直接操作Unity对象。标准解决方案是使用线程安全队列如ConcurrentQueue或Queue加锁网络接收线程将收到的完整消息包放入一个队列。Unity主线程的Update()方法中每帧去检查这个队列取出并处理所有累积的消息。// 在主线程中声明 private ConcurrentQueuestring messageQueue new ConcurrentQueuestring(); // 在网络线程中 messageQueue.Enqueue(receivedMessage); // 在Unity主线程的Update()中 while (messageQueue.TryDequeue(out string msg)) { // 在这里安全地处理消息更新UI或游戏状态 Debug.Log($收到消息: {msg}); }4. 实操过程构建简易TCP服务器4.1 创建控制台应用服务器我们首先创建一个.NET Core或.NET Framework的控制台应用作为服务器这样逻辑更清晰。项目与引用在Visual Studio或Rider中新建一个“控制台应用”项目。System.Net.Sockets在标准库中无需额外安装。核心服务器类创建一个TcpServer类包含启动、停止、处理客户端的方法。using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; public class SimpleTcpServer { private Socket _serverSocket; private bool _isRunning; private int _port 8080; // 监听端口 public void Start() { _serverSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // 允许地址重用避免“端口已占用”错误 _serverSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); IPEndPoint endPoint new IPEndPoint(IPAddress.Any, _port); // 监听所有网络接口 _serverSocket.Bind(endPoint); _serverSocket.Listen(10); // 开始监听等待队列长度为10 _isRunning true; Console.WriteLine($服务器启动监听端口 {_port}...); // 在一个新线程中接受客户端连接避免阻塞主线程 Thread acceptThread new Thread(AcceptClientLoop); acceptThread.IsBackground true; acceptThread.Start(); } private void AcceptClientLoop() { while (_isRunning) { try { Socket clientSocket _serverSocket.Accept(); // 阻塞直到有客户端连接 string clientInfo clientSocket.RemoteEndPoint.ToString(); Console.WriteLine($客户端已连接: {clientInfo}); // 为每个客户端创建一个独立的线程进行处理 Thread clientThread new Thread(() HandleClient(clientSocket)); clientThread.IsBackground true; clientThread.Start(); } catch (Exception ex) { if (_isRunning) // 如果不是因为停止导致的异常则打印日志 Console.WriteLine($接受连接时出错: {ex.Message}); } } } private void HandleClient(Socket clientSocket) { string clientInfo clientSocket.RemoteEndPoint.ToString(); byte[] buffer new byte[1024]; // 接收缓冲区 try { while (_isRunning clientSocket.Connected) { // 接收数据 int receiveLength clientSocket.Receive(buffer); // 阻塞 if (receiveLength 0) { // 客户端优雅关闭连接 Console.WriteLine($客户端 {clientInfo} 断开连接。); break; } string receivedMessage Encoding.UTF8.GetString(buffer, 0, receiveLength); Console.WriteLine($[来自 {clientInfo}]: {receivedMessage}); // 简单回声 string echoMessage $服务器已收到: {receivedMessage}; byte[] echoData Encoding.UTF8.GetBytes(echoMessage); clientSocket.Send(echoData); } } catch (SocketException se) { Console.WriteLine($与客户端 {clientInfo} 通信时发生Socket异常: {se.Message}); } catch (Exception ex) { Console.WriteLine($处理客户端 {clientInfo} 时发生未知异常: {ex.Message}); } finally { // 确保关闭Socket clientSocket?.Shutdown(SocketShutdown.Both); clientSocket?.Close(); Console.WriteLine($客户端 {clientInfo} 处理线程结束。); } } public void Stop() { _isRunning false; if (_serverSocket ! null) { _serverSocket.Close(); Console.WriteLine(服务器已停止。); } } }实操心得AcceptClientLoop和HandleClient都运行在后台线程IsBackground true。这样当主控制台线程退出时这些线程会自动终止避免程序无法关闭。但更好的做法是使用CancellationToken进行更优雅的线程协作。程序入口在Main函数中启动服务器。class Program { static void Main(string[] args) { SimpleTcpServer server new SimpleTcpServer(); server.Start(); Console.WriteLine(按任意键停止服务器...); Console.ReadKey(); server.Stop(); } }4.2 处理多客户端与资源管理上面的服务器为每个客户端都创建了一个新线程Thread这在连接数少时没问题但连接数上百时线程切换开销巨大。在生产环境中应使用异步I/OBeginAccept/BeginReceive或更高效的IOCPI/O完成端口模型或者直接使用更高级的库如NetworkStream配合async/await。对于学习目的我们保持简单线程模型但必须注意资源释放每个客户端Socket必须在finally块中关闭。异常处理网络操作极易异常客户端断线、网络波动必须用try-catch包裹防止一个客户端出错导致整个服务器线程崩溃。状态同步_isRunning标志位用于安全地停止所有循环。注意在多线程读写时理论上应该使用volatile关键字或锁来保证可见性在这个简单例子中布尔类型的读写通常是原子的但严谨的做法应使用CancellationTokenSource。5. 实操过程在Unity中构建TCP客户端5.1 创建Unity项目与客户端管理器新建Unity项目创建一个空对象命名为NetworkManager。创建客户端脚本在NetworkManager上挂载一个TcpClientManager脚本。using UnityEngine; using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Collections.Concurrent; public class TcpClientManager : MonoBehaviour { [Header(服务器设置)] public string serverIP 127.0.0.1; // 本地回路地址 public int serverPort 8080; private Socket _clientSocket; private Thread _receiveThread; private bool _isConnected false; // 用于主线程与网络线程通信的队列 private ConcurrentQueuestring _messageQueue new ConcurrentQueuestring(); [Header(UI引用可选)] public UnityEngine.UI.InputField inputField; public UnityEngine.UI.Button sendButton; public UnityEngine.UI.Text logText; void Start() { // 初始化UI事件 if (sendButton ! null) sendButton.onClick.AddListener(SendMessageToServer); } void Update() { // 主线程每帧处理接收到的消息 while (_messageQueue.TryDequeue(out string message)) { HandleReceivedMessage(message); } } // 由UI按钮调用连接服务器 public void ConnectToServer() { if (_isConnected) { AddLog(已经连接到服务器。); return; } try { _clientSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); IPAddress ipAddress IPAddress.Parse(serverIP); IPEndPoint remoteEP new IPEndPoint(ipAddress, serverPort); // 异步连接避免阻塞主线程。这里用BeginConnect/EndConnect模式 IAsyncResult result _clientSocket.BeginConnect(remoteEP, null, null); // 设置连接超时例如3秒 bool success result.AsyncWaitHandle.WaitOne(3000, true); if (success _clientSocket.Connected) { _clientSocket.EndConnect(result); _isConnected true; AddLog($成功连接到服务器 {serverIP}:{serverPort}); // 启动接收线程 _receiveThread new Thread(ReceiveLoop); _receiveThread.IsBackground true; _receiveThread.Start(); } else { _clientSocket.Close(); AddLog(连接服务器超时或失败。); } } catch (Exception ex) { AddLog($连接时发生错误: {ex.Message}); _clientSocket?.Close(); } } // 接收数据的线程方法 private void ReceiveLoop() { byte[] buffer new byte[1024]; while (_isConnected _clientSocket ! null _clientSocket.Connected) { try { int bytesRead _clientSocket.Receive(buffer); // 阻塞 if (bytesRead 0) { // 服务器关闭了连接 AddLogInQueue(服务器断开连接。); Disconnect(); break; } string receivedMessage Encoding.UTF8.GetString(buffer, 0, bytesRead); AddLogInQueue($[服务器说]: {receivedMessage}); } catch (SocketException se) { // 10054: 连接被对方强制关闭 if (se.ErrorCode 10054) { AddLogInQueue(连接被服务器重置。); } else { AddLogInQueue($接收数据时Socket异常: {se.Message}); } Disconnect(); break; } catch (Exception ex) { AddLogInQueue($接收数据时未知异常: {ex.Message}); Disconnect(); break; } } } // 发送消息 public void SendMessageToServer() { if (!_isConnected || inputField null) { AddLog(未连接服务器或输入框未设置。); return; } string message inputField.text; if (string.IsNullOrEmpty(message)) return; try { byte[] data Encoding.UTF8.GetBytes(message); _clientSocket.Send(data); AddLog($[我]: {message}); inputField.text ; // 清空输入框 } catch (Exception ex) { AddLog($发送消息失败: {ex.Message}); Disconnect(); } } // 将日志添加到队列供主线程处理 private void AddLogInQueue(string log) { _messageQueue.Enqueue(log); } // 在主线程中处理日志和消息 private void HandleReceivedMessage(string message) { AddLog(message); // 这里可以扩展根据消息内容更新游戏状态 // 例如if(message.StartsWith(POS:)) { ParsePosition(message); } } private void AddLog(string log) { Debug.Log(log); if (logText ! null) { // 注意UI Text的赋值必须在主线程这里已经是主线程了 logText.text log \n logText.text; // 新日志加在最前面 } } // 断开连接与清理 private void Disconnect() { _isConnected false; if (_clientSocket ! null _clientSocket.Connected) { _clientSocket.Shutdown(SocketShutdown.Both); _clientSocket.Close(); } _clientSocket null; AddLogInQueue(已断开与服务器的连接。); } void OnApplicationQuit() { Disconnect(); if (_receiveThread ! null _receiveThread.IsAlive) { _receiveThread.Join(500); // 等待接收线程结束最多500ms } } }5.2 构建简单UI进行测试在Unity Canvas下创建一个InputField(用于输入消息)一个Button(用于发送消息)一个Text(用于显示日志)将其Vertical Overflow设置为Overflow以便显示多行。将InputField、Button、Text拖拽到TcpClientManager脚本的对应公共字段上。再创建一个额外的Button为其OnClick()事件添加TcpClientManager.ConnectToServer方法。运行Unity客户端先点击“连接”按钮然后在输入框输入文字点击“发送”。同时运行之前写的控制台服务器。你将在Unity客户端看到服务器的回声在服务器控制台看到客户端发送的消息。踩坑记录在Unity编辑器里运行客户端如果服务器也在同一台机器上IP用127.0.0.1或localhost。如果服务器在另一台机器需要填服务器的真实局域网IP并确保防火墙放行了对应端口如8080。第一次测试时我因为Windows防火墙没关连接一直失败排查了很久。6. 进阶实现解决粘包与定义协议6.1 实现消息缓冲区MessageBuffer我们来升级之前的简单收发逻辑实现一个能处理粘包拆包的缓冲区。在客户端和服务器端都可以使用这个辅助类。using System; using System.Collections.Generic; using System.Text; public class MessageBuffer { private Listbyte _buffer new Listbyte(); private int _headerSize sizeof(int); // 我们约定头部长4字节(int) // 将一条完整消息字符串打包成带长度的字节数组 public byte[] PackMessage(string message) { byte[] data Encoding.UTF8.GetBytes(message); byte[] header BitConverter.GetBytes(data.Length); // 长度头 byte[] packed new byte[header.Length data.Length]; Buffer.BlockCopy(header, 0, packed, 0, header.Length); Buffer.BlockCopy(data, 0, packed, header.Length, data.Length); return packed; } // 将收到的字节数据放入缓冲区并尝试提取出完整的消息 public string[] UnpackData(byte[] newData, int bytesReceived) { Liststring completeMessages new Liststring(); // 1. 将新数据加入缓冲区 byte[] actualData new byte[bytesReceived]; Buffer.BlockCopy(newData, 0, actualData, 0, bytesReceived); _buffer.AddRange(actualData); // 2. 循环检查缓冲区是否能提取出一条完整消息 while (_buffer.Count _headerSize) { // 读取头部获取消息体长度 byte[] headerBytes _buffer.GetRange(0, _headerSize).ToArray(); int bodyLength BitConverter.ToInt32(headerBytes, 0); // 检查缓冲区是否包含完整的“头部消息体” if (_buffer.Count _headerSize bodyLength) { // 提取消息体 byte[] bodyBytes _buffer.GetRange(_headerSize, bodyLength).ToArray(); string message Encoding.UTF8.GetString(bodyBytes); completeMessages.Add(message); // 从缓冲区中移除已处理的数据 _buffer.RemoveRange(0, _headerSize bodyLength); } else { // 数据还不够一条完整消息等待下次接收 break; } } return completeMessages.ToArray(); } // 清空缓冲区例如连接重置时 public void Clear() { _buffer.Clear(); } }6.2 在客户端和服务器端应用MessageBuffer服务器端HandleClient方法修改如下private void HandleClient(Socket clientSocket) { // ... 前面代码不变 ... MessageBuffer buffer new MessageBuffer(); // 每个客户端独立一个缓冲区 try { while (_isRunning clientSocket.Connected) { int receiveLength clientSocket.Receive(tempBuffer); if (receiveLength 0) break; // 使用缓冲区解包 string[] messages buffer.UnpackData(tempBuffer, receiveLength); foreach (var msg in messages) { Console.WriteLine($[来自 {clientInfo}]: {msg}); // 打包后发送回声 byte[] echoPackedData buffer.PackMessage($Echo: {msg}); clientSocket.Send(echoPackedData); } } } // ... 异常处理不变 ... }客户端ReceiveLoop方法修改如下private void ReceiveLoop() { byte[] tempBuffer new byte[4096]; // 临时缓冲区可以大一些 MessageBuffer messageBuffer new MessageBuffer(); while (_isConnected _clientSocket.Connected) { try { int bytesRead _clientSocket.Receive(tempBuffer); if (bytesRead 0) { AddLogInQueue(服务器断开连接。); Disconnect(); break; } // 使用缓冲区解包 string[] messages messageBuffer.UnpackData(tempBuffer, bytesRead); foreach (var msg in messages) { AddLogInQueue($[服务器]: {msg}); } } // ... 异常处理不变 ... } }客户端发送也需要打包public void SendMessageToServer() { // ... 前面检查不变 ... string message inputField.text; if (string.IsNullOrEmpty(message)) return; try { // 使用MessageBuffer打包 MessageBuffer buffer new MessageBuffer(); byte[] packedData buffer.PackMessage(message); _clientSocket.Send(packedData); AddLog($[我]: {message}); inputField.text ; } catch (Exception ex) { AddLog($发送消息失败: {ex.Message}); Disconnect(); } }现在无论你发送多长的消息或者快速连续发送多条消息接收方都能正确地将它们解析为一条条独立的消息彻底解决了粘包问题。7. 常见问题与排查技巧实录在实际操作中你几乎一定会遇到下面这些问题。这里是我踩过坑后总结的排查清单。7.1 连接失败类问题问题现象可能原因排查步骤与解决方案SocketException: No connection could be made because the target machine actively refused it. (10061)1. 服务器未启动。2. 服务器IP或端口写错。3. 服务器防火墙阻止了该端口。1.确认服务器程序已运行。2.检查IP和端口在服务器命令行用netstat -anoSocketException: A connection attempt failed because the connected party did not properly respond after a period of time. (10060)连接超时。可能网络不通或者服务器忙没有及时Accept。1.检查网络ping一下服务器IP看是否通。2.检查服务器负载服务器Accept循环是否卡住Listen的backlog是否太小3.客户端增加超时设置如上面代码所示使用BeginConnect配合WaitOne设置超时时间。SocketException: Only one usage of each socket address is normally permitted. (10048)端口被占用。通常是上次运行的程序没有完全释放Socket。1.换一个端口试试。2. 在服务器Socket调用Bind之前设置ReuseAddress选项serverSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);。3. 重启电脑或等待几分钟TCP的TIME_WAIT状态通常持续2分钟。7.2 数据传输类问题问题现象可能原因排查步骤与解决方案发送的消息和接收的消息对不上或者多条消息粘在一起。粘包/拆包问题TCP是流式协议没有消息边界。必须定义应用层协议。采用上面实现的“消息长度头消息体”方案。这是网络编程的必修课没有捷径。接收方收不到数据但发送方没报错。1. 数据还在操作系统缓冲区未触发接收。2.Receive的缓冲区大小不足。3. 网络线程已崩溃或阻塞。1. 确保接收方在持续调用Receive。2. 发送方可以尝试调用Socket.Flush()但Stream方式更常用或者确保数据量足够大以触发发送。3.检查接收线程是否因为未处理异常而退出。用try-catch包裹整个接收循环。发送大数据时Send方法只发送了一部分。Send方法不保证一次性发送所有你给它的数据。它返回实际发送的字节数。循环发送直到所有字节发送完毕。csharpbrint totalSent 0;brwhile(totalSent data.Length) {br int sent socket.Send(data, totalSent, data.Length - totalSent, SocketFlags.None);br if(sent 0) throw new Exception(连接已断开);br totalSent sent;br}br7.3 Unity特定问题问题现象可能原因排查步骤与解决方案在Unity编辑器里运行正常打包成exe后连接失败。打包后客户端尝试连接的服务器地址可能还是编辑器环境用的127.0.0.1。将服务器IP做成可配置的如通过UI输入框或者在打包前确认IP地址配置正确。网络操作导致Unity编辑器卡死或无响应。在Unity主线程中调用了阻塞式的Socket方法如Connect,Receive。绝对禁止在主线程进行阻塞式网络调用。必须使用Thread或async/await需注意Unity对多线程的支持将耗时的、阻塞的IO操作放到后台。在接收线程中直接修改UI或GameObject报错。Unity API不是线程安全的。使用线程安全队列如ConcurrentQueue作为桥梁。网络线程入队主线程Update中出队并处理。这是Unity多线程编程的经典模式。断开连接或退出游戏时线程没有正确关闭。OnApplicationQuit或OnDestroy时没有妥善关闭Socket和终止线程。实现一个Disconnect方法在其中设置标志位、关闭Socket。在OnApplicationQuit中调用它并尝试Join接收线程给予超时。7.4 性能与资源管理线程不是越多越好一个连接一个线程Thread-Per-Connection模型在连接数多时性能极差。学习完成后下一步应该研究异步SocketBeginReceive/EndReceive或基于事件的Select模型以及.NET中更现代的async/await配合NetworkStream进行读写。缓冲区大小Receive的缓冲区大小如1024是每次调用希望读取的最大字节数。设得太小会增加系统调用次数设得大会占用更多内存。通常4KB-8KB是个不错的起点。对于消息缓冲区使用Listbyte或MemoryStream动态扩容更合理。异常处理是必须的网络是不可靠的断线、超时、数据错误是常态。每一个Socket操作Connect,Accept,Send,Receive,Close都应该放在try-catch中并进行相应的错误处理和资源清理。亲手实现一遍这个简单的TCP通讯项目虽然代码量不大但几乎涵盖了网络编程最核心的概念和坑点。当你再遇到那些成熟的网络库时你会清楚地知道它们无非是用更优雅、更高效的方式封装了这些底层的Socket操作并处理了更多边界情况。这份理解是直接使用插件无法获得的。下次当你游戏的玩家遇到网络延迟时你至少能知道该从协议层、Socket缓冲区还是应用层逻辑开始查起了。