网络框架的选择

C++语言里面有asio和libuv等网络库, 可以方便的进行各种高效编程. 但是C#里面, 情况不太一样, C#自带的网络API有多种. 例如:

  • Socket
  • TcpStream(同步接口和BeginXXX异步接口)
  • TcpStream Async/Await
  • Pipeline IO
  • ASP.NET Core Bedrock

众多网络库, 但是每个编程模型都不太一样, 和C++里面我常用的reactor模型有很大区别. 最重要的是, 编程难度和性能不是很好. 尤其是后面三种模型, 都是面对轻负载的互联网应用设计, 每个玩家跑两个协程(一读一写)会对进程造成额外的负担.

Golang面世的时候, 大家都说协程好用, 简单, 性能高. 可是面对大量 高频交互的应用, 最终还是需要重新编写网络层(参见Gnet). 因为协程上下文切换需要消耗微秒左右的时间(通常是0.5us到1微秒左右), 另外有栈协程占用额外的内存(无栈协程不存在这个问题).

所以在C#里面需要选择一个类似于Reactor模型的网络库. Java里面有Netty. 好在微软把Netty移植到了.NET里面, 所以我们只需要照着Netty的文档和DotNetty的Sample(包括源码)就可以写出高效的网络框架.

另外DotNetty有libuv的插件, 可以将传输层放到libuv内, 减少托管语言的消耗.

DotNetty编程

由于我们是服务器编程, 需要处理多个Socket而不像客户端只需要处理一两个Socket, 所以在每个Socket上, 都需要做一些标记信息, 用来标记当前Socket的状态(是否登录, 用户是哪个等等); 还需要一个管理维护的这些Socket的管理者类.

链接状态

Socket的状态可以使用IChannel.GetAttribute来实现, 我们可以给IChannel上面增加一个SessionInfo的属性, 用来保存当前链接的其他可变属性. 那么可以这么做:

public class SessionInfo
{
//SessionID不可变
private readonly long sessionID; public SessionInfo(long sessionID)
{
this.sessionID = sessionID;
}
//其他属性
} static readonly AttributeKey<ConnectionSessionInfo> SESSION_INFO = AttributeKey<ConnectionSessionInfo>.ValueOf("SessionInfo");
//新链接
bootstrap.ChildHandler(new ActionChannelInitializer<IChannel>(channel =>
{
var sessionInfo = new SessionInfo(++seed);
channel.GetAttribute(SESSION_INFO).Set(sessionInfo); //其他参数
}));

由于游戏服务器通常是有状态服务, 所以链接上还需要保存PlayerID, OpenID等信息, 方便解码器在解码的时候, 直接把消息派发给相应的处理器.

管理器和生命周期

托管语言有GC, 但是对于非托管资源还是需要手动管理. C#有IDisposable模式, 可以简化异常场景下资源释放问题, 但是对于Socket这种生命周期比较长的资源就无能为力了.

所以, 我们必须要编写自己的ChannelManager类, 并且遵从:

  • 新链接一定要立刻放到Manager里面
  • 通过ID来获取IChannel, 不做长时间持有
  • 想要长时间持有, 则使用WeakReference
  • MessageHandler的异常里面释放Manager里面的IChannel
  • 心跳超时也要释放IChannel

对于IChannel对象的持有, 一定要是短时间的持有, 比如在一次函数调用内获取, 否则问题会变得很复杂.

防止主动关闭Socket和异常同时发生, IChannel.CloseAsync()函数调用需要try catch.

参数调节

GameServer一般来讲单个网络线程就够了, 但是作为网关是绝对不够的, 所以网络库需要支持多线程Loop. 好在DotNetty这方面比较简单, 只需要构造的时候改一下参数, 具体可以看看Sample, 托管和Libuv的传输层构造不一样.

var bootstrap = new ServerBootstrap();
//1个boss线程, N个工作线程
bootstrap.Group(this.bossGroup, this.workerGroup); if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
|| RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
//Linux下需要重用端口, 否则服务器立马重启会端口占用
bootstrap
.Option(ChannelOption.SoReuseport, true)
.ChildOption(ChannelOption.SoReuseaddr, true);
} bootstrap
.Channel<TcpServerChannel>()
//Linux默认backlog只有128, 并发较高的时候新链接会连不上来
.Option(ChannelOption.SoBacklog, 1024)
//跑满一个网络需要最少 带宽*延迟 的滑动窗口
//移动网络延迟比较高, 建议设置成64KB以上
//如果是内网通讯, 建议设置成128KB以上
.Option(ChannelOption.SoRcvbuf, 128 * 1024)
.Option(ChannelOption.SoSndbuf, 128 * 1024)
//将默认的内存分配器改成 内存池版本的分配器
//会占用较多的内存, 但是GC负担比较小
//一个堆16M, 会占用多个堆
//彩虹联萌的服务器大概会有400M左右
.Option(ChannelOption.Allocator, PooledByteBufferAllocator.Default)
.ChildOption(ChannelOption.TcpNodelay, true)
.ChildOption(ChannelOption.SoKeepalive, true)
//开启高低水位
.ChildOption(ChannelOption.WriteBufferLowWaterMark, 64 * 1024)
.ChildOption(ChannelOption.WriteBufferHighWaterMark, 128 * 1024)
.ChildHandler(new ActionChannelInitializer<IChannel>(channel =>
{

这里强调一下高低水位. 如果往一个Socket不停的发消息, 但是对端接收很慢, 那么正确的做法就是要把他T掉, 否则一直发下去, 服务器可能会内存不足. 这部分内存是无法GC的, 处理不当可能会被攻击.

编解码器和ByteBuffer的使用

DotNetty有封装好的IByteBuffer类, 该类是一个Stream, 支持Mark/Reset/Read/Write. 和Netty不太一样的是ByteBuffer类没有大小端, 而是在接口上做了大小端处理.

对于一个解码器, 大致的样式是:

public static (int length, uint msgID, IByteBuffer bytes) DecodeOneMessage(IByteBuffer buffer)
{
if (buffer.ReadableBytes < MinPacketLength)
{
return (0, 0, null);
} buffer.MarkReaderIndex(); //这只是示例代码, 实际需要根据具体情况调整
var head = buffer.ReadUnsignedIntLE();
var msgID = buffer.ReadUnsignedIntLE();
var bodyLength = head & 0xFFFFFF; if (buffer.ReadableBytes < bodyLength)
{
buffer.ResetReaderIndex();
return (0, 0, null);
} var bodyBytes = buffer.Allocator.Buffer(bodyLength);
buffer.ReadBytes(bodyBytes, bodyLength); return (bodyLength + 4 + 4, msgID, bodyBytes);
}

真实情况肯定要比这个复杂, 这里只是一个简单的sample. 读取消息因为需要考虑半包的存在, 所以需要ResetReaderIndex, 在编码的时候就不存在这个情况.

编码的情况就要稍微简单一些, 因为解码可能包不完整, 但是编码不会出现半个消息的情况, 所以在编码初期就能知道整个消息的大小(也有部分序列化类型会不知道消息长度).

var allocator = PooledByteBufferAllocator.Default;
var buffer = allocator.Buffer(Length); buffer.WriteIntLE(Header);
buffer.WriteIntLE(MsgID);
//xxx这边写body

用ByteBuffer编码Protobuf

之所以这边要单独提出来, 是因为高性能的服务器编程, 需要榨干一些能榨干的东西(在力所能及的范围内).

很多人做Protobuf IMessage序列化的时候, 就是简单的一句msg.ToByteArray(). 如果服务器是轻负载服务器, 那么这么写一点问题都没有; 否则就会多产生一个byte[]数组对象. 这显然不是我们想要的.

对于编码器来讲, 我们肯定是希望我给定一个预定的byte[], 你序列化的时候往这里面写. 所以我们来研究一下Protobuf的消息序列化.

//反编译的代码
public static Byte[] ToByteArray(this IMessage message)
{
ProtoPreconditions.CheckNotNull(message, "message");
CodedOutputStream codedOutputStream = new CodedOutputStream(new Byte[message.CalculateSize()]);
message.WriteTo(codedOutputStream);
return (Byte[])codedOutputStream.CheckNoSpaceLeft();
}

通过代码分析可以看出内部在使用CodedOutputStream做编码, 但是这个类的构造函数, 没有支持Slice的重载. 通过dnSpy反汇编发现有一个私有的重载:

private CodedOutputStream(byte[] buffer, int offset, int length)
{
this.output = null;
this.buffer = buffer;
this.position = offset;
this.limit = offset + length;
this.leaveOpen = true;
}

这就是我们所需要的接口, 有了这个接口就可以在ByteBuffer上面先申请好内存, 然后在写到ByteBuffer上, 减少了一次拷贝内存申请操作, 主要是对GC的压力会减轻不少.

这边给出示意代码:

var messageLength = msg.CalculateSize();
var buffer = allocator.Buffer(messageLength);
ArraySegment<byte> data = buffer.GetIoBuffer(buffer.WriterIndex, messageLength);
//这边需要通过反射去调用CodedOutputStream对象的私有构造函数
//具体可以研究一下
using var stream = createCodedOutputStream(data.Array, data.Offset, messageLength);
msg.WriteTo(stream);
stream.Flush();

至此, 我们就实现了高效的编码和解码器.

网络小包的处理

小包处理的一般思路不外乎合批, 合批压缩. 后者实现的难度要稍微高一点. 主要是游戏的流量还没有高到每一帧都会发送超过几百字节(小于128Byte的包压缩起来效果没那么好).

所以, 只有登录的时候, 服务器把玩家的几十K到上百K数据发送给客户端的时候, 压缩的时候才有效果; 平时只需要合批就可以了.

合批还能解决另外一个问题, 就是网卡PPS的瓶颈. 虽然是千兆网, 但是PPS一般都是在60W~100Wpps这个范围. 意味着一味的发小包, 一秒最多收发60W到100W个小包, 所以需要通过合批来突破PPS的瓶颈.

这是腾讯云SA2机型PPS的数据:

DotNetty中合批的两种实现方式. 先说第一种.

DotNetty发送消息有两个API:

  • WriteAsync
  • WriteAndFlushAsync 其中第一个API只是把ByteBuffer塞到Channel要发送的队列里面去, 第二个API塞到队列里面去还会触发真正的Send操作.

比如说我们要发送4个消息, 那么可以先:

//queue是一个List<IMessage>
for(int i = 0; i < queue.Count; ++i)
{
if ((i + 1) % 4 == 0)
{
channel.WriteAndAsync(queue[i]);
} else
{
channel.WriteAsync(queue[i]);
}
}
channel.Flush();

然后我们研究DotNetty的源码, 发现他底层实现也是调用发送一个List的API, 那么就可以达到我们想要的效果.

还有一种方式, 就是把想要发送的消息攒一攒, 通过Allocter New一个更大的Buffer, 然后把这些消息全部塞进去, 再一次性发出去. 彩虹联萌服务器用的就是这种方式, 大概10ms主动发送一次.

DotNetty的缺点

与其说是DotNetty的缺点, 不如说是所有托管内存语言的缺点. 所有托语言申请和释放资源的开销是不固定的, 这是IO密集型应用面临的巨大挑战.

在C++/Rust带有RAII的语言里面, 申请一块Buffer和释放一块Buffer的消耗都是比较固定的. 比如New一块内存大概是25ns, Delete一块大概是30~50ns.

但是在托管内存语言里面, New一块内存大概25ns, Delete就不一定了. 因为你不能手动Delete, 只能靠GC来Delete. 但是GC释放资源的时候, 会有Stop. 不管是并行GC还是非并行GC, 只是Stop时间的长短.

只有消除GC之后, 程序才会跑得非常快, 和Benchmark Game内跑的一样快.

所以, 为了避免这个问题, 需要:

  1. 把IO和计算分开

    这就是传统游戏服务器把Gateway和GameServer分开的好处. IO密集在Gateway, GC Stop对GameServer影响不大, 对玩家收发消息影响也不大.

  2. 把IO放到C++/Rust里面去

    这不是奇思妙想, 是大家都这么做. 例如ASP.NET Core就用libuv当做传输层.

    所以对于游戏服务器来讲, 可以在C++/Rust内实现传输层, 然后通过P/Invoke来和Native层通讯, 降低IO不断分配内存对计算部分的影响.

  3. 将程序改造成Alloc Free

    如果我不分配对象, 就不会有GC, 也就不会对计算有影响. 这也是笔者才彩虹联萌服务器内做的事情.

    Alloc Free是我自己造的词汇, 类似于Lock Free. 但是不是说不分配任何内存, 只是把高频分配降低了, 低频分配还是允许的, 否则代码会非常难写.

参考:

  1. C# Socket
  2. TcpStream
  3. ASP.NET Core Bedrock
  4. Golang Gnet
  5. Netty
  6. DotNetty
  7. DotNetty Send
  8. C# Benchmark

[01] C#网络编程的最佳实践的更多相关文章

  1. Android学习之基础知识十二 — 第二讲:网络编程的最佳实践

    上一讲已经掌握了HttpURLConnection和OkHttp的用法,知道如何发起HTTP请求,以及解析服务器返回的数据,但是也许你还没发现,之前我们的写法其实是很有问题的,因为一个应用程序很可能会 ...

  2. javascript编程的最佳实践推荐

    推荐的javascript编程的最佳实践,摘要记录在这里: 可维护的代码保证代码的性能部署代码 1 可维护的代码1.1什么是维护的代码:可理解性——其他人可以接手代码并理解它的意图和一般途径,而无需原 ...

  3. python高级编程之最佳实践,描述符与属性01

    # -*- coding: utf-8 -*- # python:2.x __author__ = 'Administrator' #最佳实践 """ 为了避免前面所有的 ...

  4. 【TCP/IP网络编程】:01理解网络编程和套接字

    1.网络编程和套接字 网络编程与C语言中的printf函数和scanf函数以及文件的输入输出类似,本质上也是一种基于I/O的编程方法.之所以这么说,是因为网络编程大多是基于套接字(socket,网络数 ...

  5. 三十天学不会TCP,UDP/IP网络编程 - UDP的实践--DHCP

    在经历了一顿忙碌加出去玩了玩之后,我又开始重新更新了~这是最新的一篇~完整版可以去gitbook(https://www.gitbook.com/@rogerzhu/)看到,在gitbook的后台流量 ...

  6. jQuery编程的最佳实践

    好像是feedly订阅里看到的文章,读完后觉得非常不错,译之备用,多看受益. 加载jQuery 1.坚持使用CDN来加载jQuery,这种别人服务器免费帮你托管文件的便宜干嘛不占呢.点击查看使用CDN ...

  7. GOLANG接口编程的最佳实践一 (sort.Sort(data Interface ) )

    package main import( "fmt" "sort" "math/rand" ) //定义一个武当派的结构体 type Wud ...

  8. 网络Devops探索与实践 流程管理分析师

    https://mp.weixin.qq.com/s/OKLiDi78uB8ZkPG2kUVxvA 网络Devops探索与实践 王镇 鹅厂网事 2020-09-23  9月16日举办的2020 ODC ...

  9. 分布式 PostgreSQL 集群(Citus),分布式表中的分布列选择最佳实践

    确定应用程序类型 在 Citus 集群上运行高效查询要求数据在机器之间正确分布.这因应用程序类型及其查询模式而异. 大致上有两种应用程序在 Citus 上运行良好.数据建模的第一步是确定哪些应用程序类 ...

随机推荐

  1. 比原链CTO James | Go语言成为区块链主流开发语言的四点理由

    11月24日,比原链CTO James参加了Go中国举办的Gopher Meetup杭州站活动,与来自阿里.网易的技术专家带来Kubernetes.区块链.日志采集.云原生等话题的分享.James向大 ...

  2. 37 Reasons why your Neural Network is not working

    37 Reasons why your Neural Network is not working Neural Network Check List 如何使用这个指南 数据问题 检查输入数据 试一下 ...

  3. 动态路由 - EIGRP

    EIGRP 特性 EIGRP(增强内部网关路由协议)是思科的私有协议,属于距离矢量路由协议,但又具有链路状态的特性.并且支持 VLSM(可变长子网和无类路由协议).但在本质上说还是传送路由条目. 具有 ...

  4. NodeJs nrm 和 nvm

    nrm 和 nvm nrm (npm registry manager)是npm的镜像源管理工具 nvm (node version manager)是nodejs的版本管理工具 nrm # nrm ...

  5. Vue 引入指定目录文件夹所有的组件 require.context

    require.context require.context是webpack中用来管理依赖的一个函数,此方法会生成一个上下文模块,包含目录下所有的模块的引用,同构正则表达式匹配,然后require进 ...

  6. next()与nextLine()的区别

    abc def ghij kl mno pqr st uvw xyz 你用next(),第一次取的是abc,第二次取的是def,第三次取的是ghij 你用nextLine(),第一次取的是abc de ...

  7. 用前端姿势玩docker【五】快速构建中类Unix系统与Windows系统的差异化处理

    目录 用前端姿势玩docker[一]Docker通俗理解常用功能汇总与操作埋坑 用前端姿势玩docker[二]dockerfile定制镜像初体验 用前端姿势玩docker[三]基于nvm的前端环境构建 ...

  8. 一、常用的Dos命令

    # 查看目录下所有文件 dir # 切换目录 cd cd .. //返回上一级 # 清理屏幕 cls # 查询电脑ip地址 ipconfig # 退出终端 exit # 创建文件夹 md test # ...

  9. AMD 5700 XT显卡装ubuntu18.04.* 驱动的问题解决(全)

    公司开发需要测试新的 AMD显卡,由于测试服务器上的显卡是英伟达的显卡所以换完后要安装相应的驱动.由于之前装机的同事装的ubuntu是18.04.5 恰巧18.04.5在amd官网上没有相匹配的驱动( ...

  10. 手牵手,使用uni-app从零开发一款视频小程序 (系列上 准备工作篇)

    系列文章 手牵手,使用uni-app从零开发一款视频小程序 (系列上 准备工作篇) 手牵手,使用uni-app从零开发一款视频小程序 (系列下 开发实战篇) 前言 好久不见,很久没更新博客了,前段时间 ...