<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          C#網(wǎng)絡(luò)編程的最佳實(shí)踐

          共 8128字,需瀏覽 17分鐘

           ·

          2023-11-11 23:42


          轉(zhuǎn)自:egmkang
          cnblogs.com/egmkang/p/13637768.html

          網(wǎng)絡(luò)框架的選擇


          C++語(yǔ)言里面有asio和libuv等網(wǎng)絡(luò)庫(kù), 可以方便的進(jìn)行各種高效編程. 但是C#里面, 情況不太一樣, C#自帶的網(wǎng)絡(luò)API有多種. 例如:


          • Socket


          • TcpStream(同步接口和BeginXXX異步接口)


          • TcpStream Async/Await


          • Pipeline IO


          • ASP.NET Core Bedrock


          眾多網(wǎng)絡(luò)庫(kù), 但是每個(gè)編程模型都不太一樣, 和C++里面我常用的reactor模型有很大區(qū)別. 最重要的是, 編程難度和性能不是很好. 尤其是后面三種模型, 都是面對(duì)輕負(fù)載的互聯(lián)網(wǎng)應(yīng)用設(shè)計(jì), 每個(gè)玩家跑兩個(gè)協(xié)程(一讀一寫)會(huì)對(duì)進(jìn)程造成額外的負(fù)擔(dān).


          Golang面世的時(shí)候, 大家都說(shuō)協(xié)程好用, 簡(jiǎn)單, 性能高. 可是面對(duì)大量 高頻交互的應(yīng)用, 最終還是需要重新編寫網(wǎng)絡(luò)層(參見Gnet). 


          因?yàn)閰f(xié)程上下文切換需要消耗微秒左右的時(shí)間(通常是0.5us到1微秒左右), 另外有棧協(xié)程占用額外的內(nèi)存(無(wú)棧協(xié)程不存在這個(gè)問(wèn)題).


          所以在C#里面需要選擇一個(gè)類似于Reactor模型的網(wǎng)絡(luò)庫(kù). Java里面有Netty. 好在微軟把Netty移植到了.NET里面, 所以我們只需要照著Netty的文檔和DotNetty的Sample(包括源碼)就可以寫出高效的網(wǎng)絡(luò)框架.


          另外DotNetty有l(wèi)ibuv的插件, 可以將傳輸層放到libuv內(nèi), 減少托管語(yǔ)言的消耗.


          DotNetty編程


          由于我們是服務(wù)器編程, 需要處理多個(gè)Socket而不像客戶端只需要處理一兩個(gè)Socket, 所以在每個(gè)Socket上, 都需要做一些標(biāo)記信息, 用來(lái)標(biāo)記當(dāng)前Socket的狀態(tài)(是否登錄, 用戶是哪個(gè)等等); 還需要一個(gè)管理維護(hù)的這些Socket的管理者類.


          鏈接狀態(tài)


          Socket的狀態(tài)可以使用IChannel.GetAttribute來(lái)實(shí)現(xiàn), 我們可以給IChannel上面增加一個(gè)SessionInfo的屬性, 用來(lái)保存當(dāng)前鏈接的其他可變屬性. 那么可以這么做:


          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);
          //其他參數(shù)
          }));


          由于游戲服務(wù)器通常是有狀態(tài)服務(wù), 所以鏈接上還需要保存PlayerID, OpenID等信息, 方便解碼器在解碼的時(shí)候, 直接把消息派發(fā)給相應(yīng)的處理器.


          管理器和生命周期


          托管語(yǔ)言有GC, 但是對(duì)于非托管資源還是需要手動(dòng)管理. C#有IDisposable模式, 可以簡(jiǎn)化異常場(chǎng)景下資源釋放問(wèn)題, 但是對(duì)于Socket這種生命周期比較長(zhǎng)的資源就無(wú)能為力了.


          所以, 我們必須要編寫自己的ChannelManager類, 并且遵從:


          • 新鏈接一定要立刻放到Manager里面


          • 通過(guò)ID來(lái)獲取IChannel, 不做長(zhǎng)時(shí)間持有


          • 想要長(zhǎng)時(shí)間持有, 則使用WeakReference


          • MessageHandler的異常里面釋放Manager里面的IChannel


          • 心跳超時(shí)也要釋放IChannel


          對(duì)于IChannel對(duì)象的持有, 一定要是短時(shí)間的持有, 比如在一次函數(shù)調(diào)用內(nèi)獲取, 否則問(wèn)題會(huì)變得很復(fù)雜.


          防止主動(dòng)關(guān)閉Socket和異常同時(shí)發(fā)生, IChannel.CloseAsync()函數(shù)調(diào)用需要try catch.


          參數(shù)調(diào)節(jié)


          GameServer一般來(lái)講單個(gè)網(wǎng)絡(luò)線程就夠了, 但是作為網(wǎng)關(guān)是絕對(duì)不夠的, 所以網(wǎng)絡(luò)庫(kù)需要支持多線程Loop. 好在DotNetty這方面比較簡(jiǎn)單, 只需要構(gòu)造的時(shí)候改一下參數(shù), 具體可以看看Sample, 托管和Libuv的傳輸層構(gòu)造不一樣.

          var bootstrap = new ServerBootstrap();
          //1個(gè)boss線程, N個(gè)工作線程
          bootstrap.Group(this.bossGroup, this.workerGroup);
          if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
          || RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
          {
          //Linux下需要重用端口, 否則服務(wù)器立馬重啟會(huì)端口占用
          bootstrap
          .Option(ChannelOption.SoReuseport, true)
          .ChildOption(ChannelOption.SoReuseaddr, true);
          }

          bootstrap
          .Channel<TcpServerChannel>()
          //Linux默認(rèn)backlog只有128, 并發(fā)較高的時(shí)候新鏈接會(huì)連不上來(lái)
          .Option(ChannelOption.SoBacklog, 1024)
          //跑滿一個(gè)網(wǎng)絡(luò)需要最少 帶寬*延遲 的滑動(dòng)窗口
          //移動(dòng)網(wǎng)絡(luò)延遲比較高, 建議設(shè)置成64KB以上
          //如果是內(nèi)網(wǎng)通訊, 建議設(shè)置成128KB以上
          .Option(ChannelOption.SoRcvbuf, 128 * 1024)
          .Option(ChannelOption.SoSndbuf, 128 * 1024)
          //將默認(rèn)的內(nèi)存分配器改成 內(nèi)存池版本的分配器
          //會(huì)占用較多的內(nèi)存, 但是GC負(fù)擔(dān)比較小
          //一個(gè)堆16M, 會(huì)占用多個(gè)堆
          .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 =>
          {



          這里強(qiáng)調(diào)一下高低水位. 如果往一個(gè)Socket不停的發(fā)消息, 但是對(duì)端接收很慢, 那么正確的做法就是要把他T掉, 否則一直發(fā)下去, 服務(wù)器可能會(huì)內(nèi)存不足. 這部分內(nèi)存是無(wú)法GC的, 處理不當(dāng)可能會(huì)被攻擊.


          編解碼器和ByteBuffer的使用


          DotNetty有封裝好的IByteBuffer類, 該類是一個(gè)Stream, 支持Mark/Reset/Read/Write. 和Netty不太一樣的是ByteBuffer類沒(méi)有大小端, 而是在接口上做了大小端處理.


          對(duì)于一個(gè)解碼器, 大致的樣式是:


          public static (int length, uint msgID, IByteBuffer bytes) DecodeOneMessage(IByteBuffer buffer)
          {
          if (buffer.ReadableBytes < MinPacketLength)
          {
          return (0, 0, null);
          }
          buffer.MarkReaderIndex();
          //這只是示例代碼, 實(shí)際需要根據(jù)具體情況調(diào)整
          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);
          }


          真實(shí)情況肯定要比這個(gè)復(fù)雜, 這里只是一個(gè)簡(jiǎn)單的sample. 讀取消息因?yàn)樾枰紤]半包的存在, 所以需要ResetReaderIndex, 在編碼的時(shí)候就不存在這個(gè)情況.


          編碼的情況就要稍微簡(jiǎn)單一些, 因?yàn)榻獯a可能包不完整, 但是編碼不會(huì)出現(xiàn)半個(gè)消息的情況, 所以在編碼初期就能知道整個(gè)消息的大小(也有部分序列化類型會(huì)不知道消息長(zhǎng)度).


          var allocator = PooledByteBufferAllocator.Default;
          var buffer = allocator.Buffer(Length);

          buffer.WriteIntLE(Header);
          buffer.WriteIntLE(MsgID);
          //xxx這邊寫body


          用ByteBuffer編碼Protobuf


          之所以這邊要單獨(dú)提出來(lái), 是因?yàn)楦咝阅艿姆?wù)器編程, 需要榨干一些能榨干的東西(在力所能及的范圍內(nèi)).


          很多人做Protobuf IMessage序列化的時(shí)候, 就是簡(jiǎn)單的一句msg.ToByteArray(). 如果服務(wù)器是輕負(fù)載服務(wù)器, 那么這么寫一點(diǎn)問(wèn)題都沒(méi)有; 否則就會(huì)多產(chǎn)生一個(gè)byte[]數(shù)組對(duì)象. 這顯然不是我們想要的.


          對(duì)于編碼器來(lái)講, 我們肯定是希望我給定一個(gè)預(yù)定的byte[], 你序列化的時(shí)候往這里面寫. 所以我們來(lái)研究一下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();
          }


          通過(guò)代碼分析可以看出內(nèi)部在使用CodedOutputStream做編碼, 但是這個(gè)類的構(gòu)造函數(shù), 沒(méi)有支持Slice的重載. 通過(guò)dnSpy反匯編發(fā)現(xiàn)有一個(gè)私有的重載:


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


          這就是我們所需要的接口, 有了這個(gè)接口就可以在ByteBuffer上面先申請(qǐng)好內(nèi)存, 然后在寫到ByteBuffer上, 減少了一次拷貝和內(nèi)存申請(qǐng)操作, 主要是對(duì)GC的壓力會(huì)減輕不少.


          這邊給出示意代碼:


          var messageLength = msg.CalculateSize();
          var buffer = allocator.Buffer(messageLength);
          ArraySegment<byte> data = buffer.GetIoBuffer(buffer.WriterIndex, messageLength);
          //這邊需要通過(guò)反射去調(diào)用CodedOutputStream對(duì)象的私有構(gòu)造函數(shù)
          //具體可以研究一下
          using var stream = createCodedOutputStream(data.Array, data.Offset, messageLength);
          msg.WriteTo(stream);
          stream.Flush();


          至此, 我們就實(shí)現(xiàn)了高效的編碼和解碼器.


          網(wǎng)絡(luò)小包的處理


          小包處理的一般思路不外乎合批, 合批壓縮. 后者實(shí)現(xiàn)的難度要稍微高一點(diǎn). 主要是游戲的流量還沒(méi)有高到每一幀都會(huì)發(fā)送超過(guò)幾百字節(jié)(小于128Byte的包壓縮起來(lái)效果沒(méi)那么好).


          所以, 只有登錄的時(shí)候, 服務(wù)器把玩家的幾十K到上百K數(shù)據(jù)發(fā)送給客戶端的時(shí)候, 壓縮的時(shí)候才有效果; 平時(shí)只需要合批就可以了.


          合批還能解決另外一個(gè)問(wèn)題, 就是網(wǎng)卡PPS的瓶頸. 雖然是千兆網(wǎng), 但是PPS一般都是在60W~100Wpps這個(gè)范圍. 意味著一味的發(fā)小包, 一秒最多收發(fā)60W到100W個(gè)小包, 所以需要通過(guò)合批來(lái)突破PPS的瓶頸.


          這是騰訊云SA2機(jī)型PPS的數(shù)據(jù):


           

          DotNetty中合批的兩種實(shí)現(xiàn)方式. 先說(shuō)第一種.


          DotNetty發(fā)送消息有兩個(gè)API:


          • WriteAsync


          • WriteAndFlushAsync 其中第一個(gè)API只是把ByteBuffer塞到Channel要發(fā)送的隊(duì)列里面去, 第二個(gè)API塞到隊(duì)列里面去還會(huì)觸發(fā)真正的Send操作.


          比如說(shuō)我們要發(fā)送4個(gè)消息, 那么可以先:


          //queue是一個(gè)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的源碼, 發(fā)現(xiàn)他底層實(shí)現(xiàn)也是調(diào)用發(fā)送一個(gè)List的API, 那么就可以達(dá)到我們想要的效果.


          還有一種方式, 就是把想要發(fā)送的消息攢一攢, 通過(guò)Allocter New一個(gè)更大的Buffer, 然后把這些消息全部塞進(jìn)去, 再一次性發(fā)出去. 彩虹聯(lián)萌服務(wù)器用的就是這種方式, 大概10ms主動(dòng)發(fā)送一次.


          DotNetty的缺點(diǎn)


          與其說(shuō)是DotNetty的缺點(diǎn), 不如說(shuō)是所有托管內(nèi)存語(yǔ)言的缺點(diǎn). 所有托語(yǔ)言申請(qǐng)和釋放資源的開銷是不固定的, 這是IO密集型應(yīng)用面臨的巨大挑戰(zhàn).


          在C++/Rust帶有RAII的語(yǔ)言里面, 申請(qǐng)一塊Buffer和釋放一塊Buffer的消耗都是比較固定的. 比如New一塊內(nèi)存大概是25ns, Delete一塊大概是30~50ns.


          但是在托管內(nèi)存語(yǔ)言里面, New一塊內(nèi)存大概25ns, Delete就不一定了. 因?yàn)槟悴荒苁謩?dòng)Delete, 只能靠GC來(lái)Delete. 但是GC釋放資源的時(shí)候, 會(huì)有Stop. 不管是并行GC還是非并行GC, 只是Stop時(shí)間的長(zhǎng)短.


          只有消除GC之后, 程序才會(huì)跑得非常快, 和Benchmark Game內(nèi)跑的一樣快.


          所以, 為了避免這個(gè)問(wèn)題, 需要:


          1、把IO和計(jì)算分開


          這就是傳統(tǒng)游戲服務(wù)器把Gateway和GameServer分開的好處. IO密集在Gateway, GC Stop對(duì)GameServer影響不大, 對(duì)玩家收發(fā)消息影響也不大.


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


          這不是奇思妙想, 是大家都這么做. 例如ASP.NET Core就用libuv當(dāng)做傳輸層.


          所以對(duì)于游戲服務(wù)器來(lái)講, 可以在C++/Rust內(nèi)實(shí)現(xiàn)傳輸層, 然后通過(guò)P/Invoke來(lái)和Native層通訊, 降低IO不斷分配內(nèi)存對(duì)計(jì)算部分的影響.


          3、將程序改造成Alloc Free


          如果我不分配對(duì)象, 就不會(huì)有GC, 也就不會(huì)對(duì)計(jì)算有影響. 這也是筆者才彩虹聯(lián)萌服務(wù)器內(nèi)做的事情.


          Alloc Free是我自己造的詞匯, 類似于Lock Free. 但是不是說(shuō)不分配任何內(nèi)存, 只是把高頻分配降低了, 低頻分配還是允許的, 否則代碼會(huì)非常難寫.







          回復(fù) 【關(guān)閉】學(xué)永久關(guān)閉App開屏廣告
          回復(fù) 【刪除】學(xué)自動(dòng)檢測(cè)那個(gè)微信好友刪除、拉黑
          回復(fù) 【手冊(cè)】獲取3萬(wàn)字.NET、C#工程師面試手冊(cè)
          回復(fù) 【幫助】獲取100+個(gè)常用的C#幫助類庫(kù)
          回復(fù) 【加群】加入DotNet學(xué)習(xí)交流群

          瀏覽 659
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  日本在线观看 | 亚洲日本Ⅴa中文字幕无码 | 影音先锋AV麻豆啪啪资源网 | 色丁香午夜婷 | 婷婷五月天亚洲 |