首页 » 网站推广 » phpsoreuseaddr技巧_C收集编程的最佳实践

phpsoreuseaddr技巧_C收集编程的最佳实践

访客 2024-12-07 0

扫一扫用手机浏览

文章目录 [+]

SocketTcpStream(同步接口和BeginXXX异步接口)TcpStream Async/AwaitPipeline IOASP.NET Core Bedrock

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

phpsoreuseaddr技巧_C收集编程的最佳实践

Golang面世的时候, 大家都说协程好用, 大略, 性能高. 可是面对大量 高频交互的运用, 终极还是须要重新编写网络层(拜会Gnet).

phpsoreuseaddr技巧_C收集编程的最佳实践
(图片来自网络侵删)

由于协程高下文切换须要花费微秒旁边的韶光(常日是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, 不做永劫光持有想要永劫光持有, 则利用WeakReferenceMessageHandler的非常里面开释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, 会占用多个堆 .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:

WriteAsyncWriteAndFlushAsync 个中第一个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. 但是不是说不分配任何内存, 只是把高频分配降落了, 低频分配还是许可的, 否则代码会非常难写.

标签:

相关文章