引言:

之前写过一个 demo 案例大致讲解了 Socket 通信的过程,并和自建的服务器完成连接和简单的数据通信,详细的内容可以查看 Unity3D —— Socket通信(C#)。但是在实际项目应用的过程中,这个 demo 的实现方式显得异常简陋,而且对应多个业务同时发起 Socket 通信请求的处理能力也是有限,总不能每个请求都创建一个线程去监听返回结果,所以有必要进一步优化一番,例如加入线程池管理已经用一个队列来管理同时发起的请求,让 Socket 请求和接收异步执行,基本的思路就是引入 多线程 和 异步 这两个概念。

设计思路:

正如上面提及的,我们优化的思路主要两点:多线程 和 异步 Socket

1.多线程:

考虑到并发处理能力,假如是服务端,我们需要对每个连接上来的 Socket 客户端创建管理线程,通常会用线程池来管理。而在客户端,我们可以选择创建三个核心的线程,根据功能分为:

  • 发送数据线程 SenderThread:负责将数据发送给服务端;
  • 接收数据线程 ReceiverThread:负责接收服务端的数据;
  • 其他业务线程 MainThread:负责收发数据之外的操作,例如:压缩数据、加密解密和数据打包解包等。

由于在 .NET 中已经提供了 System.Threading.Thread 这个线程的基础类,所以我们可以通过使用这个类来创建我们自己的线程管理类,添加一些附加属性。

大致步骤:

  • 先创建一个 Socket 对象,然后通过对应的 API 去连接服务器 IP 端口,可以有两种方式:阻塞式 的 Connect 接口; 非阻塞式 的 BeginConnect 接口;
  • 连接完成后创建 发送线程 和 接收线程 ,并将套接字 Socket 对象传入它们的操作方法中;
  • 发送线程的发送方法是被调用才触发的,接收线程则需要一个轮休等待网络数据的方法。
2.异步 Socket:

在这一点上,主要做法就是使用队列的概念,即不管要发送消息还是收到消息,都先把消息放到队列(发送队列、接收队列)中,然后依次从队列中中取出消息来执行。

队列:在 .NET 中,提供了一个 Queue 的结构实现队列的功能,队列里面可以存放任何可以转化为 object 的对象或数据,所以自然也能存放我们的消息。

  • 发送队列: 当我们要发送一条网络协议的时候,不是直接将消息丢给丢给 Socket 通道进行传输,而是先通过 Queue.Enqueue 接口压入队列,然后在一个循环执行的逻辑(轮询)中不断地从队列中通过 Queue.Dequeue 接口取出队列中的消息然后再传递给 Socket 进行真实的网络传输。
  • 接收队列: 接收网络数据也是相似的过程,从缓冲区取到字节流数据,解析成功后,先存在接收队列中,然后再循环遍历操作中从队列里取出消息进行处理。

当然,为了解决队列在多线程中的并发问题,还需要了解一下 lock 方法,这是解决问题的方法之一,但不是唯一办法,这里我的原则是在不严重影响效率的情况下怎么简单怎么来,使用大致如下:

//线程安全的访问队列的方式
lock (mQueue)
{
    ...
}

网上还有另一种方案 Unity 异步网络方案 IOCP Socket + ThreadSafe Queue

3.内存流缓存池:

在做消息数据的读写和格式转换操作时,都会用到 MemoryStream ,这是一个流结构,其后备存储是内存,也就是每次创建一个这样的对象都会占用一定的内存空间,假如每一次操作都创建一个 MemoryStream 对象,用完又不做主动回收,显然效率较低。

  • 内存流池化思想:我们可以在池中使用一个 List<MemoryStream> 列表来保存已创建的流对象,再使用 Dictionary<MemoryStream,bool> 字典来存储每一个对象是否可用的状态,每次需要创建流对象的步骤:
  • 通过判断列表中 MemoryStream 个数是否大于0,假如大于0则取出第一个,并删除列表中第一个 item (防止重复引用),在字典中记录此对象为不可用状态;
  • 假如列表中已经没有可用的 MemoryStream,则创建新的 MemoryStream 对象并在字典中记录状态,省去了删除列表 item 的步骤;
  • 使用完毕后,将 MemoryStream 对象的属性重置,并放回管理列表,字典中该对象的状态置为可用状态。
  • 多个内存池:为了避免多线程的并发问题,所以一般一个内存池只允许一个线程来访问,根据上述我们创建三个核心线程的思想,所以这里我们也要对应地创建三个池,分别为:发送线程使用 MemoryStream 池、接收线程使用 MemoryStream 池 和 业务线程线程使用 MemoryStream 池。所以最终每个池需要提供至少两个接口:
  • New 用于从池中获取一个可用的 MemoryStream 对象;
  • Delete 用于将一个使用完后的 MemoryStream 对象回收到池中。
4.收发包线程管理类:

这个类主要完成连接的建立和断开的管理,以及在连接成功后创建读写包的线程,并将网络读写操作的对象传递给收发包的线程以便后续网络数据包的接收和发送操作:

  • 建立连接: 在 .Net 中提供了两种创建 Socket 连接的方式(用于客户端):
  • 使用 System.Net.Sockets.TcpClient 创建一个 TcpClient 对象,然后使用 TcpClient.BeginConnect 接口去连接指定 IP 端口,使用这种方式创建连接后;
  • 使用 System.Net.Sockets.Socket 创建一个 Socket 对象,然后通过 Socket.Connect 去连接指定 IP 端口。
  • 数据收发:
  • TcpClient 通过 TcpClient.GetStream() 接口得到一个 NetworkStream 对象,然后分别使用 NetworkStream.Write 和 NetworkStream.Read 完成网络数据的发送和接收;
  • Socket 连接完成后,直接使用最开始创建的 Socket 套接字对象调用 Socket.Send 和 Scoket.Receive 来完成网络数据的发送和接收。
  • 断开连接:
  • TcpClient 需要执行两步操作:一是关闭 NetworkStream,二是关闭 TcpClient ,都是调用对应的关闭方法: TcpClient.GetStream().Close() / TcpClient.Close();
  • Socket 直接关闭即可 Socket.Close()。

不了解 TcpClient 的可以查看一下 《C# Tcp协议收发数据(TCPClient发,Socket收)》 不了解 Socket 的可以查看一下 《.net网络编程之一:Socket编程》

5.调用异步的 API:

出于灵活性的考虑,我还是选择使用 Socket 来收发数据而不使用封装好的 TcpClient 中获取的 NetworkStream 对象来实现,主要考虑两点:

  • 其一,我打算使用2个字节的 ushort 类型数据来表示数据包体的大小,将每次的数据包控制在 65535 byte 之内;
  • 其二,传输数据类型没有限制。
6.NetworkStream.Write 和 Socket.Send 对比:

这两个都是 TCP 通信中发送数据的接口,但是,NetworkStream 在使用 Write(byte[] buffer, int offset, int size) 传输数据时,会在数据的头部加上一个 int 类型的数据,即传入的 size 参数,用于表示 buffer 的大小,但假如定制网络协议的时候规定只允许在头部使用2个字节表示数据的长度,即一个 short 类型,那此时 NetworkStream 就无法满足需求了,只能使用 Socket 来完成。

参考 官方介绍,C# 中的 ushort 取值范围是 0 ~ 65535,即2个字节表示的包头,一次最大传输数据大小是 65535 byte 大概看为是 64kb。

7.异步收发接口:
  • 发送: 使用 Socket.BeginSend 和 Socket.EnSend 这对 API 来实现异步的数据接收操作;
  • 接收: 使用 Socket.BeginReceive 和 ``Socket.EnReceive 这对 API 来实现异步的数据接收操作。

案例:

网上也有使用类似的思路对 C# 的 Socket 进行封装的,这里简单列举几个:

  • 【Unity3D_常用模块】 Socket网络模块(超级详细完整,上线项目中稳定使用着)
  • FireFly_U3D_Socket网络框架插件

参考资料:

  • C# 实现的多线程异步Socket数据包接收器框架
  • 可扩展多线程异步Socket服务器框架EMTASS 2.0
  • 一个.net客户端通讯框架的设计(一)—前言
  • .NET平台下可复用的Tcp通信层实现
  • (C#)使用队列(Queue)解决简单的并发问题
  • MemoryStream 类
  • 关于Socket和NetWorkstream介绍