服务端相对特殊处理一下,因为是多线程处理,而客户端的断开连接会造成socket容器里socket数量减少
这样的处理方式有很多问题,但解决的话甚至要从根本上修改,所以,在这更多是知识点的演示
客户端是正常的模式
VS的服务端代码里,每次访问clientDic的时候都加锁,不让别的线程调用,停住等待
防止多线程操作出现的对同一区域的同时修改,造成不必要的异常,所以加锁
TCP 通信中----TCP 面向字节流------数据在发送过程中可能会发生分包(一个完整消息被拆分成多个 TCP 包)或者黏包(多个消息在接收端一次性收到在同一个 TCP 包中)的现象。
为了解决这类问题,通常需要在应用层设计一种消息边界协议,最简单的方式就是在每个消息前面加上一个固定长度的头部,用来表示消息体的长度。下面提供一个使用 4 字节头部(代表消息体长度)的 C# 例子,演示如何处理分包和黏包问题:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Text;public class TcpReceiver
{// 用于存储从 Socket 接收到的原始数据private List<byte> buffer = new List<byte>();/// <summary>/// 处理从 Socket 中接收到的数据,解决分包和黏包问题。/// 约定:每个消息前 4 个字节表示消息体的长度(Int32),后续为消息数据(采用 UTF8 编码)。/// </summary>/// <param name="socket">已经建立连接的 Socket 对象</param>public void ProcessReceivedData(Socket socket){byte[] tempBuffer = new byte[1024];try{while (true){// 从 Socket 接收数据int bytesRead = socket.Receive(tempBuffer);if (bytesRead <= 0){// 没有数据了或者连接关闭,退出循环break;}// 将接收到的数据添加到缓存中buffer.AddRange(tempBuffer.Take(bytesRead));// 检查缓存中是否存在至少一个完整的消息while (buffer.Count >= 4) // 4 字节消息头{// 前4个字节为消息长度(假设采用小端模式)int messageLength = BitConverter.ToInt32(buffer.ToArray(), 0);// 如果缓存中数据足够构成一个完整消息(头部+消息体)if (buffer.Count >= 4 + messageLength){// 提取消息体byte[] messageBytes = buffer.GetRange(4, messageLength).ToArray();string message = Encoding.UTF8.GetString(messageBytes);Console.WriteLine("接收到消息: " + message);// 从缓存中移除已经处理的字节(头部和消息体)buffer.RemoveRange(0, 4 + messageLength);}else{// 数据还不够,跳出循环等待更多数据break;}}}}catch (SocketException ex){Console.WriteLine("Socket 异常: " + ex.Message);}finally{// 关闭 Socketsocket.Shutdown(SocketShutdown.Both);socket.Close();}}
}
如何区分消息
为发送的信息添加标识,比如添加消息ID
所有发送的消息的头部加上消息ID(intshort、byte、long都可以,根据实际情况选择)
举例:
如果选用int类型作为消息ID的类型
前4个字节为消息ID,后面的字节才是为数据类的内容
这样每次收到消息时,先把前4个字节取出来解析为消息ID
再根据ID进行消息反序列化即可
区分消息里的字节数组要转换的类型是什么,可以通过这种继承解决方法
对于这种信息传输管理类,过场景的时候一般不会删掉
为了提高网络数据的传输效率,系统会为 Socket 分配发送缓冲区和接收缓冲区。这些缓冲区用于暂存待发送的数据以及接收到的数据,直到应用程序调用相应的发送或接收函数。
通常建议在确保所有数据都已经传输完毕、对方也已准备好结束通信时再调用 Shutdown
如果调用 Shutdown 时,数据已经在发送缓冲区中,TCP 会尝试将缓冲区中的数据发送出去,然后再发送 FIN 信号。但如果对方还在等待数据,而你提前调用了 Shutdown,可能会导致对方无法接收到剩余数据(或者收到“连接已关闭”的信号),这取决于你程序的设计和双方协议的处理方式。
因为再怎么说也是unity挂在对象上的脚本,所以可以再加一个生命周期函数,ondestroy调用close关闭连接
也可以换线程池处理函数任务treadpool.queueUserWorkItem()
而被encoding成字符串的塞入了receive队列,里面装的都是类型已经转换处理好的数据
唐老师于是在update函数里使用了这些(也就是正常的打印掉,实际上这些收到的数据也是 要在某些地方把数据用了)
针对于receive函数的信息队列,是也是string类型,因为是socket.Receive()已经接下了字节数组,然后 唐老师 在那处理了字节数组转string
这里Send和receive各用了一个队列容器存储需要处理的信息,而各开了一个线程while循环处理自己的Send和receive函数
把需要处理的信息先用队列装起来(这里先把信息全部当做字符串,所以queue也是原信息string的一个大容器
发送函数为了防止Send对主线程的可能阻塞,所以就用了queue的容器来解决
客户端要连接服务器其实非常简单,只需要声明一个socket。然后去调用它的connect,连接成功过后,我们就可以用它的send和receive方法来进行这个消息的收发了。
需要注意的就是receive和send是阻塞式的方法,也就是说连接建立后,如果我们去收消息和发消息,如果在主线程里面调用的话,它可能会影响我们主线程的执行。
把unity的客户端的信息收发管理单例写了
对于客户端处理连接的函数
在开发测试中:如果你想在本机调试多个 ASP.NET Core 项目,Visual Studio 会自动给每个项目分配不同的本地端口(例如 5000
, 5001
, 5002
等)来避免冲突
同一台计算机上,同一个 IP 地址 和 端口 组合只允许一个进程监听
也就是说,如果一个应用程序(进程)已经成功绑定(Bind)到 127.0.0.1:8080
,那么其他应用程序就无法再绑定到同样的 127.0.0.1:8080
,否则会发生冲突导致绑定失败(抛出异常)。这并不是说“不同程序用的 8080 都是指自己且不冲突”,而是操作系统只允许其中一个占用该端口。
打包出去,同时开启多个应用程序,这样也可以有多个客户端
利用了元组的知识,和线程池的使用
返回的是字节的个数,receive里的参数是会被注入数据的字节数组
-
线程资源的来源
- 内存分配:
每创建一个线程,操作系统都会为该线程分配一块独立的栈内存(默认大小通常在数百 KB 到 1 MB 不等,具体取决于操作系统和配置)。这块内存是从进程的虚拟地址空间中分配的,与主线程的栈是独立的。 - 内核对象和调度数据:
操作系统还会为每个线程维护调度信息、线程控制块(TCB)以及相关的内核对象。这些都是线程管理所必需的开销。
- 内存分配:
-
主线程和新线程的关系
- 新创建的线程的资源是独立分配的,不会“夺走”主线程已经使用的资源。主线程和其他线程都是共享进程的整体资源(例如内存、CPU 时间),但各自有独立的栈和调度上下文。
- 也就是说,主线程并不会因此而“少”了什么资源,新线程所使用的资源是额外分配的。
-
CPU 使用情况
- 阻塞状态下的线程:
当一个线程调用Accept()
或其他阻塞调用等待网络连接时,它处于阻塞状态,此时该线程不会占用 CPU 资源,因为操作系统会将其挂起,直到有事件发生(例如有新的连接到来)时再唤醒。 - CPU 占用:
CPU 是根据线程的活动状态来调度的。一个空闲或阻塞的线程不会频繁地占用 CPU 时间,仅在被唤醒、开始执行代码时才会真正使用 CPU。 - 因此,虽然线程总是会占用一定的内存和系统资源,但在等待状态下,它们不会造成 CPU 的持续高负载。
- 阻塞状态下的线程:
-
资源浪费与优化
- 资源占用的成本:
如果每个等待操作都创建一个新的线程,确实会占用一定的内存和内核对象等资源。在高并发的场景下,如果不加控制,大量线程可能会带来额外的资源消耗。 - 常用优化手段:
- 线程池:可以复用已有的线程,避免频繁创建和销毁线程,从而降低开销。
- 异步 I/O 模型:比如 .NET 中的 async/await、I/O 完成端口(IOCP)等机制,可以在不为每个等待操作专门分配一个线程的情况下,高效地处理大量并发连接。
这些优化方式能更高效地利用系统资源,尤其是在需要同时处理大量连接时。
- 资源占用的成本:
脱离了unity写在vs里的那些代码都是服务端的Socket的配置
服务端需要可以receive多个和自己建立了连接的客户端Socket Send的消息,
而且要可以同时同时收到,不因为Socket的accept方法的长时间等待而阻塞主线程,所以要
运用线程的知识,额外开线程,while等待accept,而收消息的话,则也可以通过再开一个线程来不停receive新的字节流
发送数据(Send 方法):
- 当客户端调用
socket.Send("你好")
时,它会把字符串 "你好"(经过编码成字节数组)写入该 Socket 的发送缓冲区,并通过 TCP 连接发送出去。 - 数据的目的地是已经与这个 Socket 建立连接的远程端点(例如服务端的某个 Socket 对象),而不是直接指定的某个 IP 地址。连接在建立时就已经确定了通信的双方。
数据传输:
- TCP 协议会负责将发送的数据从客户端传输到服务端。这一过程包括数据包的封装、网络传输、重传(如果出现丢包)等机制,确保数据能够可靠地到达。
接收数据(Receive 方法):
- 服务端必须在合适的时机调用
socket.Receive
(或其他类似方法)来读取其接收缓冲区中的数据。 - 如果服务端没有调用
Receive
,那么客户端发送的数据将一直在服务端的接收缓冲区中等待,直到服务端读取或者缓冲区满(可能导致网络拥塞或数据丢失)。
这是在写服务端的逻辑
绑定(Bind):将 Socket 绑定到一个特定的 IP 地址和端口(这里是 127.0.0.1:8080
),使得该 Socket 可以接收发送到这个地址和端口的连接请求。
监听(Listen):将 Socket 设置为监听状态,等待客户端的连接
接受连接(Accept):阻塞等待并接受一个客户端
Socket socket=new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);socket.Bind(ipPoint);socket.Listen(1000);socket.Accept();
客户端与服务端在技术实现上都使用 Socket
类,但它们在连接建立前后的行为和责任不同。
数据交换上是双向的,双方都是端点,只不过在连接建立的过程中,一个主动发起连接(客户端),另一个被动等待(服务端)。
如果觉得直接使用 Socket 太复杂,.NET 提供了更高级的封装类,比如 TcpClient
、TcpListener
和 UdpClient
,它们在底层依然使用 Socket,但对开发者隐藏了部分复杂性,让常见的操作更加直观和易用。
使用 Socket,可以对传输过程中的各种细节(比如发送缓冲区大小、超时时间、是否采用非阻塞模式等)进行精细的控制。这在开发高性能网络应用或处理特殊网络需求时非常重要。
TCP 等协议是面向连接的,必须有一个明确的连接建立、数据传输和连接关闭的过程。Socket API 提供了 bind、listen、accept、connect 等方法来帮助管理这些状态
每个客户端连接会创建一个独立的 Socket 来进行后续的通信。这样设计有助于同时管理多个并发连接,而不是所有数据都混在一起。
Socket 不仅仅是发送数据,它还负责管理连接、维护传输状态、处理数据包的拆分与组装、错误处理、以及网络拥塞控制等。这些都是简单的 IP 地址类无法涵盖的。
如果不调用 Listen
,即使绑定了地址和端口,Socket 也不会主动接受任何连接请求
结合多线程知识点实现服务端服务多个客户端
1.允许多个客户端连入服务端
2.可以分别和多个客户端进行通信
服务端需要做的事情
1.创建套接字Socket
2.用Bind方法将套接字与本地地址绑定
3.用Listen方法监听
4.用Accept方法等待客户端连接
5.建立连接,Accept返回新套接字
6.用Send和Receive相关方法收发据
7.用shutdown方法释放连接
8.关闭套接字
服务端不需要和unity有关系,直接在vs里新建项目就可以了
套接字Socket
--
二进制的持久化,自己写一个持久化的基类,唐老师讲的这个解决方法,大概是说,
一个基类BaseData
里面有int float bool string (需要的可以自己加list和Dic)的转字符串方法
里面内置了一个总的 字符串writing成字节数组,以及 总的 字符串reading成字节数组
继承了这个基类的各个自己写的类,则是统一把 自己这个实例的数据转字节数组 和 把字节数组转成自己这个实例写成了自己的方法
添加上了泛型以让不同的类之间的 转换方法不同,
熟练利用了ref对index的改造,用泛型接住了需要转换的类型是什么,然后调用这个同样是basedata子类的类里的reading方法,写到这个引用类型的byte数组里,而ref下的index,同样会在其他类如果包含自己的情况下,让自己也能像一个int float 那样正常读成对象
这里用的真巧妙,list和dic按这样来也的确可以自定义了,也不需要什么别的,
既可以写在这个基类里面,每个继承该类的子类都可以当list 或者dic成基本的int float bool一样,需要的是自己定个逻辑遍历里面的所有数据,
还有就是要带上泛型而已,这样存和取也分的明白
这样看来,为了存取可以分明白 ,特意写成两个分开的writing 和reading的各个基本类型的使用,强调了这两个步骤的分开,其实也不坏
BitConverter解决除字符串外的所有类型的转换
字节数组转非字符串类型 --关键类 BitConverter
最基本的写法,自己的每一个成员变量都写一个转字节数组的方法,但这里可以优化,可以用泛型和反射----但这里依然是使用了正常的类型单独转换,一个类型对应一个转 字节数组的的函数
字符串一般先存长度再存对应的 字符串转的字节数组
所以这两个类在网络通信中很重要,担任了二进制格式化的任务
我们不会使用BinaryFormatter类来进行数据的序列化和反序列化
因为客户端和服务端的开发语言大多数情况下是不同的
BinaryFormatter类序列化的数据无法兼容其它语言
网络通信的最终目的,是数据的通讯
网页有默认端口号
比如百度,IP地址有两个,但是域名的别名就没有
主机别名(Alias) 指的是某个域名的 别名
IP别名不见得都有,甚至一般都没有
async
方法和普通方法几乎一样,唯一不同点,它支持 await
,可以暂停执行等待异步任务完成后继续执行
Task
线程修饰后的这个异步函数,就可以await 该函数
async void MyMethod1() // ❌ 不推荐
{await Task.Delay(1000);Console.WriteLine("MyMethod1 执行完成");
}async Task MyMethod2() // ✅ 推荐
{await Task.Delay(1000);Console.WriteLine("MyMethod2 执行完成");
}async Task<int> MyMethod3() // ✅ 适用于返回值
{await Task.Delay(1000);return 100;
}async void Start()
{MyMethod1(); // 无法 await,可能会导致意外行为await MyMethod2(); // 正确int result = await MyMethod3();Console.WriteLine(result);
}
Task<T>
表示有返回值的异步方法(例如 Task<int>
)
Task
代表异步操作,让调用者知道该方法 不是立即返回值
一般异步方法都需要 Task
或 Task<T>
作为返回类型
await
不是返回值,而是 让异步任务暂停,等待它完成后继续执行
域名解析 域名到IP地址的转换过程。
域名的解析工作由DNS服务器完成 进行通信时有时会有需求通过域名获取IP
端口类(也带了IP)
IP类
唉,看到了一些可悲的命运差距,也讲了一些本不该由我讲的东西,不过我既然如此的渺小和轻,我对这些事情投入关注点,关注那些所谓的自我要求,也变得不重要了,,在这种天生差距之前,这些担心有什么用呢,,我还疑惑着犹豫着不敢去试自己想试的,这样真的在这种大是大非之前,是多么的轻啊,,,只试自己想试的,,可是我又。。,灰色空间的问题晾一边先
visualStudio当服务端,unity当客户端
代码示例(最基础的,仅仅是连接上了,而一些问题的解决,比如信息的封装啊,序列化,分包黏包等等,都先不管,能连上再说)
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;namespace SocketServerExample
{class Program{static void Main(string[] args){// 定义服务器 IP 和端口(这里用回环地址和 8080 端口)IPAddress ip = IPAddress.Parse("127.0.0.1");int port = 8080;IPEndPoint localEndPoint = new IPEndPoint(ip, port);// 创建一个 TCP SocketSocket listener = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);try{// 绑定并开始监听listener.Bind(localEndPoint);listener.Listen(10);Console.WriteLine("服务器已启动,等待客户端连接...");// 阻塞等待客户端连接Socket handler = listener.Accept();// 接收客户端发送的数据byte[] buffer = new byte[1024];int bytesRec = handler.Receive(buffer);string data = Encoding.UTF8.GetString(buffer, 0, bytesRec);Console.WriteLine("接收到客户端数据: " + data);// 向客户端发送响应数据string reply = "你好,客户端,我是服务器";byte[] msg = Encoding.UTF8.GetBytes(reply);handler.Send(msg);// 优雅地关闭连接handler.Shutdown(SocketShutdown.Both);handler.Close();}catch (Exception ex){Console.WriteLine("发生异常:" + ex.ToString());}Console.WriteLine("按任意键退出...");Console.ReadKey();}}
}
unity中的客户端
using UnityEngine;
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;public class SocketClient : MonoBehaviour
{// 设置服务器的 IP 和端口(必须和服务端一致)public string serverIP = "127.0.0.1";public int serverPort = 8080;private Socket clientSocket;void Start(){// 建立连接最好在 Start 中调用(注意:同步调用可能会导致主线程短暂阻塞,// 实际项目中建议使用异步或线程处理网络通信)ConnectToServer();}void ConnectToServer(){try{// 解析服务器地址IPAddress ip = IPAddress.Parse(serverIP);IPEndPoint remoteEP = new IPEndPoint(ip, serverPort);// 创建一个 TCP Socket 并连接到服务器clientSocket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);clientSocket.Connect(remoteEP);Debug.Log("成功连接到服务器");// 发送数据到服务器string message = "你好,服务器,我是 Unity 客户端";byte[] msg = Encoding.UTF8.GetBytes(message);clientSocket.Send(msg);Debug.Log("已发送数据: " + message);// 接收来自服务器的响应(这里直接调用 Receive,会阻塞直到收到数据)byte[] buffer = new byte[1024];int bytesRec = clientSocket.Receive(buffer);string response = Encoding.UTF8.GetString(buffer, 0, bytesRec);Debug.Log("接收到服务器响应: " + response);// 关闭连接clientSocket.Shutdown(SocketShutdown.Both);clientSocket.Close();}catch (Exception ex){Debug.LogError("连接异常: " + ex.ToString());}}
}